1
0
Fork 0

feat: full report management

fix-1
Paul Makles 2023-07-28 17:37:51 +01:00
parent 006acdb6ac
commit 45bce9d5fb
No known key found for this signature in database
GPG Key ID: 5059F398521BB0F6
9 changed files with 339 additions and 40 deletions

View File

@ -28,9 +28,11 @@ export default async function Message({
</CardHeader> </CardHeader>
</Card> </Card>
<Link href={`/panel/inspect/user/${author!._id}`}> {author && (
<UserCard user={author!} subtitle="Message Author" /> <Link href={`/panel/inspect/user/${author!._id}`}>
</Link> <UserCard user={author!} subtitle="Message Author" />
</Link>
)}
<Link href={`/panel/inspect/channel/${channel!._id}`}> <Link href={`/panel/inspect/channel/${channel!._id}`}>
<ChannelCard channel={channel!} subtitle="Channel" /> <ChannelCard channel={channel!} subtitle="Channel" />
</Link> </Link>

View File

@ -41,8 +41,18 @@ export default async function Reports({ params }: { params: { id: string } }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<NavigationToolbar>Viewing Report</NavigationToolbar> <NavigationToolbar>Viewing Report</NavigationToolbar>
<ReportCard report={report} /> <ReportActions
<ReportActions report={report} /> report={report}
reference={`${report._id.substring(20, 26)}, ${
snapshots[0]._type === "Message"
? "Message"
: snapshots[0]._type === "User"
? `${snapshots[0].username}#${snapshots[0].discriminator}`
: snapshots[0].name
}, ${report.content.report_reason}${
report.additional_context ? `, ${report.additional_context}` : ""
}`}
/>
<Link href={`/panel/inspect/user/${author!._id}`}> <Link href={`/panel/inspect/user/${author!._id}`}>
<UserCard user={author!} subtitle="Report Author" /> <UserCard user={author!} subtitle="Report Author" />

View File

@ -68,9 +68,9 @@ export function CompactMessage({
</div> </div>
)} )}
<div className="flex-[2] min-w-0 overflow-ellipsis overflow-hidden text-left"> <div className="flex-[2] min-w-0 overflow-ellipsis overflow-hidden text-left">
{(message.attachments || message.embeds) && ( {message.attachments?.length || message.embeds?.length ? (
<ImageIcon size={12} className="inline align-middle" /> <ImageIcon size={12} className="inline align-middle" />
)}{" "} ) : null}{" "}
{message.content ?? <span className="text-gray-500">No text.</span>} {message.content ?? <span className="text-gray-500">No text.</span>}
</div> </div>
</div> </div>
@ -80,9 +80,7 @@ export function CompactMessage({
<AlertDialogTitle> <AlertDialogTitle>
{user?.avatar && ( {user?.avatar && (
<Avatar className="inline-block align-middle mr-1"> <Avatar className="inline-block align-middle mr-1">
<AvatarImage <AvatarImage src={`${AUTUMN_URL}/avatars/${user.avatar._id}`} />
src={`${AUTUMN_URL}/avatars/${user.avatar._id}`}
/>
</Avatar> </Avatar>
)}{" "} )}{" "}
{user?.username}#{user?.discriminator} {user?.username}#{user?.discriminator}

View File

@ -16,17 +16,28 @@ export function ReportCard({ report }: { report: Report }) {
report.status !== "Created" ? "text-gray-500" : "" report.status !== "Created" ? "text-gray-500" : ""
}`} }`}
> >
{report.content.report_reason.includes("Illegal") && ( {report.status === "Resolved" ? (
<Badge className="align-middle" variant="destructive"> <Badge className="align-middle">Resolved</Badge>
Urgent ) : report.status === "Rejected" ? (
<Badge className="align-middle">
Closed for {report.rejection_reason}
</Badge> </Badge>
) : (
report.content.report_reason.includes("Illegal") && (
<Badge className="align-middle" variant="destructive">
Urgent
</Badge>
)
)}{" "} )}{" "}
{report.additional_context || "No reason specified"} {report.additional_context || "No reason specified"}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{report._id.toString().substring(20, 26)} &middot;{" "} {report._id.toString().substring(20, 26)} &middot;{" "}
{report.content.report_reason} &middot; {report.content.type} &middot;{" "} {report.content.report_reason} &middot; {report.content.type} &middot;{" "}
{dayjs(decodeTime(report._id)).fromNow()} {dayjs(decodeTime(report._id)).fromNow()}{" "}
{report.status !== "Created" && report.closed_at && (
<>&middot; Closed {dayjs(report.closed_at).fromNow()}</>
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>

View File

@ -17,7 +17,7 @@ export async function RecentMessages({
query: Filter<Message>; query: Filter<Message>;
users?: boolean | User[]; users?: boolean | User[];
}) { }) {
const recentMessages = await fetchMessages(query); const recentMessages = (await fetchMessages(query)).reverse();
const userList = ( const userList = (
users === true users === true

View File

@ -68,7 +68,9 @@ export function RelevantModerationNotices({
{strikesDraft.map((strike) => ( {strikesDraft.map((strike) => (
<TableRow key={strike._id}> <TableRow key={strike._id}>
<TableCell>{strike.reason}</TableCell> <TableCell>{strike.reason}</TableCell>
<TableCell>{dayjs(decodeTime(strike._id)).fromNow()}</TableCell> <TableCell>
{dayjs(decodeTime(strike._id))?.fromNow()}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@ -11,16 +11,77 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { useRef, useState } from "react"; import { useState } from "react";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { updateReportNotes } from "@/lib/actions"; import {
rejectReport,
reopenReport,
resolveReport,
sendAlert,
updateReportNotes,
} from "@/lib/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { ReportCard } from "../cards/ReportCard";
export function ReportActions({ report }: { report: Report }) { const template: Record<string, (ref: string) => string> = {
resolved: (ref) =>
`Your report (${ref}) has been actioned and appropriate action has been taken.`,
invalid: (ref) => `Your report (${ref}) has been marked as invalid.`,
false: (ref) =>
`Your report (${ref}) has been marked as false or spam. False reports may lead to additional action against your account.`,
duplicate: (ref) => `Your report (${ref}) has been marked as a duplicate.`,
"not enough evidence": (ref) =>
`Your report (${ref}) has not been actioned at this time due to a lack of supporting evidence, if you have additional information to support your report, please either report individual relevant messages or send an email to contact@revolt.chat.`,
clarify: (ref) =>
`Your report (${ref}) needs clarification, please provide additional information.`,
acknowledged: (ref) =>
`Your report (${ref}) has been acknowledged, we will be monitoring the situation.`,
default: (ref) =>
`Report (${ref})\n\nNo template found for rejection reason, please specify.`,
};
export function ReportActions({
report,
reference,
}: {
report: Report;
reference: string;
}) {
const { toast } = useToast(); const { toast } = useToast();
const [reportDraft, setDraft] = useState(report); const [reportDraft, setDraft] = useState(report);
function rejectHandler(reason: string) {
return async () => {
try {
const $set = await rejectReport(report._id, reason);
setDraft((report) => ({ ...report, ...$set }));
toast({
title: "Rejected report",
});
} catch (err) {
toast({
title: "Failed to reject report",
description: String(err),
variant: "destructive",
});
}
};
}
return ( return (
<> <>
<ReportCard report={reportDraft} />
<Textarea <Textarea
placeholder="Enter notes here... (save on unfocus)" placeholder="Enter notes here... (save on unfocus)"
className="!min-h-0 !h-[76px]" className="!min-h-0 !h-[76px]"
@ -46,9 +107,26 @@ export function ReportActions({ report }: { report: Report }) {
/> />
<div className="flex gap-2"> <div className="flex gap-2">
{report.status === "Created" ? ( {reportDraft.status === "Created" ? (
<> <>
<Button className="flex-1 bg-green-400 hover:bg-green-300"> <Button
className="flex-1 bg-green-400 hover:bg-green-300"
onClick={async () => {
try {
const $set = await resolveReport(report._id);
setDraft((report) => ({ ...report, ...$set }));
toast({
title: "Resolved report",
});
} catch (err) {
toast({
title: "Failed to resolve report",
description: String(err),
variant: "destructive",
});
}
}}
>
Resolve Resolve
</Button> </Button>
<DropdownMenu> <DropdownMenu>
@ -58,23 +136,117 @@ export function ReportActions({ report }: { report: Report }) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem>Custom Reason</DropdownMenuItem> <DropdownMenuItem
onClick={() => {
const reason = prompt("Enter a custom reason:");
// TODO: modal
reason && rejectHandler(reason)();
}}
>
Custom Reason
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>Presets</DropdownMenuLabel> <DropdownMenuLabel>Presets</DropdownMenuLabel>
<DropdownMenuItem>Invalid</DropdownMenuItem> <DropdownMenuItem onClick={rejectHandler("invalid")}>
<DropdownMenuItem>False or Spam</DropdownMenuItem> Invalid
<DropdownMenuItem>Duplicate</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem>Not Enough Evidence</DropdownMenuItem> <DropdownMenuItem onClick={rejectHandler("false")}>
<DropdownMenuItem>Request Clarification</DropdownMenuItem> False or Spam
<DropdownMenuItem>Acknowledge Only</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem>Ignore</DropdownMenuItem> <DropdownMenuItem onClick={rejectHandler("duplicate")}>
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={rejectHandler("not enough evidence")}
>
Not Enough Evidence
</DropdownMenuItem>
<DropdownMenuItem onClick={rejectHandler("clarify")}>
Request Clarification
</DropdownMenuItem>
<DropdownMenuItem onClick={rejectHandler("acknowledged")}>
Acknowledge Only
</DropdownMenuItem>
<DropdownMenuItem onClick={rejectHandler("ignore")}>
Ignore
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
) : ( ) : (
<> <>
<Button className="flex-1">Re-open</Button> <Button
<Button className="flex-1">Send resolution notification</Button> className="flex-1"
onClick={async () => {
try {
const $set = await reopenReport(report._id);
setDraft((report) => ({ ...report, ...$set }));
toast({
title: "Opened report again",
});
} catch (err) {
toast({
title: "Failed to re-open report",
description: String(err),
variant: "destructive",
});
}
}}
>
Re-open
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="flex-1">Send resolution notification</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send Notification</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
<span>
This will send a message from the Platform Moderation
account.
</span>
<Textarea
id="notification-content"
defaultValue={(
template[
reportDraft.status === "Rejected"
? reportDraft.rejection_reason
: "resolved"
] ?? template["default"]
)(reference)}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const msg = (
document.getElementById(
"notification-content"
) as HTMLTextAreaElement
).value;
if (!msg) return;
sendAlert(report.author_id, msg)
.then(() => toast({ title: "Sent Alert" }))
.catch((err) =>
toast({
title: "Failed to send alert!",
description: String(err),
variant: "destructive",
})
);
}}
>
Send
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</> </>
)} )}
</div> </div>

View File

@ -22,6 +22,7 @@ import {
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { import {
banUser, banUser,
closeReportsByUser,
sendAlert, sendAlert,
suspendUser, suspendUser,
unsuspendUser, unsuspendUser,
@ -249,17 +250,10 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
More Options More Options
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent className="flex flex-col">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<DropdownMenuItem <Button variant="ghost">Send Alert</Button>
onClick={() => {
throw "Cancel immediate propagation.";
}}
disabled={userInaccessible}
>
Send Alert
</DropdownMenuItem>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@ -302,6 +296,42 @@ export function UserActions({ user, bot }: { user: User; bot?: Bot }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">Close Open Reports</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Close Open Reports</AlertDialogTitle>
<AlertDialogDescription className="flex flex-col gap-2">
<span>
This will close all reports still open by this user.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
closeReportsByUser(user._id)
.then((reports) =>
toast({ title: `Closed ${reports} Reports` })
)
.catch((err) =>
toast({
title: "Failed to close reports!",
description: String(err),
variant: "destructive",
})
)
}
>
Continue
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* <DropdownMenuItem> {/* <DropdownMenuItem>
Clear ({counts.pending}) Friend Requests Clear ({counts.pending}) Friend Requests
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -85,6 +85,80 @@ export async function updateReportNotes(reportId: string, notes: string) {
); );
} }
export async function resolveReport(reportId: string) {
const $set = {
status: "Resolved",
closed_at: new Date().toISOString(),
} as Report;
await mongo().db("revolt").collection<Report>("safety_reports").updateOne(
{ _id: reportId },
{
$set,
}
);
return $set;
}
export async function rejectReport(reportId: string, reason: string) {
const $set = {
status: "Rejected",
rejection_reason: reason,
closed_at: new Date().toISOString(),
} as Report;
await mongo().db("revolt").collection<Report>("safety_reports").updateOne(
{ _id: reportId },
{
$set,
}
);
return $set;
}
export async function reopenReport(reportId: string) {
const $set = {
status: "Created",
} as Report;
await mongo()
.db("revolt")
.collection<Report>("safety_reports")
.updateOne(
{ _id: reportId },
{
$set,
$unset: {
closed_at: 1,
} as never,
}
);
return $set;
}
export async function closeReportsByUser(userId: string) {
return await mongo()
.db("revolt")
.collection<Report>("safety_reports")
.updateMany(
{
status: "Created",
author_id: userId,
},
{
$set: {
status: "Rejected",
rejection_reason: "bulk close",
closed_at: new Date().toISOString(),
},
}
)
.then((res) => res.modifiedCount);
}
export async function disableAccount(userId: string) { export async function disableAccount(userId: string) {
await mongo() await mongo()
.db("revolt") .db("revolt")