diff --git a/backend/controllers/admin.controller.ts b/backend/controllers/admin.controller.ts index 9605ff6..4457fa5 100644 --- a/backend/controllers/admin.controller.ts +++ b/backend/controllers/admin.controller.ts @@ -1,12 +1,18 @@ import { debug } from "debug"; import expressAsyncHandler from "express-async-handler"; -import { validationResult } from "express-validator"; +import { param, validationResult } from "express-validator"; import { FilterQuery } from "mongoose"; import { PostStatus } from "../constants"; -import { AuthorizationError } from "../errors"; +import { AuthorizationError, ValidationError } from "../errors"; import { PostDto } from "../models/posts"; +import { ReportDto } from "../models/reports"; import { UserDto } from "../models/users"; -import { PostService, ResendService, UserService } from "../services"; +import { + PostService, + ReportService, + ResendService, + UserService, +} from "../services"; import { PaginatedResponse, PostDocument, UserDocument } from "../types"; import { tryParsePaginationQuery, @@ -31,6 +37,7 @@ export class AdminController { private postService: PostService, private userService: UserService, private resendService: ResendService, + private reportService: ReportService, ) {} pre = (req) => { @@ -94,7 +101,6 @@ export class AdminController { res.json(response); }); - getUserById = expressAsyncHandler(async (req, res, next) => { this.pre(req); @@ -111,14 +117,39 @@ export class AdminController { const { id } = req.params; const user = await this.userService.getUserById(id); + const reports = await this.reportService.getUserReports(id); if (!user) { res.status(404).json({ error: `User ${id} not found.` }); return; } + if (!reports) { + res.status(404).json({ error: `Reports from ${id} not found.` }); + return; + } + const userDto = UserDto.fromDocument(user); - res.json(userDto); + const reportDtos = reports.map((report) => ReportDto.fromAggregate(report)); + + res.json({ user: userDto, reports: reportDtos }); + }); + + getUserReportsById = expressAsyncHandler(async (req, res, next) => { + await param("userId").notEmpty().run(req); + + const errors = validationResult(req); + if (!errors.isEmpty()) { + throw new ValidationError(errors.array()); + } + + const { userId } = req.params; + + const reports = await this.reportService.getUserReports(userId); + + res + .status(200) + .json(reports.map((report) => ReportDto.fromDocument(report))); }); getUsersToVerify = expressAsyncHandler(async (req, res, next) => { @@ -150,13 +181,13 @@ export class AdminController { { updatedAt: -1, createdAt: -1 }, ); - let userDtos : UserDto[]; + let userDtos: UserDto[]; if (reported_user) { userDtos = users.map((user) => UserDto.fromAggregate(user)); - }else { + } else { userDtos = users.map((user) => UserDto.fromDocument(user)); } - + const response: PaginatedResponse = { data: userDtos || [], page: page, diff --git a/backend/controllers/report.controller.ts b/backend/controllers/report.controller.ts index a8e40f0..102cc81 100644 --- a/backend/controllers/report.controller.ts +++ b/backend/controllers/report.controller.ts @@ -171,7 +171,6 @@ export class ReportController { const { userId } = req.params; const reports = await this.reportService.getUserReports(userId); - res .status(200) .json(reports.map((report) => ReportDto.fromDocument(report))); diff --git a/backend/models/reports/report.dto.ts b/backend/models/reports/report.dto.ts index 4e1612e..160754b 100644 --- a/backend/models/reports/report.dto.ts +++ b/backend/models/reports/report.dto.ts @@ -1,5 +1,5 @@ import { Document } from "mongoose"; -import { PostDocument, Report, UserDocument } from "../../types"; +import { PostDocument, Report, UserDocument, ReportDocument } from "../../types"; import { PostDto } from "../posts"; import { UserDto } from "../users"; @@ -25,6 +25,10 @@ export class ReportDto { const report = document.toObject() as Report; return new ReportDto(document.id, report); } + static fromAggregate(document: ReportDocument): ReportDto { + return new ReportDto(document._id, document); + } + } export class ReportedPostDto { diff --git a/backend/routes/admin.routes.ts b/backend/routes/admin.routes.ts index b8a5b43..0af7558 100644 --- a/backend/routes/admin.routes.ts +++ b/backend/routes/admin.routes.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { AdminController } from "../controllers/admin.controller"; import { ensureAdmin, ensureAuthenticated } from "../middlewares"; -import { PostService, ResendService, UserService } from "../services"; +import { PostService, ResendService, UserService, ReportService } from "../services"; // * middleware function to create route handlers const router = Router(); @@ -9,10 +9,12 @@ const router = Router(); const postService = new PostService(); const userService = new UserService(); const resendService = new ResendService(); +const reportService = new ReportService(); const adminController = new AdminController( postService, userService, resendService, + reportService, ); // * wire up routes with controller diff --git a/backend/seeder/seed.reports.ts b/backend/seeder/seed.reports.ts index 0d9ac42..6ab814f 100644 --- a/backend/seeder/seed.reports.ts +++ b/backend/seeder/seed.reports.ts @@ -5,12 +5,16 @@ import { PostDocument, ReportDocument, UserDocument } from "../types.js"; export const fakeReport = ( reporter: UserDocument, - post: PostDocument + post: PostDocument, ): ReportDocument => { + // 80% chance of being resolved + const status = + Math.random() <= 0.8 ? ReportStatus.RESOLVED : ReportStatus.UNRESOLVED; + const report = new ReportModel({ reporter: reporter, post: post, - status: ReportStatus.UNRESOLVED, + status: status, notes: faker.word.words({ count: { min: 5, max: 100 } }), }); @@ -20,7 +24,7 @@ export const fakeReport = ( export const seedReports = async ( destroy: boolean, reporter: UserDocument, - posts: PostDocument[] + posts: PostDocument[], ): Promise => { if (destroy) { console.log("🚀 ~ file: seed.reports.ts ~ seedReports ~ destroy:", destroy); diff --git a/backend/services/report.service.ts b/backend/services/report.service.ts index 62bedb7..983a61d 100644 --- a/backend/services/report.service.ts +++ b/backend/services/report.service.ts @@ -1,7 +1,8 @@ import { ObjectId } from "mongodb"; +import { PipelineStage, Types } from "mongoose"; import { ReportModel } from "../models/reports"; +import { UserModel } from "../models/users"; import { PostDocument, Report, ReportDocument } from "../types"; - export class ReportService { async createReport(report: Partial) { const newReport = new ReportModel({ @@ -20,7 +21,7 @@ export class ReportService { async getReportedPosts( page: number, - limit: number + limit: number, ): Promise< [{ _id: string; outstanding_reports: number; post: PostDocument }[], number] > { @@ -123,7 +124,55 @@ export class ReportService { } async getUserReports(userId: string): Promise { - const reports = await ReportModel.find({ userId: userId }); + const pipeline: PipelineStage[] = [ + { + $lookup: { + from: "posts", + localField: "post", + foreignField: "_id", + as: "post", + }, + }, + { $unwind: "$post" }, + { + $match: { + "post.author": new Types.ObjectId(userId), + }, + }, + { + $group: { + _id: "$status", + reports: { $push: "$$ROOT" }, + count: { $sum: 1 }, + }, + }, + { + $sort: { + count: -1, + updatedAt: -1, + createdAt: -1, + }, + }, + { + $project: { + _id: 0, + reports: 1, + }, + }, + { + $unwind: "$reports", + }, + { + $replaceRoot: { + newRoot: "$reports", + }, + }, + ]; + + const reports = await ReportModel.aggregate(pipeline); + + await UserModel.populate(reports, { path: "reporter" }); + await UserModel.populate(reports, { path: "resolver" }); return reports; } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 57e6eb4..24f102b 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1,5 +1,6 @@ { "title": "Title", + "open": "Open", "add": "Add", "edit": "Edit", "delete": "Delete", @@ -277,6 +278,7 @@ "active": "Active", "inactive": "Inactive", "location_information": "Location information", + "report_information": "Report information", "postal_code": "Postal code", "organization_information": "Organization information", "phone_number": "Phone number", diff --git a/frontend/public/locales/fr/common.json b/frontend/public/locales/fr/common.json index 6f3b26b..5d49869 100644 --- a/frontend/public/locales/fr/common.json +++ b/frontend/public/locales/fr/common.json @@ -1,5 +1,6 @@ { "title": "Titre", + "open": "Ouvrir", "add": "Ajouter", "edit": "Modifier", "delete": "Supprimer", @@ -277,6 +278,7 @@ "active": "Actif", "inactive": "Inactif", "location_information": "Informations de localisation", + "report_information": "Informations sur les rapports", "postal_code": "Code postal", "organization_information": "Informations sur l'organisation", "phone_number": "Numéro de téléphone", diff --git a/frontend/src/components/Users/UserDetails.tsx b/frontend/src/components/Users/UserDetails.tsx index 0ff5cb7..d07f834 100644 --- a/frontend/src/components/Users/UserDetails.tsx +++ b/frontend/src/components/Users/UserDetails.tsx @@ -12,12 +12,15 @@ import { import { faCancel, faCheckCircle, + faRectangleList, faRecycle, faTrash, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ReportSummaryItem } from "@pages/Admin/components/ReportSummaryItem"; import { DialogClose } from "@radix-ui/react-dialog"; import { + ApiModel, useGetUserAdminQuery, useToggleUserActiveAdminMutation, useVerifyUserAdminMutation, @@ -44,13 +47,17 @@ export const UserDetails = ({ const [activeDialogOopen, setActiveDialogOpen] = useState(false); const [serverMessage, setServerMessage] = useState(); const [reason, setReason] = useState(""); + const [unresolvedReports, setUnresolvedReports] = useState( + [], + ); + const [resolvedReports, setResolvedReports] = useState([]); const [verifyUserApi, { isSuccess: isVerifySuccess, error: verifyError }] = useVerifyUserAdminMutation(); const [toggleUserActive] = useToggleUserActiveAdminMutation(); const { - data: user, + data: reportResponse, isLoading, isError, error: getUserError, @@ -62,20 +69,32 @@ export const UserDetails = ({ } }, [isError]); + useEffect(() => { + const unresolved = reportResponse?.reports?.filter( + (x) => x.status === "unresolved", + )!; + setUnresolvedReports(unresolved); + + const resolved = reportResponse?.reports?.filter( + (x) => x.status === "resolved", + )!; + setResolvedReports(resolved); + }, [reportResponse]); + const onVerify = async () => { - if (!user?.id) { + if (!reportResponse?.user?.id) { onError({ status: 500, message: "User ID must be available" }); return; } - verifyUserApi({ userId: user.id }); + verifyUserApi({ userId: reportResponse?.user?.id }); }; const onToggleActive = async () => { - if (!user?.id) { + if (!reportResponse?.user?.id) { onError({ status: 500, message: "User ID must be available" }); return; } - toggleUserActive({ userId: user.id, reason: reason }); + toggleUserActive({ userId: reportResponse?.user?.id, reason: reason }); setReason(""); setActiveDialogOpen(false); }; @@ -108,7 +127,7 @@ export const UserDetails = ({ return

{t("loading")}

; } - if (!user) { + if (!reportResponse?.user) { return

{t("errors.unknown_server_error")}

; } @@ -134,8 +153,9 @@ export const UserDetails = ({ location, organization, activeStatusChangeReason, - } = user; - const verified = user.organization?.verified ?? true; + } = reportResponse?.user!; + + const verified = reportResponse?.user!.organization?.verified ?? true; return (
@@ -364,6 +384,33 @@ export const UserDetails = ({
+ +
+

+ {t("users.report_information")} +

+ {reportResponse?.reports === undefined || + reportResponse?.reports.length === 0 ? ( +
+ + + {t("reports.no_reports_found")} + +
+ ) : ( +
    + {unresolvedReports?.map((report) => { + return ; + })} +
+ )} +
); }; diff --git a/frontend/src/pages/Account/components/UpdateUserInfoForm.tsx b/frontend/src/pages/Account/components/UpdateUserInfoForm.tsx index bd59ef6..3f5a8df 100644 --- a/frontend/src/pages/Account/components/UpdateUserInfoForm.tsx +++ b/frontend/src/pages/Account/components/UpdateUserInfoForm.tsx @@ -63,7 +63,6 @@ export const UpdateUserInfoForm = () => { // handle server error message useEffect(() => { if (error) { - console.log(error); if ("status" in error) { const err: any = "error" in error ? error.error : error.data; setServerMessage(err.errors.join(",") ?? "An error occurred"); diff --git a/frontend/src/pages/Admin/ReportsByUser.tsx b/frontend/src/pages/Admin/ReportsByUser.tsx index 8f66006..dcd1949 100644 --- a/frontend/src/pages/Admin/ReportsByUser.tsx +++ b/frontend/src/pages/Admin/ReportsByUser.tsx @@ -55,12 +55,9 @@ export const ReportsByUserPage = () => { // useMemo to filter reportedPosts by loggedInUserId const filteredReportedPosts = useMemo(() => { - console.log("Reported Posts:", reportedPostsResponse?.data); - console.log("Logged In User ID:", loggedInUserId); // Assuming each reportedPost has a post.author.id you want to match against loggedInUserId return ( reportedPostsResponse?.data.filter((reportedPost) => { - console.log("Post Author ID:", reportedPost.post.author.id); // Debug log return reportedPost.post.author.id !== loggedInUserId; }) || [] ); diff --git a/frontend/src/pages/Admin/Users.tsx b/frontend/src/pages/Admin/Users.tsx index b627be5..2f7e46a 100644 --- a/frontend/src/pages/Admin/Users.tsx +++ b/frontend/src/pages/Admin/Users.tsx @@ -18,12 +18,6 @@ export const AdminUsersPage = () => { user_type: userType, reported_user: withReport, }); - console.log({ - per_page: perPage, - page: page, - user_type: userType, - reported_user: withReport, - }); return (
diff --git a/frontend/src/pages/Admin/components/ReportSummaryItem.tsx b/frontend/src/pages/Admin/components/ReportSummaryItem.tsx new file mode 100644 index 0000000..4903dab --- /dev/null +++ b/frontend/src/pages/Admin/components/ReportSummaryItem.tsx @@ -0,0 +1,95 @@ +import { Badge } from "@components/Badge"; +import { Link } from "@components/Controls"; +import autoAnimate from "@formkit/auto-animate"; +import { + faChevronDown, + faChevronRight, +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ApiModel } from "@store/services/types"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface SummaryItemProps { + label: string; + postId: string; + children: React.ReactNode; +} + +interface ReportSummaryItemProps { + report: ApiModel.Report; +} + +const SummaryItem = ({ label, postId, children }: SummaryItemProps) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + const parent = useRef(null); + + useEffect(() => { + parent.current && autoAnimate(parent.current); + }, [parent]); + + return ( +
+
+ +
+ {isExpanded && ( +
+
+ {children} +
+ + {t("open")} + +
+ )} +
+ ); +}; + +export const ReportSummaryItem = ({ report }: ReportSummaryItemProps) => { + const { t } = useTranslation(); + const categoryString = + t(`posts.item_categories.${report.post.item.category}`) || ""; + const summaryLabel = `[${categoryString}] ${report.post.item.name}`; + + return ( +
  • +
    +
    +
    +

    + {t("reports.reporter")}: + {report.reporter.displayName} +

    +
    + + +
    + +

    {report.notes}

    +
    +
    +
  • + ); +}; diff --git a/frontend/src/store/services/api.ts b/frontend/src/store/services/api.ts index 9033497..93b1a7c 100644 --- a/frontend/src/store/services/api.ts +++ b/frontend/src/store/services/api.ts @@ -391,7 +391,7 @@ export const api = createApi({ ] : [{ type: "users", id: "admin-list" }], }), - getUserAdmin: builder.query({ + getUserAdmin: builder.query({ query: ({ userId }) => ({ url: `admin/users/${userId}`, method: "GET" }), providesTags: (result, error, args) => [ { type: "users", id: args.userId }, diff --git a/frontend/src/store/services/types.ts b/frontend/src/store/services/types.ts index 90af5a9..058c3c0 100644 --- a/frontend/src/store/services/types.ts +++ b/frontend/src/store/services/types.ts @@ -56,6 +56,11 @@ export namespace ApiModel { notes: string; }; + export type UserReports = { + user: User; + reports: Report[]; + }; + export type UserOrganization = { name: string; address: Address;