diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f83d920..a52d71a 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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)
diff --git a/src/app/(app)/pets/[petId]/edit/page.tsx b/src/app/(app)/pets/[petId]/edit/page.tsx
index c50953a..d9b66e5 100644
--- a/src/app/(app)/pets/[petId]/edit/page.tsx
+++ b/src/app/(app)/pets/[petId]/edit/page.tsx
@@ -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() {
+ {/* Verification */}
+
+
+
Verification
+ {pet.isVerified ? (
+
+
+ This pet is verified.
+
+ ) : verificationRequest?.status === "PENDING" ? (
+
+ Your verification request is pending review.
+
+ ) : (
+
+ {verificationRequest?.status === "REJECTED" && verificationRequest.reviewNote && (
+
+ Previous request was rejected: {verificationRequest.reviewNote}
+
+ )}
+
+ 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.
+
+
+ )}
+
+
{/* Destructive zone */}
diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx
index 997d34c..6482169 100644
--- a/src/app/p/[secret]/layout.tsx
+++ b/src/app/p/[secret]/layout.tsx
@@ -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 (
diff --git a/src/app/p/[secret]/verification/page.tsx b/src/app/p/[secret]/verification/page.tsx
new file mode 100644
index 0000000..1598ec0
--- /dev/null
+++ b/src/app/p/[secret]/verification/page.tsx
@@ -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
;
+
+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("PENDING");
+ const [reviewTarget, setReviewTarget] = useState(null);
+ const [checklist, setChecklist] = useState(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 (
+
+
+
Verification Requests
+ setStatus(v as RequestStatus)}>
+
+
+
+
+ Pending
+ Approved
+ Rejected
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : requests.length === 0 ? (
+
+
+
No {status.toLowerCase()} requests.
+
+ ) : (
+
+ {requests.map((req) => (
+
+
+
+
+ {req.pet.name}
+
+ {req.pet.isVerified && (
+
+
+ Verified
+
+ )}
+ {req.pet.ownerId}
+
+ {new Date(req.createdAt).toLocaleString()}
+
+
+
{req.message}
+ {req.status !== "PENDING" && req.reviewNote && (
+
+ {req.status === "APPROVED" ? "Genehmigt" : "Abgelehnt"}: {req.reviewNote}
+
+ )}
+
+ {req.status === "PENDING" && (
+
openReview(req.id)}
+ >
+ Review
+
+ )}
+
+ ))}
+
+ {hasNextPage && (
+
+ void fetchNextPage()}
+ disabled={isFetchingNextPage}
+ className="border-zinc-700 text-zinc-300"
+ >
+ {isFetchingNextPage ? (
+
+ ) : (
+
+ )}
+ Load more
+
+
+ )}
+
+ )}
+
+ {/* Review dialog — the checklist itself decides the outcome */}
+
{ if (!o) setReviewTarget(null); }}>
+
+
+ Review request
+
+
+
+ {CHECKLIST.map((c) => (
+
+
+ setChecklist((prev) => ({ ...prev, [c.key]: e.target.checked }))
+ }
+ className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
+ />
+
+ {c.label}
+ {c.hint}
+
+
+ ))}
+
+
+
+ {allChecked
+ ? "Wird genehmigt — alle Kriterien erfüllt."
+ : `Wird abgelehnt — fehlend: ${missingLabels.join(", ")}`}
+
+
+
+
+
+ );
+}
diff --git a/src/lib/team-pet.ts b/src/lib/team-pet.ts
new file mode 100644
index 0000000..b7cd614
--- /dev/null
+++ b/src/lib/team-pet.ts
@@ -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 {
+ 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 });
+}
diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts
index 2fa0de9..aa1100f 100644
--- a/src/trpc/routers/admin.ts
+++ b/src/trpc/routers/admin.ts
@@ -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." });
diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts
index 88f9df4..80ba01e 100644
--- a/src/trpc/routers/pets.ts
+++ b/src/trpc/routers/pets.ts
@@ -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" },
+ });
+ }),
});