- Anyone who can see a play can read its comments; the player and the people they follow can write. The author, the player and admins can delete; comments can be reported (admins see the text). - Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its own setting. Reaction and comment notifications now open the play. - A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on the app (/play/[id]) and the website. Threads refresh every few seconds while open. - Blocking removes comments between the two people. Plays with comments survive history trimming. - Privacy page updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
77 lines
3.8 KiB
TypeScript
77 lines
3.8 KiB
TypeScript
/** 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) },
|
|
comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
|
|
}));
|
|
});
|
|
|
|
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 };
|
|
});
|
|
}
|