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. +

+