feat: add multi-user support for favorites management and room clock synchronization

- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
Marco Mooren
2026-05-13 13:53:12 +02:00
parent f6cdfd975c
commit 29423288ca
41 changed files with 4229 additions and 275 deletions

View File

@@ -8,6 +8,7 @@ import {
ensurePersonalRoom
} from '../rooms.js';
import { getStation } from '../stations.js';
import { dispatchRoomCommand, hasDisplay } from '../ws.js';
export const router = Router();
router.use(requireUser);
@@ -59,3 +60,38 @@ router.delete('/:slug/members/:userId', (req, res) => {
removeMember(room.id, Number(req.params.userId));
res.json(listMembers(room.id));
});
// Inject a playback intent for the room from outside the WS hub. Used by
// smart-home/scripts/integrations: the request behaves like a synthetic
// controller — the master (display) actually plays the audio and emits its
// authoritative `state`, which the server rebroadcasts so every other peer
// stays in sync. 409 when no display is connected, because without a master
// there's no audio source to honour the intent.
router.post('/:slug/command', (req, res) => {
const room = getRoomBySlug(req.params.slug);
if (!room) return res.status(404).json({ error: 'not found' });
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
const action = String(req.body?.action || '');
if (!['play', 'pause', 'stop', 'volume'].includes(action)) {
return res.status(400).json({ error: 'action must be one of play|pause|stop|volume' });
}
const msg = { type: 'command', action };
if (action === 'play') {
const stationId = Number(req.body?.stationId);
if (!Number.isFinite(stationId)) return res.status(400).json({ error: 'stationId required for play' });
msg.stationId = stationId;
} else if (action === 'volume') {
const value = Number(req.body?.value);
if (!Number.isFinite(value)) return res.status(400).json({ error: 'numeric value required for volume' });
msg.value = Math.max(0, Math.min(1, value));
}
if (!hasDisplay(room.slug)) {
return res.status(409).json({ error: 'no display connected to room', slug: room.slug });
}
const result = dispatchRoomCommand(room, msg);
if (!result) return res.status(400).json({ error: 'command rejected' });
res.json({ ok: true, room: { id: room.id, slug: room.slug, name: room.name }, state: { ...result.state, station: result.station } });
});