openvibers/openvibe.community Commits
Community publishes events (openvibe-contracts 0.35.0): community.paste.created|updated|deleted, community.thread.created, community.post.created and community.comment.created, each queued in the transaction of the write through the openvibe-sdk outbox and relayed with Community's service token. Payloads carry ids, owner/author subject, visibility and the public URL, never a title, body or content; items that are not public (unlisted pastes, members-only or staff spaces, hidden threads) travel as internal events without a URL. Off unless EVENTS_URL and the client secret are set. test/events.test.js
Changes
@@@ -17,7 +17,8 @@"helmet": "^8.1.0","highlight.js": "^11.11.1","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"},"engines": {@@@ -1056,9 +1057,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",-- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",++ "version": "0.35.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1071,6 +1072,15 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "version": "0.6.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",++ "integrity": "sha512-v6PFh8Xf/bE1XDbuSkbVXVC5cN+q9fkwQi/qc9kmiGPr9Zze47ydS+qS0i3RXUmQF2v0/BdCzAMcowsdzf1ezQ==",++ "license": "MIT",++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"version": "1.5.1","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",@@@ -2,14 +2,19 @@"name": "openvibe-community","version": "1.0.0","private": true,-- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",++ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).","main": "server/index.js","scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js","test": "node test/run.js"},-- "keywords": ["openvibe", "community", "pastes", "pastebin"],++ "keywords": [++ "openvibe",++ "community",++ "pastes",++ "pastebin"++ ],"author": "OpenVibe Community","license": "MIT","dependencies": {@@@ -21,7 +26,8 @@"helmet": "^8.1.0","highlight.js": "^11.11.1","multer": "^2.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"},"engines": {@@@ -59,7 +59,10 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,.run(thread_id, parent_id, author_subject, anon_name, origin, message);db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);-- return getComment(db, info.lastInsertRowid);++ const comment = getComment(db, info.lastInsertRowid);++ const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);++ if (cthread) require('../events').commentCreated(comment, cthread); // community.comment.created++ return comment;})();}nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++/**++ * Community → OpenVibe.Events (contracts community.*@1, openvibe-contracts 0.35.0).++ *++ * community.paste.created | updated | deleted server/pastes/store.js++ * community.thread.created, community.post.created server/forum/store.js++ * community.comment.created server/comments/store.js++ *++ * Each event is written to Community's own outbox inside the SAME transaction as the change (the SDK++ * outbox refuses anything else), and a relay publishes it with Community's service token (audience++ * openvibe.events, capability events.event.publish). Events down: rows wait and are retried; a write++ * never waits on Events. Payloads carry ids, the owner/author subject, visibility and the public URL,++ * never a body, title or content. Envelope visibility is public only for public items.++ *++ * Off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set (EVENTS_PUBLISH=off disables it).++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createEventsClient, createOutbox } = require('openvibe-sdk/events');++const config = require('./config');++++let outbox = null;++const stats = { queued: 0, lastError: null };++const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;++++function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oauth && config.oauth.clientSecret, fetchImpl, intervalMs } = {}) {++ if (outbox) return outbox;++ if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;++ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });++ const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });++ outbox = createOutbox(db, {++ events: createEventsClient(client, { source: 'community' }),++ intervalMs: intervalMs || 2000,++ onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },++ });++ outbox.ensureSchema();++ outbox.start();++ const prune = setInterval(() => { try { outbox.prune(); } catch { /* next time */ } }, PRUNE_EVERY_MS);++ if (prune.unref) prune.unref();++ console.log(`[Events] community → ${eventsUrl} (${outbox.pending()} pending)`);++ return outbox;++}++++const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? { type: 'user', id: sub } : (sub && /^gst_/.test(sub) ? { type: 'guest', id: sub } : null));++++/**++ * Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,++ * so the change rolls back with it. A no-op (null) while publishing is off.++ */++function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {++ if (!outbox) return null;++ const env = outbox.enqueue({++ event_type: eventType,++ actor: actor || { type: 'service', id: 'community' },++ subject,++ visibility: isPublic ? 'public' : 'internal',++ priority: 'low',++ payload,++ });++ stats.queued++;++ setImmediate(() => outbox && outbox.kick());++ return env;++}++++// ── Builders (read the row as it is after the change) ─────────────────────────────++const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);++const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };++++function pasteCreated(p) {++ return record('community.paste.created', { type: 'paste', id: p.slug }, {++ paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',++ visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),++ }, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });++}++function pasteUpdated(p, changed) {++ const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));++ if (!list.length) return null;++ return record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {++ paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),++ }, { isPublic: p.visibility === 'public' });++}++function pasteDeleted(slug) {++ return record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });++}++// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).++const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');++function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {++ const visibility = forumVisibility(thread, spaceVisibility);++ return record('community.thread.created', { type: 'thread', id: String(thread.id) }, {++ thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,++ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,++ }, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });++}++function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {++ const visibility = forumVisibility(thread, spaceVisibility);++ return record('community.post.created', { type: 'post', id: String(post.id) }, {++ post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,++ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,++ }, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });++}++function commentCreated(comment, cthread) {++ const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';++ return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {++ comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },++ author: subjectRef(comment.author_subject), visibility, url: visibility === 'hidden' ? null : `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}`,++ }, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });++}++++function status() {++ if (!outbox) return { enabled: false };++ return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };++}++function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }++++module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };@@@ -65,7 +65,10 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'usconst threadId = info.lastInsertRowid;const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)').run(threadId, author_subject, origin, body_markdown);-- return { thread: getThread(db, threadId), post: getPost(db, p.lastInsertRowid) };++ const thread = getThread(db, threadId);++ const space = db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(space_id);++ require('../events').threadCreated(thread, space ? space.slug : String(space_id), space ? space.visibility : 'members'); // community.thread.created++ return { thread, post: getPost(db, p.lastInsertRowid) };})();}@@@ -136,7 +139,11 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_mconst info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown) VALUES (?, ?, ?, ?)').run(thread_id, author_subject, origin, body_markdown);db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);-- return getPost(db, info.lastInsertRowid);++ const post = getPost(db, info.lastInsertRowid);++ const thread = getThread(db, thread_id);++ const space = thread && db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(thread.space_id);++ if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space ? space.visibility : 'members'); // community.post.created++ return post;})();}@@@ -8,6 +8,8 @@ const config = require('./config');const { createApp } = require('./app');const app = createApp();++// Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.++try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }const server = app.listen(config.port, config.host, () => {console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);console.log(config.pastesAuthority === 'community'@@@ -126,9 +126,13 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con/** Insert a new paste (fields already validated); returns the stored row. */function insertPaste(db, fields) {const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);-- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)-- .run(...cols.map((c) => fields[c]));-- return getById(db, info.lastInsertRowid);++ return db.transaction(() => {++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)++ .run(...cols.map((c) => fields[c]));++ const row = getById(db, info.lastInsertRowid);++ require('../events').pasteCreated(row); // community.paste.created, in this transaction++ return row;++ })();}/**@@@ -164,6 +168,8 @@ function updatePaste(db, id, patch, editedBy = null) {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);}++ const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);++ if (changed.length) require('../events').pasteUpdated(next, changed);return next;})();}@@@ -174,8 +180,13 @@ function listVersions(db, pasteId) {/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */function softDelete(db, id) {-- return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,++ return db.transaction(() => {++ const cur = getById(db, id);++ const n = db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;++ if (n && cur) require('../events').pasteDeleted(cur.slug); // community.paste.deleted++ return n;++ })();}/**@@@ -301,7 +312,12 @@ function setScreenshot(db, pasteId, url, mediaRef) {}function setVisibility(db, pasteId, visibility) {-- return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;++ return db.transaction(() => {++ const cur = getById(db, pasteId);++ const r = db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);++ if (r.changes && cur && cur.visibility !== visibility) require('../events').pasteUpdated(getById(db, pasteId), ['visibility']);++ return r.changes;++ })();}/** Media's admin stats shape. */nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * Community's events: every write queues its community.* event in the same transaction (a failed write++ * queues nothing), payloads validate against openvibe-contracts and never carry content, and items++ * that are not public travel as internal events without a URL.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { openDb } = require('../server/db');++const events = require('../server/events');++const pastes = require('../server/pastes/store');++const forum = require('../server/forum/store');++const comments = require('../server/comments/store');++++const db = openDb(':memory:');++// A relay that never publishes during the test: the rows stay in the outbox for inspection.++events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });++const queued = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope));++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };++++let n = 0;++function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }++++check('a paste create, edit, visibility change and delete each queue one event, never the content', () => {++ const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });++ pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);++ pastes.setVisibility(db, p.id, 'unlisted');++ pastes.softDelete(db, p.id);++ const evs = queued();++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);++ evs.forEach(ok);++ assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');++ assert.strictEqual(evs[0].visibility, 'public');++ assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));++ assert.deepStrictEqual(evs[1].payload.changed, ['content']);++ assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');++ assert.strictEqual(evs[2].payload.url, null);++ assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });++});++++check('a write that fails queues nothing (the event is in its transaction)', () => {++ const before = queued().length;++ assert.throws(() => pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /UNIQUE/);++ assert.strictEqual(queued().length, before);++});++++check('threads and posts: public spaces only are public events; staff spaces are members-only', () => {++ db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();++ const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;++ const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;++ const before = queued().length;++ const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });++ forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });++ forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });++ const evs = queued().slice(before);++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);++ evs.forEach(ok);++ assert.strictEqual(evs[0].visibility, 'public');++ assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));++ assert.strictEqual(evs[2].visibility, 'internal');++ assert.strictEqual(evs[2].payload.visibility, 'members');++ assert.strictEqual(evs[2].payload.url, null);++ assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');++});++++check('a comment on a Live VOD queues community.comment.created with its ref', () => {++ const before = queued().length;++ const t = comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' }).thread;++ comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });++ const evs = queued().slice(before);++ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);++ evs.forEach(ok);++ assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });++ assert.ok(!JSON.stringify(evs).includes('nice stream'));++});++++events._reset();++console.log(`community events: ${n} checks passed`);++process.exit(0);
@@ -17,7 +17,8 @@
@@ -17,7 +17,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
},
"engines": {
"engines": {
@@ -1056,9 +1057,9 @@
@@ -1056,9 +1057,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
- "integrity": "sha512-7q8NUhcPxgb8lxrVdC3pFbiPdjzliJM1mMP/1G5a2PmOr2tJVAXwYa8+a8YgCJvYeMSeMHWijLkIEiZ9IhllOg==",
+ "version": "0.35.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "integrity": "sha512-5jkH9Gt9MVbYaxo/Fi1VSH0RwdPkbary7SOm2m9e5EvXYu8L/cJHMm6KrMKxa7MV6TJCATDeRpGLDOCaUeZmSw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1071,6 +1072,15 @@
@@ -1071,6 +1072,15 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.6.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
+ "integrity": "sha512-v6PFh8Xf/bE1XDbuSkbVXVC5cN+q9fkwQi/qc9kmiGPr9Zze47ydS+qS0i3RXUmQF2v0/BdCzAMcowsdzf1ezQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"version": "1.5.1",
"version": "1.5.1",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
@@ -2,14 +2,19 @@
@@ -2,14 +2,19 @@
"name": "openvibe-community",
"name": "openvibe-community",
"version": "1.0.0",
"version": "1.0.0",
"private": true,
"private": true,
- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
+ "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, and Pulse; submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js"
"test": "node test/run.js"
},
},
- "keywords": ["openvibe", "community", "pastes", "pastebin"],
+ "keywords": [
+ "openvibe",
+ "community",
+ "pastes",
+ "pastebin"
+ ],
"author": "OpenVibe Community",
"author": "OpenVibe Community",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
@@ -21,7 +26,8 @@
@@ -21,7 +26,8 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"multer": "^2.4.0",
"multer": "^2.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.35.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.6.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1"
},
},
"engines": {
"engines": {
@@ -59,7 +59,10 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
@@ -59,7 +59,10 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
- return getComment(db, info.lastInsertRowid);
+ const comment = getComment(db, info.lastInsertRowid);
+ const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
+ if (cthread) require('../events').commentCreated(comment, cthread); // community.comment.created
+ return comment;
})();
})();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * Community → OpenVibe.Events (contracts community.*@1, openvibe-contracts 0.35.0).
+ *
+ * community.paste.created | updated | deleted server/pastes/store.js
+ * community.thread.created, community.post.created server/forum/store.js
+ * community.comment.created server/comments/store.js
+ *
+ * Each event is written to Community's own outbox inside the SAME transaction as the change (the SDK
+ * outbox refuses anything else), and a relay publishes it with Community's service token (audience
+ * openvibe.events, capability events.event.publish). Events down: rows wait and are retried; a write
+ * never waits on Events. Payloads carry ids, the owner/author subject, visibility and the public URL,
+ * never a body, title or content. Envelope visibility is public only for public items.
+ *
+ * Off unless EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set (EVENTS_PUBLISH=off disables it).
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
+const config = require('./config');
+
+let outbox = null;
+const stats = { queued: 0, lastError: null };
+const PRUNE_EVERY_MS = 6 * 60 * 60 * 1000;
+
+function init(db, { eventsUrl = process.env.EVENTS_URL, clientSecret = config.oauth && config.oauth.clientSecret, fetchImpl, intervalMs } = {}) {
+ if (outbox) return outbox;
+ if (process.env.EVENTS_PUBLISH === 'off' || !eventsUrl || !clientSecret) return null;
+ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: (config.oauth && config.oauth.clientId) || 'community', clientSecret, fetch: fetchImpl });
+ const client = createClient({ baseUrls: { events: String(eventsUrl).replace(/\/+$/, '') }, tokenProvider: tokens, fetch: fetchImpl, retries: 0 });
+ outbox = createOutbox(db, {
+ events: createEventsClient(client, { source: 'community' }),
+ intervalMs: intervalMs || 2000,
+ onError: (err) => { const m = err && err.message; if (m !== stats.lastError) console.warn('[Events] publish failed (will retry):', m); stats.lastError = m; },
+ });
+ outbox.ensureSchema();
+ outbox.start();
+ const prune = setInterval(() => { try { outbox.prune(); } catch { /* next time */ } }, PRUNE_EVERY_MS);
+ if (prune.unref) prune.unref();
+ console.log(`[Events] community → ${eventsUrl} (${outbox.pending()} pending)`);
+ return outbox;
+}
+
+const subjectRef = (sub) => (sub && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(sub) ? { type: 'user', id: sub } : (sub && /^gst_/.test(sub) ? { type: 'guest', id: sub } : null));
+
+/**
+ * Queue one event. MUST run inside the transaction that makes the change; throws if the insert fails,
+ * so the change rolls back with it. A no-op (null) while publishing is off.
+ */
+function record(eventType, subject, payload, { isPublic = false, actor = null } = {}) {
+ if (!outbox) return null;
+ const env = outbox.enqueue({
+ event_type: eventType,
+ actor: actor || { type: 'service', id: 'community' },
+ subject,
+ visibility: isPublic ? 'public' : 'internal',
+ priority: 'low',
+ payload,
+ });
+ stats.queued++;
+ setImmediate(() => outbox && outbox.kick());
+ return env;
+}
+
+// ── Builders (read the row as it is after the change) ─────────────────────────────
+const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);
+const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
+
+function pasteCreated(p) {
+ return record('community.paste.created', { type: 'paste', id: p.slug }, {
+ paste_id: p.slug, type: p.type === 'screenshot' ? 'screenshot' : 'paste', origin: ['user', 'ai', 'import'].includes(p.origin) ? p.origin : 'user',
+ visibility: p.visibility || 'public', owner: subjectRef(p.owner_subject), url: pasteUrl(p),
+ }, { isPublic: p.visibility === 'public', actor: actorOf(p.owner_subject) });
+}
+function pasteUpdated(p, changed) {
+ const list = [...new Set(changed)].filter((c) => ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].includes(c));
+ if (!list.length) return null;
+ return record('community.paste.updated', { type: 'paste', id: p.slug, revision: Number(p.revision) || 1 }, {
+ paste_id: p.slug, changed: list, visibility: p.deleted_at ? 'deleted' : (p.visibility || 'public'), revision: Number(p.revision) || 1, url: pasteUrl(p),
+ }, { isPublic: p.visibility === 'public' });
+}
+function pasteDeleted(slug) {
+ return record('community.paste.deleted', { type: 'paste', id: slug }, { paste_id: slug }, { isPublic: true });
+}
+// A thread or post is public only in a public space and outside a members-only thread (staff spaces count as members).
+const forumVisibility = (thread, spaceVisibility) => (thread.members_only_owner || spaceVisibility !== 'public' ? 'members' : 'public');
+function threadCreated(thread, spaceSlug, spaceVisibility = 'public') {
+ const visibility = forumVisibility(thread, spaceVisibility);
+ return record('community.thread.created', { type: 'thread', id: String(thread.id) }, {
+ thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(thread.author_subject), visibility,
+ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}` : null,
+ }, { isPublic: visibility === 'public', actor: actorOf(thread.author_subject) });
+}
+function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
+ const visibility = forumVisibility(thread, spaceVisibility);
+ return record('community.post.created', { type: 'post', id: String(post.id) }, {
+ post_id: Number(post.id), thread_id: Number(thread.id), space: spaceSlug, author: subjectRef(post.author_subject), visibility,
+ url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
+ }, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
+}
+function commentCreated(comment, cthread) {
+ const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
+ return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
+ comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
+ author: subjectRef(comment.author_subject), visibility, url: visibility === 'hidden' ? null : `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}`,
+ }, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
+}
+
+function status() {
+ if (!outbox) return { enabled: false };
+ return { enabled: true, pending: outbox.pending(), rejected: outbox.rejected(), queued_since_boot: stats.queued, last_error: stats.lastError };
+}
+function _reset() { if (outbox) outbox.stop(); outbox = null; stats.queued = 0; stats.lastError = null; }
+
+module.exports = { init, record, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, _reset };
@@ -65,7 +65,10 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
@@ -65,7 +65,10 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
const threadId = info.lastInsertRowid;
const threadId = info.lastInsertRowid;
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
.run(threadId, author_subject, origin, body_markdown);
.run(threadId, author_subject, origin, body_markdown);
- return { thread: getThread(db, threadId), post: getPost(db, p.lastInsertRowid) };
+ const thread = getThread(db, threadId);
+ const space = db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(space_id);
+ require('../events').threadCreated(thread, space ? space.slug : String(space_id), space ? space.visibility : 'members'); // community.thread.created
+ return { thread, post: getPost(db, p.lastInsertRowid) };
})();
})();
}
}
@@ -136,7 +139,11 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_m
@@ -136,7 +139,11 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_m
const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown) VALUES (?, ?, ?, ?)')
const info = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, body_markdown) VALUES (?, ?, ?, ?)')
.run(thread_id, author_subject, origin, body_markdown);
.run(thread_id, author_subject, origin, body_markdown);
db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
- return getPost(db, info.lastInsertRowid);
+ const post = getPost(db, info.lastInsertRowid);
+ const thread = getThread(db, thread_id);
+ const space = thread && db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(thread.space_id);
+ if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space ? space.visibility : 'members'); // community.post.created
+ return post;
})();
})();
}
}
@@ -8,6 +8,8 @@ const config = require('./config');
@@ -8,6 +8,8 @@ const config = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
const app = createApp();
const app = createApp();
+// Community → OpenVibe.Events (server/events.js): off unless EVENTS_URL and the client secret are set.
+try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
console.log(config.pastesAuthority === 'community'
console.log(config.pastesAuthority === 'community'
@@ -126,9 +126,13 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con
@@ -126,9 +126,13 @@ const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'con
/** Insert a new paste (fields already validated); returns the stored row. */
/** Insert a new paste (fields already validated); returns the stored row. */
function insertPaste(db, fields) {
function insertPaste(db, fields) {
const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
- const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
- .run(...cols.map((c) => fields[c]));
- return getById(db, info.lastInsertRowid);
+ return db.transaction(() => {
+ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
+ .run(...cols.map((c) => fields[c]));
+ const row = getById(db, info.lastInsertRowid);
+ require('../events').pasteCreated(row); // community.paste.created, in this transaction
+ return row;
+ })();
}
}
/**
/**
@@ -164,6 +168,8 @@ function updatePaste(db, id, patch, editedBy = null) {
@@ -164,6 +168,8 @@ function updatePaste(db, id, patch, editedBy = null) {
db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
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);
.run(id, next.revision, next.title, next.content, next.language, editedBy);
}
}
+ const changed = ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned'].filter((k) => patch[k] !== undefined && patch[k] !== cur[k]);
+ if (changed.length) require('../events').pasteUpdated(next, changed);
return next;
return next;
})();
})();
}
}
@@ -174,8 +180,13 @@ function listVersions(db, pasteId) {
@@ -174,8 +180,13 @@ function listVersions(db, pasteId) {
/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
function softDelete(db, id) {
function softDelete(db, id) {
- return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
+ return db.transaction(() => {
+ const cur = getById(db, id);
+ const n = db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
+ if (n && cur) require('../events').pasteDeleted(cur.slug); // community.paste.deleted
+ return n;
+ })();
}
}
/**
/**
@@ -301,7 +312,12 @@ function setScreenshot(db, pasteId, url, mediaRef) {
@@ -301,7 +312,12 @@ function setScreenshot(db, pasteId, url, mediaRef) {
}
}
function setVisibility(db, pasteId, visibility) {
function setVisibility(db, pasteId, visibility) {
- return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;
+ return db.transaction(() => {
+ const cur = getById(db, pasteId);
+ const r = db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
+ if (r.changes && cur && cur.visibility !== visibility) require('../events').pasteUpdated(getById(db, pasteId), ['visibility']);
+ return r.changes;
+ })();
}
}
/** Media's admin stats shape. */
/** Media's admin stats shape. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * Community's events: every write queues its community.* event in the same transaction (a failed write
+ * queues nothing), payloads validate against openvibe-contracts and never carry content, and items
+ * that are not public travel as internal events without a URL.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { openDb } = require('../server/db');
+const events = require('../server/events');
+const pastes = require('../server/pastes/store');
+const forum = require('../server/forum/store');
+const comments = require('../server/comments/store');
+
+const db = openDb(':memory:');
+// A relay that never publishes during the test: the rows stay in the outbox for inspection.
+events.init(db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'test-secret', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
+const queued = () => db.prepare("SELECT envelope FROM event_outbox ORDER BY id").all().map((r) => JSON.parse(r.envelope));
+const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ok = (env) => { const r = contracts.validate(`${env.event_type}@1`, env.payload); assert.ok(r.valid, `${env.event_type}: ${JSON.stringify(r.errors)}`); };
+
+let n = 0;
+function check(name, fn) { fn(); n++; console.log(` ✓ ${name}`); }
+
+check('a paste create, edit, visibility change and delete each queue one event, never the content', () => {
+ const p = pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, origin: 'user', type: 'paste', title: 'secret title', content: 'secret body', language: 'text', visibility: 'public' });
+ pastes.updatePaste(db, p.id, { content: 'edited body' }, USR);
+ pastes.setVisibility(db, p.id, 'unlisted');
+ pastes.softDelete(db, p.id);
+ const evs = queued();
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.paste.created', 'community.paste.updated', 'community.paste.updated', 'community.paste.deleted']);
+ evs.forEach(ok);
+ assert.ok(!JSON.stringify(evs).includes('secret'), 'no title or content in any event');
+ assert.strictEqual(evs[0].visibility, 'public');
+ assert.strictEqual(evs[0].payload.url, 'https://openvibe.community/p/amber-fox-42'.replace('https://openvibe.community', require('../server/config').baseUrl));
+ assert.deepStrictEqual(evs[1].payload.changed, ['content']);
+ assert.strictEqual(evs[2].visibility, 'internal', 'an unlisted paste is not a public event');
+ assert.strictEqual(evs[2].payload.url, null);
+ assert.deepStrictEqual(evs[0].actor, { type: 'user', id: USR });
+});
+
+check('a write that fails queues nothing (the event is in its transaction)', () => {
+ const before = queued().length;
+ assert.throws(() => pastes.insertPaste(db, { slug: 'amber-fox-42', owner_subject: USR, type: 'paste', content: 'dup', visibility: 'public' }), /UNIQUE/);
+ assert.strictEqual(queued().length, before);
+});
+
+check('threads and posts: public spaces only are public events; staff spaces are members-only', () => {
+ db.prepare("INSERT OR IGNORE INTO spaces (slug, name, visibility) VALUES ('general', 'General', 'public'), ('staff', 'Staff', 'staff')").run();
+ const general = db.prepare("SELECT id FROM spaces WHERE slug = 'general'").get().id;
+ const staff = db.prepare("SELECT id FROM spaces WHERE slug = 'staff'").get().id;
+ const before = queued().length;
+ const { thread } = forum.createThread(db, { space_id: general, title: 'Hello there', author_subject: USR, body_markdown: 'first post body' });
+ forum.addPost(db, { thread_id: thread.id, author_subject: USR, body_markdown: 'a reply body' });
+ forum.createThread(db, { space_id: staff, title: 'Staff only', author_subject: USR, body_markdown: 'hidden body' });
+ const evs = queued().slice(before);
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.thread.created', 'community.post.created', 'community.thread.created']);
+ evs.forEach(ok);
+ assert.strictEqual(evs[0].visibility, 'public');
+ assert.ok(/\/s\/general\/t\//.test(evs[0].payload.url));
+ assert.strictEqual(evs[2].visibility, 'internal');
+ assert.strictEqual(evs[2].payload.visibility, 'members');
+ assert.strictEqual(evs[2].payload.url, null);
+ assert.ok(!JSON.stringify(evs).includes('body'), 'no post body');
+});
+
+check('a comment on a Live VOD queues community.comment.created with its ref', () => {
+ const before = queued().length;
+ const t = comments.resolveThread(db, { service: 'live', type: 'vod', id: '906' }).thread;
+ comments.insertComment(db, { thread_id: t.id, author_subject: USR, message: 'nice stream' });
+ const evs = queued().slice(before);
+ assert.deepStrictEqual(evs.map((e) => e.event_type), ['community.comment.created']);
+ evs.forEach(ok);
+ assert.deepStrictEqual(evs[0].payload.ref, { service: 'live', type: 'vod', id: '906' });
+ assert.ok(!JSON.stringify(evs).includes('nice stream'));
+});
+
+events._reset();
+console.log(`community events: ${n} checks passed`);
+process.exit(0);