openvibers/openvibe.live Commits

Live: setup hub tasks emit live.setup.task_completed for Quest (contracts 0.190.0) (#139)

OP OpenVibers committed 29 days ago · 0c210185f4 · 7 files, +179 −5

The first time a go-live setup task reads as done, Live records it in
live_setup_tasks (migration 0007, expand) and queues
live.setup.task_completed in the same transaction. OpenVibe.Quest turns
each task into a quest (owner Q16).

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 48092778..5b945165 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -217,6 +217,7 @@ transaction as the change it describes.
|---|---|---|
| `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream <id>` |
| `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release <head sha>` |
++| `live.setup.task_completed` | a go-live setup hub task reads as done for the first time ([server/streaming/setup-progress.js](../server/streaming/setup-progress.js)), queued with its `live_setup_tasks` row; payload: `task`, `user`, `title`, `group`, `completed_at`, `done`, `total`. OpenVibe.Quest turns each task into a quest | `user <usr_…>` |
The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and
stores the rolling card (C-84). Live keeps no chat table of its own.
ddiff --git a/migrations/0007_setup_tasks.sql b/migrations/0007_setup_tasks.sql
nnew file mode 100644
iindex 00000000..cc4e1087
---- /dev/null
++++ b/migrations/0007_setup_tasks.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- The go-live setup hub's tasks each person has finished, once per task (server/streaming/setup-progress.js): the first
++-- time a task reads as done, Live records it here and queues live.setup.task_completed in the same transaction, which
++-- OpenVibe.Quest turns into quests. A task undone later (an emote deleted) stays recorded: the quest was earned.
++CREATE TABLE IF NOT EXISTS live_setup_tasks (
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ task text NOT NULL,
++ completed_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (user_id, task)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex a6a75e7b..8eafb934 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -23,7 +23,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.1",
@@@ -2644,9 +2644,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.188.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
-- "integrity": "sha512-E64gQI7roKudmmJFnenyh1BjViJyI0j0MoVT4o35/jUD4rpzMkElwEh/fGbsPpnstdDFIcJR6gEcScbL0ZQhbg==",
++ "version": "0.190.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
++ "integrity": "sha512-zh71oYBT6xHuyIj4OirWbWmJ33Xe5HeBAG6Q4Sq2S0aqA6gG4LVK5+k9P/UEV0AuSkcqqlQm84PjBF8ZTY90Ow==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 55ac5c54..2dd9e7ea 100644
---- a/package.json
++++ b/package.json
@@@ -42,7 +42,7 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.188.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.190.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.46.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.2.1",
"pg": "^8.23.1",
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 3f9e06de..20e702f2 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1516,6 +1516,9 @@ router.get('/setup-progress', requireAuth, async (req, res) => {
];
const done = tasks.filter(t => t.done).length;
const next = tasks.find(t => !t.done) || null;
++ // A task done for the first time is recorded once, with live.setup.task_completed for OpenVibe.Quest. The hub
++ // answers even when that fails: the next visit records it.
++ await require('./setup-progress').record(uid, tasks).catch((err) => console.warn('[SetupProgress] record failed:', err.message));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.json({ tasks, done, total: tasks.length, next, username: user.username });
});
ddiff --git a/server/streaming/setup-progress.js b/server/streaming/setup-progress.js
nnew file mode 100644
iindex 00000000..31ab64b7
---- /dev/null
++++ b/server/streaming/setup-progress.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The go-live setup hub's finished tasks, recorded once per person, and live.setup.task_completed for each one
++ * (openvibe-contracts 0.190.0). OpenVibe.Quest turns every task into a quest (owner Q16: Live's setup steps become
++ * quests), so finishing "Upload a custom emote" here completes the matching quest there.
++ *
++ * GET /api/streams/setup-progress works the tasks out (server/streaming/routes.js) and hands them to record(). A task
++ * that reads as done and has no live_setup_tasks row yet gets one, and its event is queued in Live's outbox in the
++ * same transaction, so the row and the event commit together. A task is recorded at most once: the primary key
++ * (user_id, task) makes a second request that raced this one insert nothing and queue nothing.
++ *
++ * Nothing is recorded for a person Live cannot name on the network (no linked subject yet): the next visit tries
++ * again, so the quest is not lost. The event says which task it was and how far along the person is, never what
++ * they made.
++ *
++ * envelope subject { type: 'user', id: usr_… } (the streamer), actor the same, visibility internal
++ * payload { task, user: { type: 'user', id }, title, group, completed_at, done, total }
++ */
++const db = require('../db/database');
++const identity = require('../auth/identity-sync');
++const streamEvents = require('../events/stream-events');
++
++const EVENT_TYPE = 'live.setup.task_completed';
++
++function toIso(v) {
++ const d = v instanceof Date ? v : new Date(v);
++ return Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
++}
++
++/**
++ * Record the newly finished tasks of `tasks` (the hub's list: { id, group, title, done }) for `userId`.
++ * Returns the ids it recorded now (empty when nothing was new).
++ */
++async function record(userId, tasks) {
++ const finished = (tasks || []).filter((t) => t && t.done);
++ if (!finished.length) return [];
++ const known = new Set((await db.all('SELECT task FROM live_setup_tasks WHERE user_id = ?', [userId])).map((r) => r.task));
++ const fresh = finished.filter((t) => !known.has(t.id));
++ if (!fresh.length) return [];
++ const subjectId = await identity.subjectOf(userId);
++ if (!subjectId) return [];
++ const person = { type: 'user', id: subjectId };
++ const recorded = [];
++ await db.tx(async () => {
++ for (const t of fresh) {
++ const row = await db.get('INSERT INTO live_setup_tasks (user_id, task) VALUES (?, ?) ON CONFLICT DO NOTHING RETURNING completed_at', [userId, t.id]);
++ if (!row) continue; // another request recorded it first, and queued its event
++ recorded.push(t.id);
++ await streamEvents.enqueue({
++ event_type: EVENT_TYPE,
++ actor: person,
++ subject: person,
++ visibility: 'internal',
++ priority: 'low',
++ payload: {
++ task: t.id,
++ user: person,
++ title: String(t.title || '').slice(0, 120),
++ group: String(t.group || '').slice(0, 40),
++ completed_at: toIso(row.completed_at),
++ done: finished.length,
++ total: tasks.length,
++ },
++ });
++ }
++ });
++ if (recorded.length) streamEvents.kick();
++ return recorded;
++}
++
++module.exports = { EVENT_TYPE, record };
ddiff --git a/test/setup-progress-events.test.js b/test/setup-progress-events.test.js
nnew file mode 100644
iindex 00000000..8ca10400
---- /dev/null
++++ b/test/setup-progress-events.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++
++// The go-live setup hub → OpenVibe.Quest (owner Q16): server/streaming/setup-progress.js records each task the first
++// time it reads as done (live_setup_tasks) and queues live.setup.task_completed in the same transaction; a second
++// look, or a task undone later, queues nothing; a person Live cannot name on the network gets nothing recorded yet.
++// Stub Network (token) + stub Events (publish), as test/stream-events.test.js.
++
++const assert = require('assert');
++const http = require('http');
++
++const published = [];
++const stub = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ res.setHeader('Content-Type', 'application/json');
++ if (req.url === '/oauth/token') return res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }));
++ if (req.url === '/api/v1/events' && req.method === 'POST') {
++ const parsed = JSON.parse(body);
++ const list = parsed.events || [parsed];
++ const results = list.map((e) => { published.push(e); return { event_id: e.event_id, seq: published.length, duplicate: false }; });
++ return res.end(JSON.stringify(parsed.events ? { results } : results[0]));
++ }
++ res.statusCode = 404; res.end('{}');
++ });
++});
++
++const SUBJECT = 'usr_01J8Z3V9Q6N1X2Y3Z4A5B6C7D8';
++const task = (id, done) => ({ id, group: 'Look & feel', title: `Task ${id}`, done });
++
++(async () => {
++ await new Promise((r) => stub.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${stub.address().port}`;
++ process.env.OV_NETWORK_INTERNAL_URL = base;
++
++ const db = require('../server/db/database');
++ await db.initDb();
++ const d = db.getDb();
++ await d.prepare("INSERT INTO users (id, username, display_name) OVERRIDING SYSTEM VALUE VALUES (611, 'setupper', 'Setupper'), (612, 'unlinked', 'Unlinked')").run();
++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (611, 'network', '611', ?)").run(SUBJECT);
++
++ const outbox = require('../server/events/stream-events').init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
++ assert.ok(outbox);
++ const setup = require('../server/streaming/setup-progress');
++
++ // Two of three done: both recorded, both queued with the person, the task and the progress.
++ const first = [task('slot', true), task('emote', true), task('sound', false)];
++ assert.deepStrictEqual(await setup.record(611, first), ['slot', 'emote']);
++ await outbox.flush();
++ assert.strictEqual(published.length, 2);
++ const ev = published.find((e) => e.payload.task === 'emote');
++ assert.strictEqual(ev.event_type, 'live.setup.task_completed');
++ assert.strictEqual(ev.source, 'live');
++ assert.strictEqual(ev.visibility, 'internal');
++ assert.deepStrictEqual(ev.subject, { type: 'user', id: SUBJECT });
++ assert.deepStrictEqual(ev.actor, { type: 'user', id: SUBJECT });
++ assert.deepStrictEqual(ev.payload.user, { type: 'user', id: SUBJECT });
++ assert.strictEqual(ev.payload.done, 2);
++ assert.strictEqual(ev.payload.total, 3);
++ assert.strictEqual(ev.payload.title, 'Task emote');
++ assert.match(ev.payload.completed_at, /^\d{4}-\d\d-\d\dT/);
++ require('openvibe-contracts').assertValid('live.setup.task_completed@1', ev.payload);
++
++ // The same look again: nothing new. The third task done: only it. An emote deleted later stays recorded.
++ assert.deepStrictEqual(await setup.record(611, first), []);
++ assert.deepStrictEqual(await setup.record(611, [task('slot', true), task('emote', false), task('sound', true)]), ['sound']);
++ await outbox.flush();
++ assert.deepStrictEqual(published.map((e) => e.payload.task).sort(), ['emote', 'slot', 'sound']);
++ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 611').get()).n, 3);
++
++ // Two looks at once record and queue a task once.
++ const both = await Promise.all([setup.record(611, [task('goal', true)]), setup.record(611, [task('goal', true)])]);
++ assert.deepStrictEqual(both.flat(), ['goal']);
++ await outbox.flush();
++ assert.strictEqual(published.filter((e) => e.payload.task === 'goal').length, 1);
++
++ // No network subject yet: nothing recorded, so the next visit after linking still earns the quest.
++ assert.deepStrictEqual(await setup.record(612, [task('slot', true)]), []);
++ assert.strictEqual((await d.prepare('SELECT COUNT(*) AS n FROM live_setup_tasks WHERE user_id = 612').get()).n, 0);
++
++ // The route records through it.
++ const src = require('fs').readFileSync(require('path').join(__dirname, '../server/streaming/routes.js'), 'utf8');
++ assert.ok(/require\('\.\/setup-progress'\)\.record\(uid, tasks\)/.test(src), 'GET /setup-progress records the tasks');
++
++ await outbox.stop();
++ stub.close();
++ console.log('setup-progress-events: ok');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });