Files
petfeed/src/trpc/routers/admin.ts
T
admin a4c4568065 feat(verification): self-service request flow with mandatory review checklist
Gitea #10 Option B:
- new VerificationRequest model — owner submits a message via the pet
  edit page, moderator reviews against a fixed 4-item checklist
  (authenticity/impersonation risk, complete profile, good standing,
  established presence). All four met -> approved, Pet.isVerified flips
  on; any unmet -> auto-rejected with an itemized note of what's missing
- new admin queue /p/[secret]/verification (Moderator+ only, same gate
  as the audit log), status filter, review dialog enforces the checklist
- owner is notified automatically either way via a "PawFeed Team" DM —
  extracted the team-pet lookup/create logic (previously only inline in
  startTeamConversation) into src/lib/team-pet.ts, adding sendTeamMessage
  which reuses the normal Conversation/Message + MESSAGE notification
- review decisions log to ModerationLog with role+IP, same as every
  other Gitea #10 action this session
2026-08-12 10:03:59 +02:00

1230 lines
49 KiB
TypeScript

import "server-only";
import { z } from "zod";
import { randomUUID } from "crypto";
import { TRPCError } from "@trpc/server";
import { AdminRoleType, type PrismaClient, type ReportReason } from "@prisma/client";
import { clerkClient } from "@clerk/nextjs/server";
import { router, protectedProcedure } from "@/trpc/init";
import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
} from "@/lib/supabase-storage";
import { buildDataExport } from "@/lib/gdpr-export";
import { uploadExport } from "@/lib/data-export-storage";
import { sendMail } from "@/lib/mail";
import { deleteOwnerAccount } from "@/lib/delete-account";
import { ensureTeamPet, sendTeamMessage } from "@/lib/team-pet";
const DATA_EXPORT_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/** True if userId is the env-bootstrapped super-owner or holds any AdminRole row. */
async function isAdmin(prisma: PrismaClient, userId: string): Promise<boolean> {
if (process.env.ADMIN_OWNER_ID === userId) return true;
const role = await prisma.adminRole.findUnique({ where: { ownerId: userId } });
return role !== null;
}
/** A caller's resolved admin identity for logging — "BOOTSTRAP" has no AdminRole row of its own. */
export type ResolvedAdminRole = AdminRoleType | "BOOTSTRAP";
const ROLE_RANK: Record<AdminRoleType, number> = {
[AdminRoleType.SUPPORT]: 0,
[AdminRoleType.MODERATOR]: 1,
[AdminRoleType.SUPER_ADMIN]: 2,
};
/**
* Resolves the caller's admin role, throwing FORBIDDEN unless it meets
* `minRole` on the SUPPORT < MODERATOR < SUPER_ADMIN scale (permission
* matrix, Gitea #10). Returns the resolved role so mutation call sites can
* stamp it onto the ModerationLog entry they write in the same request.
*/
export async function assertAdmin(
prisma: PrismaClient,
userId: string,
minRole: AdminRoleType = AdminRoleType.SUPPORT
): Promise<ResolvedAdminRole> {
if (process.env.ADMIN_OWNER_ID === userId) return "BOOTSTRAP";
const role = await prisma.adminRole.findUnique({ where: { ownerId: userId } });
if (!role || ROLE_RANK[role.role] < ROLE_RANK[minRole]) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return role.role;
}
type Granularity = "day" | "week" | "month";
const BUCKET_COUNT: Record<Granularity, number> = { day: 30, week: 12, month: 12 };
/** Truncates a date down to the start of its bucket, in UTC (day/ISO-week-Monday/month-1st). */
function truncateUTC(date: Date, granularity: Granularity): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
if (granularity === "month") return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
if (granularity === "week") {
const isoDow = d.getUTCDay() || 7; // Mon=1..Sun=7
d.setUTCDate(d.getUTCDate() - isoDow + 1);
}
return d;
}
function stepBackOneBucket(date: Date, granularity: Granularity): Date {
const d = new Date(date);
if (granularity === "month") d.setUTCMonth(d.getUTCMonth() - 1);
else if (granularity === "week") d.setUTCDate(d.getUTCDate() - 7);
else d.setUTCDate(d.getUTCDate() - 1);
return d;
}
/** Ordered bucket start-dates (oldest first) ending at the current bucket. */
function buildBuckets(granularity: Granularity): Date[] {
const buckets: Date[] = [];
let cursor = truncateUTC(new Date(), granularity);
for (let i = 0; i < BUCKET_COUNT[granularity]; i++) {
buckets.unshift(cursor);
cursor = stepBackOneBucket(cursor, granularity);
}
return buckets;
}
type ReportSeverity = "HIGH" | "MEDIUM" | "LOW";
/** Derived from ReportReason — reporters don't self-rate severity. */
const REPORT_SEVERITY: Record<ReportReason, ReportSeverity> = {
HARASSMENT: "HIGH",
INAPPROPRIATE: "HIGH",
MISINFORMATION: "MEDIUM",
SPAM: "LOW",
OTHER: "LOW",
};
const SEVERITY_RANK: Record<ReportSeverity, number> = { HIGH: 2, MEDIUM: 1, LOW: 0 };
/** Counts how many timestamps fall in each bucket (zero-filled — no gaps). */
function countByBucket(timestamps: Date[], buckets: Date[], granularity: Granularity): number[] {
const counts = new Map<number, number>();
for (const ts of timestamps) {
const key = truncateUTC(ts, granularity).getTime();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return buckets.map((b) => counts.get(b.getTime()) ?? 0);
}
export const adminRouter = router({
/** The caller's own admin identity — no minRole gate, anyone can ask "what am I". */
getMyRole: protectedProcedure.query(async ({ ctx }): Promise<ResolvedAdminRole | null> => {
if (process.env.ADMIN_OWNER_ID === ctx.userId) return "BOOTSTRAP";
const role = await ctx.prisma.adminRole.findUnique({ where: { ownerId: ctx.userId } });
return role?.role ?? null;
}),
// ─── Ban status check (no admin role required) ───────────────────────────────
getActiveBan: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
// Only the owner themselves or an admin should query ban status
if (input.ownerId !== ctx.userId && !(await isAdmin(ctx.prisma, ctx.userId))) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const ban = await ctx.prisma.userBan.findUnique({ where: { ownerId: input.ownerId } });
if (!ban) return null;
// Check if a temporary ban has expired
if (ban.expiresAt && ban.expiresAt < new Date()) {
await ctx.prisma.userBan.delete({ where: { id: ban.id } }).catch(() => {});
return null;
}
return ban;
}),
// ─── Dashboard ───────────────────────────────────────────────────────────────
getStats: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const [owners, pets, posts, bans, ads, reports] = await Promise.all([
ctx.prisma.owner.count(),
ctx.prisma.pet.count(),
ctx.prisma.post.count(),
ctx.prisma.userBan.count(),
ctx.prisma.advertisement.count({ where: { isActive: true } }),
ctx.prisma.report.count(),
]);
return { owners, pets, posts, bans, activeAds: ads, reports };
}),
/** New posts per bucket over the trailing window (30 days / 12 weeks / 12 months). */
getPostsOverTime: protectedProcedure
.input(z.object({ granularity: z.enum(["day", "week", "month"]) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const buckets = buildBuckets(input.granularity);
const posts = await ctx.prisma.post.findMany({
where: { createdAt: { gte: buckets[0] } },
select: { createdAt: true },
});
const counts = countByBucket(posts.map((p) => p.createdAt), buckets, input.granularity);
return buckets.map((date, i) => ({ date: date.toISOString(), value: counts[i]! }));
}),
/** Cumulative total owners per bucket — the growth curve, not just new signups. */
getOwnerGrowth: protectedProcedure
.input(z.object({ granularity: z.enum(["day", "week", "month"]) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const buckets = buildBuckets(input.granularity);
const [baselineCount, owners] = await Promise.all([
ctx.prisma.owner.count({ where: { createdAt: { lt: buckets[0] } } }),
ctx.prisma.owner.findMany({
where: { createdAt: { gte: buckets[0] } },
select: { createdAt: true },
}),
]);
const newPerBucket = countByBucket(owners.map((o) => o.createdAt), buckets, input.granularity);
let running = baselineCount;
const cumulative = newPerBucket.map((n) => (running += n));
return buckets.map((date, i) => ({ date: date.toISOString(), value: cumulative[i]! }));
}),
// ─── Post moderation ─────────────────────────────────────────────────────────
listPosts: protectedProcedure
.input(
z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const posts = await ctx.prisma.post.findMany({
where: input.search
? {
OR: [
{ caption: { contains: input.search, mode: "insensitive" } },
{ pet: { name: { contains: input.search, mode: "insensitive" } } },
],
}
: undefined,
include: {
pet: { select: { id: true, name: true, avatarKey: true, ownerId: true } },
images: { orderBy: { position: "asc" }, take: 1 },
videoPost: { select: { muxPlaybackId: true } },
milestone: true,
repost: {
include: {
originalPost: {
include: {
pet: { select: { id: true, name: true } },
images: { orderBy: { position: "asc" }, take: 1 },
milestone: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = posts.length > input.limit;
const items = hasNext ? posts.slice(0, input.limit) : posts;
// Report has no Prisma relation to Post (targetPostId is a raw string,
// not a FK — see listReports below), so report counts are computed
// separately rather than via _count.
const reportCounts = items.length
? await ctx.prisma.report.groupBy({
by: ["targetPostId"],
where: { targetPostId: { in: items.map((p) => p.id) } },
_count: true,
})
: [];
const reportCountMap = new Map(
reportCounts.map((r) => [r.targetPostId, r._count])
);
return {
posts: items.map((post) => ({
...post,
reportCount: reportCountMap.get(post.id) ?? 0,
})),
nextCursor: hasNext ? items[items.length - 1]!.id : null,
};
}),
deletePost: protectedProcedure
.input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
// If this post is itself a repost, deleting it must decrement the
// original post's repostCount — the same denormalized counter
// reposts.ts's user-facing delete keeps in sync (see Post model comment).
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { repost: { select: { originalPostId: true } } },
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.prisma.$transaction([
ctx.prisma.post.delete({ where: { id: input.postId } }),
...(post.repost
? [
ctx.prisma.post.update({
where: { id: post.repost.originalPostId },
data: { repostCount: { decrement: 1 } },
}),
]
: []),
ctx.prisma.moderationLog.create({
data: {
action: "DELETE_POST",
targetType: "POST",
targetId: input.postId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
listComments: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
return ctx.prisma.comment.findMany({
where: { postId: input.postId },
include: { pet: { select: { id: true, name: true, avatarKey: true } } },
orderBy: { createdAt: "asc" },
});
}),
deleteComment: protectedProcedure
.input(z.object({ commentId: z.string().min(1), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
await ctx.prisma.$transaction(async (tx) => {
const deleted = await tx.comment.delete({ where: { id: input.commentId } });
await tx.post.update({
where: { id: deleted.postId },
data: { commentCount: { decrement: 1 } },
});
await tx.moderationLog.create({
data: {
action: "DELETE_COMMENT",
targetType: "COMMENT",
targetId: input.commentId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
});
});
return { success: true };
}),
// ─── Users / bans ────────────────────────────────────────────────────────────
listUsers: protectedProcedure
.input(
z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
search: z.string().optional(),
banStatus: z.enum(["all", "banned", "active"]).default("all"),
role: z.union([z.literal("all"), z.literal("none"), z.nativeEnum(AdminRoleType)]).default("all"),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const clerk = await clerkClient();
// Owner has no local name/email (Clerk owns that) — resolve matching
// owner IDs by unioning Clerk's own query search (name/email/username)
// with a local Pet.name match, since admins commonly search by pet
// name too. Clerk search is capped at 100 matches for now.
let searchedIds: string[] | null = null;
if (input.search) {
const [clerkMatches, petMatches] = await Promise.all([
clerk.users.getUserList({ query: input.search, limit: 100 }),
ctx.prisma.pet.findMany({
where: { name: { contains: input.search, mode: "insensitive" } },
select: { ownerId: true },
}),
]);
searchedIds = [
...new Set([...clerkMatches.data.map((u) => u.id), ...petMatches.map((p) => p.ownerId)]),
];
if (searchedIds.length === 0) {
return { users: [], nextCursor: null };
}
}
const owners = await ctx.prisma.owner.findMany({
where: {
...(searchedIds ? { id: { in: searchedIds } } : {}),
...(input.banStatus === "banned" ? { ban: { isNot: null } } : {}),
...(input.banStatus === "active" ? { ban: null } : {}),
...(input.role === "none" ? { adminRole: null } : {}),
...(input.role !== "all" && input.role !== "none" ? { adminRole: { role: input.role } } : {}),
},
include: {
pets: { select: { id: true, name: true, avatarKey: true, isVerified: true }, take: 3 },
ban: true,
adminRole: true,
_count: { select: { pets: true, warnings: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = owners.length > input.limit;
const items = hasNext ? owners.slice(0, input.limit) : owners;
// Batch-fetch Clerk profile data (email/name) — Owner only stores the
// Clerk userId locally, everything else lives in Clerk (Gitea #6).
const clerkUsers = items.length > 0
? (await clerk.users.getUserList({ userId: items.map((o) => o.id), limit: items.length })).data
: [];
const clerkById = new Map(clerkUsers.map((u) => [u.id, u]));
const usersWithClerkData = items.map((owner) => {
const clerkUser = clerkById.get(owner.id);
const primaryEmail = clerkUser?.emailAddresses.find(
(e) => e.id === clerkUser.primaryEmailAddressId
)?.emailAddress;
return {
...owner,
clerk: clerkUser
? {
email: primaryEmail ?? null,
name: [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || null,
createdAt: new Date(clerkUser.createdAt),
}
: null,
};
});
return { users: usersWithClerkData, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
banUser: protectedProcedure
.input(
z.object({
ownerId: z.string().min(1),
reason: z.string().max(500).optional(),
expiresAt: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
if (input.ownerId === ctx.userId)
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot ban yourself." });
await ctx.prisma.$transaction([
ctx.prisma.userBan.upsert({
where: { ownerId: input.ownerId },
create: {
ownerId: input.ownerId,
reason: input.reason ?? null,
bannedBy: ctx.userId,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
},
update: {
reason: input.reason ?? null,
bannedBy: ctx.userId,
bannedAt: new Date(),
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
},
}),
ctx.prisma.moderationLog.create({
data: {
action: "BAN_USER",
targetType: "USER",
targetId: input.ownerId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
unbanUser: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
await ctx.prisma.$transaction([
ctx.prisma.userBan.delete({ where: { ownerId: input.ownerId } }),
ctx.prisma.moderationLog.create({
data: {
action: "UNBAN_USER",
targetType: "USER",
targetId: input.ownerId,
reason: null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
// ─── Warnings (append-only, no delete/edit — Gitea #10 permission matrix) ────
listWarnings: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.warning.findMany({
where: { ownerId: input.ownerId },
orderBy: { createdAt: "desc" },
});
}),
issueWarning: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), reason: z.string().min(1).max(500) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const [warning] = await ctx.prisma.$transaction([
ctx.prisma.warning.create({
data: { ownerId: input.ownerId, reason: input.reason, issuedBy: ctx.userId },
}),
ctx.prisma.moderationLog.create({
data: {
action: "WARN_USER",
targetType: "USER",
targetId: input.ownerId,
reason: input.reason,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return warning;
}),
// ─── Verification checkmark (lives on Pet, not Owner — pets are the social
// identity here, see CLAUDE.md) ────────────────────────────────────────────
setPetVerified: protectedProcedure
.input(z.object({ petId: z.string().min(1), isVerified: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const [pet] = await ctx.prisma.$transaction([
ctx.prisma.pet.update({
where: { id: input.petId },
data: { isVerified: input.isVerified },
}),
ctx.prisma.moderationLog.create({
data: {
action: input.isVerified ? "VERIFY_PET" : "UNVERIFY_PET",
targetType: "PET",
targetId: input.petId,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return pet;
}),
// ─── Self-service verification requests (Gitea #10) ──────────────────────────
listVerificationRequests: protectedProcedure
.input(
z.object({
status: z.enum(["PENDING", "APPROVED", "REJECTED"]).default("PENDING"),
cursor: z.string().nullish(),
limit: z.number().min(1).max(50).default(20),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const requests = await ctx.prisma.verificationRequest.findMany({
where: { status: input.status },
include: {
pet: { select: { id: true, name: true, avatarKey: true, isVerified: true, ownerId: true } },
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = requests.length > input.limit;
const items = hasNext ? requests.slice(0, input.limit) : requests;
return { requests: items, nextCursor: hasNext ? items[items.length - 1]!.id : null };
}),
// The checklist IS the decision — there's no separate approve/reject
// action. All four true → APPROVED + Pet.isVerified flips on. Any false →
// REJECTED with an itemized note built from the unmet items, so a
// moderator can't approve on vibes and can't reject without saying why.
reviewVerificationRequest: protectedProcedure
.input(
z.object({
requestId: z.string().min(1),
meetsAuthenticity: z.boolean(),
meetsCompleteProfile: z.boolean(),
meetsGoodStanding: z.boolean(),
meetsEstablishedPresence: z.boolean(),
note: z.string().max(500).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const request = await ctx.prisma.verificationRequest.findUnique({
where: { id: input.requestId },
select: { petId: true, status: true },
});
if (!request) throw new TRPCError({ code: "NOT_FOUND" });
if (request.status !== "PENDING") {
throw new TRPCError({ code: "BAD_REQUEST", message: "This request was already reviewed." });
}
const allMet =
input.meetsAuthenticity &&
input.meetsCompleteProfile &&
input.meetsGoodStanding &&
input.meetsEstablishedPresence;
const missing: string[] = [];
if (!input.meetsAuthenticity) missing.push("Echtheits-/Verwechslungsrisiko nicht erkennbar");
if (!input.meetsCompleteProfile) missing.push("Profil unvollständig");
if (!input.meetsGoodStanding) missing.push("Kein sauberer Stand (Ban/offene Verwarnung)");
if (!input.meetsEstablishedPresence) missing.push("Keine etablierte Präsenz");
const reviewNote = allMet
? (input.note ?? null)
: [`Fehlende Kriterien: ${missing.join(", ")}`, input.note].filter(Boolean).join(" — ");
await ctx.prisma.$transaction([
ctx.prisma.verificationRequest.update({
where: { id: input.requestId },
data: {
status: allMet ? "APPROVED" : "REJECTED",
meetsAuthenticity: input.meetsAuthenticity,
meetsCompleteProfile: input.meetsCompleteProfile,
meetsGoodStanding: input.meetsGoodStanding,
meetsEstablishedPresence: input.meetsEstablishedPresence,
reviewNote,
reviewedBy: ctx.userId,
reviewedAt: new Date(),
},
}),
...(allMet
? [ctx.prisma.pet.update({ where: { id: request.petId }, data: { isVerified: true } })]
: []),
ctx.prisma.moderationLog.create({
data: {
action: allMet ? "APPROVE_VERIFICATION_REQUEST" : "REJECT_VERIFICATION_REQUEST",
targetType: "PET",
targetId: request.petId,
reason: reviewNote,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
// Best-effort — the review itself already succeeded and is persisted;
// a failed notification must not roll that back or fail the request.
await sendTeamMessage(
ctx.prisma,
request.petId,
allMet
? "Gute Nachrichten — dein Verifizierungsantrag wurde genehmigt! Der Verifizierungs-Haken ist jetzt auf deinem Profil sichtbar."
: `Dein Verifizierungsantrag wurde leider abgelehnt.\n\n${reviewNote}\n\nDu kannst jederzeit erneut einen Antrag stellen, sobald die fehlenden Punkte erfüllt sind.`
).catch(() => {});
return { success: true, approved: allMet };
}),
// ─── Admin-triggered GDPR Art. 17 erasure ("right to be forgotten") ──────────
// Reuses the same deleteOwnerAccount() the self-service /api/account/delete
// route calls, so both paths stay in sync. `confirmation` must equal the
// target ownerId verbatim — mirrors the pet-name-confirmation friction step
// on the self-service flow, adapted since an admin acting on someone else's
// account can't "prove" they know a pet name the way the owner can.
deleteUserAccount: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), confirmation: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
if (input.ownerId === ctx.userId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot delete your own account here." });
}
if (input.confirmation !== input.ownerId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Confirmation does not match." });
}
await deleteOwnerAccount(ctx.prisma, input.ownerId);
// Written after deletion succeeds — targetId/moderatorId have no FK to
// Owner, so the now-deleted account doesn't block this insert.
await ctx.prisma.moderationLog.create({
data: {
action: "DELETE_ACCOUNT",
targetType: "USER",
targetId: input.ownerId,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
});
return { success: true };
}),
// ─── Team messaging (Gitea #6) ────────────────────────────────────────────────
// Reuses the existing pet-to-pet Conversation/Message model instead of a
// parallel admin-inbox system: a single "PawFeed Team" system Pet, owned by
// the env-bootstrapped ADMIN_OWNER_ID, DMs the target pet through the normal
// conversation flow. The admin panel switches its ActivePet to Team and
// opens the ordinary /messages/[conversationId] thread — no new UI needed on
// the recipient's side, and no changes to messages.ts.
//
// Known limitation: only the literal ADMIN_OWNER_ID account can act as Team
// (assertPetOwnership requires ctx.userId === pet.ownerId), since the Team
// pet needs a single owner. Extending this to every AdminRole holder would
// need either a shared-ownership model or relaxing that check for this pet
// specifically — out of scope for this first version.
startTeamConversation: protectedProcedure
.input(z.object({ targetPetId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const targetPet = await ctx.prisma.pet.findUnique({
where: { id: input.targetPetId },
select: { id: true },
});
if (!targetPet) throw new TRPCError({ code: "NOT_FOUND" });
const teamPet = await ensureTeamPet(ctx.prisma);
if (teamPet.id === input.targetPetId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot message the Team pet itself." });
}
const [petAId, petBId] = [teamPet.id, input.targetPetId].sort();
const conversation = await ctx.prisma.conversation.upsert({
where: { petAId_petBId: { petAId, petBId } },
update: {},
create: { petAId, petBId },
});
return { conversationId: conversation.id, teamPetId: teamPet.id };
}),
// ─── GDPR Art. 15 data export (Gitea #6, Plan B + pawfeed.org token follow-up) ─
// Aggregates everything held about an Owner (gdpr-export.ts), uploads it as
// JSON to a private Supabase Storage bucket, and emails a 7-day download
// link. The link points at /api/gdpr-export/[token] on pawfeed.org, backed
// by a bespoke DataExportToken row — deliberately NOT Supabase's native
// signed URL, so the storage backend never appears in the URL the user
// sees (see Gitea #6 follow-up comment). Synchronous: acceptable for now,
// but a heavy account (many posts/messages) could make this request slow —
// flagged in the Gitea #6 research note as a scaling risk for later.
exportUserData: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId);
const exportData = await buildDataExport(ctx.prisma, input.ownerId);
if (!exportData) throw new TRPCError({ code: "NOT_FOUND" });
const email = exportData.account.clerkProfile?.email;
if (!email) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No email on file for this owner (Clerk profile missing or has no email).",
});
}
const key = `${input.ownerId}/${Date.now()}.json`;
await uploadExport(key, JSON.stringify(exportData, null, 2));
const exportToken = await ctx.prisma.dataExportToken.create({
data: {
ownerId: input.ownerId,
storageKey: key,
expiresAt: new Date(Date.now() + DATA_EXPORT_TOKEN_TTL_MS),
},
});
const downloadUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/gdpr-export/${exportToken.token}`;
await sendMail({
to: email,
subject: "Deine Daten bei PawFeed — Auskunft gemäß Art. 15 DSGVO",
text: `Hallo,\n\nanbei der Link zu deiner Datenauskunft gemäß Art. 15 DSGVO. Der Link ist 7 Tage gültig:\n\n${downloadUrl}\n\nBei Fragen antworte einfach auf diese E-Mail.\n\nDein PawFeed-Team`,
html: `<p>Hallo,</p><p>anbei der Link zu deiner Datenauskunft gemäß Art. 15 DSGVO. Der Link ist <strong>7 Tage</strong> gültig:</p><p><a href="${downloadUrl}">${downloadUrl}</a></p><p>Bei Fragen antworte einfach auf diese E-Mail.</p><p>Dein PawFeed-Team</p>`,
});
await ctx.prisma.moderationLog.create({
data: {
action: "DATA_EXPORT",
targetType: "USER",
targetId: input.ownerId,
reason: null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
});
return { success: true, sentTo: email };
}),
// ─── Reports ─────────────────────────────────────────────────────────────────
listReports: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const reports = await ctx.prisma.report.findMany({
include: {
reporter: { select: { id: true, name: true, avatarKey: true } },
},
orderBy: { createdAt: "desc" },
take: 100,
});
// targetPostId is a raw string — no Prisma relation, so fetch posts separately
const postIds = [...new Set(
reports.map((r) => r.targetPostId).filter((id): id is string => !!id)
)];
const posts = postIds.length > 0
? await ctx.prisma.post.findMany({
where: { id: { in: postIds } },
select: {
id: true,
caption: true,
type: true,
createdAt: true,
reactionCount: true,
commentCount: true,
pet: { select: { id: true, name: true, avatarKey: true } },
images: {
orderBy: { position: "asc" },
take: 4,
select: { storageKey: true, position: true, blurhash: true },
},
},
})
: [];
const postMap = new Map(posts.map((p) => [p.id, p]));
// Severity-first, newest-within-severity — reports don't carry an
// explicit severity column, it's derived from `reason` (Gitea #10).
return reports
.map((r) => ({
...r,
severity: REPORT_SEVERITY[r.reason],
post: r.targetPostId ? (postMap.get(r.targetPostId) ?? null) : null,
}))
.sort(
(a, b) =>
SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
b.createdAt.getTime() - a.createdAt.getTime()
);
}),
dismissReport: protectedProcedure
.input(z.object({ reportId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const report = await ctx.prisma.report.findUnique({ where: { id: input.reportId } });
if (!report) throw new TRPCError({ code: "NOT_FOUND" });
await ctx.prisma.$transaction([
ctx.prisma.report.delete({ where: { id: input.reportId } }),
ctx.prisma.moderationLog.create({
data: {
action: "DISMISS_REPORT",
targetType: "REPORT",
targetId: input.reportId,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
deleteReportedPost: protectedProcedure
.input(z.object({ postId: z.string().min(1), reason: z.string().max(500).optional() }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
// Same repostCount fix as deletePost above.
const post = await ctx.prisma.post.findUnique({
where: { id: input.postId },
select: { repost: { select: { originalPostId: true } } },
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
// Delete the post (cascades to reports, comments, reactions via Prisma schema)
await ctx.prisma.$transaction([
ctx.prisma.post.delete({ where: { id: input.postId } }),
...(post.repost
? [
ctx.prisma.post.update({
where: { id: post.repost.originalPostId },
data: { repostCount: { decrement: 1 } },
}),
]
: []),
ctx.prisma.moderationLog.create({
data: {
action: "DELETE_POST",
targetType: "POST",
targetId: input.postId,
reason: input.reason ?? null,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
// ─── Ad media upload ─────────────────────────────────────────────────────────
getAdImagePresignedUrl: protectedProcedure
.input(
z.object({
contentType: z.enum(ALLOWED_CONTENT_TYPES),
fileSize: z.number().max(MAX_FILE_SIZE, "Image must be under 10 MB"),
})
)
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const ext = input.contentType.split("/")[1]!.replace("jpeg", "jpg");
const key = `ads/${ctx.userId}/${randomUUID()}.${ext}`;
const { signedUrl } = await createSignedUploadUrl(key);
return { presignedUrl: signedUrl, key };
}),
// ─── Advertisements ──────────────────────────────────────────────────────────
listAds: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.advertisement.findMany({ orderBy: { createdAt: "desc" } });
}),
createAd: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(100),
caption: z.string().max(500).optional(),
imageKey: z.string().optional(),
targetUrl: z.string().url().or(z.literal("")).optional(),
isActive: z.boolean().default(true),
species: z.array(z.string()).default([]),
breeds: z.array(z.string()).default([]),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
// Interactive transaction (not the array form used elsewhere in this file)
// because the log entry needs the DB-generated ad id, which only exists
// after the create resolves.
return ctx.prisma.$transaction(async (tx) => {
const ad = await tx.advertisement.create({
data: {
title: input.title,
caption: input.caption ?? null,
imageKey: input.imageKey ?? null,
targetUrl: input.targetUrl || null,
isActive: input.isActive,
species: input.species,
breeds: input.breeds,
startDate: input.startDate ? new Date(input.startDate) : null,
endDate: input.endDate ? new Date(input.endDate) : null,
createdBy: ctx.userId,
},
});
await tx.moderationLog.create({
data: {
action: "CREATE_AD",
targetType: "AD",
targetId: ad.id,
reason: input.title,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
});
return ad;
});
}),
updateAd: protectedProcedure
.input(
z.object({
id: z.string().min(1),
title: z.string().min(1).max(100).optional(),
caption: z.string().max(500).nullable().optional(),
imageKey: z.string().nullable().optional(),
targetUrl: z.string().url().or(z.literal("")).nullable().optional(),
isActive: z.boolean().optional(),
species: z.array(z.string()).optional(),
breeds: z.array(z.string()).optional(),
startDate: z.string().datetime().nullable().optional(),
endDate: z.string().datetime().nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const { id, targetUrl, startDate, endDate, ...rest } = input;
const [ad] = await ctx.prisma.$transaction([
ctx.prisma.advertisement.update({
where: { id },
data: {
...rest,
targetUrl: targetUrl === "" ? null : targetUrl,
startDate: startDate ? new Date(startDate) : startDate === null ? null : undefined,
endDate: endDate ? new Date(endDate) : endDate === null ? null : undefined,
},
}),
ctx.prisma.moderationLog.create({
data: {
action: "UPDATE_AD",
targetType: "AD",
targetId: id,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return ad;
}),
deleteAd: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction([
ctx.prisma.advertisement.delete({ where: { id: input.id } }),
ctx.prisma.moderationLog.create({
data: {
action: "DELETE_AD",
targetType: "AD",
targetId: input.id,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
// Species/breed targeting (Gitea #9): species is the primary filter — empty
// means "all species", a non-empty match required otherwise. breeds, if set,
// narrows WITHIN an already-matched species (e.g. "Cats" + "Siamese" shows
// only to Siamese cats, while "Cats" alone with no breeds shows to any cat).
// A pet with no breed set never matches a breed-narrowed ad.
getActiveAds: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const pet = await ctx.prisma.pet.findUnique({
where: { id: input.petId },
select: { speciesId: true, breedId: true },
});
if (!pet) return [];
const now = new Date();
const ads = await ctx.prisma.advertisement.findMany({
where: {
isActive: true,
OR: [{ startDate: null }, { startDate: { lte: now } }],
AND: [{ OR: [{ endDate: null }, { endDate: { gte: now } }] }],
},
orderBy: { createdAt: "desc" },
});
return ads.filter((ad) => {
if (ad.species.length === 0) return true;
if (!ad.species.includes(pet.speciesId)) return false;
if (ad.breeds.length === 0) return true;
return !!pet.breedId && ad.breeds.includes(pet.breedId);
});
}),
// ─── Moderator management (grant/revoke stay super-admin only) ───────────────
listModerators: protectedProcedure.query(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.adminRole.findMany({
include: {
owner: {
include: { pets: { select: { name: true, avatarKey: true }, take: 1 } },
},
},
orderBy: { grantedAt: "desc" },
});
}),
grantRole: protectedProcedure
.input(z.object({ ownerId: z.string().min(1), role: z.nativeEnum(AdminRoleType) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const [role] = await ctx.prisma.$transaction([
ctx.prisma.adminRole.upsert({
where: { ownerId: input.ownerId },
create: { ownerId: input.ownerId, role: input.role, grantedBy: ctx.userId },
update: { role: input.role, grantedBy: ctx.userId, grantedAt: new Date() },
}),
ctx.prisma.moderationLog.create({
data: {
action: "GRANT_ROLE",
targetType: "USER",
targetId: input.ownerId,
reason: input.role,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return role;
}),
revokeRole: protectedProcedure
.input(z.object({ ownerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const callerRole = await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
await ctx.prisma.$transaction([
ctx.prisma.adminRole.delete({ where: { ownerId: input.ownerId } }),
ctx.prisma.moderationLog.create({
data: {
action: "REVOKE_ROLE",
targetType: "USER",
targetId: input.ownerId,
moderatorId: ctx.userId,
moderatorRole: callerRole,
ipAddress: ctx.ip,
},
}),
]);
return { success: true };
}),
getModerationLog: protectedProcedure
.input(
z.object({
cursor: z.string().nullish(),
limit: z.number().min(1).max(100).default(50),
action: z.string().optional(),
targetType: z.string().optional(),
moderatorId: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const entries = await ctx.prisma.moderationLog.findMany({
where: {
...(input.action ? { action: input.action } : {}),
...(input.targetType ? { targetType: input.targetType } : {}),
...(input.moderatorId ? { moderatorId: input.moderatorId } : {}),
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
});
const hasNext = entries.length > input.limit;
const items = hasNext ? entries.slice(0, input.limit) : entries;
// moderatorId is a raw ownerId — resolve a display name the same way
// listModerators does, so the log doesn't just show opaque Clerk IDs.
const moderatorIds = [...new Set(items.map((e) => e.moderatorId))];
const owners = moderatorIds.length > 0
? await ctx.prisma.owner.findMany({
where: { id: { in: moderatorIds } },
select: { id: true, pets: { select: { name: true }, take: 1 } },
})
: [];
const nameByOwnerId = new Map(owners.map((o) => [o.id, o.pets[0]?.name ?? null]));
return {
entries: items.map((e) => ({ ...e, moderatorName: nameByOwnerId.get(e.moderatorId) ?? null })),
nextCursor: hasNext ? items[items.length - 1]!.id : null,
};
}),
/** Action count per moderator over the trailing window — throughput for the Moderators page. */
getModeratorThroughput: protectedProcedure
.input(z.object({ days: z.number().min(1).max(365).default(30) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
const since = new Date(Date.now() - input.days * 24 * 60 * 60 * 1000);
const grouped = await ctx.prisma.moderationLog.groupBy({
by: ["moderatorId"],
where: { createdAt: { gte: since } },
_count: true,
});
return grouped
.map((g) => ({ moderatorId: g.moderatorId, actionCount: g._count }))
.sort((a, b) => b.actionCount - a.actionCount);
}),
// ─── Invite codes ─────────────────────────────────────────────────────────────
listInviteCodes: protectedProcedure
.input(z.object({ limit: z.number().min(1).max(200).default(100) }))
.query(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
return ctx.prisma.inviteCode.findMany({
orderBy: { createdAt: "desc" },
take: input.limit,
include: {
issuedBy: { select: { id: true, pets: { select: { name: true }, take: 1 } } },
},
});
}),
createRootInvite: protectedProcedure.mutation(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const { generateInviteCode } = await import("@/lib/invite-codes");
return ctx.prisma.inviteCode.create({
data: { code: generateInviteCode(), issuedById: null },
});
}),
revokeInvite: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.MODERATOR);
await ctx.prisma.inviteCode.update({
where: { id: input.id },
data: { revoked: true },
});
return { success: true };
}),
backfillInviteCodes: protectedProcedure.mutation(async ({ ctx }) => {
await assertAdmin(ctx.prisma, ctx.userId, AdminRoleType.SUPER_ADMIN);
const { generateInviteCode, INVITE_CODES_PER_USER } = await import("@/lib/invite-codes");
const ownersWithoutCodes = await ctx.prisma.owner.findMany({
where: { NOT: { issuedInvites: { some: {} } } },
select: { id: true },
});
if (ownersWithoutCodes.length === 0) return { updated: 0 };
await ctx.prisma.inviteCode.createMany({
data: ownersWithoutCodes.flatMap(({ id }) =>
Array.from({ length: INVITE_CODES_PER_USER }, () => ({
code: generateInviteCode(),
issuedById: id,
}))
),
});
return { updated: ownersWithoutCodes.length };
}),
});