import { createHash } from "node:crypto"; import { HttpStatus, Inject, Injectable } from "@nestjs/common"; import { ErrorCode } from "@drift/contracts"; import { Prisma } from "@prisma/client"; import { DomainException } from "../common/domain.exception.js"; import { PrismaService } from "../database/prisma.service.js"; import { SafetyLockService } from "../safety/safety-lock.service.js"; import type { ResolveReportDto, SanctionDto } from "./dto.js"; import { NotificationService } from "../notification/notification.service.js"; @Injectable() export class AdminService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(SafetyLockService) private readonly locks: SafetyLockService, @Inject(NotificationService) private readonly notifications: NotificationService, ) {} async reports( actorId: string, status: "PENDING" | "REVIEWING" | "RESOLVED" | "DISMISSED" | undefined, limit: number, ) { const rows = await this.prisma.report.findMany({ where: status ? { status } : {}, orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: limit, }); await this.prisma.auditLog.create({ data: { actorId, action: "REPORTS_QUERIED", entityType: "REPORT", metadata: { status: status ?? null, limit, resultCount: rows.length }, }, }); return { items: rows.map((x) => ({ id: x.id, status: x.status, reason: x.reason, details: x.details, targetSnapshot: x.targetSnapshot, resolution: x.resolution, createdAt: x.createdAt, })), }; } async resolve(actorId: string, id: string, dto: ResolveReportDto) { if (dto.decision === "DISMISSED" && dto.sanction) throw new DomainException( ErrorCode.VALIDATION_ERROR, "Invalid sanction", HttpStatus.BAD_REQUEST, ); return this.prisma.$transaction( async (tx) => { const report = await tx.report.findUnique({ where: { id } }); if (!report) throw new DomainException( ErrorCode.NOT_FOUND, "Not found", HttpStatus.NOT_FOUND, ); const accountId = report.reportedAccountId ?? (report.messageId ? ( await tx.message.findUnique({ where: { id: report.messageId }, select: { senderId: true }, }) )?.senderId : report.bottleId ? ( await tx.bottle.findUnique({ where: { id: report.bottleId }, select: { authorId: true }, }) )?.authorId : report.conversationId ? ( await tx.anonymousProfile.findUnique({ where: { publicId: ( report.targetSnapshot as { subjectPublicId?: string; } ).subjectPublicId ?? "00000000-0000-0000-0000-000000000000", }, select: { accountId: true }, }) )?.accountId : undefined); await this.locks.lockAccounts(tx, [ actorId, ...(accountId ? [accountId] : []), ]); const changed = await tx.report.updateMany({ where: { id, status: { in: ["PENDING", "REVIEWING"] } }, data: { status: dto.decision === "UPHELD" ? "RESOLVED" : "DISMISSED", resolution: dto.resolution, }, }); if (changed.count !== 1) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Report already resolved", HttpStatus.CONFLICT, ); const task = await tx.moderationTask.update({ where: { targetType_targetId: { targetType: "REPORT", targetId: id }, }, data: { status: "COMPLETED", decision: dto.decision, result: { decision: dto.decision }, assignedToId: actorId, reviewedAt: new Date(), }, }); if (dto.decision === "UPHELD" && dto.sanction && accountId) await this.issue(tx, actorId, accountId, dto.sanction, task.id); await tx.auditLog.create({ data: { actorId, action: "REPORT_RESOLVED", entityType: "REPORT", entityId: id, metadata: { decision: dto.decision, sanctionType: dto.sanction?.type, }, }, }); await this.notifications.createInApp( tx, report.reporterId, `report:${id}:reporter`, "REPORT_RESOLVED", { reportId: id, decision: dto.decision }, ); if (dto.decision === "UPHELD" && dto.sanction && accountId) await this.notifications.createInApp( tx, accountId, `report:${id}:target`, "ACCOUNT_ACTION", { type: "ACCOUNT_ACTION" }, ); return tx.report.findUniqueOrThrow({ where: { id }, select: { id: true, status: true, resolution: true }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, ); } async sanctions(actorId: string, publicId: string, dto: SanctionDto) { const profile = await this.prisma.anonymousProfile.findUnique({ where: { publicId }, }); if (!profile) throw new DomainException( ErrorCode.NOT_FOUND, "Not found", HttpStatus.NOT_FOUND, ); return this.prisma.$transaction(async (tx) => { await this.locks.lockAccounts(tx, [actorId, profile.accountId]); const sanction = await this.issue(tx, actorId, profile.accountId, dto); await this.notifications.createInApp( tx, profile.accountId, `sanction:${sanction.id}:account-action`, "ACCOUNT_ACTION", { type: "ACCOUNT_ACTION" }, ); await tx.auditLog.create({ data: { actorId, action: "SANCTION_ISSUED", entityType: "ACCOUNT", entityId: profile.accountId, metadata: { type: dto.type }, }, }); return sanction; }); } async moderation(actorId: string, limit: number) { const items = await this.prisma.moderationTask.findMany({ where: { status: "COMPLETED", decision: "MANUAL_REVIEW", targetType: { in: ["PROFILE", "BOTTLE"] }, }, select: { id: true, targetType: true, targetId: true, riskLabels: true, createdAt: true, }, orderBy: { createdAt: "asc" }, take: limit, }); await this.prisma.auditLog.create({ data: { actorId, action: "MODERATION_QUERIED", entityType: "MODERATION_TASK", metadata: { limit, resultCount: items.length }, }, }); return { items }; } async moderate( actorId: string, id: string, decision: "APPROVED" | "REJECTED", reason: string, ) { return this.prisma.$transaction(async (tx) => { // Only the worker's terminal MANUAL_REVIEW result may be overridden. The // outbox must already be published, so no leased worker can later write // a stale moderation decision over the administrator's decision. const task = await tx.moderationTask.findUnique({ where: { id } }); if ( !task || task.status !== "COMPLETED" || task.decision !== "MANUAL_REVIEW" || (task.targetType !== "BOTTLE" && task.targetType !== "PROFILE") ) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Task not awaiting manual review", HttpStatus.CONFLICT, ); const event = await tx.outboxEvent.findFirst({ where: { eventType: task.targetType === "BOTTLE" ? "BOTTLE_MODERATION_REQUESTED" : "PROFILE_MODERATION_REQUESTED", aggregateId: task.targetId, status: "PUBLISHED", payload: { path: ["taskId"], equals: task.id }, }, }); if (!event) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Moderation event not published", HttpStatus.CONFLICT, ); if (task.targetType === "BOTTLE") { const bottle = await tx.bottle.findUnique({ where: { id: task.targetId }, }); if ( !bottle || bottle.reviewStatus !== "MANUAL_REVIEW" || bottle.poolStatus !== "CLOSED" || task.payloadHash !== createHash("sha256").update(bottle.contentText).digest("hex") ) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Bottle state changed", HttpStatus.CONFLICT, ); const updated = await tx.bottle.updateMany({ where: { id: bottle.id, reviewStatus: "MANUAL_REVIEW", poolStatus: "CLOSED", contentText: bottle.contentText, }, data: { reviewStatus: decision, poolStatus: decision === "APPROVED" ? "IN_POOL" : "CLOSED", approvedAt: decision === "APPROVED" ? new Date() : null, }, }); if (updated.count !== 1) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Bottle state changed", HttpStatus.CONFLICT, ); } else { const profile = await tx.anonymousProfile.findUnique({ where: { id: task.targetId }, }); const hash = profile && createHash("sha256") .update( JSON.stringify([ profile.nickname, profile.avatarColor, profile.bio, ]), ) .digest("hex"); const version = Number( event.dedupeKey.slice(event.dedupeKey.lastIndexOf(":") + 1), ); if ( !profile || profile.reviewStatus !== "MANUAL_REVIEW" || profile.version !== version || hash !== task.payloadHash ) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Profile state changed", HttpStatus.CONFLICT, ); const updated = await tx.anonymousProfile.updateMany({ where: { id: profile.id, reviewStatus: "MANUAL_REVIEW", version }, data: { reviewStatus: decision }, }); if (updated.count !== 1) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Profile state changed", HttpStatus.CONFLICT, ); } const changed = await tx.moderationTask.updateMany({ where: { id, status: "COMPLETED", decision: "MANUAL_REVIEW", payloadHash: task.payloadHash, }, data: { decision, result: { decision }, assignedToId: actorId, reviewedAt: new Date(), }, }); if (changed.count !== 1) throw new DomainException( ErrorCode.IDEMPOTENCY_CONFLICT, "Task already resolved", HttpStatus.CONFLICT, ); await tx.auditLog.create({ data: { actorId, action: "MODERATION_RESOLVED", entityType: task.targetType, entityId: task.targetId, metadata: { decision, reason, previousDecision: "MANUAL_REVIEW", eventId: event.id, }, }, }); return { id, status: "COMPLETED", decision }; }); } private async issue( tx: Prisma.TransactionClient, actorId: string, accountId: string, dto: SanctionDto, moderationTaskId?: string, ) { if ( dto.expiresAt && (new Date(dto.expiresAt).getTime() <= Date.now() || new Date(dto.expiresAt).getTime() > Date.now() + 365 * 24 * 60 * 60 * 1000) ) throw new DomainException( ErrorCode.VALIDATION_ERROR, "Invalid sanction expiry", HttpStatus.BAD_REQUEST, ); const sanction = await tx.sanction.create({ data: { accountId, issuedById: actorId, moderationTaskId: moderationTaskId ?? null, type: dto.type, reason: dto.reason, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, }, }); if (dto.type === "SUSPENSION" || dto.type === "BAN") { await tx.account.update({ where: { id: accountId }, data: { tokenVersion: { increment: 1 } }, }); await tx.session.updateMany({ where: { accountId, revokedAt: null }, data: { revokedAt: new Date() }, }); } return sanction; } }