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

+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" },
});
}),
});