diff --git a/messages/en.json b/messages/en.json
index f7cf6a1e..ce6120a4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql b/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql
new file mode 100644
index 00000000..20f2dfee
--- /dev/null
+++ b/prisma/migrations/20251222132600_add_user_flagged_and_unusual_activity/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index beb828c2..8a5bbeb8 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
}
diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts
index 15b39dd4..c0708d34 100644
--- a/src/app/api/admin/users/[id]/route.ts
+++ b/src/app/api/admin/users/[id]/route.ts
@@ -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);
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
index 79c63563..1018153f 100644
--- a/src/app/api/admin/users/route.ts
+++ b/src/app/api/admin/users/route.ts
@@ -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: {
diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts
index 03287d69..1ba988b0 100644
--- a/src/app/api/prompts/route.ts
+++ b/src/app/api/prompts/route.ts
@@ -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,
diff --git a/src/components/admin/users-table.tsx b/src/components/admin/users-table.tsx
index 4e9b2031..f1e5b372 100644
--- a/src/components/admin/users-table.tsx
+++ b/src/components/admin/users-table.tsx
@@ -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() {