openvibers/openvibe.community Commits
Categories, feature requests and a public Roadmap space (WS-J tasks 1 and 8): categories per space (Feedback: Ideas, Bugs, Questions), filters by category and status, Feedback threads are requests (open to votes, statuses set by staff), the Roadmap space follows docs/roadmap/public.json at every boot (one thread per item, summaries edited in place, replies and votes kept), staff-only roadmap items; API routes, no-JS pages and forms
Changes
@@@ -322,13 +322,23 @@ member for now), `staff` (moderators only; looks missing to everyone else).thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)pin, lock and delete; locked threads take no replies or votes.++- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a++ `discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questions++ categories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`++ only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):++ every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary++ is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.++API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):| Route | What || --- | --- || `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |--| `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |--| `POST /spaces/:space/threads` `{ title, body, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |++| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |++| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |++| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |++| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |++| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` || `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) || `DELETE /spaces/:space/threads/:slug` | Author or moderator || `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |nnew file mode 100644@@@ -0,0 +1,39 @@++{++ "about": "OpenVibe's public roadmap: the plain-language summary of what is being built next, synced into the Roadmap space on openvibe.community at every boot (server/forum/roadmap.js). Edit this file to change the roadmap; each item keeps its thread (by key), so replies and votes stay with it.",++ "items": [++ { "key": "openvibe-chat", "category": "launches", "status": "done", "title": "OpenVibe.Chat, on its own site",++ "summary": "Global chat, direct messages, rooms and your chat settings at [openvibe.chat](https://openvibe.chat), working with or without JavaScript and even while Live is down. Opened on 24 September 2026; rooms followed on 25 September." },++ { "key": "openre-stream", "category": "launches", "status": "in_progress", "title": "OpenRe.Stream: stream once, restream everywhere",++ "summary": "Send your stream to one ingest and let OpenRe.Stream forward it to every platform, with keys, destinations and output health in one place. The service is running; moving Live's stream slots onto it needs the new ingest address and a short switch-over window for each broadcaster." },++ { "key": "openvibe-tips-vip", "category": "launches", "status": "planned", "title": "OpenVibe.Tips and OpenVibe.VIP",++ "summary": "Tips, goals and paid messages for creators, and creator memberships with perks recognised on every site. Both are built; they open once all money moves through the network's billing ledger, so every tip and membership is recorded exactly once." },++ { "key": "openvibe-news-reviews", "category": "launches", "status": "planned", "title": "OpenVibe.News and OpenVibe.Reviews",++ "summary": "Source-backed stories with citations and several perspectives, and reviews gathered with their sources. They open when their source lists have been reviewed: nothing is published without a source." },++ { "key": "openvibe-deals-coupons", "category": "launches", "status": "planned", "title": "OpenVibe.Deals and OpenVibe.Coupons",++ "summary": "Community deals with the source, price and freshness always shown, and coupon codes checked by the people who use them." },++ { "key": "openvibe-trade", "category": "launches", "status": "planned", "title": "OpenVibe.Trade: watchlists and alerts",++ "summary": "Watchlists, sourced market context and alerts. Information only: no custody and no orders." },++ { "key": "openvibe-host", "category": "launches", "status": "planned", "title": "OpenVibe.Host",++ "summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },++ { "key": "chat-calls", "category": "features", "status": "planned", "title": "Voice and video calls in OpenVibe.Chat",++ "summary": "Calls move from Live into OpenVibe.Chat, so rooms and direct messages can turn into a call on any site." },++ { "key": "community-attachments", "category": "features", "status": "planned", "title": "Images and files on Community posts",++ "summary": "Attach images and files to threads and replies, stored in OpenVibe.Media like every other upload." },++ { "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",++ "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },++ { "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",++ "summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },++ { "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",++ "summary": "Set a style, a length and a perspective once on your account, and OpenVibe AI follows them whenever it writes something for you. Turn off \"Keep what I ask AI\" and it keeps neither your request nor a reusable copy of the answer." },++ { "key": "favourite-tools", "category": "features", "status": "done", "title": "Favourite tools on every device",++ "summary": "Star a tool on openvibe.tools or on your account page, and it leads your tools everywhere you sign in." },++ { "key": "sign-out-everywhere", "category": "platform", "status": "done", "title": "Sign out everywhere means everywhere",++ "summary": "Signing out everywhere, changing your password or a ban now reaches every OpenVibe site within seconds: older sign-ins stop working and open chat and game connections close." },++ { "key": "channel-renames", "category": "platform", "status": "done", "title": "Rename your channel without breaking links",++ "summary": "When you change your username, links to your old channel address forward to the new one, and nobody else can take your old name for six months." },++ { "key": "media-reliability", "category": "platform", "status": "in_progress", "title": "Every VOD and clip checked and recoverable",++ "summary": "Recordings and clips are stored in more than one place and checked regularly. Work in progress: showing a video only once its bytes are verified, holds that keep evidence from being deleted, and a regular report of stray files." },++ { "key": "discord-relay", "category": "platform", "status": "planned", "title": "Community threads on Discord, and back",++ "summary": "New threads are already announced on Discord. Next: replies both ways, edits and deletions kept in step, with no loops." }++ ]++}@@@ -167,6 +167,22 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-.pulse-source { flex: none; margin-top: 2px; }.badge-ai { border-color: var(--info); color: var(--info); }.badge-vis { font-size: .7rem; vertical-align: middle; }++/* Categories, requests and the roadmap (forum pages) */++.badge-status, .badge-category { font-size: .7rem; vertical-align: middle; }++.badge-category { color: var(--text-secondary); }++.badge-category:hover { border-color: var(--accent); text-decoration: none; }++.status-planned, .status-open { border-color: var(--info); color: var(--info); }++.status-in_progress { border-color: var(--warning); color: var(--warning); }++.status-done { border-color: var(--success); color: var(--success); }++.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }++.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }++.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }++.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: .85rem; font-weight: 600; }++.chip:hover { border-color: var(--accent); text-decoration: none; }++.chip.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }++.chip.active .muted { color: inherit; opacity: .8; }++.inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }.thread-head { display: flex; gap: 16px; align-items: flex-start; }.thread-head h1 { word-break: break-word; }.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }@@@ -245,6 +245,18 @@ CREATE TABLE IF NOT EXISTS thread_votes (-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never-- live in the database.++-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.++CREATE TABLE IF NOT EXISTS categories (++ id INTEGER PRIMARY KEY AUTOINCREMENT,++ space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,++ slug TEXT NOT NULL,++ name TEXT NOT NULL,++ description TEXT,++ position INTEGER NOT NULL DEFAULT 0,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ UNIQUE (space_id, slug)++);++CREATE TABLE IF NOT EXISTS relay_mappings (id INTEGER PRIMARY KEY AUTOINCREMENT,space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,@@@ -358,6 +370,34 @@ function migrate(db) {const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);}++ // Categories, feature requests and the roadmap (WS-J tasks 1 and 8; forum/service.js):++ // spaces.thread_kind what a new thread in the space is: discussion, request (votes and a status set++ // by staff) or roadmap (staff and the roadmap sync only; forum/roadmap.js)++ // threads.kind/status/category_id/external_key the thread's kind, its status for requests and roadmap++ // items, its category, and the roadmap item key it was synced from++ const spaceCols = new Set(db.prepare('PRAGMA table_info(spaces)').all().map((x) => x.name));++ if (!spaceCols.has('thread_kind')) db.exec("ALTER TABLE spaces ADD COLUMN thread_kind TEXT NOT NULL DEFAULT 'discussion'");++ const threadCols = new Set(db.prepare('PRAGMA table_info(threads)').all().map((x) => x.name));++ if (!threadCols.has('kind')) db.exec("ALTER TABLE threads ADD COLUMN kind TEXT NOT NULL DEFAULT 'discussion'");++ if (!threadCols.has('status')) db.exec('ALTER TABLE threads ADD COLUMN status TEXT');++ if (!threadCols.has('category_id')) db.exec('ALTER TABLE threads ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');++ if (!threadCols.has('external_key')) db.exec('ALTER TABLE threads ADD COLUMN external_key TEXT');++ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;++ CREATE INDEX IF NOT EXISTS idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;++ INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, thread_kind) VALUES++ ('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');++ UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'++ WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);++ const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)++ SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);++ for (const [space, slug, name, description, position] of [++ ['feedback', 'ideas', 'Ideas', 'Something new, or something better.', 1],++ ['feedback', 'bugs', 'Bugs', 'Something is broken or wrong.', 2],++ ['feedback', 'questions', 'Questions', 'How do I…? Why does…?', 3],++ ['roadmap', 'launches', 'New sites', 'Sites that open next.', 1],++ ['roadmap', 'features', 'Features', 'New things on sites that are already open.', 2],++ ['roadmap', 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3],++ ]) seedCategory.run(slug, name, description, position, space);}/** cth_ + 22 base64url characters (16 random bytes). */@@@ -16,6 +16,12 @@* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread++ * GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)++ * PUT /spaces/:space/categories/:category { name, description?, position? } moderators++ * DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)++ * PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators++ * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,++ * roadmap items planned|in_progress|done|paused (?status= filters)* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions** Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and@@@ -41,6 +47,11 @@ function createSpacesApi({ forum, viewers }) {router.get('/', run((req) => forum.listSpaces(req.viewer)));router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));++ router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));++ router.put('/:space/categories/:category', serviceCap(MOD), jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));++ router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));++ router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));++ router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++++/**++ * The Roadmap space (WS-J task 8): OpenVibe's public roadmap summary (docs/roadmap/public.json) as one++ * thread per item, written by the site itself (origin 'system', shown as OpenVibe). Every boot syncs the++ * file: a new item becomes a thread; a changed title, status, category or summary updates the item's++ * thread (the summary as an edit of its opening post, so the history stays); an item that left the file++ * keeps its thread. Items are matched by key (threads.external_key), so replies and votes stay with them.++ */++const fs = require('fs');++const path = require('path');++const store = require('./store');++const { STATUSES } = require('./service');++++const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');++const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;++++/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */++function syncRoadmap(db, items, { log = console } = {}) {++ const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };++ const space = store.getSpace(db, 'roadmap');++ if (!space) return out;++ for (const item of Array.isArray(items) ? items : []) {++ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();++ const summary = String((item && item.summary) || '').trim();++ if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !summary || !STATUSES.roadmap.includes(item.status)) {++ out.skipped++;++ log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);++ continue;++ }++ const category = item.category ? store.getCategory(db, space.id, item.category) : null;++ const categoryId = category ? category.id : null;++ const existing = store.getThreadByKey(db, space.id, item.key);++ if (!existing) {++ store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });++ out.created++;++ continue;++ }++ let changed = false;++ if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {++ db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);++ changed = true;++ }++ const opening = db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);++ if (opening && opening.body_markdown !== summary) { store.editPost(db, opening.id, summary, 'system'); changed = true; }++ if (changed) out.updated++; else out.unchanged++;++ }++ return out;++}++++/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */++function syncFromFile(db, { file = FILE, log = console } = {}) {++ let doc;++ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }++ return syncRoadmap(db, doc.items, { log });++}++++module.exports = { syncRoadmap, syncFromFile, FILE };@@@ -64,7 +64,7 @@ function createForumRoutes({ forum, viewers, config }) {router.get('/s/:space', withViewer, wrap(async (req, res, next) => {try {-- const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page });++ const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');html(res, forumPages.spacePage({ ...out, user: req.user }));} catch (err) { failPage(req, res, err, next); }@@@ -73,15 +73,17 @@ function createForumRoutes({ forum, viewers, config }) {router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {try {// A members-only space: the teaser, not the form, for someone who may not post there.-- await forum.listThreads(req.viewer, req.params.space, { limit: 1 });++ const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });++ if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');const { space } = await forum.space(req.viewer, req.params.space);-- html(res, forumPages.newThreadPage({ space, user: req.user }));++ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories }));} catch (err) { failPage(req, res, err, next); }}));router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };if ((req.body || {}).members_only === '1') values.members_only = true;++ if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);try {const out = await forum.createThread(req.viewer, req.params.space, values);seo.resetCaches();@@@ -90,7 +92,8 @@ function createForumRoutes({ forum, viewers, config }) {if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);try {const { space } = await forum.space(req.viewer, req.params.space);-- html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);++ const { categories } = forum.categories(req.viewer, req.params.space);++ html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories }), err.status);} catch (e) { failPage(req, res, e, next); }}}));@@@ -150,6 +153,21 @@ function createForumRoutes({ forum, viewers, config }) {} catch (err) { failPage(req, res, err, next); }}));++ // Status (moderators) and category (the author or moderators), no JS.++ router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });++ res.redirect(303, back(req));++ } catch (err) { failPage(req, res, err, next); }++ }));++++ router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {++ try {++ await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });++ res.redirect(303, back(req));++ } catch (err) { failPage(req, res, err, next); }++ }));++router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {try {forum.deleteThread(req.viewer, req.params.space, req.params.slug);@@@ -38,6 +38,18 @@ const { renderMarkdown } = require('../render/markdown');const { isUserSubject } = require('../vip');const THREADS_PER_PAGE = 25;++/**++ * What a thread is (spaces.thread_kind decides it for new threads) and the statuses it can have:++ * discussion no status++ * request a feature request, bug or question (Feedback): open → planned → in_progress → done, or declined; staff set it++ * roadmap a roadmap item (the Roadmap space; staff and the roadmap sync start them): planned, in_progress, done, paused++ */++const STATUSES = Object.freeze({++ request: ['open', 'planned', 'in_progress', 'done', 'declined'],++ roadmap: ['planned', 'in_progress', 'done', 'paused'],++});++const FIRST_STATUS = { request: 'open', roadmap: 'planned' };++const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;const POSTS_PER_PAGE = 50;const TITLE_MIN = 3, TITLE_MAX = 200;const BODY_MAX = 40_000;@@@ -148,11 +160,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi// ── shapes ───────────────────────────────────────────────const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;++ const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);++function shapeThread(t, space, v, projections, votes) {const mo = t.members_only_owner || null;const p = mo && projections ? projections.get(mo) : null;return {id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),++ kind: t.kind || 'discussion', status: t.status || null,++ category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,@@@ -184,6 +200,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vifunction shapeSpace(s, mo = null) {return {slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,++ thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,thread_count: s.thread_count != null ? s.thread_count : undefined,last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,@@@ -234,20 +251,75 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };},-- /** A page of threads. ?sort=hot|new|top&page= */++ /** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */async listThreads(v, spaceSlug, q = {}) {const space = spaceFor(v, spaceSlug);await requireMembership(v, space);const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';const page = Math.max(parseInt(q.page, 10) || 1, 1);const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);-- const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });++ const category = q.category ? store.getCategory(db, space.id, q.category) : null;++ if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');++ const statuses = STATUSES[space.thread_kind] || [];++ if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');++ const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });return {space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),++ categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,++ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v) },threads: await shapeThreads(rows, () => space, v),};},++ /** The categories of a space. */++ categories(v, spaceSlug) {++ const space = spaceFor(v, spaceSlug);++ return { space: space.slug, categories: store.listCategories(db, space.id).map(shapeCategory) };++ },++++ /** Create or change a category { slug, name, description?, position? } — moderators. */++ putCategory(v, spaceSlug, slug, body = {}) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');++ const space = spaceFor(v, spaceSlug);++ if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');++ const name = cleanTitle(body.name);++ if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');++ const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;++ const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;++ return { category: shapeCategory(store.upsertCategory(db, space.id, { slug, name, description, position })) };++ },++++ /** Delete a category — moderators. Its threads stay, without a category. */++ deleteCategory(v, spaceSlug, slug) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');++ const space = spaceFor(v, spaceSlug);++ if (!store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');++ return { ok: true };++ },++++ /** Move a thread to a category { category: slug | null } — its author or a moderator. */++ async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');++ await requireMembership(v, space, thread);++ const category = body.category ? store.getCategory(db, space.id, body.category) : null;++ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');++ const next = store.setThreadCategory(db, thread.id, category ? category.id : null);++ const projections = await authors.projectionsFor([next.author_subject]);++ return { thread: shapeThread(next, space, v, projections, null) };++ },++++ /** A request's or roadmap item's status { status } — moderators. */++ async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {++ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');++ const { space, thread } = threadFor(v, spaceSlug, threadSlug);++ const statuses = STATUSES[thread.kind] || [];++ if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');++ const next = store.setThreadStatus(db, thread.id, body.status);++ const projections = await authors.projectionsFor([next.author_subject]);++ return { thread: shapeThread(next, space, v, projections, null) };++ },++/** A thread with a page of its posts. ?page= */async getThread(v, spaceSlug, threadSlug, q = {}) {const { space, thread } = threadFor(v, spaceSlug, threadSlug);@@@ -260,6 +332,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vispace: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),thread: shapeThread(thread, space, v, projections, votes),posts: rows.map((p) => shapePost(p, v, projections)),++ categories: store.listCategories(db, space.id).map(shapeCategory),page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,viewer: {signed_in: person(v),@@@ -282,6 +355,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vimayPostIn(v, space);await requireMembership(v, space);const gate = gateOwner(v, body.members_only, w.author);++ const kind = space.thread_kind || 'discussion';++ if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');++ const category = body.category ? store.getCategory(db, space.id, body.category) : null;++ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');const title = cleanTitle(body.title);if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);const text = cleanBody(body.body != null ? body.body : body.body_markdown);@@@ -289,7 +366,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vithreadLimiter.check(w.key, title);if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);}-- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate });++ const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,++ kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });threadLimiter.record(w.key, title);if (pulse) hook(() => pulse.threadCreated(thread, space));if (relay) hook(() => relay.enqueueThread(thread, space));@@@ -429,4 +507,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi};}--module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE };++module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES };@@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {}/** New thread + its opening post, in one transaction. → { thread, post } */--function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null }) {++function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null }) {return db.transaction(() => {const slug = uniqueSlug(db, space_id, slugify(title));-- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner) VALUES (?, ?, ?, ?, ?, ?)')-- .run(space_id, slug, title, author_subject, origin, members_only_owner);++ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')++ .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key);const 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);@@@ -79,19 +79,68 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us* top highest score first* → { rows, total }*/--function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date() } = {}) {++function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);const order = {hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',new: 'pinned DESC, created_at DESC, id DESC',top: 'pinned DESC, score DESC, created_at DESC, id DESC',}[SORTS.includes(sort) ? sort : 'hot'];-- const rows = db.prepare(`SELECT * FROM threads WHERE space_id = @space AND deleted_at IS NULL ORDER BY ${order} LIMIT @limit OFFSET @offset`)-- .all({ space: spaceId, now: nowSql, limit, offset });-- const { total } = db.prepare('SELECT COUNT(*) AS total FROM threads WHERE space_id = ? AND deleted_at IS NULL').get(spaceId);++ const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';++ const params = { space: spaceId, category: categoryId, status };++ const rows = db.prepare(`SELECT * FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)++ .all({ ...params, now: nowSql, limit, offset });++ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);return { rows, total };}++// ── Categories (WS-J task 1) ─────────────────────────────────++++function listCategories(db, spaceId) {++ return db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count++ FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE`).all(spaceId);++}++++function getCategory(db, spaceId, slug) {++ return db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;++}++++function getCategoryById(db, id) {++ return id ? db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;++}++++function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {++ db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)++ ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)++ .run(spaceId, slug, name, description, position);++ return getCategory(db, spaceId, slug);++}++++/** Threads keep their place; they just lose the category. */++function deleteCategory(db, spaceId, slug) {++ return db.transaction(() => {++ const c = getCategory(db, spaceId, slug);++ if (!c) return false;++ db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);++ db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);++ return true;++ })();++}++++function setThreadCategory(db, id, categoryId) {++ db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);++ return getThread(db, id);++}++++function setThreadStatus(db, id, status) {++ db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);++ return getThread(db, id);++}++++function getThreadByKey(db, spaceId, key) {++ return db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;++}++/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id@@@ -196,5 +245,6 @@ module.exports = {SORTS, slugify,listSpaces, getSpace, getSpaceById,getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,++ listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,};@@@ -24,6 +24,7 @@ function createAuthors({ db, network = null }) {*/function author(subject, origin, projections) {if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };++ if (origin === 'system' && !subject) return { subject: null, username: null, display_name: 'OpenVibe', avatar_url: null, profile_color: null, is_system: true };if (!subject) return null;const p = projections.get(subject);return {@@@ -10,6 +10,11 @@ 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); }++// The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).++try {++ const r = require('./forum/roadmap').syncFromFile(require('./db').getDb());++ if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);++} catch (err) { console.warn('[Roadmap] not synced:', 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'@@@ -13,16 +13,23 @@ const { markdownToText } = require('./markdown');const { timeTag, fmtDate, num, authorHtml } = require('./pages');const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };++const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };++/** A request's or roadmap item's status, and a thread's category. */++const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');++const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };function who(a) {if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;++ if (a.is_system) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span>`;return authorHtml(a);}--function spaceHref(slug, { sort = 'hot', page = 1 } = {}) {++function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = null } = {}) {const q = new URLSearchParams();++ if (category) q.set('category', category);++ if (status) q.set('status', status);if (sort && sort !== 'hot') q.set('sort', sort);if (page > 1) q.set('page', String(page));const s = q.toString();@@@ -76,32 +83,42 @@ function threadRow(t, space) {return `<li class="thread-row"><span class="thread-score" title="Score">${num(t.score)}</span><div class="thread-main">-- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${vipBadge(t)}++ <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p></div></li>`;}--function spacePage({ space, threads, sort, page, pages, total, user }) {-- const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');-- const indexable = space.visibility === 'public' && !space.members_only;++function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {} }) {++ const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');++ const filtered = !!(category || status);++ const indexable = space.visibility === 'public' && !space.members_only && !filtered;++ const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;++ const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';++ const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';++ const canStart = viewer.can_start !== false;++ const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';++ const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`++ : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;++ const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;const body = `<header class="page-head"><nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav><h1>${esc(space.name)}${visBadge(space)}</h1>${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}-- <p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New thread</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>++ ${start}</header>++${categoryNav}${statusNav}<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav><section data-results>-- ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>`}-- ${pager((n) => spaceHref(space.slug, { sort, page: n }), page, pages)}++ ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}++ ${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}</section>`;return renderPage({-- title: `${space.name} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,++ title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,-- canonicalPath: spaceHref(space.slug, { sort, page }),-- robots: indexable ? 'index,follow' : 'noindex,nofollow',++ canonicalPath: spaceHref(space.slug, { sort, page, category, status }),++ robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',active: 'spaces',feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],@@@ -127,7 +144,7 @@ function voteForm(base, thread, viewer) {return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;}--function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {++function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [] }) {const base = `/s/${space.slug}/t/${thread.slug}`;const opening = posts.find((p) => p.is_opening) || null;const gated = !!(space.members_only || thread.members_only);@@@ -146,6 +163,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u<form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}++ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}++ ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}</div>` : '';const body = `@@@ -155,6 +174,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u${voteForm(base, thread, viewer)}<div><h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>++ ${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p></div></header>@@@ -186,10 +206,13 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u}// ── /s/:space/new ────────────────────────────────────────────--function newThreadPage({ space, user, values = {}, error = null }) {++function newThreadPage({ space, user, values = {}, error = null, categories = [] }) {++ const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new">${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}++ ${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>++ ${categoryField}<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label><label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label><div class="form-actions">nnew file mode 100644@@@ -0,0 +1,138 @@++'use strict';++/**++ * Categories, feature requests and the Roadmap space (WS-J tasks 1 and 8): seeded categories, filtering++ * by category and status, a request's first status and staff-only status changes, category moves by the++ * author or staff, staff-only roadmap threads, the roadmap sync from docs/roadmap/public.json (create,++ * update in place with the history kept, unchanged, bad items skipped) and the no-JS pages.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');++++(async () => {++ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });++ const call = (path, { method = 'GET', cookie, json, form } = {}) => {++ const h = {};++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }++ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++++ await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {++ const spaces = (await call('/api/v1/spaces')).json().spaces;++ assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['feedback', 'request'], ['showcase', 'discussion'], ['roadmap', 'roadmap']]);++ assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);++ const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;++ assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);++ });++++ let req;++ await check('a request starts open, in its category; unknown categories are refused', async () => {++ let r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Dark mode for clips', body: 'Please.', category: 'ideas' } });++ assert.strictEqual(r.status, 201, r.text);++ req = r.json().thread;++ assert.strictEqual(req.kind, 'request'); assert.strictEqual(req.status, 'open');++ assert.deepStrictEqual([req.category.slug, req.category.name], ['ideas', 'Ideas']);++ r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Player crashes', body: 'On Safari.', category: 'bugs' } });++ assert.strictEqual(r.status, 201);++ r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Nope', body: 'x', category: 'nope' } });++ assert.strictEqual(r.status, 404); assert.strictEqual(r.json().code, 'category.not_found');++ const plain = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Just talk', body: 'hi' } })).json().thread;++ assert.strictEqual(plain.kind, 'discussion'); assert.strictEqual(plain.status, null);++ });++++ await check('filters: by category and by status; a bad status is 400', async () => {++ let r = (await call('/api/v1/spaces/feedback/threads?category=bugs')).json();++ assert.deepStrictEqual(r.threads.map((x) => x.title), ['Player crashes']);++ assert.strictEqual(r.categories.find((c) => c.slug === 'bugs').thread_count, 1);++ r = (await call('/api/v1/spaces/feedback/threads?status=open')).json();++ assert.strictEqual(r.total, 2);++ assert.strictEqual((await call('/api/v1/spaces/feedback/threads?status=shipped')).status, 400);++ assert.strictEqual((await call('/api/v1/spaces/general/threads?status=open')).status, 400, 'discussions have no status');++ });++++ await check('status: staff only, from the kind\'s list', async () => {++ let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: alexJwt, json: { status: 'done' } });++ assert.strictEqual(r.status, 403);++ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'paused' } });++ assert.strictEqual(r.status, 400, 'paused is a roadmap status');++ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'planned' } });++ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().thread.status, 'planned');++ assert.deepStrictEqual((await call('/api/v1/spaces/feedback/threads?status=planned')).json().threads.map((x) => x.slug), [req.slug]);++ });++++ await check('category: the author or staff move a thread; others cannot; deleting a category keeps its threads', async () => {++ let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: samJwt, json: { category: 'bugs' } });++ assert.strictEqual(r.status, 403);++ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: alexJwt, json: { category: 'questions' } });++ assert.strictEqual(r.json().thread.category.slug, 'questions');++ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: alexJwt, json: { name: 'Polls' } });++ assert.strictEqual(r.status, 403, 'only staff manage categories');++ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: modJwt, json: { name: 'Polls', position: 9 } });++ assert.strictEqual(r.status, 200, r.text);++ await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: modJwt, json: { category: 'polls' } });++ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'DELETE', cookie: modJwt });++ assert.strictEqual(r.status, 200);++ const thread = (await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread;++ assert.strictEqual(thread.category, null); assert.strictEqual(thread.status, 'planned');++ });++++ await check('roadmap: people cannot start items; staff can', async () => {++ let r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: alexJwt, json: { title: 'My idea', body: 'x' } });++ assert.strictEqual(r.status, 403); assert.strictEqual(r.json().code, 'space.staff_threads');++ r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: modJwt, json: { title: 'A staff item', body: 'x', category: 'features' } });++ assert.strictEqual(r.status, 201); assert.strictEqual(r.json().thread.status, 'planned');++ });++++ await check('roadmap sync: creates, updates in place (history kept), leaves unchanged items alone, skips bad ones', async () => {++ const db = t.db;++ const items = [++ { key: 'chat-calls', category: 'features', status: 'planned', title: 'Calls in Chat', summary: 'Calls move to Chat.' },++ { key: 'bad key!', status: 'planned', title: 'x', summary: 'y' },++ { key: 'nostatus', title: 'No status', summary: 'y' },++ ];++ const quiet = { warn() {} };++ assert.deepStrictEqual(syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });++ let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');++ assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');++ assert.deepStrictEqual(syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });++ const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];++ assert.deepStrictEqual(syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });++ const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();++ assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');++ assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');++ const file = syncFromFile(db, { log: quiet });++ assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));++ assert.deepStrictEqual(syncFromFile(db, { log: quiet }).updated, 0, 'the shipped file syncs to a fixed point');++ });++++ await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {++ let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });++ assert.strictEqual(r.status, 200);++ const html = r.text;++ assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));++ assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');++ r = await call('/s/roadmap', { cookie: alexJwt });++ assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));++ assert.ok(r.text.includes('badge-status status-in_progress'));++ r = await call('/s/roadmap/new', { cookie: alexJwt });++ assert.strictEqual(r.status, 403);++ r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });++ assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));++ r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');++ r = await call('/s/feedback/new', { cookie: alexJwt });++ assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));++ });++++ await done(t);++})();@@@ -33,10 +33,10 @@ const forumStore = require('../server/forum/store');let first;-- await check('seeded spaces: general, feedback, showcase — all public', async () => {++ await check('seeded spaces: general, feedback, showcase, roadmap — all public', async () => {const r = await call('/api/v1/spaces');assert.strictEqual(r.status, 200);-- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public']]);++ assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public'], ['roadmap', 'public']]);assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);});
@@ -322,13 +322,23 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -322,13 +322,23 @@ member for now), `staff` (moderators only; looks missing to everyone else).
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
thread). Moderators (admin/global_mod browsers; services with `community.comment.moderate`)
pin, lock and delete; locked threads take no replies or votes.
pin, lock and delete; locked threads take no replies or votes.
+- **Categories, requests and the roadmap.** A space's `thread_kind` decides what a new thread is. In a
+ `discussion` space it is a plain thread. In `request` (Feedback, with Ideas, Bugs and Questions
+ categories) it is open to votes and starts `open`; staff move it through its statuses. In `roadmap`
+ only staff start items. The Roadmap space follows [`docs/roadmap/public.json`](docs/roadmap/public.json):
+ every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary
+ is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.
+
API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| Route | What |
| Route | What |
| --- | --- |
| --- | --- |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
-| `GET /spaces/:space/threads?sort=&page=&limit=` | Threads (server pagination: `page`, `pages`, `total`) |
-| `POST /spaces/:space/threads` `{ title, body, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
+| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |
+| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
+| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |
+| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
+| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `GET /spaces/:space/threads/:slug?page=` | Thread + posts (`body_markdown` and rendered `body_html`) |
| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
| `DELETE /spaces/:space/threads/:slug` | Author or moderator |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
| `POST /spaces/:space/threads/:slug/posts` `{ body }` | Reply |
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+{
+ "about": "OpenVibe's public roadmap: the plain-language summary of what is being built next, synced into the Roadmap space on openvibe.community at every boot (server/forum/roadmap.js). Edit this file to change the roadmap; each item keeps its thread (by key), so replies and votes stay with it.",
+ "items": [
+ { "key": "openvibe-chat", "category": "launches", "status": "done", "title": "OpenVibe.Chat, on its own site",
+ "summary": "Global chat, direct messages, rooms and your chat settings at [openvibe.chat](https://openvibe.chat), working with or without JavaScript and even while Live is down. Opened on 24 September 2026; rooms followed on 25 September." },
+ { "key": "openre-stream", "category": "launches", "status": "in_progress", "title": "OpenRe.Stream: stream once, restream everywhere",
+ "summary": "Send your stream to one ingest and let OpenRe.Stream forward it to every platform, with keys, destinations and output health in one place. The service is running; moving Live's stream slots onto it needs the new ingest address and a short switch-over window for each broadcaster." },
+ { "key": "openvibe-tips-vip", "category": "launches", "status": "planned", "title": "OpenVibe.Tips and OpenVibe.VIP",
+ "summary": "Tips, goals and paid messages for creators, and creator memberships with perks recognised on every site. Both are built; they open once all money moves through the network's billing ledger, so every tip and membership is recorded exactly once." },
+ { "key": "openvibe-news-reviews", "category": "launches", "status": "planned", "title": "OpenVibe.News and OpenVibe.Reviews",
+ "summary": "Source-backed stories with citations and several perspectives, and reviews gathered with their sources. They open when their source lists have been reviewed: nothing is published without a source." },
+ { "key": "openvibe-deals-coupons", "category": "launches", "status": "planned", "title": "OpenVibe.Deals and OpenVibe.Coupons",
+ "summary": "Community deals with the source, price and freshness always shown, and coupon codes checked by the people who use them." },
+ { "key": "openvibe-trade", "category": "launches", "status": "planned", "title": "OpenVibe.Trade: watchlists and alerts",
+ "summary": "Watchlists, sourced market context and alerts. Information only: no custody and no orders." },
+ { "key": "openvibe-host", "category": "launches", "status": "planned", "title": "OpenVibe.Host",
+ "summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },
+ { "key": "chat-calls", "category": "features", "status": "planned", "title": "Voice and video calls in OpenVibe.Chat",
+ "summary": "Calls move from Live into OpenVibe.Chat, so rooms and direct messages can turn into a call on any site." },
+ { "key": "community-attachments", "category": "features", "status": "planned", "title": "Images and files on Community posts",
+ "summary": "Attach images and files to threads and replies, stored in OpenVibe.Media like every other upload." },
+ { "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",
+ "summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },
+ { "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
+ "summary": "The AI & Data tab on your account lists what OpenVibe sites keep with your account, and lets you download it or delete any of it. Next: one export with everything from every site, and account deletion with a grace period." },
+ { "key": "ai-preferences", "category": "features", "status": "done", "title": "AI that writes the way you ask",
+ "summary": "Set a style, a length and a perspective once on your account, and OpenVibe AI follows them whenever it writes something for you. Turn off \"Keep what I ask AI\" and it keeps neither your request nor a reusable copy of the answer." },
+ { "key": "favourite-tools", "category": "features", "status": "done", "title": "Favourite tools on every device",
+ "summary": "Star a tool on openvibe.tools or on your account page, and it leads your tools everywhere you sign in." },
+ { "key": "sign-out-everywhere", "category": "platform", "status": "done", "title": "Sign out everywhere means everywhere",
+ "summary": "Signing out everywhere, changing your password or a ban now reaches every OpenVibe site within seconds: older sign-ins stop working and open chat and game connections close." },
+ { "key": "channel-renames", "category": "platform", "status": "done", "title": "Rename your channel without breaking links",
+ "summary": "When you change your username, links to your old channel address forward to the new one, and nobody else can take your old name for six months." },
+ { "key": "media-reliability", "category": "platform", "status": "in_progress", "title": "Every VOD and clip checked and recoverable",
+ "summary": "Recordings and clips are stored in more than one place and checked regularly. Work in progress: showing a video only once its bytes are verified, holds that keep evidence from being deleted, and a regular report of stray files." },
+ { "key": "discord-relay", "category": "platform", "status": "planned", "title": "Community threads on Discord, and back",
+ "summary": "New threads are already announced on Discord. Next: replies both ways, edits and deletions kept in step, with no loops." }
+ ]
+}
@@ -167,6 +167,22 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -167,6 +167,22 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.pulse-source { flex: none; margin-top: 2px; }
.pulse-source { flex: none; margin-top: 2px; }
.badge-ai { border-color: var(--info); color: var(--info); }
.badge-ai { border-color: var(--info); color: var(--info); }
.badge-vis { font-size: .7rem; vertical-align: middle; }
.badge-vis { font-size: .7rem; vertical-align: middle; }
+/* Categories, requests and the roadmap (forum pages) */
+.badge-status, .badge-category { font-size: .7rem; vertical-align: middle; }
+.badge-category { color: var(--text-secondary); }
+.badge-category:hover { border-color: var(--accent); text-decoration: none; }
+.status-planned, .status-open { border-color: var(--info); color: var(--info); }
+.status-in_progress { border-color: var(--warning); color: var(--warning); }
+.status-done { border-color: var(--success); color: var(--success); }
+.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }
+.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
+.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
+.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: .85rem; font-weight: 600; }
+.chip:hover { border-color: var(--accent); text-decoration: none; }
+.chip.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
+.chip.active .muted { color: inherit; opacity: .8; }
+.inline-form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.thread-head { display: flex; gap: 16px; align-items: flex-start; }
.thread-head { display: flex; gap: 16px; align-items: flex-start; }
.thread-head h1 { word-break: break-word; }
.thread-head h1 { word-break: break-word; }
.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }
.vote { display: flex; flex-direction: column; align-items: center; gap: 2px; min-width: 2.8em; margin: 0; }
@@ -245,6 +245,18 @@ CREATE TABLE IF NOT EXISTS thread_votes (
@@ -245,6 +245,18 @@ CREATE TABLE IF NOT EXISTS thread_votes (
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- live in the database.
-- live in the database.
+-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
+CREATE TABLE IF NOT EXISTS categories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
+ slug TEXT NOT NULL,
+ name TEXT NOT NULL,
+ description TEXT,
+ position INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (space_id, slug)
+);
+
CREATE TABLE IF NOT EXISTS relay_mappings (
CREATE TABLE IF NOT EXISTS relay_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
space_id INTEGER NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
@@ -358,6 +370,34 @@ function migrate(db) {
@@ -358,6 +370,34 @@ function migrate(db) {
const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));
const c = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((x) => x.name));
if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);
if (!c.has('members_only_owner')) db.exec(`ALTER TABLE ${table} ADD COLUMN members_only_owner TEXT`);
}
}
+ // Categories, feature requests and the roadmap (WS-J tasks 1 and 8; forum/service.js):
+ // spaces.thread_kind what a new thread in the space is: discussion, request (votes and a status set
+ // by staff) or roadmap (staff and the roadmap sync only; forum/roadmap.js)
+ // threads.kind/status/category_id/external_key the thread's kind, its status for requests and roadmap
+ // items, its category, and the roadmap item key it was synced from
+ const spaceCols = new Set(db.prepare('PRAGMA table_info(spaces)').all().map((x) => x.name));
+ if (!spaceCols.has('thread_kind')) db.exec("ALTER TABLE spaces ADD COLUMN thread_kind TEXT NOT NULL DEFAULT 'discussion'");
+ const threadCols = new Set(db.prepare('PRAGMA table_info(threads)').all().map((x) => x.name));
+ if (!threadCols.has('kind')) db.exec("ALTER TABLE threads ADD COLUMN kind TEXT NOT NULL DEFAULT 'discussion'");
+ if (!threadCols.has('status')) db.exec('ALTER TABLE threads ADD COLUMN status TEXT');
+ if (!threadCols.has('category_id')) db.exec('ALTER TABLE threads ADD COLUMN category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL');
+ if (!threadCols.has('external_key')) db.exec('ALTER TABLE threads ADD COLUMN external_key TEXT');
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_external_key ON threads(space_id, external_key) WHERE external_key IS NOT NULL;
+ CREATE INDEX IF NOT EXISTS idx_threads_category ON threads(category_id) WHERE category_id IS NOT NULL;
+ INSERT OR IGNORE INTO spaces (slug, name, description, visibility, created_by, thread_kind) VALUES
+ ('roadmap', 'Roadmap', 'What OpenVibe is building next and where each piece stands. Every item has its own thread: ask about it or argue for it there.', 'public', 'system', 'roadmap');
+ UPDATE spaces SET thread_kind = 'request', description = 'Feature requests, bugs and ideas for every OpenVibe site. Vote for what matters to you; staff mark what is planned and done.'
+ WHERE slug = 'feedback' AND thread_kind = 'discussion' AND created_by = 'system';`);
+ const seedCategory = db.prepare(`INSERT OR IGNORE INTO categories (space_id, slug, name, description, position)
+ SELECT id, ?, ?, ?, ? FROM spaces WHERE slug = ?`);
+ for (const [space, slug, name, description, position] of [
+ ['feedback', 'ideas', 'Ideas', 'Something new, or something better.', 1],
+ ['feedback', 'bugs', 'Bugs', 'Something is broken or wrong.', 2],
+ ['feedback', 'questions', 'Questions', 'How do I…? Why does…?', 3],
+ ['roadmap', 'launches', 'New sites', 'Sites that open next.', 1],
+ ['roadmap', 'features', 'Features', 'New things on sites that are already open.', 2],
+ ['roadmap', 'platform', 'Under the hood', 'Accounts, safety, reliability and the shared systems every site uses.', 3],
+ ]) seedCategory.run(slug, name, description, position, space);
}
}
/** cth_ + 22 base64url characters (16 random bytes). */
/** cth_ + 22 base64url characters (16 random bytes). */
@@ -16,6 +16,12 @@
@@ -16,6 +16,12 @@
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
+ * GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)
+ * PUT /spaces/:space/categories/:category { name, description?, position? } moderators
+ * DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
+ * PUT /spaces/:space/threads/:slug/category { category: slug | null } the author or moderators
+ * PUT /spaces/:space/threads/:slug/status { status } moderators: requests open|planned|in_progress|done|declined,
+ * roadmap items planned|in_progress|done|paused (?status= filters)
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
* PUT /posts/:id { body } DELETE /posts/:id GET /posts/:id/versions
*
*
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
* Services write with community.post.create (as X-OV-Subject, or as AI with X-OV-Origin: ai) and
@@ -41,6 +47,11 @@ function createSpacesApi({ forum, viewers }) {
@@ -41,6 +47,11 @@ function createSpacesApi({ forum, viewers }) {
router.get('/', run((req) => forum.listSpaces(req.viewer)));
router.get('/', run((req) => forum.listSpaces(req.viewer)));
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
+ router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
+ router.put('/:space/categories/:category', serviceCap(MOD), jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
+ router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
+ router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
+ router.put('/:space/threads/:slug/status', serviceCap(MOD), jsonBody, run((req) => forum.setThreadStatus(req.viewer, p(req).space, p(req).slug, req.body || {})));
router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.post('/:space/threads', write, jsonBody, run((req) => forum.createThread(req.viewer, p(req).space, req.body || {}), 201));
router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.get('/:space/threads/:slug', run((req) => forum.getThread(req.viewer, p(req).space, p(req).slug, req.query)));
router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
router.delete('/:space/threads/:slug', writeOrMod, run((req) => forum.deleteThread(req.viewer, p(req).space, p(req).slug)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+
+/**
+ * The Roadmap space (WS-J task 8): OpenVibe's public roadmap summary (docs/roadmap/public.json) as one
+ * thread per item, written by the site itself (origin 'system', shown as OpenVibe). Every boot syncs the
+ * file: a new item becomes a thread; a changed title, status, category or summary updates the item's
+ * thread (the summary as an edit of its opening post, so the history stays); an item that left the file
+ * keeps its thread. Items are matched by key (threads.external_key), so replies and votes stay with them.
+ */
+const fs = require('fs');
+const path = require('path');
+const store = require('./store');
+const { STATUSES } = require('./service');
+
+const FILE = path.join(__dirname, '..', '..', 'docs', 'roadmap', 'public.json');
+const KEY = /^[a-z0-9][a-z0-9-]{1,59}$/;
+
+/** items: [{ key, title, status, category?, summary }] → { created, updated, unchanged, skipped } */
+function syncRoadmap(db, items, { log = console } = {}) {
+ const out = { created: 0, updated: 0, unchanged: 0, skipped: 0 };
+ const space = store.getSpace(db, 'roadmap');
+ if (!space) return out;
+ for (const item of Array.isArray(items) ? items : []) {
+ const title = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
+ const summary = String((item && item.summary) || '').trim();
+ if (!item || !KEY.test(String(item.key || '')) || title.length < 3 || title.length > 200 || !summary || !STATUSES.roadmap.includes(item.status)) {
+ out.skipped++;
+ log.warn && log.warn(`[Roadmap] skipped an item that is not { key, title, status, summary }: ${JSON.stringify(item && item.key)}`);
+ continue;
+ }
+ const category = item.category ? store.getCategory(db, space.id, item.category) : null;
+ const categoryId = category ? category.id : null;
+ const existing = store.getThreadByKey(db, space.id, item.key);
+ if (!existing) {
+ store.createThread(db, { space_id: space.id, title, origin: 'system', body_markdown: summary, kind: 'roadmap', status: item.status, category_id: categoryId, external_key: item.key });
+ out.created++;
+ continue;
+ }
+ let changed = false;
+ if (existing.title !== title || existing.status !== item.status || (existing.category_id || null) !== categoryId) {
+ db.prepare('UPDATE threads SET title = ?, status = ?, category_id = ? WHERE id = ?').run(title, item.status, categoryId, existing.id);
+ changed = true;
+ }
+ const opening = db.prepare('SELECT id, body_markdown FROM posts WHERE thread_id = ? AND is_opening = 1').get(existing.id);
+ if (opening && opening.body_markdown !== summary) { store.editPost(db, opening.id, summary, 'system'); changed = true; }
+ if (changed) out.updated++; else out.unchanged++;
+ }
+ return out;
+}
+
+/** Sync from docs/roadmap/public.json (or `file`). A missing or broken file changes nothing. */
+function syncFromFile(db, { file = FILE, log = console } = {}) {
+ let doc;
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { log.warn && log.warn(`[Roadmap] ${file}: ${err.message}`); return null; }
+ return syncRoadmap(db, doc.items, { log });
+}
+
+module.exports = { syncRoadmap, syncFromFile, FILE };
@@ -64,7 +64,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -64,7 +64,7 @@ function createForumRoutes({ forum, viewers, config }) {
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
router.get('/s/:space', withViewer, wrap(async (req, res, next) => {
try {
try {
- const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page });
+ const out = await forum.listThreads(req.viewer, req.params.space, { sort: req.query.sort, page: req.query.page, category: req.query.category, status: req.query.status });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
html(res, forumPages.spacePage({ ...out, user: req.user }));
html(res, forumPages.spacePage({ ...out, user: req.user }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
@@ -73,15 +73,17 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -73,15 +73,17 @@ function createForumRoutes({ forum, viewers, config }) {
router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
router.get('/s/:space/new', withViewer, wrap(async (req, res, next) => {
try {
try {
// A members-only space: the teaser, not the form, for someone who may not post there.
// A members-only space: the teaser, not the form, for someone who may not post there.
- await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
+ const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
+ if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
- html(res, forumPages.newThreadPage({ space, user: req.user }));
+ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories }));
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).members_only === '1') values.members_only = true;
+ if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
try {
try {
const out = await forum.createThread(req.viewer, req.params.space, values);
const out = await forum.createThread(req.viewer, req.params.space, values);
seo.resetCaches();
seo.resetCaches();
@@ -90,7 +92,8 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -90,7 +92,8 @@ function createForumRoutes({ forum, viewers, config }) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
try {
try {
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
- html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message }), err.status);
+ const { categories } = forum.categories(req.viewer, req.params.space);
+ html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories }), err.status);
} catch (e) { failPage(req, res, e, next); }
} catch (e) { failPage(req, res, e, next); }
}
}
}));
}));
@@ -150,6 +153,21 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -150,6 +153,21 @@ function createForumRoutes({ forum, viewers, config }) {
} catch (err) { failPage(req, res, err, next); }
} catch (err) { failPage(req, res, err, next); }
}));
}));
+ // Status (moderators) and category (the author or moderators), no JS.
+ router.post('/s/:space/t/:slug/status', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.setThreadStatus(req.viewer, req.params.space, req.params.slug, { status: String((req.body || {}).status || '') });
+ res.redirect(303, back(req));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
+ router.post('/s/:space/t/:slug/category', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
+ try {
+ await forum.setThreadCategory(req.viewer, req.params.space, req.params.slug, { category: String((req.body || {}).category || '') || null });
+ res.redirect(303, back(req));
+ } catch (err) { failPage(req, res, err, next); }
+ }));
+
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {
router.post('/s/:space/t/:slug/delete', withViewer, sameOrigin, form, (req, res, next) => {
try {
try {
forum.deleteThread(req.viewer, req.params.space, req.params.slug);
forum.deleteThread(req.viewer, req.params.space, req.params.slug);
@@ -38,6 +38,18 @@ const { renderMarkdown } = require('../render/markdown');
@@ -38,6 +38,18 @@ const { renderMarkdown } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
const THREADS_PER_PAGE = 25;
const THREADS_PER_PAGE = 25;
+/**
+ * What a thread is (spaces.thread_kind decides it for new threads) and the statuses it can have:
+ * discussion no status
+ * request a feature request, bug or question (Feedback): open → planned → in_progress → done, or declined; staff set it
+ * roadmap a roadmap item (the Roadmap space; staff and the roadmap sync start them): planned, in_progress, done, paused
+ */
+const STATUSES = Object.freeze({
+ request: ['open', 'planned', 'in_progress', 'done', 'declined'],
+ roadmap: ['planned', 'in_progress', 'done', 'paused'],
+});
+const FIRST_STATUS = { request: 'open', roadmap: 'planned' };
+const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
const POSTS_PER_PAGE = 50;
const POSTS_PER_PAGE = 50;
const TITLE_MIN = 3, TITLE_MAX = 200;
const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
@@ -148,11 +160,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -148,11 +160,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// ── shapes ───────────────────────────────────────────────
// ── shapes ───────────────────────────────────────────────
const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
const threadUrl = (space, t) => `/s/${space.slug}/t/${t.slug}`;
+ const shapeCategory = (c) => (c ? { slug: c.slug, name: c.name, description: c.description || null, position: c.position, thread_count: c.thread_count != null ? c.thread_count : undefined } : null);
+
function shapeThread(t, space, v, projections, votes) {
function shapeThread(t, space, v, projections, votes) {
const mo = t.members_only_owner || null;
const mo = t.members_only_owner || null;
const p = mo && projections ? projections.get(mo) : null;
const p = mo && projections ? projections.get(mo) : null;
return {
return {
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
id: t.id, space: space.slug, slug: t.slug, title: t.title, url: threadUrl(space, t),
+ kind: t.kind || 'discussion', status: t.status || null,
+ category: t.category_id ? shapeCategory(store.getCategoryById(db, t.category_id)) : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
members_only: mo ? { owner: mo, owner_username: p && p.username ? p.username : null, join_url: vip ? vip.joinUrl(mo, p && p.username) : null } : null,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
author: authors.author(t.author_subject, t.origin, projections), origin: t.origin,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
pinned: !!t.pinned, locked: !!t.locked, score: t.score, reply_count: t.reply_count,
@@ -184,6 +200,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -184,6 +200,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
function shapeSpace(s, mo = null) {
function shapeSpace(s, mo = null) {
return {
return {
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
slug: s.slug, name: s.name, description: s.description, visibility: s.visibility, url: `/s/${s.slug}`,
+ thread_kind: s.thread_kind || 'discussion', statuses: STATUSES[s.thread_kind] || [],
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
members_only: s.members_only_owner ? (mo || { owner: s.members_only_owner, owner_username: null, join_url: vip ? vip.joinUrl(s.members_only_owner) : null }) : null,
thread_count: s.thread_count != null ? s.thread_count : undefined,
thread_count: s.thread_count != null ? s.thread_count : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
last_activity_at: s.last_activity_at !== undefined ? isoTime(s.last_activity_at) : undefined,
@@ -234,20 +251,75 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -234,20 +251,75 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
return { space: shapeSpace(s, await membersOnly(s.members_only_owner)) };
},
},
- /** A page of threads. ?sort=hot|new|top&page= */
+ /** A page of threads. ?sort=hot|new|top&page=&category=<slug>&status=<status> */
async listThreads(v, spaceSlug, q = {}) {
async listThreads(v, spaceSlug, q = {}) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
await requireMembership(v, space);
await requireMembership(v, space);
const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
const sort = store.SORTS.includes(q.sort) ? q.sort : 'hot';
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const page = Math.max(parseInt(q.page, 10) || 1, 1);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
const perPage = Math.min(Math.max(parseInt(q.limit, 10) || THREADS_PER_PAGE, 1), 100);
- const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date() });
+ const category = q.category ? store.getCategory(db, space.id, q.category) : null;
+ if (q.category && !category) fail(404, 'category.not_found', 'No such category in this space');
+ const statuses = STATUSES[space.thread_kind] || [];
+ if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this space have no status');
+ const { rows, total } = store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
return {
return {
space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
space: shapeSpace(space, await membersOnly(space.members_only_owner)), sort, page, per_page: perPage, total, pages: Math.max(Math.ceil(total / perPage), 1),
+ categories: store.listCategories(db, space.id).map(shapeCategory), category: category ? category.slug : null, status: q.status || null,
+ viewer: { can_start: space.thread_kind !== 'roadmap' || moderator(v), can_moderate: moderator(v) },
threads: await shapeThreads(rows, () => space, v),
threads: await shapeThreads(rows, () => space, v),
};
};
},
},
+ /** The categories of a space. */
+ categories(v, spaceSlug) {
+ const space = spaceFor(v, spaceSlug);
+ return { space: space.slug, categories: store.listCategories(db, space.id).map(shapeCategory) };
+ },
+
+ /** Create or change a category { slug, name, description?, position? } — moderators. */
+ putCategory(v, spaceSlug, slug, body = {}) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
+ const space = spaceFor(v, spaceSlug);
+ if (!CATEGORY_SLUG.test(String(slug || ''))) fail(400, 'category.invalid_slug', 'A category slug is 1 to 40 lowercase letters, digits and dashes');
+ const name = cleanTitle(body.name);
+ if (name.length < 2 || name.length > 40) fail(400, 'category.invalid_name', 'Category names are 2 to 40 characters');
+ const description = body.description == null ? null : cleanTitle(body.description).slice(0, 200) || null;
+ const position = Number.isInteger(Number(body.position)) ? Number(body.position) : 0;
+ return { category: shapeCategory(store.upsertCategory(db, space.id, { slug, name, description, position })) };
+ },
+
+ /** Delete a category — moderators. Its threads stay, without a category. */
+ deleteCategory(v, spaceSlug, slug) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators manage categories');
+ const space = spaceFor(v, spaceSlug);
+ if (!store.deleteCategory(db, space.id, slug)) fail(404, 'category.not_found', 'No such category in this space');
+ return { ok: true };
+ },
+
+ /** Move a thread to a category { category: slug | null } — its author or a moderator. */
+ async setThreadCategory(v, spaceSlug, threadSlug, body = {}) {
+ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ if (!(person(v) && thread.author_subject === v.subject) && !moderator(v)) fail(403, 'thread.not_yours', 'Only the author or a moderator changes the category');
+ await requireMembership(v, space, thread);
+ const category = body.category ? store.getCategory(db, space.id, body.category) : null;
+ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
+ const next = store.setThreadCategory(db, thread.id, category ? category.id : null);
+ const projections = await authors.projectionsFor([next.author_subject]);
+ return { thread: shapeThread(next, space, v, projections, null) };
+ },
+
+ /** A request's or roadmap item's status { status } — moderators. */
+ async setThreadStatus(v, spaceSlug, threadSlug, body = {}) {
+ if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a status');
+ const { space, thread } = threadFor(v, spaceSlug, threadSlug);
+ const statuses = STATUSES[thread.kind] || [];
+ if (!statuses.includes(body.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'This thread has no status');
+ const next = store.setThreadStatus(db, thread.id, body.status);
+ const projections = await authors.projectionsFor([next.author_subject]);
+ return { thread: shapeThread(next, space, v, projections, null) };
+ },
+
/** A thread with a page of its posts. ?page= */
/** A thread with a page of its posts. ?page= */
async getThread(v, spaceSlug, threadSlug, q = {}) {
async getThread(v, spaceSlug, threadSlug, q = {}) {
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
const { space, thread } = threadFor(v, spaceSlug, threadSlug);
@@ -260,6 +332,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -260,6 +332,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
thread: shapeThread(thread, space, v, projections, votes),
thread: shapeThread(thread, space, v, projections, votes),
posts: rows.map((p) => shapePost(p, v, projections)),
posts: rows.map((p) => shapePost(p, v, projections)),
+ categories: store.listCategories(db, space.id).map(shapeCategory),
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
viewer: {
signed_in: person(v),
signed_in: person(v),
@@ -282,6 +355,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -282,6 +355,10 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space);
await requireMembership(v, space);
const gate = gateOwner(v, body.members_only, w.author);
const gate = gateOwner(v, body.members_only, w.author);
+ const kind = space.thread_kind || 'discussion';
+ if (kind === 'roadmap' && !moderator(v)) fail(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
+ const category = body.category ? store.getCategory(db, space.id, body.category) : null;
+ if (body.category && !category) fail(404, 'category.not_found', 'No such category in this space');
const title = cleanTitle(body.title);
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
@@ -289,7 +366,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -289,7 +366,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
threadLimiter.check(w.key, title);
threadLimiter.check(w.key, title);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
}
}
- const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate });
+ const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
+ kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
@@ -429,4 +507,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -429,4 +507,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
};
}
}
-module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE };
+module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES };
@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {
@@ -57,11 +57,11 @@ function getThreadBySlug(db, spaceId, slug) {
}
}
/** New thread + its opening post, in one transaction. → { thread, post } */
/** New thread + its opening post, in one transaction. → { thread, post } */
-function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null }) {
+function createThread(db, { space_id, title, author_subject = null, origin = 'user', body_markdown, members_only_owner = null, kind = 'discussion', status = null, category_id = null, external_key = null }) {
return db.transaction(() => {
return db.transaction(() => {
const slug = uniqueSlug(db, space_id, slugify(title));
const slug = uniqueSlug(db, space_id, slugify(title));
- const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner) VALUES (?, ?, ?, ?, ?, ?)')
- .run(space_id, slug, title, author_subject, origin, members_only_owner);
+ const info = db.prepare('INSERT INTO threads (space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(space_id, slug, title, author_subject, origin, members_only_owner, kind, status, category_id, external_key);
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);
@@ -79,19 +79,68 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
@@ -79,19 +79,68 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
* top highest score first
* top highest score first
* → { rows, total }
* → { rows, total }
*/
*/
-function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date() } = {}) {
+function listThreads(db, spaceId, { sort = 'hot', limit = 25, offset = 0, now = new Date(), categoryId = null, status = null } = {}) {
const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
const nowSql = new Date(now).toISOString().replace('T', ' ').slice(0, 19);
const order = {
const order = {
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
hot: 'pinned DESC, ov_hot(score, (julianday(@now) - julianday(created_at)) * 24) DESC, last_activity_at DESC, id DESC',
new: 'pinned DESC, created_at DESC, id DESC',
new: 'pinned DESC, created_at DESC, id DESC',
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
top: 'pinned DESC, score DESC, created_at DESC, id DESC',
}[SORTS.includes(sort) ? sort : 'hot'];
}[SORTS.includes(sort) ? sort : 'hot'];
- const rows = db.prepare(`SELECT * FROM threads WHERE space_id = @space AND deleted_at IS NULL ORDER BY ${order} LIMIT @limit OFFSET @offset`)
- .all({ space: spaceId, now: nowSql, limit, offset });
- const { total } = db.prepare('SELECT COUNT(*) AS total FROM threads WHERE space_id = ? AND deleted_at IS NULL').get(spaceId);
+ const where = 'space_id = @space AND deleted_at IS NULL AND (@category IS NULL OR category_id = @category) AND (@status IS NULL OR status = @status)';
+ const params = { space: spaceId, category: categoryId, status };
+ const rows = db.prepare(`SELECT * FROM threads WHERE ${where} ORDER BY ${order} LIMIT @limit OFFSET @offset`)
+ .all({ ...params, now: nowSql, limit, offset });
+ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM threads WHERE ${where}`).get(params);
return { rows, total };
return { rows, total };
}
}
+// ── Categories (WS-J task 1) ─────────────────────────────────
+
+function listCategories(db, spaceId) {
+ return db.prepare(`SELECT c.*, (SELECT COUNT(*) FROM threads t WHERE t.category_id = c.id AND t.deleted_at IS NULL) AS thread_count
+ FROM categories c WHERE c.space_id = ? ORDER BY c.position, c.name COLLATE NOCASE`).all(spaceId);
+}
+
+function getCategory(db, spaceId, slug) {
+ return db.prepare('SELECT * FROM categories WHERE space_id = ? AND slug = ?').get(spaceId, String(slug)) || null;
+}
+
+function getCategoryById(db, id) {
+ return id ? db.prepare('SELECT * FROM categories WHERE id = ?').get(id) || null : null;
+}
+
+function upsertCategory(db, spaceId, { slug, name, description = null, position = 0 }) {
+ db.prepare(`INSERT INTO categories (space_id, slug, name, description, position) VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT (space_id, slug) DO UPDATE SET name = excluded.name, description = excluded.description, position = excluded.position`)
+ .run(spaceId, slug, name, description, position);
+ return getCategory(db, spaceId, slug);
+}
+
+/** Threads keep their place; they just lose the category. */
+function deleteCategory(db, spaceId, slug) {
+ return db.transaction(() => {
+ const c = getCategory(db, spaceId, slug);
+ if (!c) return false;
+ db.prepare('UPDATE threads SET category_id = NULL WHERE category_id = ?').run(c.id);
+ db.prepare('DELETE FROM categories WHERE id = ?').run(c.id);
+ return true;
+ })();
+}
+
+function setThreadCategory(db, id, categoryId) {
+ db.prepare('UPDATE threads SET category_id = ? WHERE id = ?').run(categoryId, id);
+ return getThread(db, id);
+}
+
+function setThreadStatus(db, id, status) {
+ db.prepare('UPDATE threads SET status = ? WHERE id = ?').run(status, id);
+ return getThread(db, id);
+}
+
+function getThreadByKey(db, spaceId, key) {
+ return db.prepare('SELECT * FROM threads WHERE space_id = ? AND external_key = ?').get(spaceId, String(key)) || null;
+}
+
/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
/** Latest threads across spaces of the given visibilities (sitemap, feeds); members-only ones never. */
function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
function recentThreads(db, { visibilities = ['public'], limit = 50, spaceSlug = null } = {}) {
return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
return db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
@@ -196,5 +245,6 @@ module.exports = {
@@ -196,5 +245,6 @@ module.exports = {
SORTS, slugify,
SORTS, slugify,
listSpaces, getSpace, getSpaceById,
listSpaces, getSpace, getSpaceById,
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
getThread, getThreadBySlug, createThread, listThreads, recentThreads, setThreadFlags, setThreadMembersOnly, setSpaceMembersOnly, softDeleteThread, countThreadsSince,
+ listCategories, getCategory, getCategoryById, upsertCategory, deleteCategory, setThreadCategory, setThreadStatus, getThreadByKey,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
getPost, addPost, listPosts, editPost, listPostVersions, softDeletePost, countPostsSince,
};
};
@@ -24,6 +24,7 @@ function createAuthors({ db, network = null }) {
@@ -24,6 +24,7 @@ function createAuthors({ db, network = null }) {
*/
*/
function author(subject, origin, projections) {
function author(subject, origin, projections) {
if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };
if (origin === 'ai') return { subject: null, username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null, is_ai: true };
+ if (origin === 'system' && !subject) return { subject: null, username: null, display_name: 'OpenVibe', avatar_url: null, profile_color: null, is_system: true };
if (!subject) return null;
if (!subject) return null;
const p = projections.get(subject);
const p = projections.get(subject);
return {
return {
@@ -10,6 +10,11 @@ const { createApp } = require('./app');
@@ -10,6 +10,11 @@ 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.
// 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); }
try { require('./events').init(require('./db').getDb()); } catch (err) { console.warn('[Events] not started:', err.message); }
+// The Roadmap space follows docs/roadmap/public.json (server/forum/roadmap.js).
+try {
+ const r = require('./forum/roadmap').syncFromFile(require('./db').getDb());
+ if (r && (r.created || r.updated)) console.log(`[Roadmap] ${r.created} item(s) added, ${r.updated} updated`);
+} catch (err) { console.warn('[Roadmap] not synced:', 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'
@@ -13,16 +13,23 @@ const { markdownToText } = require('./markdown');
@@ -13,16 +13,23 @@ const { markdownToText } = require('./markdown');
const { timeTag, fmtDate, num, authorHtml } = require('./pages');
const { timeTag, fmtDate, num, authorHtml } = require('./pages');
const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
const SORT_LABELS = { hot: 'Hot', new: 'New', top: 'Top' };
+const STATUS_LABELS = { open: 'Open', planned: 'Planned', in_progress: 'In progress', done: 'Done', declined: 'Declined', paused: 'Paused' };
+/** A request's or roadmap item's status, and a thread's category. */
+const statusBadge = (t) => (t && t.status ? ` <span class="badge badge-status status-${esc(t.status)}">${esc(STATUS_LABELS[t.status] || t.status)}</span>` : '');
+const categoryBadge = (t, space) => (t && t.category ? ` <a class="badge badge-category" href="${esc(spaceHref(space.slug, { category: t.category.slug }))}">${esc(t.category.name)}</a>` : '');
const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };
const VIS_LABELS = { members: 'Members only', staff: 'Staff only' };
function who(a) {
function who(a) {
if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';
if (!a) return '<span class="author"><span class="avatar avatar-letter" aria-hidden="true">?</span><span>Anonymous</span></span>';
if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;
if (a.is_ai) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span> <span class="badge badge-ai" title="Written by AI, not by a person">AI</span>`;
+ if (a.is_system) return `<span class="author">${authorHtml({ display_name: a.display_name }, { link: false })}</span>`;
return authorHtml(a);
return authorHtml(a);
}
}
-function spaceHref(slug, { sort = 'hot', page = 1 } = {}) {
+function spaceHref(slug, { sort = 'hot', page = 1, category = null, status = null } = {}) {
const q = new URLSearchParams();
const q = new URLSearchParams();
+ if (category) q.set('category', category);
+ if (status) q.set('status', status);
if (sort && sort !== 'hot') q.set('sort', sort);
if (sort && sort !== 'hot') q.set('sort', sort);
if (page > 1) q.set('page', String(page));
if (page > 1) q.set('page', String(page));
const s = q.toString();
const s = q.toString();
@@ -76,32 +83,42 @@ function threadRow(t, space) {
@@ -76,32 +83,42 @@ function threadRow(t, space) {
return `<li class="thread-row">
return `<li class="thread-row">
<span class="thread-score" title="Score">${num(t.score)}</span>
<span class="thread-score" title="Score">${num(t.score)}</span>
<div class="thread-main">
<div class="thread-main">
- <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${vipBadge(t)}
+ <a class="thread-title" href="/s/${esc(space.slug)}/t/${esc(t.slug)}">${flags}${esc(t.title)}</a>${statusBadge(t)}${categoryBadge(t, space)}${vipBadge(t)}
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
<p class="thread-meta">${who(t.author)} <span class="sep">·</span> ${timeTag(t.created_at)} <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(t.reply_count)} ${t.reply_count === 1 ? 'reply' : 'replies'}</span>${t.reply_count ? ` <span class="sep">·</span> active ${timeTag(t.last_activity_at)}` : ''}</p>
</div>
</div>
</li>`;
</li>`;
}
}
-function spacePage({ space, threads, sort, page, pages, total, user }) {
- const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
- const indexable = space.visibility === 'public' && !space.members_only;
+function spacePage({ space, threads, sort, page, pages, total, user, categories = [], category = null, status = null, viewer = {} }) {
+ const tabs = Object.keys(SORT_LABELS).map((s) => `<a class="tab${s === sort ? ' active' : ''}" href="${esc(spaceHref(space.slug, { sort: s, category, status }))}"${s === sort ? ' aria-current="page"' : ''}>${SORT_LABELS[s]}</a>`).join('');
+ const filtered = !!(category || status);
+ const indexable = space.visibility === 'public' && !space.members_only && !filtered;
+ const chip = (label, href, on) => `<a class="chip${on ? ' active' : ''}" href="${esc(href)}"${on ? ' aria-current="true"' : ''}>${label}</a>`;
+ const categoryNav = categories.length ? `<nav class="chips" aria-label="Categories">${chip('All', spaceHref(space.slug, { sort, status }), !category)}${categories.map((c) => chip(`${esc(c.name)} <span class="muted">${num(c.thread_count || 0)}</span>`, spaceHref(space.slug, { sort, status, category: c.slug }), c.slug === category)).join('')}</nav>` : '';
+ const statusNav = (space.statuses || []).length ? `<nav class="chips" aria-label="Status">${chip('Any status', spaceHref(space.slug, { sort, category }), !status)}${space.statuses.map((st) => chip(esc(STATUS_LABELS[st] || st), spaceHref(space.slug, { sort, category, status: st }), st === status)).join('')}</nav>` : '';
+ const canStart = viewer.can_start !== false;
+ const startLabel = space.thread_kind === 'request' ? 'New request' : space.thread_kind === 'roadmap' ? 'New roadmap item' : 'New thread';
+ const start = canStart ? `<p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> ${startLabel}</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>`
+ : `<p class="muted small">Staff add roadmap items. Open one to ask about it or argue for it, or suggest something new in <a href="/s/feedback">Feedback</a>.</p>`;
+ const categoryName = category ? (categories.find((c) => c.slug === category) || {}).name : null;
const body = `
const body = `
<header class="page-head">
<header class="page-head">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/s">Spaces</a> › <span aria-current="page">${esc(space.name)}</span></nav>
<h1>${esc(space.name)}${visBadge(space)}</h1>
<h1>${esc(space.name)}${visBadge(space)}</h1>
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
${space.description ? `<p class="muted">${esc(space.description)}</p>` : ''}
- <p><a class="btn btn-primary" href="/s/${esc(space.slug)}/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New thread</a>${user ? '' : ' <span class="muted small">Sign in with your OpenVibe account to post.</span>'}</p>
+ ${start}
</header>
</header>
+${categoryNav}${statusNav}
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<nav class="tabs sort-tabs" aria-label="Sort threads">${tabs}</nav>
<section data-results>
<section data-results>
- ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>`}
- ${pager((n) => spaceHref(space.slug, { sort, page: n }), page, pages)}
+ ${threads.length ? `<ol class="thread-list">${threads.map((t) => threadRow(t, space)).join('')}</ol>` : filtered ? `<p class="empty">Nothing here yet. <a href="${esc(spaceHref(space.slug))}">See everything in ${esc(space.name)}</a>.</p>` : canStart ? `<p class="empty">No threads yet — <a href="/s/${esc(space.slug)}/new">start the first one</a>.</p>` : '<p class="empty">Nothing here yet.</p>'}
+ ${pager((n) => spaceHref(space.slug, { sort, page: n, category, status }), page, pages)}
</section>`;
</section>`;
return renderPage({
return renderPage({
- title: `${space.name} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
+ title: `${space.name}${categoryName ? ` · ${categoryName}` : ''}${status ? ` · ${STATUS_LABELS[status] || status}` : ''} — ${SORT_LABELS[sort].toLowerCase()} threads${page > 1 ? ` (page ${page})` : ''}`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
description: `${space.description || `Threads in ${space.name}`} ${num(total)} ${total === 1 ? 'thread' : 'threads'} on OpenVibe.Community.`,
- canonicalPath: spaceHref(space.slug, { sort, page }),
- robots: indexable ? 'index,follow' : 'noindex,nofollow',
+ canonicalPath: spaceHref(space.slug, { sort, page, category, status }),
+ robots: indexable ? 'index,follow' : filtered && space.visibility === 'public' && !space.members_only ? 'noindex,follow' : 'noindex,nofollow',
active: 'spaces',
active: 'spaces',
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
feeds: indexable ? [{ title: `OpenVibe.Community — ${space.name}`, href: `/s/${space.slug}/feed.xml` }] : [],
jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
jsonLd: [seo.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Spaces', url: '/s' }, { name: space.name, url: `/s/${space.slug}` }])],
@@ -127,7 +144,7 @@ function voteForm(base, thread, viewer) {
@@ -127,7 +144,7 @@ function voteForm(base, thread, viewer) {
return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
}
}
-function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '' }) {
+function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [] }) {
const base = `/s/${space.slug}/t/${thread.slug}`;
const base = `/s/${space.slug}/t/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const gated = !!(space.members_only || thread.members_only);
@@ -146,6 +163,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -146,6 +163,8 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
<form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
<form method="post" action="${esc(base)}/state"><input type="hidden" name="locked" value="${thread.locked ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-lock" aria-hidden="true"></i> ${thread.locked ? 'Unlock' : 'Lock'}</button></form>` : ''}
${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
${viewer.can_delete ? `<form method="post" action="${esc(base)}/delete"><button class="btn btn-sm btn-danger" type="submit"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete thread</button></form>` : ''}
${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
${viewer.can_gate ? `<form method="post" action="${esc(base)}/members-only"><input type="hidden" name="on" value="${thread.members_only ? 0 : 1}"><button class="btn btn-sm" type="submit"><i class="fa-solid fa-star" aria-hidden="true"></i> ${thread.members_only ? 'Open to everyone' : 'VIP members only'}</button></form>` : ''}
+ ${viewer.can_moderate && (space.statuses || []).length && thread.status ? `<form class="inline-form" method="post" action="${esc(base)}/status"><label class="sr-only" for="thread-status">Status</label><select id="thread-status" name="status">${space.statuses.map((st) => `<option value="${esc(st)}"${st === thread.status ? ' selected' : ''}>${esc(STATUS_LABELS[st] || st)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set status</button></form>` : ''}
+ ${categories.length && (viewer.can_moderate || viewer.can_delete) ? `<form class="inline-form" method="post" action="${esc(base)}/category"><label class="sr-only" for="thread-category">Category</label><select id="thread-category" name="category"><option value="">No category</option>${categories.map((c) => `<option value="${esc(c.slug)}"${thread.category && thread.category.slug === c.slug ? ' selected' : ''}>${esc(c.name)}</option>`).join('')}</select><button class="btn btn-sm" type="submit">Set category</button></form>` : ''}
</div>` : '';
</div>` : '';
const body = `
const body = `
@@ -155,6 +174,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -155,6 +174,7 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
${voteForm(base, thread, viewer)}
${voteForm(base, thread, viewer)}
<div>
<div>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
<h1>${thread.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned" aria-label="Pinned"></i> ' : ''}${thread.locked ? '<i class="fa-solid fa-lock" title="Locked" aria-label="Locked"></i> ' : ''}${esc(thread.title)}${vipBadge(thread)}</h1>
+ ${thread.status || thread.category ? `<p class="thread-tags">${statusBadge(thread)}${categoryBadge(thread, space)}</p>` : ''}
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
<p class="paste-meta">${who(thread.author)} <span class="sep">·</span> <time datetime="${esc(thread.created_at || '')}">${esc(fmtDate(thread.created_at))}</time> <span class="sep">·</span> <span class="stat"><i class="fa-solid fa-comment" aria-hidden="true"></i> ${num(thread.reply_count)} ${thread.reply_count === 1 ? 'reply' : 'replies'}</span></p>
</div>
</div>
</header>
</header>
@@ -186,10 +206,13 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
@@ -186,10 +206,13 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
}
}
// ── /s/:space/new ────────────────────────────────────────────
// ── /s/:space/new ────────────────────────────────────────────
-function newThreadPage({ space, user, values = {}, error = null }) {
+function newThreadPage({ space, user, values = {}, error = null, categories = [] }) {
+ const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new">
const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
+ ${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
+ ${categoryField}
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<div class="form-actions">
<div class="form-actions">
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+'use strict';
+/**
+ * Categories, feature requests and the Roadmap space (WS-J tasks 1 and 8): seeded categories, filtering
+ * by category and status, a request's first status and staff-only status changes, category moves by the
+ * author or staff, staff-only roadmap threads, the roadmap sync from docs/roadmap/public.json (create,
+ * update in place with the history kept, unchanged, bad items skipped) and the no-JS pages.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+const { syncRoadmap, syncFromFile } = require('../server/forum/roadmap');
+
+(async () => {
+ const t = await boot({ authority: 'community', appOpts: { forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+ const modJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'global_mod' });
+ const call = (path, { method = 'GET', cookie, json, form } = {}) => {
+ const h = {};
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
+ return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+
+ await check('seeded: Feedback takes requests with ideas/bugs/questions; Roadmap is public and staff-started', async () => {
+ const spaces = (await call('/api/v1/spaces')).json().spaces;
+ assert.deepStrictEqual(spaces.map((s) => [s.slug, s.thread_kind]), [['general', 'discussion'], ['feedback', 'request'], ['showcase', 'discussion'], ['roadmap', 'roadmap']]);
+ assert.deepStrictEqual(spaces.find((s) => s.slug === 'feedback').statuses, ['open', 'planned', 'in_progress', 'done', 'declined']);
+ const cats = (await call('/api/v1/spaces/feedback/categories')).json().categories;
+ assert.deepStrictEqual(cats.map((c) => c.slug), ['ideas', 'bugs', 'questions']);
+ });
+
+ let req;
+ await check('a request starts open, in its category; unknown categories are refused', async () => {
+ let r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: alexJwt, json: { title: 'Dark mode for clips', body: 'Please.', category: 'ideas' } });
+ assert.strictEqual(r.status, 201, r.text);
+ req = r.json().thread;
+ assert.strictEqual(req.kind, 'request'); assert.strictEqual(req.status, 'open');
+ assert.deepStrictEqual([req.category.slug, req.category.name], ['ideas', 'Ideas']);
+ r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Player crashes', body: 'On Safari.', category: 'bugs' } });
+ assert.strictEqual(r.status, 201);
+ r = await call('/api/v1/spaces/feedback/threads', { method: 'POST', cookie: samJwt, json: { title: 'Nope', body: 'x', category: 'nope' } });
+ assert.strictEqual(r.status, 404); assert.strictEqual(r.json().code, 'category.not_found');
+ const plain = (await call('/api/v1/spaces/general/threads', { method: 'POST', cookie: samJwt, json: { title: 'Just talk', body: 'hi' } })).json().thread;
+ assert.strictEqual(plain.kind, 'discussion'); assert.strictEqual(plain.status, null);
+ });
+
+ await check('filters: by category and by status; a bad status is 400', async () => {
+ let r = (await call('/api/v1/spaces/feedback/threads?category=bugs')).json();
+ assert.deepStrictEqual(r.threads.map((x) => x.title), ['Player crashes']);
+ assert.strictEqual(r.categories.find((c) => c.slug === 'bugs').thread_count, 1);
+ r = (await call('/api/v1/spaces/feedback/threads?status=open')).json();
+ assert.strictEqual(r.total, 2);
+ assert.strictEqual((await call('/api/v1/spaces/feedback/threads?status=shipped')).status, 400);
+ assert.strictEqual((await call('/api/v1/spaces/general/threads?status=open')).status, 400, 'discussions have no status');
+ });
+
+ await check('status: staff only, from the kind\'s list', async () => {
+ let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: alexJwt, json: { status: 'done' } });
+ assert.strictEqual(r.status, 403);
+ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'paused' } });
+ assert.strictEqual(r.status, 400, 'paused is a roadmap status');
+ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/status`, { method: 'PUT', cookie: modJwt, json: { status: 'planned' } });
+ assert.strictEqual(r.status, 200, r.text); assert.strictEqual(r.json().thread.status, 'planned');
+ assert.deepStrictEqual((await call('/api/v1/spaces/feedback/threads?status=planned')).json().threads.map((x) => x.slug), [req.slug]);
+ });
+
+ await check('category: the author or staff move a thread; others cannot; deleting a category keeps its threads', async () => {
+ let r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: samJwt, json: { category: 'bugs' } });
+ assert.strictEqual(r.status, 403);
+ r = await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: alexJwt, json: { category: 'questions' } });
+ assert.strictEqual(r.json().thread.category.slug, 'questions');
+ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: alexJwt, json: { name: 'Polls' } });
+ assert.strictEqual(r.status, 403, 'only staff manage categories');
+ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'PUT', cookie: modJwt, json: { name: 'Polls', position: 9 } });
+ assert.strictEqual(r.status, 200, r.text);
+ await call(`/api/v1/spaces/feedback/threads/${req.slug}/category`, { method: 'PUT', cookie: modJwt, json: { category: 'polls' } });
+ r = await call('/api/v1/spaces/feedback/categories/polls', { method: 'DELETE', cookie: modJwt });
+ assert.strictEqual(r.status, 200);
+ const thread = (await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread;
+ assert.strictEqual(thread.category, null); assert.strictEqual(thread.status, 'planned');
+ });
+
+ await check('roadmap: people cannot start items; staff can', async () => {
+ let r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: alexJwt, json: { title: 'My idea', body: 'x' } });
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.json().code, 'space.staff_threads');
+ r = await call('/api/v1/spaces/roadmap/threads', { method: 'POST', cookie: modJwt, json: { title: 'A staff item', body: 'x', category: 'features' } });
+ assert.strictEqual(r.status, 201); assert.strictEqual(r.json().thread.status, 'planned');
+ });
+
+ await check('roadmap sync: creates, updates in place (history kept), leaves unchanged items alone, skips bad ones', async () => {
+ const db = t.db;
+ const items = [
+ { key: 'chat-calls', category: 'features', status: 'planned', title: 'Calls in Chat', summary: 'Calls move to Chat.' },
+ { key: 'bad key!', status: 'planned', title: 'x', summary: 'y' },
+ { key: 'nostatus', title: 'No status', summary: 'y' },
+ ];
+ const quiet = { warn() {} };
+ assert.deepStrictEqual(syncRoadmap(db, items, { log: quiet }), { created: 1, updated: 0, unchanged: 0, skipped: 2 });
+ let th = (await call('/api/v1/spaces/roadmap/threads?category=features')).json().threads.find((x) => x.title === 'Calls in Chat');
+ assert.strictEqual(th.author.display_name, 'OpenVibe'); assert.strictEqual(th.origin, 'system');
+ assert.deepStrictEqual(syncRoadmap(db, items.slice(0, 1), { log: quiet }), { created: 0, updated: 0, unchanged: 1, skipped: 0 });
+ const next = [{ ...items[0], status: 'in_progress', title: 'Voice and video calls in Chat', summary: 'Calls move to Chat. Started.' }];
+ assert.deepStrictEqual(syncRoadmap(db, next, { log: quiet }), { created: 0, updated: 1, unchanged: 0, skipped: 0 });
+ const page = (await call(`/api/v1/spaces/roadmap/threads/${th.slug}`)).json();
+ assert.strictEqual(page.thread.title, 'Voice and video calls in Chat'); assert.strictEqual(page.thread.status, 'in_progress');
+ assert.strictEqual(page.posts[0].body_markdown, 'Calls move to Chat. Started.'); assert.strictEqual(page.posts[0].revision, 2, 'the summary edit keeps the history');
+ const file = syncFromFile(db, { log: quiet });
+ assert.ok(file && file.created >= 10 && file.skipped === 0, JSON.stringify(file));
+ assert.deepStrictEqual(syncFromFile(db, { log: quiet }).updated, 0, 'the shipped file syncs to a fixed point');
+ });
+
+ await check('pages: chips, badges, staff forms; no New thread button on the roadmap for people', async () => {
+ let r = await call('/s/feedback?category=ideas', { cookie: alexJwt });
+ assert.strictEqual(r.status, 200);
+ const html = r.text;
+ assert.ok(html.includes('aria-label="Categories"') && html.includes('aria-label="Status"') && html.includes('New request'));
+ assert.ok(/<meta name="robots" content="noindex,follow"/.test(html), 'filtered lists are not indexed');
+ r = await call('/s/roadmap', { cookie: alexJwt });
+ assert.ok(!r.text.includes('New roadmap item') && r.text.includes('Staff add roadmap items'));
+ assert.ok(r.text.includes('badge-status status-in_progress'));
+ r = await call('/s/roadmap/new', { cookie: alexJwt });
+ assert.strictEqual(r.status, 403);
+ r = await call(`/s/feedback/t/${req.slug}`, { cookie: modJwt });
+ assert.ok(r.text.includes('Set status') && r.text.includes('Set category'));
+ r = await call(`/s/feedback/t/${req.slug}/status`, { method: 'POST', cookie: modJwt, form: { status: 'done' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual((await call(`/api/v1/spaces/feedback/threads/${req.slug}`)).json().thread.status, 'done');
+ r = await call('/s/feedback/new', { cookie: alexJwt });
+ assert.ok(r.text.includes('name="category"') && r.text.includes('Search first'));
+ });
+
+ await done(t);
+})();
@@ -33,10 +33,10 @@ const forumStore = require('../server/forum/store');
@@ -33,10 +33,10 @@ const forumStore = require('../server/forum/store');
let first;
let first;
- await check('seeded spaces: general, feedback, showcase — all public', async () => {
+ await check('seeded spaces: general, feedback, showcase, roadmap — all public', async () => {
const r = await call('/api/v1/spaces');
const r = await call('/api/v1/spaces');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public']]);
+ assert.deepStrictEqual(r.json().spaces.map((s) => [s.slug, s.visibility]), [['general', 'public'], ['feedback', 'public'], ['showcase', 'public'], ['roadmap', 'public']]);
assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
assert.strictEqual((await call('/api/v1/spaces/nope')).status, 404);
});
});