Skip to content

Commit 29b7403

Browse files
committed
Add activity flagging for users and admin help modal
- Add flagActivity mutation so participants can report suspicious activities from the feed (creates flag record, updates activity, adds audit history) - Add report button via 3-dot menu on activity cards with reason dialog - Add "How flagging works" help modal on admin flagged-activities page explaining the state machine, resolution behavior, and admin actions https://claude.ai/code/session_011e981SwLJfCusHF5pDRfN6
1 parent 890dad0 commit 29b7403

5 files changed

Lines changed: 434 additions & 5 deletions

File tree

apps/web/app/challenges/[id]/admin/flagged-activities/page.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge";
1111
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
1212
import { Button } from "@/components/ui/button";
1313
import { Input } from "@/components/ui/input";
14+
import { FlaggingHelpDialog } from "@/components/admin/flagging-help-dialog";
1415

1516
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
1617

@@ -70,11 +71,14 @@ export default async function FlaggedActivitiesPage({
7071
return (
7172
<Card>
7273
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
73-
<div>
74-
<CardTitle>Flagged Activities</CardTitle>
75-
<p className="text-sm text-muted-foreground">
76-
Review activities that have been flagged for follow-up.
77-
</p>
74+
<div className="flex items-start gap-3">
75+
<div>
76+
<CardTitle>Flagged Activities</CardTitle>
77+
<p className="text-sm text-muted-foreground">
78+
Review activities that have been flagged for follow-up.
79+
</p>
80+
</div>
81+
<FlaggingHelpDialog />
7882
</div>
7983
<form className="flex items-center gap-2" action="" method="get">
8084
<Input
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import {
5+
ArrowRight,
6+
CheckCircle2,
7+
CircleDot,
8+
Flag,
9+
HelpCircle,
10+
MessageSquare,
11+
Pencil,
12+
RotateCcw,
13+
} from "lucide-react";
14+
15+
import { Button } from "@/components/ui/button";
16+
import {
17+
Dialog,
18+
DialogContent,
19+
DialogDescription,
20+
DialogHeader,
21+
DialogTitle,
22+
DialogTrigger,
23+
} from "@/components/ui/dialog";
24+
25+
export function FlaggingHelpDialog() {
26+
const [open, setOpen] = useState(false);
27+
28+
return (
29+
<Dialog open={open} onOpenChange={setOpen}>
30+
<DialogTrigger asChild>
31+
<Button variant="outline" size="sm">
32+
<HelpCircle className="mr-2 h-4 w-4" />
33+
How flagging works
34+
</Button>
35+
</DialogTrigger>
36+
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
37+
<DialogHeader>
38+
<DialogTitle>Activity Flagging System</DialogTitle>
39+
<DialogDescription>
40+
How participants report activities and how admins resolve them.
41+
</DialogDescription>
42+
</DialogHeader>
43+
44+
<div className="space-y-6 text-sm">
45+
{/* How flags are created */}
46+
<section className="space-y-2">
47+
<h3 className="flex items-center gap-2 font-semibold">
48+
<Flag className="h-4 w-4 text-destructive" />
49+
How activities get flagged
50+
</h3>
51+
<p className="text-muted-foreground">
52+
Any challenge participant can report another participant&apos;s activity
53+
by clicking the <strong>&hellip;</strong> menu on an activity card in the
54+
feed and selecting <strong>Report activity</strong>. They must provide
55+
a reason (e.g., incorrect points, suspicious entry). Users cannot flag
56+
their own activities, and each user can only flag a given activity once.
57+
</p>
58+
</section>
59+
60+
{/* State machine */}
61+
<section className="space-y-3">
62+
<h3 className="flex items-center gap-2 font-semibold">
63+
<CircleDot className="h-4 w-4 text-amber-500" />
64+
Flag status lifecycle
65+
</h3>
66+
<div className="rounded-lg border p-4">
67+
<div className="flex flex-col gap-3">
68+
<div className="flex items-center gap-3">
69+
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-500">
70+
<CircleDot className="h-3 w-3" />
71+
Pending
72+
</span>
73+
<ArrowRight className="h-4 w-4 text-muted-foreground" />
74+
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
75+
<CheckCircle2 className="h-3 w-3" />
76+
Resolved
77+
</span>
78+
</div>
79+
<div className="flex items-center gap-3">
80+
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
81+
<CheckCircle2 className="h-3 w-3" />
82+
Resolved
83+
</span>
84+
<ArrowRight className="h-4 w-4 text-muted-foreground" />
85+
<span className="inline-flex items-center gap-1.5 rounded-full border border-orange-500/30 bg-orange-500/10 px-3 py-1 text-xs font-medium text-orange-500">
86+
<RotateCcw className="h-3 w-3" />
87+
Reopened
88+
</span>
89+
</div>
90+
<div className="flex items-center gap-3">
91+
<span className="inline-flex items-center gap-1.5 rounded-full border border-orange-500/30 bg-orange-500/10 px-3 py-1 text-xs font-medium text-orange-500">
92+
<RotateCcw className="h-3 w-3" />
93+
Reopened
94+
</span>
95+
<ArrowRight className="h-4 w-4 text-muted-foreground" />
96+
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-500">
97+
<CheckCircle2 className="h-3 w-3" />
98+
Resolved
99+
</span>
100+
</div>
101+
</div>
102+
</div>
103+
<ul className="ml-1 space-y-1.5 text-muted-foreground">
104+
<li>
105+
<strong className="text-foreground">Pending</strong> &mdash; A new
106+
flag starts here. The activity is marked as flagged and appears in
107+
this list for review.
108+
</li>
109+
<li>
110+
<strong className="text-foreground">Resolved</strong> &mdash; An
111+
admin has reviewed and handled the flag. The activity is unflagged
112+
and returns to normal in the feed.
113+
</li>
114+
<li>
115+
<strong className="text-foreground">Reopened</strong> &mdash; A
116+
previously resolved flag has been reopened for further review. The
117+
activity becomes flagged again.
118+
</li>
119+
</ul>
120+
</section>
121+
122+
{/* What happens on resolution */}
123+
<section className="space-y-2">
124+
<h3 className="flex items-center gap-2 font-semibold">
125+
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
126+
What happens when resolved
127+
</h3>
128+
<ul className="ml-1 space-y-1.5 text-muted-foreground">
129+
<li>
130+
The activity&apos;s <strong className="text-foreground">flagged</strong> status
131+
is cleared, removing it from this queue.
132+
</li>
133+
<li>
134+
The resolution timestamp and the resolving admin are recorded for
135+
audit purposes.
136+
</li>
137+
<li>
138+
If you added a participant-visible comment, the participant receives
139+
a notification with your message.
140+
</li>
141+
</ul>
142+
</section>
143+
144+
{/* Admin actions */}
145+
<section className="space-y-2">
146+
<h3 className="flex items-center gap-2 font-semibold">
147+
<Pencil className="h-4 w-4 text-indigo-400" />
148+
Admin actions available
149+
</h3>
150+
<p className="text-muted-foreground">
151+
Click <strong>View Details</strong> on any flagged activity to access
152+
the full admin toolkit:
153+
</p>
154+
<ul className="ml-1 space-y-1.5 text-muted-foreground">
155+
<li>
156+
<strong className="text-foreground">Change status</strong> &mdash;
157+
Mark as pending, resolved, or reopened.
158+
</li>
159+
<li>
160+
<strong className="text-foreground">Add admin comment</strong>{" "}
161+
<MessageSquare className="inline h-3 w-3" /> &mdash; Leave an
162+
internal note (visible to admins only) or a participant-visible
163+
comment that sends a notification to the user.
164+
</li>
165+
<li>
166+
<strong className="text-foreground">Edit activity</strong> &mdash;
167+
Adjust points earned or notes. Point changes automatically update
168+
the participant&apos;s total score and notify them.
169+
</li>
170+
</ul>
171+
</section>
172+
173+
{/* Audit trail */}
174+
<section className="space-y-2">
175+
<h3 className="text-sm font-semibold">Audit trail</h3>
176+
<p className="text-muted-foreground">
177+
Every admin action (status change, comment, edit) is recorded in the
178+
activity&apos;s history timeline, including who performed the action and
179+
when. This provides full accountability for all moderation decisions.
180+
</p>
181+
</section>
182+
</div>
183+
</DialogContent>
184+
</Dialog>
185+
);
186+
}

apps/web/components/dashboard/activity-feed.tsx

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { useMemo, useState } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { formatDistanceToNow } from 'date-fns';
66
import {
7+
Flag,
78
Loader2,
89
MessageCircle,
10+
MoreHorizontal,
911
RefreshCw,
1012
Share2,
1113
ThumbsUp,
@@ -31,6 +33,21 @@ import {
3133
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
3234
import { useMentionableUsers } from '@/hooks/use-mentionable-users';
3335
import { isEditorContentEmpty, type MentionableUser } from '@/lib/rich-text';
36+
import {
37+
Dialog,
38+
DialogContent,
39+
DialogDescription,
40+
DialogFooter,
41+
DialogHeader,
42+
DialogTitle,
43+
} from '@/components/ui/dialog';
44+
import {
45+
DropdownMenu,
46+
DropdownMenuContent,
47+
DropdownMenuItem,
48+
DropdownMenuTrigger,
49+
} from '@/components/ui/dropdown-menu';
50+
import { Textarea } from '@/components/ui/textarea';
3451
import { cn } from '@/lib/utils';
3552

3653
interface BonusThreshold {
@@ -326,6 +343,13 @@ function ActivityCard({
326343
}: ActivityCardProps) {
327344
const router = useRouter();
328345
const [showComments, setShowComments] = useState(false);
346+
const [showFlagDialog, setShowFlagDialog] = useState(false);
347+
const [flagReason, setFlagReason] = useState('');
348+
const [flagSubmitting, setFlagSubmitting] = useState(false);
349+
const [flagError, setFlagError] = useState<string | null>(null);
350+
const [flagSuccess, setFlagSuccess] = useState(false);
351+
352+
const flagActivity = useMutation(api.mutations.activities.flagActivity);
329353

330354
const activityUrl = `/challenges/${challengeId}/activities/${item.activity.id}`;
331355

@@ -344,6 +368,26 @@ function ActivityCard({
344368
router.push(activityUrl);
345369
};
346370

371+
const handleFlagSubmit = async () => {
372+
if (!flagReason.trim()) return;
373+
setFlagSubmitting(true);
374+
setFlagError(null);
375+
try {
376+
await flagActivity({
377+
activityId: item.activity.id as Id<"activities">,
378+
reason: flagReason.trim(),
379+
});
380+
setFlagSuccess(true);
381+
setFlagReason('');
382+
} catch (err) {
383+
setFlagError(
384+
err instanceof Error ? err.message : 'Failed to report activity'
385+
);
386+
} finally {
387+
setFlagSubmitting(false);
388+
}
389+
};
390+
347391
const handleShare = async () => {
348392
const url = `${window.location.origin}${activityUrl}`;
349393

@@ -470,6 +514,93 @@ function ActivityCard({
470514
<Button variant="ghost" size="sm" onClick={handleShare}>
471515
<Share2 className="mr-2 h-4 w-4" /> Share
472516
</Button>
517+
<DropdownMenu>
518+
<DropdownMenuTrigger asChild>
519+
<Button variant="ghost" size="sm" className="ml-auto h-8 w-8 p-0">
520+
<MoreHorizontal className="h-4 w-4" />
521+
<span className="sr-only">More options</span>
522+
</Button>
523+
</DropdownMenuTrigger>
524+
<DropdownMenuContent align="end">
525+
<DropdownMenuItem
526+
onClick={() => {
527+
setFlagSuccess(false);
528+
setFlagError(null);
529+
setFlagReason('');
530+
setShowFlagDialog(true);
531+
}}
532+
className="text-destructive focus:text-destructive"
533+
>
534+
<Flag className="mr-2 h-4 w-4" />
535+
Report activity
536+
</DropdownMenuItem>
537+
</DropdownMenuContent>
538+
</DropdownMenu>
539+
540+
<Dialog open={showFlagDialog} onOpenChange={setShowFlagDialog}>
541+
<DialogContent>
542+
<DialogHeader>
543+
<DialogTitle>Report Activity</DialogTitle>
544+
<DialogDescription>
545+
Flag this activity for admin review. Please describe why you think
546+
this activity should be reviewed.
547+
</DialogDescription>
548+
</DialogHeader>
549+
{flagSuccess ? (
550+
<div className="py-4 text-center">
551+
<p className="text-sm text-muted-foreground">
552+
Thank you for your report. An admin will review this activity.
553+
</p>
554+
</div>
555+
) : (
556+
<>
557+
<Textarea
558+
value={flagReason}
559+
onChange={(e) => setFlagReason(e.target.value)}
560+
placeholder="Describe the issue (e.g., incorrect points, suspicious activity...)"
561+
rows={3}
562+
maxLength={2000}
563+
/>
564+
{flagError && (
565+
<p className="text-sm text-destructive">{flagError}</p>
566+
)}
567+
</>
568+
)}
569+
<DialogFooter>
570+
{flagSuccess ? (
571+
<Button
572+
variant="outline"
573+
onClick={() => setShowFlagDialog(false)}
574+
>
575+
Close
576+
</Button>
577+
) : (
578+
<>
579+
<Button
580+
variant="outline"
581+
onClick={() => setShowFlagDialog(false)}
582+
>
583+
Cancel
584+
</Button>
585+
<Button
586+
variant="destructive"
587+
onClick={handleFlagSubmit}
588+
disabled={flagSubmitting || !flagReason.trim()}
589+
>
590+
{flagSubmitting ? (
591+
<>
592+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
593+
Submitting
594+
</>
595+
) : (
596+
'Submit Report'
597+
)}
598+
</Button>
599+
</>
600+
)}
601+
</DialogFooter>
602+
</DialogContent>
603+
</Dialog>
473604
</CardFooter>
474605

475606
{showComments && (

0 commit comments

Comments
 (0)