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:
70
server/routes/users.js
Normal file
70
server/routes/users.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// /api/users — cross-user reads for the favorites-as-tabs UI.
|
||||
//
|
||||
// Writes are still owned by /api/me/favorites. The one exception is the
|
||||
// shared "main" user (e.g. morphix) which any admin may edit so the house
|
||||
// favorites tab is collaboratively curated without revealing morphix's
|
||||
// password.
|
||||
|
||||
import { Router } from 'express';
|
||||
import { requireUser, requireAdmin, getMainUser } from '../auth.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { getStatsMap } from '../stats.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireUser);
|
||||
|
||||
function findUserByName(username) {
|
||||
return getDb().prepare(
|
||||
'SELECT id, username, is_main, avatar_color, avatar_emoji FROM users WHERE username = ?'
|
||||
).get(username);
|
||||
}
|
||||
|
||||
router.get('/:username/favorites', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!user) return res.status(404).json({ error: 'unknown user' });
|
||||
const rows = getDb().prepare(`
|
||||
SELECT s.*, f.position
|
||||
FROM favorites f JOIN stations s ON s.id = f.station_id
|
||||
WHERE f.user_id = ? AND s.enabled = 1
|
||||
ORDER BY f.position ASC, f.created_at ASC
|
||||
`).all(user.id);
|
||||
// Stats are scoped to the *viewer* (so my_vote reflects me, not the owner).
|
||||
const stats = getStatsMap(req.user.id);
|
||||
res.json(rows.map((r) => {
|
||||
const st = stats.get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
|
||||
return {
|
||||
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
|
||||
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position,
|
||||
up: st.up, down: st.down, plays: st.plays, my_vote: st.myVote, score: st.score
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
// Admin shortcut for editing the shared "main" user's favorites without
|
||||
// logging in as them. Any other user is rejected so we don't accidentally
|
||||
// mutate someone else's library.
|
||||
function canWriteFavoritesFor(viewer, target) {
|
||||
if (!target) return false;
|
||||
if (viewer.id === target.id) return true;
|
||||
return viewer.role === 'admin' && target.is_main === 1;
|
||||
}
|
||||
|
||||
router.put('/:username/favorites/:stationId', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
|
||||
const stationId = Number(req.params.stationId);
|
||||
const position = Number(req.body?.position ?? 0);
|
||||
getDb().prepare(`
|
||||
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
|
||||
`).run(user.id, stationId, position);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete('/:username/favorites/:stationId', (req, res) => {
|
||||
const user = findUserByName(req.params.username);
|
||||
if (!canWriteFavoritesFor(req.user, user)) return res.status(403).json({ error: 'forbidden' });
|
||||
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
|
||||
.run(user.id, Number(req.params.stationId));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
Reference in New Issue
Block a user