feat(api): add user flagging functionality

This commit is contained in:
Fatih Kadir Akın
2025-12-22 14:32:21 +03:00
parent 7ef373c73d
commit 613224a1f8
7 changed files with 106 additions and 5 deletions
+8 -2
View File
@@ -502,8 +502,14 @@
"admin": "Admins", "admin": "Admins",
"user": "Users", "user": "Users",
"verified": "Verified", "verified": "Verified",
"unverified": "Unverified" "unverified": "Unverified",
} "flagged": "Flagged"
},
"flag": "Flag User",
"unflag": "Unflag User",
"flagged": "User flagged",
"unflagged": "User unflagged",
"flagFailed": "Failed to update flag status"
}, },
"categories": { "categories": {
"title": "Category Management", "title": "Category Management",
@@ -0,0 +1,7 @@
-- AlterEnum
ALTER TYPE "DelistReason" ADD VALUE 'UNUSUAL_ACTIVITY';
-- AlterTable
ALTER TABLE "users" ADD COLUMN "flagged" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "users" ADD COLUMN "flaggedAt" TIMESTAMP(3);
ALTER TABLE "users" ADD COLUMN "flaggedReason" TEXT;
+4
View File
@@ -23,6 +23,9 @@ model User {
githubUsername String? githubUsername String?
apiKey String? @unique apiKey String? @unique
mcpPromptsPublicByDefault Boolean @default(false) mcpPromptsPublicByDefault Boolean @default(false)
flagged Boolean @default(false)
flaggedAt DateTime?
flaggedReason String?
accounts Account[] accounts Account[]
subscriptions CategorySubscription[] subscriptions CategorySubscription[]
changeRequests ChangeRequest[] changeRequests ChangeRequest[]
@@ -408,4 +411,5 @@ enum DelistReason {
LOW_QUALITY LOW_QUALITY
NOT_LLM_INSTRUCTION NOT_LLM_INSTRUCTION
MANUAL MANUAL
UNUSUAL_ACTIVITY
} }
+32 -2
View File
@@ -15,10 +15,16 @@ export async function PATCH(
const { id } = await params; const { id } = await params;
const body = await request.json(); const body = await request.json();
const { role, verified } = body; const { role, verified, flagged, flaggedReason } = body;
// Build update data // Build update data
const updateData: { role?: "ADMIN" | "USER"; verified?: boolean } = {}; const updateData: {
role?: "ADMIN" | "USER";
verified?: boolean;
flagged?: boolean;
flaggedAt?: Date | null;
flaggedReason?: string | null;
} = {};
if (role !== undefined) { if (role !== undefined) {
if (!["ADMIN", "USER"].includes(role)) { if (!["ADMIN", "USER"].includes(role)) {
@@ -31,9 +37,33 @@ export async function PATCH(
updateData.verified = verified; updateData.verified = verified;
} }
if (flagged !== undefined) {
updateData.flagged = flagged;
if (flagged) {
updateData.flaggedAt = new Date();
updateData.flaggedReason = flaggedReason || null;
} else {
updateData.flaggedAt = null;
updateData.flaggedReason = null;
}
}
const user = await db.user.update({ const user = await db.user.update({
where: { id }, where: { id },
data: updateData, data: updateData,
select: {
id: true,
email: true,
username: true,
name: true,
avatar: true,
role: true,
verified: true,
flagged: true,
flaggedAt: true,
flaggedReason: true,
createdAt: true,
},
}); });
return NextResponse.json(user); return NextResponse.json(user);
+7
View File
@@ -41,6 +41,7 @@ export async function GET(request: NextRequest) {
OR?: Array<{ email?: { contains: string; mode: "insensitive" }; username?: { contains: string; mode: "insensitive" }; name?: { contains: string; mode: "insensitive" } }>; OR?: Array<{ email?: { contains: string; mode: "insensitive" }; username?: { contains: string; mode: "insensitive" }; name?: { contains: string; mode: "insensitive" } }>;
role?: "ADMIN" | "USER"; role?: "ADMIN" | "USER";
verified?: boolean; verified?: boolean;
flagged?: boolean;
}; };
const filterConditions: WhereCondition = {}; const filterConditions: WhereCondition = {};
@@ -58,6 +59,9 @@ export async function GET(request: NextRequest) {
case "unverified": case "unverified":
filterConditions.verified = false; filterConditions.verified = false;
break; break;
case "flagged":
filterConditions.flagged = true;
break;
default: default:
// "all" - no filter // "all" - no filter
break; break;
@@ -95,6 +99,9 @@ export async function GET(request: NextRequest) {
avatar: true, avatar: true,
role: true, role: true,
verified: true, verified: true,
flagged: true,
flaggedAt: true,
flaggedReason: true,
createdAt: true, createdAt: true,
_count: { _count: {
select: { select: {
+14
View File
@@ -48,6 +48,13 @@ export async function POST(request: Request) {
const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data; const { title, description, content, type, structuredFormat, categoryId, tagIds, contributorIds, isPrivate, mediaUrl, requiresMediaUpload, requiredMediaType, requiredMediaCount } = parsed.data;
// Check if user is flagged (for auto-delisting)
const currentUser = await db.user.findUnique({
where: { id: session.user.id },
select: { flagged: true },
});
const isUserFlagged = currentUser?.flagged ?? false;
// Rate limit: Check if user created a prompt in the last 30 seconds // Rate limit: Check if user created a prompt in the last 30 seconds
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000); const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
const recentPrompt = await db.prompt.findFirst({ const recentPrompt = await db.prompt.findFirst({
@@ -135,6 +142,7 @@ export async function POST(request: Request) {
const slug = await generatePromptSlug(title); const slug = await generatePromptSlug(title);
// Create prompt with tags // Create prompt with tags
// Auto-delist if user is flagged
const prompt = await db.prompt.create({ const prompt = await db.prompt.create({
data: { data: {
title, title,
@@ -150,6 +158,12 @@ export async function POST(request: Request) {
requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null, requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null,
authorId: session.user.id, authorId: session.user.id,
categoryId: categoryId || null, categoryId: categoryId || null,
// Auto-delist prompts from flagged users
...(isUserFlagged && {
isUnlisted: true,
unlistedAt: new Date(),
delistReason: "UNUSUAL_ACTIVITY",
}),
tags: { tags: {
create: tagIds.map((tagId) => ({ create: tagIds.map((tagId) => ({
tagId, tagId,
+34 -1
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { formatDistanceToNow } from "@/lib/date"; import { formatDistanceToNow } from "@/lib/date";
import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter } from "lucide-react"; import { MoreHorizontal, Shield, User, Trash2, BadgeCheck, Search, Loader2, ChevronLeft, ChevronRight, Filter, Flag, AlertTriangle } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -51,6 +51,9 @@ interface UserData {
avatar: string | null; avatar: string | null;
role: "ADMIN" | "USER"; role: "ADMIN" | "USER";
verified: boolean; verified: boolean;
flagged: boolean;
flaggedAt: string | null;
flaggedReason: string | null;
createdAt: string; createdAt: string;
_count: { _count: {
prompts: number; prompts: number;
@@ -148,12 +151,31 @@ export function UsersTable() {
if (!res.ok) throw new Error("Failed to update verification"); if (!res.ok) throw new Error("Failed to update verification");
toast.success(verified ? t("verified") : t("unverified")); toast.success(verified ? t("verified") : t("unverified"));
fetchUsers(currentPage, searchQuery, userFilter);
router.refresh(); router.refresh();
} catch { } catch {
toast.error(t("verifyFailed")); toast.error(t("verifyFailed"));
} }
}; };
const handleFlagToggle = async (userId: string, flagged: boolean) => {
try {
const res = await fetch(`/api/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flagged, flaggedReason: flagged ? "Unusual activity" : null }),
});
if (!res.ok) throw new Error("Failed to update flag status");
toast.success(flagged ? t("flagged") : t("unflagged"));
fetchUsers(currentPage, searchQuery, userFilter);
router.refresh();
} catch {
toast.error(t("flagFailed"));
}
};
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteUserId) return; if (!deleteUserId) return;
@@ -195,6 +217,7 @@ export function UsersTable() {
<SelectItem value="user">{t("filters.user")}</SelectItem> <SelectItem value="user">{t("filters.user")}</SelectItem>
<SelectItem value="verified">{t("filters.verified")}</SelectItem> <SelectItem value="verified">{t("filters.verified")}</SelectItem>
<SelectItem value="unverified">{t("filters.unverified")}</SelectItem> <SelectItem value="unverified">{t("filters.unverified")}</SelectItem>
<SelectItem value="flagged">{t("filters.flagged")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -241,6 +264,7 @@ export function UsersTable() {
<div className="font-medium flex items-center gap-1 truncate"> <div className="font-medium flex items-center gap-1 truncate">
{user.name || user.username} {user.name || user.username}
{user.verified && <BadgeCheck className="h-4 w-4 text-blue-500 flex-shrink-0" />} {user.verified && <BadgeCheck className="h-4 w-4 text-blue-500 flex-shrink-0" />}
{user.flagged && <AlertTriangle className="h-4 w-4 text-amber-500 flex-shrink-0" />}
</div> </div>
<div className="text-xs text-muted-foreground">@{user.username}</div> <div className="text-xs text-muted-foreground">@{user.username}</div>
</div> </div>
@@ -267,6 +291,10 @@ export function UsersTable() {
<BadgeCheck className="h-4 w-4 mr-2" /> <BadgeCheck className="h-4 w-4 mr-2" />
{user.verified ? t("unverify") : t("verify")} {user.verified ? t("unverify") : t("verify")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlagToggle(user.id, !user.flagged)}>
<Flag className="h-4 w-4 mr-2" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
@@ -321,6 +349,7 @@ export function UsersTable() {
<div className="font-medium flex items-center gap-1"> <div className="font-medium flex items-center gap-1">
{user.name || user.username} {user.name || user.username}
{user.verified && <BadgeCheck className="h-4 w-4 text-blue-500" />} {user.verified && <BadgeCheck className="h-4 w-4 text-blue-500" />}
{user.flagged && <AlertTriangle className="h-4 w-4 text-amber-500" />}
</div> </div>
<div className="text-xs text-muted-foreground">@{user.username}</div> <div className="text-xs text-muted-foreground">@{user.username}</div>
</div> </div>
@@ -360,6 +389,10 @@ export function UsersTable() {
<BadgeCheck className="h-4 w-4 mr-2" /> <BadgeCheck className="h-4 w-4 mr-2" />
{user.verified ? t("unverify") : t("verify")} {user.verified ? t("unverify") : t("verify")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlagToggle(user.id, !user.flagged)}>
<Flag className="h-4 w-4 mr-2" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"