feat(api): add user flagging functionality
This commit is contained in:
+8
-2
@@ -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;
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user