openvibers/openvibe.quest Commits

Expose the daily streak aggregate to Network's user card (#270)

OP OpenVibers committed 3 days ago · a4fe165606 · 3 files, +58 −7

Changes

ddiff --git a/server/http/api.js b/server/http/api.js
iindex 2d7a64f..e14a59d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -178,6 +178,15 @@ function createApi(ctx) {
});
// ── Someone's badges (public: ids, names and dates only) ──
++ // Network applies public-profile and per-field visibility before requesting this private aggregate.
++ r.get('/profiles/:subject/streak', limits.reads('quest.badge.read'), async (req, res) => {
++ if (req.principal.kind !== 'app' || req.principal.requester !== 'service:network' || req.principal.claims.env === 'sandbox' || !Array.isArray(req.principal.claims.cap) || !req.principal.claims.cap.includes('quest.badge.read')) {
++ return problem(req, res, 403, 'quest.network_only', 'Only Network may read the card aggregate.');
++ }
++ if (!contracts.ids.isSubjectId('user', req.params.subject)) return problem(req, res, 400, 'quest.invalid_subject', 'Use a user subject.');
++ res.json({ streak: await ctx.daily.streakOf(req.params.subject) });
++ });
++
r.get('/profiles/:subject/badges', limits.reads('quest.badge.read'), async (req, res) => {
const subject = String(req.params.subject || '');
if (!SUBJECT_RE.test(subject)) return problem(req, res, 404, 'quest.profile.not_found', 'No such person.');
ddiff --git a/server/quests/daily.js b/server/quests/daily.js
iindex 9f3dade..246f08b 100644
---- a/server/quests/daily.js
++++ b/server/quests/daily.js
@@@ -193,6 +193,18 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
ON CONFLICT (event_id) DO NOTHING`, [event.event_id, subject, event.event_type, occurred.toISOString()]);
return subject;
}
++ // Read-only: unlike progress(), a card must never ensure quests or settle rewards.
++ async function streakOf(subject, day = null) {
++ day = day || localDay(s.iso(), await zoneOf(subject));
++ const rows = await db.many('SELECT day FROM daily_completions WHERE subject=$1 AND day <= $2 ORDER BY day DESC LIMIT 366', [subject, day]);
++ const days = new Set(rows.map(r => r.day));
++ let streak = 0;
++ for (let cursor = Date.parse(day + 'T00:00:00Z') - (days.has(day) ? 0 : 86400000); streak < 365; cursor -= 86400000) {
++ if (!days.has(utcDay(cursor))) break;
++ streak++;
++ }
++ return streak;
++ }
async function progress(subject, day = null, selectedQuest = null) {
const savedZone = await db.value('SELECT time_zone FROM daily_preferences WHERE subject = $1', [subject]);
const zone = savedZone || 'UTC';
@@@ -215,12 +227,7 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
if (row && coins.enabled) await coins.settle(row);
}
const completion = await db.maybe('SELECT completed_at FROM daily_completions WHERE subject=$1 AND day=$2', [subject, day]);
-- let streak = 0;
-- for (let cursor = Date.parse(day + 'T00:00:00Z') - (completion ? 0 : 86400000); streak < 365; cursor -= 86400000) {
-- const d = utcDay(cursor);
-- if (!(await db.maybe('SELECT day FROM daily_completions WHERE subject=$1 AND day=$2', [subject, d]))) break;
-- streak++;
-- }
++ const streak = await streakOf(subject, day);
const finished_today = Number(await db.value('SELECT count(*) FROM daily_completions WHERE day=$1', [day]));
return { ...q, steps, finished_today, progress: steps.filter((x) => x.done).length, completed: !!completion,
completed_at: completion && completion.completed_at, streak, time_zone: zone };
@@@ -270,6 +277,6 @@ function createDaily({ s, config, coins, glyphs, fetchImpl = globalThis.fetch, a
void due();
return () => { if (timer) clearTimeout(timer); };
}
-- return { ensure, read, publicQuest, progress, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
++ return { ensure, read, publicQuest, progress, streakOf, today, ensurePersonal, refreshAfterEvent, recordEvent, setZone, zoneOf, allTypes, contribute, review, start };
}
module.exports = { createDaily, BASE_TYPES, KNOWN_TOPICS, validateSelection, rotation, localDay, validZone, utcDay };
ddiff --git a/test/card-streak.test.js b/test/card-streak.test.js
nnew file mode 100644
iindex 0000000..5418920
---- /dev/null
++++ b/test/card-streak.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { serviceAuth } = require('openvibe-contracts');
++(async () => {
++ const t = await boot({ now: () => Date.parse('2026-11-10T02:00:00Z') });
++ const u = t.network.addUser('card');
++ const path = `/api/v1/profiles/${u.subject}/streak`;
++ const token = (sub, cap = ['quest.badge.read']) => {
++ const at = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.quest'], cap,
++ iat: at, exp: at + 300, jti: 'card-test' }, t.network.privatePem);
++ };
++ try {
++ await check('streak aggregate is restricted to Network with its read capability', async () => {
++ assert.strictEqual((await t.get(path)).status, 403);
++ assert.strictEqual((await t.get(path, { as: u })).status, 403);
++ assert.strictEqual((await t.get(path, { bearer: token('svc:live') })).status, 403);
++ assert.strictEqual((await t.get(path, { bearer: token('svc:network', []) })).status, 403);
++ });
++ await check('card streak respects local day, yesterday grace and a gap without creating quests or rewards', async () => {
++ await t.ctx.daily.setZone(u.subject, 'America/Los_Angeles');
++ for (const day of ['2026-11-07', '2026-11-08']) await t.ctx.s.db.exec('INSERT INTO daily_completions(subject,day,completed_at) VALUES($1,$2,$3)', [u.subject, day, day + 'T12:00:00Z']);
++ const r = await t.get(path, { bearer: token('svc:network') });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.json(), { streak: 2 });
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ assert.strictEqual(await t.ctx.daily.streakOf(u.subject, '2026-11-11'), 0);
++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM quest_completions')), 0);
++ const bad = await t.get('/api/v1/profiles/nope/streak', { bearer: token('svc:network') });
++ assert.strictEqual(bad.status, 400);
++ });
++ } finally { await t.close(); }
++ done('card-streak');
++})().catch(e => { console.error(e); process.exitCode = 1; });