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
1230 lines
49 KiB
TypeScript
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 };
|
|
}),
|
|
});
|