API: follower lists, blocking, reports, admin; drop Spotify sign-in

- Followers/following lists (paginated), 'follows you', remove a follower
- Blocking severs follows both ways and hides each user from the other
  (profile 404s, search, follows); the blocker sees activity hidden
- Reports for moderation (App Store guideline 1.2)
- First account on a server becomes admin; admins see the report queue,
  resolve reports, and suspend accounts (signs out, blocks login, hides
  the profile, stops sharing). set-role script to promote/demote
- users.onboardedAt drives first-run onboarding; existing accounts are
  backfilled as already onboarded. Empty PATCH /me no longer 500s
- Remove Spotify account sign-in and polling: Spotify caps new apps at 5
  accounts, so Spotify arrives via its Last.fm/ListenBrainz link or the
  Android reader. App-level catalog search stays for exact links
- Shared: QR code generation and profile-link parsing (verified with jsQR)
- Integration test timeout raised; password hashing is slow by design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-05 19:16:40 -04:00
co-authored by Claude Opus 5.5
parent 63d618f0e3
commit 586acdbeab
44 changed files with 4559 additions and 220 deletions
+75
View File
@@ -0,0 +1,75 @@
/** Moderation for admins: the report queue, suspensions, and a few numbers. */
import { and, count, desc, eq, isNotNull } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import type { FastifyInstance } from 'fastify';
import type { AdminReportDTO, AdminStatsDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireAdmin } from '../lib/auth';
import { HttpError } from '../lib/http';
import { pauseSharing, toUserSummary } from '../services/nowPlaying';
export async function adminRoutes(app: FastifyInstance) {
app.get('/admin/stats', async (req): Promise<AdminStatsDTO> => {
requireAdmin(req);
const [[users], [listening], [open], [suspended]] = await Promise.all([
db.select({ n: count() }).from(schema.users),
db.select({ n: count() }).from(schema.nowPlaying).where(eq(schema.nowPlaying.isPlaying, true)),
db.select({ n: count() }).from(schema.reports).where(eq(schema.reports.status, 'open')),
db.select({ n: count() }).from(schema.users).where(isNotNull(schema.users.suspendedAt)),
]);
return { users: users!.n, listeningNow: listening!.n, openReports: open!.n, suspended: suspended!.n };
});
app.get<{ Querystring: { status?: 'open' | 'resolved' } }>('/admin/reports', async (req): Promise<AdminReportDTO[]> => {
requireAdmin(req);
const reporter = alias(schema.users, 'reporter');
const reported = alias(schema.users, 'reported');
const rows = await db
.select({ report: schema.reports, reporter, reported })
.from(schema.reports)
.leftJoin(reporter, eq(reporter.id, schema.reports.reporterId))
.innerJoin(reported, eq(reported.id, schema.reports.reportedId))
.where(eq(schema.reports.status, req.query.status === 'resolved' ? 'resolved' : 'open'))
.orderBy(desc(schema.reports.createdAt))
.limit(200);
return rows.map(({ report, reporter: by, reported: who }) => ({
id: report.id,
reason: report.reason,
note: report.note,
status: report.status,
createdAt: report.createdAt.toISOString(),
reporter: by ? toUserSummary(by) : null,
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
}));
});
app.post<{ Params: { id: string } }>('/admin/reports/:id/resolve', async (req) => {
requireAdmin(req);
if (!/^[0-9a-f-]{36}$/i.test(req.params.id)) throw new HttpError(404, 'Report not found');
await db.update(schema.reports).set({ status: 'resolved' }).where(eq(schema.reports.id, req.params.id));
return { ok: true };
});
/** Suspend: signs them out everywhere, blocks login, hides their profile, stops sharing. */
app.post<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
const admin = requireAdmin(req);
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, req.params.username.toLowerCase()));
if (!user) throw new HttpError(404, 'User not found');
if (user.id === admin.id) throw new HttpError(400, "You can't suspend yourself");
if (user.role === 'admin') throw new HttpError(400, "Admins can't be suspended");
await db.update(schema.users).set({ suspendedAt: new Date() }).where(eq(schema.users.id, user.id));
await db.delete(schema.sessions).where(eq(schema.sessions.userId, user.id));
await pauseSharing(user.id);
req.log.warn({ by: admin.username, user: user.username }, 'user suspended');
return { ok: true };
});
app.delete<{ Params: { username: string } }>('/admin/users/:username/suspend', async (req) => {
requireAdmin(req);
await db
.update(schema.users)
.set({ suspendedAt: null })
.where(and(eq(schema.users.username, req.params.username.toLowerCase()), isNotNull(schema.users.suspendedAt)));
return { ok: true };
});
}