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

View File

@@ -502,8 +502,14 @@
"admin": "Admins",
"user": "Users",
"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": {
"title": "Category Management",

View File

@@ -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;

View File

@@ -23,6 +23,9 @@ model User {
githubUsername String?
apiKey String? @unique
mcpPromptsPublicByDefault Boolean @default(false)
flagged Boolean @default(false)
flaggedAt DateTime?
flaggedReason String?
accounts Account[]
subscriptions CategorySubscription[]
changeRequests ChangeRequest[]
@@ -408,4 +411,5 @@ enum DelistReason {
LOW_QUALITY
NOT_LLM_INSTRUCTION
MANUAL
UNUSUAL_ACTIVITY
}

View File

@@ -15,10 +15,16 @@ export async function PATCH(
const { id } = await params;
const body = await request.json();
const { role, verified } = body;
const { role, verified, flagged, flaggedReason } = body;
// 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 (!["ADMIN", "USER"].includes(role)) {
@@ -31,9 +37,33 @@ export async function PATCH(
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({
where: { id },
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);

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" } }>;
role?: "ADMIN" | "USER";
verified?: boolean;
flagged?: boolean;
};
const filterConditions: WhereCondition = {};
@@ -58,6 +59,9 @@ export async function GET(request: NextRequest) {
case "unverified":
filterConditions.verified = false;
break;
case "flagged":
filterConditions.flagged = true;
break;
default:
// "all" - no filter
break;
@@ -95,6 +99,9 @@ export async function GET(request: NextRequest) {
avatar: true,
role: true,
verified: true,
flagged: true,
flaggedAt: true,
flaggedReason: true,
createdAt: true,
_count: {
select: {

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;
// 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
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
const recentPrompt = await db.prompt.findFirst({
@@ -135,6 +142,7 @@ export async function POST(request: Request) {
const slug = await generatePromptSlug(title);
// Create prompt with tags
// Auto-delist if user is flagged
const prompt = await db.prompt.create({
data: {
title,
@@ -150,6 +158,12 @@ export async function POST(request: Request) {
requiredMediaCount: requiresMediaUpload ? requiredMediaCount : null,
authorId: session.user.id,
categoryId: categoryId || null,
// Auto-delist prompts from flagged users
...(isUserFlagged && {
isUnlisted: true,
unlistedAt: new Date(),
delistReason: "UNUSUAL_ACTIVITY",
}),
tags: {
create: tagIds.map((tagId) => ({
tagId,

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslations, useLocale } from "next-intl";
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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -51,6 +51,9 @@ interface UserData {
avatar: string | null;
role: "ADMIN" | "USER";
verified: boolean;
flagged: boolean;
flaggedAt: string | null;
flaggedReason: string | null;
createdAt: string;
_count: {
prompts: number;
@@ -148,12 +151,31 @@ export function UsersTable() {
if (!res.ok) throw new Error("Failed to update verification");
toast.success(verified ? t("verified") : t("unverified"));
fetchUsers(currentPage, searchQuery, userFilter);
router.refresh();
} catch {
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 () => {
if (!deleteUserId) return;
@@ -195,6 +217,7 @@ export function UsersTable() {
<SelectItem value="user">{t("filters.user")}</SelectItem>
<SelectItem value="verified">{t("filters.verified")}</SelectItem>
<SelectItem value="unverified">{t("filters.unverified")}</SelectItem>
<SelectItem value="flagged">{t("filters.flagged")}</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
@@ -241,6 +264,7 @@ export function UsersTable() {
<div className="font-medium flex items-center gap-1 truncate">
{user.name || user.username}
{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 className="text-xs text-muted-foreground">@{user.username}</div>
</div>
@@ -267,6 +291,10 @@ export function UsersTable() {
<BadgeCheck className="h-4 w-4 mr-2" />
{user.verified ? t("unverify") : t("verify")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlagToggle(user.id, !user.flagged)}>
<Flag className="h-4 w-4 mr-2" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
@@ -321,6 +349,7 @@ export function UsersTable() {
<div className="font-medium flex items-center gap-1">
{user.name || user.username}
{user.verified && <BadgeCheck className="h-4 w-4 text-blue-500" />}
{user.flagged && <AlertTriangle className="h-4 w-4 text-amber-500" />}
</div>
<div className="text-xs text-muted-foreground">@{user.username}</div>
</div>
@@ -360,6 +389,10 @@ export function UsersTable() {
<BadgeCheck className="h-4 w-4 mr-2" />
{user.verified ? t("unverify") : t("verify")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleFlagToggle(user.id, !user.flagged)}>
<Flag className="h-4 w-4 mr-2" />
{user.flagged ? t("unflag") : t("flag")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"