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
This commit is contained in:
admin committed 2026-08-12 10:03:59 +02:00
1 parent bc2dfa9a2d
commit a4c4568065
7 files changed
+606 -41

No files matched your search

+35 -1
View File
@@ -36,6 +36,38 @@ model Warning {
@@index([ownerId, createdAt(sort: Desc)])
}
enum VerificationRequestStatus {
PENDING
APPROVED
REJECTED
}
// Self-service verification request (Gitea #10). Reviewing is a fixed
// 4-item checklist, not free-form moderator judgment alone — all four must
// be true to approve; any false auto-rejects with an itemized note. The
// owner is notified via an automated "PawFeed Team" DM either way
// (src/lib/team-pet.ts) — see admin.reviewVerificationRequest.
model VerificationRequest {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String
message String @db.VarChar(1000) // requester's own justification
status VerificationRequestStatus @default(PENDING)
meetsAuthenticity Boolean? // real/notable pet — impersonation risk is the actual point of the badge
meetsCompleteProfile Boolean? // real name, bio, avatar set
meetsGoodStanding Boolean? // no active ban, no open warnings
meetsEstablishedPresence Boolean? // account has existed a while and actually posted
reviewNote String? @db.VarChar(1000)
reviewedBy String? // ownerId of the moderator who decided
reviewedAt DateTime?
createdAt DateTime @default(now())
@@index([status, createdAt(sort: Desc)])
@@index([petId])
}
model InviteCode {
id String @id @default(cuid())
code String @unique
@@ -144,6 +176,8 @@ model Pet {
adComments AdComment[] @relation("AdComments")
adReposts AdRepost[] @relation("AdReposts")
verificationRequests VerificationRequest[]
@@index([ownerId])
@@index([speciesId])
@@index([breedId])
@@ -652,7 +686,7 @@ model AdRepost {
model ModerationLog {
id String @id @default(cuid())
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT
action String @db.VarChar(50) // DELETE_POST | DELETE_COMMENT | BAN_USER | UNBAN_USER | DELETE_IMAGE | DISMISS_REPORT | CREATE_AD | UPDATE_AD | DELETE_AD | GRANT_ROLE | REVOKE_ROLE | WARN_USER | VERIFY_PET | UNVERIFY_PET | DELETE_ACCOUNT | APPROVE_VERIFICATION_REQUEST | REJECT_VERIFICATION_REQUEST
targetType String @db.VarChar(20) // POST | COMMENT | USER | IMAGE | AD | REPORT | PET
targetId String
reason String? @db.VarChar(500)
+66
View File
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { BadgeCheck } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { PetForm } from "@/components/pet/PetForm";
@@ -11,6 +12,7 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
@@ -48,6 +50,7 @@ export default function EditPetPage() {
const { activePetId, setActivePet } = useActivePet();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dmPolicy, setDmPolicy] = useState<"EVERYONE" | "FOLLOWERS_ONLY">("EVERYONE");
const [verificationMessage, setVerificationMessage] = useState("");
const {
data: pet,
@@ -57,6 +60,22 @@ export default function EditPetPage() {
...trpc.pets.byId.queryOptions({ petId }),
});
const { data: verificationRequest } = useQuery(
trpc.pets.getVerificationRequest.queryOptions({ petId })
);
const requestVerificationMutation = useMutation({
...trpc.pets.requestVerification.mutationOptions(),
onSuccess: () => {
toast.success("Verification request submitted.");
setVerificationMessage("");
void queryClient.invalidateQueries({
queryKey: trpc.pets.getVerificationRequest.queryKey({ petId }),
});
},
onError: (err) => toast.error(err.message),
});
// Sync dmPolicy from fetched pet
useEffect(() => {
if (pet?.dmPolicy) setDmPolicy(pet.dmPolicy as "EVERYONE" | "FOLLOWERS_ONLY");
@@ -161,6 +180,53 @@ export default function EditPetPage() {
</div>
</div>
{/* Verification */}
<Separator className="my-8" />
<div className="space-y-3">
<h2 className="text-sm font-medium">Verification</h2>
{pet.isVerified ? (
<p className="text-sm text-muted-foreground flex items-center gap-1.5">
<BadgeCheck className="h-4 w-4 fill-blue-500 text-background" />
This pet is verified.
</p>
) : verificationRequest?.status === "PENDING" ? (
<p className="text-sm text-muted-foreground">
Your verification request is pending review.
</p>
) : (
<div className="space-y-2">
{verificationRequest?.status === "REJECTED" && verificationRequest.reviewNote && (
<p className="text-xs text-destructive">
Previous request was rejected: {verificationRequest.reviewNote}
</p>
)}
<p className="text-xs text-muted-foreground">
We look for: a real, notable pet where impersonation would be plausible
(breeder, shelter, media pet), a complete profile, good standing, and an
established, active presence.
</p>
<Textarea
placeholder="Why should this pet be verified?"
value={verificationMessage}
onChange={(e) => setVerificationMessage(e.target.value)}
/>
<Button
variant="outline"
size="sm"
disabled={requestVerificationMutation.isPending || !verificationMessage.trim()}
onClick={() =>
requestVerificationMutation.mutate({
petId: pet.id,
message: verificationMessage.trim(),
})
}
>
Request verification
</Button>
</div>
)}
</div>
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
+8 -2
View File
@@ -12,6 +12,7 @@ import {
Ticket,
Scale,
ScrollText,
BadgeCheck,
} from "lucide-react";
interface AdminLayoutProps {
@@ -43,7 +44,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
notFound();
}
const canSeeAuditLog = myRole === "BOOTSTRAP" || myRole !== "SUPPORT";
const isModeratorOrAbove = myRole === "BOOTSTRAP" || myRole !== "SUPPORT";
const base = `/p/${secret}`;
@@ -56,7 +57,12 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps
{ href: `${base}/invites`, label: "Invites", Icon: Ticket },
{ href: `${base}/moderators`, label: "Moderators", Icon: ShieldCheck },
{ href: `${base}/legal`, label: "Legal", Icon: Scale },
...(canSeeAuditLog ? [{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText }] : []),
...(isModeratorOrAbove
? [
{ href: `${base}/log`, label: "Audit Log", Icon: ScrollText },
{ href: `${base}/verification`, label: "Verification", Icon: BadgeCheck },
]
: []),
];
return (
+259
View File
@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { Loader2, ChevronDown, BadgeCheck, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
type RequestStatus = "PENDING" | "APPROVED" | "REJECTED";
type ChecklistKey =
| "meetsAuthenticity"
| "meetsCompleteProfile"
| "meetsGoodStanding"
| "meetsEstablishedPresence";
// Mirrors the criteria discussed for Gitea #10 — shown to moderators as a
// fixed checklist, not free-form judgment. All four checked → approved;
// any unchecked → rejected with an itemized note built from these labels.
const CHECKLIST: { key: ChecklistKey; label: string; hint: string }[] = [
{
key: "meetsAuthenticity",
label: "Echtheits-/Verwechslungsrisiko",
hint: "Bekannter Züchter, Tierheim, Medien-Tier o. Ä. — Verwechslung wäre plausibel",
},
{
key: "meetsCompleteProfile",
label: "Vollständiges Profil",
hint: "Echter Name, Bio, Avatar gesetzt",
},
{
key: "meetsGoodStanding",
label: "Sauberer Stand",
hint: "Kein aktiver Ban, keine offenen Verwarnungen",
},
{
key: "meetsEstablishedPresence",
label: "Etablierte Präsenz",
hint: "Account existiert schon eine Weile und hat gepostet",
},
];
type ChecklistState = Record<ChecklistKey, boolean>;
const EMPTY_CHECKLIST: ChecklistState = {
meetsAuthenticity: false,
meetsCompleteProfile: false,
meetsGoodStanding: false,
meetsEstablishedPresence: false,
};
export default function AdminVerificationPage() {
const trpc = useTRPC();
const qc = useQueryClient();
const [status, setStatus] = useState<RequestStatus>("PENDING");
const [reviewTarget, setReviewTarget] = useState<string | null>(null);
const [checklist, setChecklist] = useState<ChecklistState>(EMPTY_CHECKLIST);
const [note, setNote] = useState("");
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery(
trpc.admin.listVerificationRequests.infiniteQueryOptions(
{ status, limit: 20 },
{ getNextPageParam: (p) => p.nextCursor, initialCursor: undefined }
)
);
const requests = data?.pages.flatMap((p) => p.requests) ?? [];
const reviewMutation = useMutation(
trpc.admin.reviewVerificationRequest.mutationOptions({
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.admin.listVerificationRequests.queryKey() });
setReviewTarget(null);
setChecklist(EMPTY_CHECKLIST);
setNote("");
},
})
);
function openReview(requestId: string) {
setReviewTarget(requestId);
setChecklist(EMPTY_CHECKLIST);
setNote("");
}
const allChecked = Object.values(checklist).every(Boolean);
const missingLabels = CHECKLIST.filter((c) => !checklist[c.key]).map((c) => c.label);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-bold text-zinc-100">Verification Requests</h1>
<Select value={status} onValueChange={(v) => setStatus(v as RequestStatus)}>
<SelectTrigger className="w-40 bg-zinc-900 border-zinc-700 text-zinc-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
<SelectItem value="PENDING">Pending</SelectItem>
<SelectItem value="APPROVED">Approved</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-zinc-500" />
</div>
) : requests.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-16 text-zinc-500">
<CheckCheck className="h-8 w-8" />
<p className="text-sm">No {status.toLowerCase()} requests.</p>
</div>
) : (
<div className="space-y-2">
{requests.map((req) => (
<div
key={req.id}
className="flex items-start gap-3 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/pets/${req.pet.id}`}
target="_blank"
className="text-sm font-medium text-zinc-100 hover:underline"
>
{req.pet.name}
</Link>
{req.pet.isVerified && (
<Badge className="bg-blue-700 text-blue-100 text-[10px]">
<BadgeCheck className="h-3 w-3 mr-1" />
Verified
</Badge>
)}
<span className="text-xs text-zinc-600 font-mono">{req.pet.ownerId}</span>
<span className="text-xs text-zinc-600">
{new Date(req.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-zinc-300 mt-1 leading-snug">{req.message}</p>
{req.status !== "PENDING" && req.reviewNote && (
<p
className={`text-xs mt-1 ${
req.status === "APPROVED" ? "text-blue-400" : "text-red-400"
}`}
>
{req.status === "APPROVED" ? "Genehmigt" : "Abgelehnt"}: {req.reviewNote}
</p>
)}
</div>
{req.status === "PENDING" && (
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100 shrink-0"
onClick={() => openReview(req.id)}
>
Review
</Button>
)}
</div>
))}
{hasNextPage && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="border-zinc-700 text-zinc-300"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<ChevronDown className="h-4 w-4 mr-2" />
)}
Load more
</Button>
</div>
)}
</div>
)}
{/* Review dialog — the checklist itself decides the outcome */}
<Dialog open={!!reviewTarget} onOpenChange={(o) => { if (!o) setReviewTarget(null); }}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-zinc-100 max-w-md">
<DialogHeader>
<DialogTitle>Review request</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{CHECKLIST.map((c) => (
<label key={c.key} className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={checklist[c.key]}
onChange={(e) =>
setChecklist((prev) => ({ ...prev, [c.key]: e.target.checked }))
}
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
/>
<span>
<span className="block text-sm text-zinc-100">{c.label}</span>
<span className="block text-xs text-zinc-500">{c.hint}</span>
</span>
</label>
))}
</div>
<div
className={`rounded-lg border px-3 py-2 text-xs ${
allChecked
? "border-blue-700 bg-blue-950/30 text-blue-300"
: "border-red-800 bg-red-950/20 text-red-300"
}`}
>
{allChecked
? "Wird genehmigt — alle Kriterien erfüllt."
: `Wird abgelehnt — fehlend: ${missingLabels.join(", ")}`}
</div>
<Textarea
placeholder="Zusätzliche Notiz (optional)…"
value={note}
onChange={(e) => setNote(e.target.value)}
className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder:text-zinc-500"
/>
<DialogFooter className="gap-2">
<Button variant="ghost" className="text-zinc-400" onClick={() => setReviewTarget(null)}>
Cancel
</Button>
<Button
onClick={() =>
reviewMutation.mutate({
requestId: reviewTarget!,
...checklist,
note: note.trim() || undefined,
})
}
disabled={reviewMutation.isPending}
className={allChecked ? "bg-blue-700 hover:bg-blue-600" : "bg-red-700 hover:bg-red-600"}
>
{reviewMutation.isPending && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
{allChecked ? "Approve" : "Reject"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import "server-only";
import type { PrismaClient } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import { notify } from "@/lib/notify";
/**
* Ensures the "PawFeed Team" system pet (owned by the env-bootstrapped
* ADMIN_OWNER_ID) exists, creating it on first use. Shared by
* admin.startTeamConversation and sendTeamMessage below — previously this
* lookup/create logic lived only inline in startTeamConversation.
*/
export async function ensureTeamPet(prisma: PrismaClient): Promise<{ id: string }> {
const adminOwnerId = process.env.ADMIN_OWNER_ID;
if (!adminOwnerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "ADMIN_OWNER_ID is not configured.",
});
}
let teamPet = await prisma.pet.findFirst({
where: { ownerId: adminOwnerId, name: "PawFeed Team" },
select: { id: true },
});
if (!teamPet) {
// Ensure the Owner row + at least one Species exist before creating
// the pet — same FK requirements as any normal pet.create.
await prisma.owner.upsert({
where: { id: adminOwnerId },
update: {},
create: { id: adminOwnerId },
});
const species = await prisma.species.findFirst({ select: { id: true } });
if (!species) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No Species rows exist — run the seed script first.",
});
}
teamPet = await prisma.pet.create({
data: {
ownerId: adminOwnerId,
speciesId: species.id,
name: "PawFeed Team",
bio: "Offizieller Support-Account von PawFeed.",
},
select: { id: true },
});
}
return teamPet;
}
/**
* Sends an automated message from the "PawFeed Team" system pet to a target
* pet — reuses the normal Conversation/Message model (same as any pet-to-pet
* DM) and fires the standard MESSAGE notification, so it shows up in the
* recipient's inbox/notifications exactly like a real message would.
*/
export async function sendTeamMessage(
prisma: PrismaClient,
targetPetId: string,
body: string
): Promise<void> {
const teamPet = await ensureTeamPet(prisma);
if (teamPet.id === targetPetId) return;
const [petAId, petBId] = [teamPet.id, targetPetId].sort();
const conversation = await prisma.conversation.upsert({
where: { petAId_petBId: { petAId, petBId } },
update: {},
create: { petAId, petBId },
});
await prisma.$transaction([
prisma.message.create({
data: { conversationId: conversation.id, senderPetId: teamPet.id, body },
}),
prisma.conversation.update({
where: { id: conversation.id },
data: { updatedAt: new Date() },
}),
]);
notify(prisma, { recipientPetId: targetPetId, type: "MESSAGE", actorPetId: teamPet.id });
}
+113 -38
View File
@@ -14,6 +14,7 @@ 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
@@ -530,6 +531,117 @@ export const adminRouter = router({
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
@@ -583,50 +695,13 @@ export const adminRouter = router({
.mutation(async ({ ctx, input }) => {
await assertAdmin(ctx.prisma, ctx.userId);
const adminOwnerId = process.env.ADMIN_OWNER_ID;
if (!adminOwnerId) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "ADMIN_OWNER_ID is not configured.",
});
}
const targetPet = await ctx.prisma.pet.findUnique({
where: { id: input.targetPetId },
select: { id: true },
});
if (!targetPet) throw new TRPCError({ code: "NOT_FOUND" });
let teamPet = await ctx.prisma.pet.findFirst({
where: { ownerId: adminOwnerId, name: "PawFeed Team" },
select: { id: true },
});
if (!teamPet) {
// Ensure the Owner row + at least one Species exist before creating
// the pet — same FK requirements as any normal pet.create.
await ctx.prisma.owner.upsert({
where: { id: adminOwnerId },
update: {},
create: { id: adminOwnerId },
});
const species = await ctx.prisma.species.findFirst({ select: { id: true } });
if (!species) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "No Species rows exist — run the seed script first.",
});
}
teamPet = await ctx.prisma.pet.create({
data: {
ownerId: adminOwnerId,
speciesId: species.id,
name: "PawFeed Team",
bio: "Offizieller Support-Account von PawFeed.",
},
select: { id: true },
});
}
const teamPet = await ensureTeamPet(ctx.prisma);
if (teamPet.id === input.targetPetId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot message the Team pet itself." });
+38
View File
@@ -237,4 +237,42 @@ export const petsRouter = router({
: b.name,
}));
}),
// ─── Self-service verification requests (Gitea #10) ──────────────────────────
requestVerification: protectedProcedure
.input(z.object({ petId: z.string().min(1), message: z.string().min(1).max(1000) }))
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const pet = await ctx.prisma.pet.findUnique({
where: { id: input.petId },
select: { isVerified: true },
});
if (!pet) throw new TRPCError({ code: "NOT_FOUND" });
if (pet.isVerified) {
throw new TRPCError({ code: "BAD_REQUEST", message: "This pet is already verified." });
}
const existingPending = await ctx.prisma.verificationRequest.findFirst({
where: { petId: input.petId, status: "PENDING" },
});
if (existingPending) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A request is already pending." });
}
return ctx.prisma.verificationRequest.create({
data: { petId: input.petId, message: input.message },
});
}),
/** Most recent verification request for this pet, if any — drives the status UI on the edit page. */
getVerificationRequest: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.verificationRequest.findFirst({
where: { petId: input.petId },
orderBy: { createdAt: "desc" },
});
}),
});