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:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user