- 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.
98 lines
4.3 KiB
JavaScript
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 } });
|
|
});
|