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

OP OpenVibers committed 25 Sept 2026 · 834a14df43 · 14 files, +519 −32


Changes

ddiff --git a/README.md b/README.md
iindex 5009dc9..c9b4eef 100644
---- a/README.md
++++ b/README.md
@@@ -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 |
ddiff --git a/docs/roadmap/public.json b/docs/roadmap/public.json
nnew file mode 100644
iindex 0000000..f0191b0
---- /dev/null
++++ b/docs/roadmap/public.json
@@@ -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." }
++ ]
++}
ddiff --git a/public/css/community.css b/public/css/community.css
iindex b1a7e08..1fe1bdd 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -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; }
ddiff --git a/server/db.js b/server/db.js
iindex 7482232..f57fcc4 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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). */
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 6f12106..0d77e16 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -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)));
ddiff --git a/server/forum/roadmap.js b/server/forum/roadmap.js
nnew file mode 100644
iindex 0000000..fd66025
---- /dev/null
++++ b/server/forum/roadmap.js
@@@ -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 };
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex 227da18..c9920bb 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -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);
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 25540c9..169ebf8 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -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, vi
function 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, vi
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 = {}) {
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, vi
space: 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, vi
mayPostIn(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, vi
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)`);
}
-- 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 };
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 2fcb387..a2ef9d1 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -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,
};
ddiff --git a/server/identity/authors.js b/server/identity/authors.js
iindex fce7614..215b2c6 100644
---- a/server/identity/authors.js
++++ b/server/identity/authors.js
@@@ -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 {
ddiff --git a/server/index.js b/server/index.js
iindex 490c14c..12655be 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 94f930e..e3dbaf4 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -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">
ddiff --git a/test/categories-roadmap.test.js b/test/categories-roadmap.test.js
nnew file mode 100644
iindex 0000000..86d7bc9
---- /dev/null
++++ b/test/categories-roadmap.test.js
@@@ -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);
++})();
ddiff --git a/test/forum.test.js b/test/forum.test.js
iindex f7975d6..a3ec93e 100644
---- a/test/forum.test.js
++++ b/test/forum.test.js
@@@ -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);
});