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