openvibers/openvibe.community Commits

Pastes: daily stat series for the over-time chart (#50)

OP OpenVibers committed 10 Oct 2026 · 19ea894f06 · 4 files, +34 −1

`GET /api/pastes/admin/stats/series?days=30` (staff, as `/admin/stats`): pastes made per UTC day over the last N days (1–365), deleted ones left out as in `stats()`. Shape `{ metric: 'pastes', days, points: [{ day, value }], total, before, prev_total }`, the same as Media's stat series.

**Why:** Live's home "Pastes" chart (stats-for-nerds) still asks Media for the series. Media's copy is the frozen legacy table, which stopped at the move to Community (2026-09-22), so the chart has been flat since. Live will ask Community instead, and Media's `pastes` table can then go (plan T4, Media cleanup).

The test checks the window, today's point, that the window plus `before` equals the total, and that service tokens without staff are refused.

Changes

ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex df0789a..8ac750d 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -118,6 +118,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
router.get('/config', anyCap, run(async (req) => await service.config(req.viewer)));
router.get('/admin/stats', staffOnly, run(async () => await service.stats()));
++ router.get('/admin/stats/series', staffOnly, run(async (req) => await service.statSeries(req.query)));
router.get('/admin/forks', staffOnly, run(async (req) => await service.forks(req.query)));
router.delete('/admin/forks', staffOnly, staffWrite, run(async (req) => await service.deleteForks(req.viewer)));
router.post('/bulk', staffOnly, staffWrite, json, jsonErrors, run(async (req) => await service.bulk(req.body || {}, req.viewer)));
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 75d6587..4336408 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -645,6 +645,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// ── staff (api.js has already checked the viewer is staff) ──
async stats() { return { stats: await store.stats(db) }; },
++ /** Daily counts behind the paste total (Live's "over time" chart; Media's frozen copy stopped at the move). */
++ async statSeries(q = {}) { return await store.statSeries(db, intIn(q.days, 30, 1, 365)); },
++
async forks(q = {}) {
const limit = intIn(q.limit, 100, 1, 500);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 4c5438e..2d5c470 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -337,6 +337,27 @@ async function stats(db) {
};
}
++/**
++ * Pastes made per UTC day over the last `days` days (deleted ones left out, as in stats()), for "over time" charts:
++ * { metric, days, points: [{ day, value }], total, before (made before the window), prev_total (the window before) }.
++ */
++async function statSeries(db, days) {
++ const now = Date.now();
++ const dayOf = (i) => new Date(now - i * 86400000).toISOString().slice(0, 10);
++ const since = dayOf(days - 1);
++ const rows = await db.prepare(`SELECT substr(created_at, 1, 10) AS day, COUNT(*) AS n FROM pastes
++ WHERE deleted_at IS NULL AND created_at >= ? GROUP BY day`).all(since);
++ const byDay = new Map(rows.map((r) => [r.day, Number(r.n) || 0]));
++ const points = [];
++ for (let i = days - 1; i >= 0; i--) points.push({ day: dayOf(i), value: byDay.get(dayOf(i)) || 0 });
++ const count = async (sql, ...params) => Number((await db.prepare(sql).get(...params)).n) || 0;
++ return {
++ metric: 'pastes', days, points, total: points.reduce((n, p) => n + p.value, 0),
++ before: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at < ?', since),
++ prev_total: await count('SELECT COUNT(*) AS n FROM pastes WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?', dayOf(2 * days - 1), since),
++ };
++}
++
async function listForks(db, limit, offset) {
const forks = await db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
@@@ -403,7 +424,7 @@ module.exports = {
generateSlug, sanitizeTitle, detectLanguage,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
-- setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
++ setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
mapGet, mapSet,
SLUG_ADJECTIVES, SLUG_NOUNS,
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 439f5da..9438a09 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -155,6 +155,14 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(bulkAdmin.json().stats.total > 0);
const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });
assert.strictEqual(bulkSvc.status, 403);
++ const series = await call('/api/pastes/admin/stats/series?days=7', { cookie: adminJwt });
++ assert.strictEqual(series.status, 200, series.text);
++ const sj = series.json();
++ assert.deepStrictEqual([sj.metric, sj.days, sj.points.length], ['pastes', 7, 7]);
++ assert.strictEqual(sj.points[6].day, new Date().toISOString().slice(0, 10), 'the last point is today (UTC)');
++ assert.ok(sj.points[6].value > 0 && sj.total >= sj.points[6].value, 'pastes made today are counted');
++ assert.strictEqual(sj.total + sj.before, bulkAdmin.json().stats.total, 'the window and what came before add up to the total');
++ assert.strictEqual((await call('/api/pastes/admin/stats/series', { token: svc([WRITE]) })).status, 403);
});
await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {