Files
Marco Mooren 29423288ca 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.
2026-05-13 13:53:12 +02:00

98 lines
4.3 KiB
JavaScript

// /api/rooms — list/create rooms, manage members, fetch state.
import { Router } from 'express';
import { requireUser } from '../auth.js';
import {
listRoomsForUser, getRoomBySlug, createRoom,
addMember, removeMember, listMembers, isMember, getRoomState,
ensurePersonalRoom
} from '../rooms.js';
import { getStation } from '../stations.js';
import { dispatchRoomCommand, hasDisplay } from '../ws.js';
export const router = Router();
router.use(requireUser);
router.get('/', (req, res) => {
// Guarantee a personal room exists for every authenticated user.
ensurePersonalRoom(req.user);
res.json(listRoomsForUser(req.user.id));
});
router.post('/', (req, res) => {
const { name, slug } = req.body || {};
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
const room = createRoom({ name: name.trim(), slug: slug?.trim() || undefined, ownerId: req.user.id });
res.status(201).json(room);
});
router.get('/:slug', (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 state = getRoomState(room.id);
const station = state.station_id ? getStation(state.station_id) : null;
res.json({ ...room, state: { ...state, station } });
});
router.get('/:slug/members', (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' });
res.json(listMembers(room.id));
});
router.post('/:slug/members', (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 userId = Number(req.body?.user_id);
if (!userId) return res.status(400).json({ error: 'user_id required' });
const role = req.body?.role === 'guest' ? 'guest' : 'member';
addMember(room.id, userId, role);
res.json(listMembers(room.id));
});
router.delete('/:slug/members/:userId', (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' });
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 } });
});