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:
1 parent
bc2dfa9a2d
commit
a4c4568065
7 files changed
+606
-41
No files matched your search
+35
-1
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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." });
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user