From d797d38dddfb4dfd0adb5a6c7722af3fcdb977f3 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 18:33:58 -0800 Subject: [PATCH] feat: implement profile reporting system with admin review Added comprehensive profile reporting system to allow users to report inappropriate profiles and admins to review reports. Features: - User can report profiles with predefined reasons + custom details - Duplicate prevention (one pending report per profile per user) - Rate limiting (5 pending reports maximum per user) - Admin dashboard to view and filter reports (All, Pending, Reviewed, etc.) - Admin review modal to update status and add review notes - Report button on profile page (only visible when viewing others) - Font Awesome icons for better UI consistency Database changes: - New ProfileReport model with ReportReason/ReportStatus enums - User relations for reports (reportsMade, reportsReceived, reportsReviewed) - Indices for efficient querying --- api/prisma/schema.prisma | 48 +- api/src/app/routes/reports/index.ts | 139 +++ api/src/app/services/report.service.ts | 312 ++++++ apps/frontend/src/app/app.routes.ts | 4 + .../admin-reports/admin-reports.component.ts | 968 ++++++++++++++++++ .../components/profile/profile.component.ts | 86 +- .../report-modal/report-modal.component.ts | 371 +++++++ .../src/app/services/report.service.ts | 74 ++ shared-types/src/lib/report.types.ts | 60 ++ 9 files changed, 2056 insertions(+), 6 deletions(-) create mode 100644 api/src/app/routes/reports/index.ts create mode 100644 api/src/app/services/report.service.ts create mode 100644 apps/frontend/src/app/components/admin-reports/admin-reports.component.ts create mode 100644 apps/frontend/src/app/components/report-modal/report-modal.component.ts create mode 100644 apps/frontend/src/app/services/report.service.ts create mode 100644 shared-types/src/lib/report.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 0bf25a9..6437f61 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -201,10 +201,13 @@ model User { isStaff Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - comments Comment[] - suggestions Suggestion[] - likes Like[] - refreshTokens RefreshToken[] + comments Comment[] + suggestions Suggestion[] + likes Like[] + refreshTokens RefreshToken[] + reportsMade ProfileReport[] @relation("Reporter") + reportsReceived ProfileReport[] @relation("ReportedUser") + reportsReviewed ProfileReport[] @relation("Reviewer") @@index([slug], map: "User_slug_key") } @@ -329,3 +332,40 @@ model RefreshToken { @@index([userId]) @@index([expiresAt]) } + +enum ReportReason { + INAPPROPRIATE_CONTENT + HARASSMENT + SPAM + IMPERSONATION + OFFENSIVE_NAME + MALICIOUS_LINKS + OTHER +} + +enum ReportStatus { + PENDING + REVIEWED + DISMISSED + ACTION_TAKEN +} + +model ProfileReport { + id String @id @default(auto()) @map("_id") @db.ObjectId + reportedUserId String @db.ObjectId + reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id]) + reporterId String @db.ObjectId + reporter User @relation("Reporter", fields: [reporterId], references: [id]) + reason ReportReason + details String + status ReportStatus @default(PENDING) + reviewedBy String? @db.ObjectId + reviewer User? @relation("Reviewer", fields: [reviewedBy], references: [id]) + reviewNotes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([reportedUserId]) + @@index([reporterId]) + @@index([status]) +} diff --git a/api/src/app/routes/reports/index.ts b/api/src/app/routes/reports/index.ts new file mode 100644 index 0000000..8fb3cdc --- /dev/null +++ b/api/src/app/routes/reports/index.ts @@ -0,0 +1,139 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { FastifyPluginAsync } from "fastify"; +import type { + CreateReportDto, + ProfileReportWithUsers, + ReportStatus, + UpdateReportDto, +} from "@library/shared-types"; +import { ReportReason } from "@library/shared-types"; + +import { ReportService } from "../../services/report.service.js"; +import { adminGuard } from "../../middleware/admin-guard.js"; + +const reportsRoutes: FastifyPluginAsync = async (fastify) => { + const reportService = new ReportService(); + + // Create a new report (authenticated users) + fastify.post<{ + Body: CreateReportDto; + Reply: ProfileReportWithUsers | { error: string }; + }>( + "/", + { + preValidation: [fastify.authenticate], + schema: { + body: { + type: "object", + required: ["reportedUserId", "reason", "details"], + properties: { + reportedUserId: { type: "string" }, + reason: { + type: "string", + enum: Object.values(ReportReason), + }, + details: { type: "string", minLength: 10, maxLength: 1000 }, + }, + }, + }, + }, + async (request, reply) => { + try { + const report = await reportService.createReport( + request.user.id, + request.body, + ); + return reply.status(201).send(report); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already have a pending report") + ) { + return reply.status(409).send({ error: error.message }); + } + throw error; + } + }, + ); + + // Get all reports (admin only) + fastify.get<{ + Querystring: { status?: ReportStatus }; + Reply: ProfileReportWithUsers[]; + }>( + "/", + { + preValidation: [fastify.authenticate, adminGuard], + schema: { + querystring: { + type: "object", + properties: { + status: { type: "string" }, + }, + }, + }, + }, + async (request, reply) => { + const reports = await reportService.getAllReports( + request.query.status, + ); + return reply.send(reports); + }, + ); + + // Get a single report by ID (admin only) + fastify.get<{ + Params: { id: string }; + Reply: ProfileReportWithUsers | { error: string }; + }>( + "/:id", + { + preValidation: [fastify.authenticate, adminGuard], + }, + async (request, reply) => { + const report = await reportService.getReportById(request.params.id); + + if (!report) { + return reply.status(404).send({ error: "Report not found" }); + } + + return reply.send(report); + }, + ); + + // Update a report (admin only) + fastify.put<{ + Params: { id: string }; + Body: UpdateReportDto; + Reply: ProfileReportWithUsers; + }>( + "/:id", + { + preValidation: [fastify.authenticate, adminGuard], + schema: { + body: { + type: "object", + required: ["status"], + properties: { + status: { type: "string" }, + reviewNotes: { type: "string", maxLength: 1000 }, + }, + }, + }, + }, + async (request, reply) => { + const report = await reportService.updateReport( + request.params.id, + request.user.id, + request.body, + ); + return reply.send(report); + }, + ); +}; + +export default reportsRoutes; diff --git a/api/src/app/services/report.service.ts b/api/src/app/services/report.service.ts new file mode 100644 index 0000000..775518b --- /dev/null +++ b/api/src/app/services/report.service.ts @@ -0,0 +1,312 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + ReportStatus as PrismaReportStatus, + ReportReason as PrismaReportReason, +} from "@prisma/client"; +import type { + CreateReportDto, + ProfileReportWithUsers, + ReportStatus, + UpdateReportDto, +} from "@library/shared-types"; +import { ReportReason } from "@library/shared-types"; +import { prisma } from "../lib/prisma.js"; + +export class ReportService { + private prisma = prisma; + + /** + * Convert Prisma ReportReason to shared-types ReportReason + */ + private toPrismaReportReason(reason: ReportReason): PrismaReportReason { + return reason as unknown as PrismaReportReason; + } + + /** + * Convert Prisma ReportStatus to shared-types ReportStatus + */ + private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus { + return status as unknown as PrismaReportStatus; + } + + /** + * Convert Prisma enum back to shared-types enum + */ + private fromPrismaReportReason(reason: PrismaReportReason): ReportReason { + return reason as unknown as ReportReason; + } + + /** + * Convert Prisma enum back to shared-types enum + */ + private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus { + return status as unknown as ReportStatus; + } + + /** + * Create a new profile report. + * + * @param reporterId - The ID of the user making the report + * @param createDto - The report details + * @returns The created report + * @throws Error if user already has a pending report for this profile + */ + async createReport( + reporterId: string, + createDto: CreateReportDto, + ): Promise { + // Check if user already has a pending report for this profile + const existingReport = await this.prisma.profileReport.findFirst({ + where: { + reporterId, + reportedUserId: createDto.reportedUserId, + status: PrismaReportStatus.PENDING, + }, + }); + + if (existingReport) { + throw new Error( + "You already have a pending report for this profile. Please wait for it to be reviewed.", + ); + } + + // Check if user has reached the limit of pending reports (5 max) + const pendingReportsCount = await this.prisma.profileReport.count({ + where: { + reporterId, + status: PrismaReportStatus.PENDING, + }, + }); + + if (pendingReportsCount >= 5) { + throw new Error( + "You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.", + ); + } + + const report = await this.prisma.profileReport.create({ + data: { + reporterId, + reportedUserId: createDto.reportedUserId, + reason: this.toPrismaReportReason(createDto.reason), + details: createDto.details, + }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }); + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + }; + } + + /** + * Get all reports (admin only). Optionally filter by status. + * + * @param status - Optional status filter + * @returns All reports matching the filter + */ + async getAllReports( + status?: ReportStatus, + ): Promise { + const reports = await this.prisma.profileReport.findMany({ + where: status ? { status: this.toPrismaReportStatus(status) } : undefined, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return reports.map((report) => ({ + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + })); + } + + /** + * Get a single report by ID (admin only). + * + * @param id - The report ID + * @returns The report or null + */ + async getReportById(id: string): Promise { + const report = await this.prisma.profileReport.findUnique({ + where: { id }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); + + if (!report) { + return null; + } + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } + + /** + * Update a report's status and review notes (admin only). + * + * @param id - The report ID + * @param reviewerId - The ID of the admin reviewing the report + * @param updateDto - The update details + * @returns The updated report + */ + async updateReport( + id: string, + reviewerId: string, + updateDto: UpdateReportDto, + ): Promise { + const report = await this.prisma.profileReport.update({ + where: { id }, + data: { + status: this.toPrismaReportStatus(updateDto.status), + reviewNotes: updateDto.reviewNotes, + reviewedBy: reviewerId, + }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } +} diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 87dcca1..a8c1e6e 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -41,6 +41,10 @@ export const appRoutes: Route[] = [ path: 'admin/suggestions', loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent) }, + { + path: 'admin/reports', + loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent) + }, { path: 'my-suggestions', loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) diff --git a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts new file mode 100644 index 0000000..4363a86 --- /dev/null +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -0,0 +1,968 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ReportService } from '../../services/report.service'; +import { AuthService } from '../../services/auth.service'; +import { ToastService } from '../../services/toast.service'; +import { ProfileReportWithUsers, ReportStatus, ReportReason } from '@library/shared-types'; + +@Component({ + selector: 'app-admin-reports', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+

Profile Reports

+ + @if (loading()) { +
+

Loading reports...

+
+ } @else if (error()) { + + } @else { + +
+ + + + + +
+ + +
+ @for (report of filteredReports(); track report.id) { +
+ +
+ + +
+ + {{ formatStatus(report.status) }} + + + {{ formatDate(report.createdAt) }} + +
+
+ + +
+
+ Reason: {{ formatReason(report.reason) }} +
+
+ Details: +

{{ report.details }}

+
+ @if (report.reviewNotes) { +
+ Review Notes: +

{{ report.reviewNotes }}

+ @if (report.reviewer) { + + Reviewed by {{ report.reviewer.username }} + + } +
+ } +
+ + +
+ +
+
+ } @empty { +
+

No reports found.

+
+ } +
+ } + + + @if (reviewingReport()) { + + } +
+ `, + styles: [` + .admin-reports-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + } + + h1 { + color: var(--witch-purple); + margin-bottom: 2rem; + font-size: 2rem; + } + + .loading, .error, .empty-state { + text-align: center; + padding: 3rem 2rem; + color: var(--witch-mauve); + font-size: 1.1rem; + } + + .error { + color: var(--witch-rose); + } + + /* Filter Tabs */ + .filter-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + flex-wrap: wrap; + border-bottom: 2px solid var(--witch-lavender); + padding-bottom: 0.5rem; + } + + .tab { + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + border-radius: 8px 8px 0 0; + color: var(--witch-mauve); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + position: relative; + } + + .tab:hover { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .tab.active { + background: var(--witch-purple); + color: var(--witch-moon); + font-weight: 600; + } + + .tab:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + /* Reports List */ + .reports-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .report-card { + background: var(--witch-moon); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 12px var(--witch-shadow); + transition: all 0.3s; + } + + .report-card:hover { + box-shadow: 0 6px 16px var(--witch-shadow); + transform: translateY(-2px); + } + + /* Report Header */ + .report-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--witch-lavender); + flex-wrap: wrap; + gap: 1rem; + } + + .user-info { + display: flex; + gap: 2rem; + flex-wrap: wrap; + flex: 1; + } + + .user-section h3 { + font-size: 0.85rem; + color: var(--witch-mauve); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .user-details { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--witch-lavender); + } + + .avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--witch-purple); + color: var(--witch-moon); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1rem; + border: 2px solid var(--witch-lavender); + } + + .user-names { + display: flex; + flex-direction: column; + gap: 0.15rem; + } + + .username { + font-weight: 600; + color: var(--witch-purple); + font-size: 0.95rem; + } + + .display-name { + font-size: 0.85rem; + color: var(--witch-mauve); + } + + .report-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + } + + .status-badge { + padding: 0.4rem 0.9rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .status-badge.pending { + background: #ffd93d; + color: #1a1a1a; + } + + .status-badge.reviewed { + background: #6ab7ff; + color: #1a1a1a; + } + + .status-badge.dismissed { + background: #b0b0b0; + color: #1a1a1a; + } + + .status-badge.action-taken { + background: #6bcf7f; + color: #1a1a1a; + } + + .report-date { + font-size: 0.85rem; + color: var(--witch-mauve); + } + + /* Report Content */ + .report-content { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .reason, .details, .review-notes { + color: var(--witch-purple); + } + + .reason strong, .details strong, .review-notes strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + .details p, .review-notes p { + color: var(--witch-mauve); + line-height: 1.6; + margin: 0; + } + + .reviewer { + display: block; + margin-top: 0.5rem; + color: var(--witch-mauve); + font-style: italic; + } + + /* Report Actions */ + .report-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + } + + .btn { + padding: 0.65rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + } + + .btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--witch-shadow); + } + + .btn:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-review { + background: var(--witch-purple); + color: var(--witch-moon); + } + + .btn-review:hover { + background: var(--witch-mauve); + } + + /* Modal */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + } + + .modal-content { + background: var(--witch-moon); + border-radius: 16px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px var(--witch-shadow); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--witch-lavender); + } + + .modal-header h2 { + color: var(--witch-purple); + margin: 0; + font-size: 1.5rem; + } + + .modal-close { + background: none; + border: none; + font-size: 2rem; + color: var(--witch-mauve); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.3s; + } + + .modal-close:hover { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .modal-close:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + .modal-body { + padding: 1.5rem; + } + + /* Review Summary */ + .review-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + } + + .summary-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .summary-item strong { + color: var(--witch-purple); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .summary-item span { + color: var(--witch-mauve); + font-size: 0.95rem; + } + + .details-section { + margin-bottom: 1.5rem; + } + + .details-section strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + .details-text { + color: var(--witch-mauve); + line-height: 1.6; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + margin: 0; + } + + /* Review Form */ + .review-form { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .form-group label { + color: var(--witch-purple); + font-weight: 600; + font-size: 0.95rem; + } + + .form-control { + padding: 0.75rem; + border: 2px solid var(--witch-lavender); + border-radius: 8px; + background: var(--witch-moon); + color: var(--witch-purple); + font-size: 1rem; + font-family: inherit; + transition: all 0.3s; + } + + .form-control:focus { + outline: none; + border-color: var(--witch-purple); + box-shadow: 0 0 0 3px rgba(155, 89, 182, 0.2); + } + + select.form-control { + cursor: pointer; + } + + textarea.form-control { + resize: vertical; + min-height: 100px; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--witch-lavender); + } + + .btn-secondary { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .btn-secondary:hover:not(:disabled) { + background: var(--witch-mauve); + color: var(--witch-moon); + } + + .btn-primary { + background: var(--witch-purple); + color: var(--witch-moon); + } + + .btn-primary:hover:not(:disabled) { + background: var(--witch-mauve); + } + + /* Responsive Design */ + @media (max-width: 768px) { + .admin-reports-container { + padding: 1rem; + } + + h1 { + font-size: 1.5rem; + } + + .filter-tabs { + flex-direction: column; + } + + .tab { + text-align: left; + } + + .report-header { + flex-direction: column; + } + + .user-info { + flex-direction: column; + gap: 1rem; + } + + .report-meta { + align-items: flex-start; + } + + .review-summary { + grid-template-columns: 1fr; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .btn { + width: 100%; + } + } + `] +}) +export class AdminReportsComponent implements OnInit { + private reportService = inject(ReportService); + private authService = inject(AuthService); + private toastService = inject(ToastService); + private router = inject(Router); + + // Make ReportStatus accessible in template + protected readonly ReportStatus = ReportStatus; + + allReports = signal([]); + loading = signal(true); + error = signal(null); + activeFilter = signal<'all' | ReportStatus>('all'); + + reviewingReport = signal(null); + submitting = signal(false); + + reviewForm = { + status: ReportStatus.PENDING, + reviewNotes: '' + }; + + filteredReports = computed(() => { + const filter = this.activeFilter(); + if (filter === 'all') { + return this.allReports(); + } + return this.allReports().filter(report => report.status === filter); + }); + + pendingCount = computed(() => + this.allReports().filter(r => r.status === ReportStatus.PENDING).length + ); + + reviewedCount = computed(() => + this.allReports().filter(r => r.status === ReportStatus.REVIEWED).length + ); + + dismissedCount = computed(() => + this.allReports().filter(r => r.status === ReportStatus.DISMISSED).length + ); + + actionTakenCount = computed(() => + this.allReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length + ); + + ngOnInit(): void { + if (!this.authService.isAdmin()) { + this.router.navigate(['/']); + return; + } + + this.loadReports(); + } + + private loadReports(): void { + this.loading.set(true); + this.error.set(null); + + this.reportService.getAllReports().subscribe({ + next: (reports) => { + this.allReports.set(reports); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to load reports'); + this.loading.set(false); + this.toastService.error('Failed to load reports'); + } + }); + } + + setFilter(filter: 'all' | ReportStatus): void { + this.activeFilter.set(filter); + } + + openReviewModal(report: ProfileReportWithUsers): void { + this.reviewingReport.set(report); + this.reviewForm = { + status: report.status, + reviewNotes: report.reviewNotes || '' + }; + } + + closeReviewModal(): void { + if (!this.submitting()) { + this.reviewingReport.set(null); + this.reviewForm = { + status: ReportStatus.PENDING, + reviewNotes: '' + }; + } + } + + submitReview(): void { + const report = this.reviewingReport(); + if (!report) { + return; + } + + this.submitting.set(true); + + this.reportService.updateReport( + report.id, + this.reviewForm.status, + this.reviewForm.reviewNotes || undefined + ).subscribe({ + next: (updatedReport) => { + this.allReports.update(reports => + reports.map(r => r.id === updatedReport.id ? updatedReport : r) + ); + this.toastService.success('Report updated successfully'); + this.submitting.set(false); + this.closeReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update report'); + this.submitting.set(false); + } + }); + } + + formatStatus(status: ReportStatus): string { + const statusMap: Record = { + [ReportStatus.PENDING]: 'Pending', + [ReportStatus.REVIEWED]: 'Reviewed', + [ReportStatus.DISMISSED]: 'Dismissed', + [ReportStatus.ACTION_TAKEN]: 'Action Taken' + }; + return statusMap[status]; + } + + formatReason(reason: ReportReason): string { + const reasonMap: Record = { + [ReportReason.INAPPROPRIATE_CONTENT]: 'Inappropriate Content', + [ReportReason.HARASSMENT]: 'Harassment', + [ReportReason.SPAM]: 'Spam', + [ReportReason.IMPERSONATION]: 'Impersonation', + [ReportReason.OFFENSIVE_NAME]: 'Offensive Name', + [ReportReason.MALICIOUS_LINKS]: 'Malicious Links', + [ReportReason.OTHER]: 'Other' + }; + return reasonMap[reason]; + } + + formatDate(date: Date): string { + return new Date(date).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } +} diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index f728627..c877d23 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -8,15 +8,17 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CommonModule } from '@angular/common'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { faGlobe, faCloud } from '@fortawesome/free-solid-svg-icons'; +import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons'; import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons'; import { UserService, UserProfileResponse } from '../../services/user.service'; import { ToastService } from '../../services/toast.service'; +import { AuthService } from '../../services/auth.service'; +import { ReportModalComponent } from '../report-modal/report-modal.component'; @Component({ selector: 'app-profile', standalone: true, - imports: [CommonModule, FontAwesomeModule], + imports: [CommonModule, FontAwesomeModule, ReportModalComponent], template: `
@if (loading()) { @@ -42,6 +44,17 @@ import { ToastService } from '../../services/toast.service';

library.nhcarrigan.com/profile/{{ profile()!.slug }}

}
+ @if (showReportButton()) { + + }
@@ -142,6 +155,14 @@ import { ToastService } from '../../services/toast.service';
} + + @if (reportModalOpen()) { + + } `, styles: [` @@ -173,6 +194,7 @@ import { ToastService } from '../../services/toast.service'; align-items: center; gap: 1.5rem; margin-bottom: 1.5rem; + position: relative; } .profile-avatar { @@ -217,6 +239,33 @@ import { ToastService } from '../../services/toast.service'; font-size: 0.9rem; } + .report-button { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .report-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4); + } + + .report-button fa-icon { + font-size: 1rem; + } + .badges-section { display: flex; flex-wrap: wrap; @@ -369,10 +418,12 @@ export class ProfileComponent implements OnInit { private route = inject(ActivatedRoute); private userService = inject(UserService); private toastService = inject(ToastService); + private authService = inject(AuthService); profile = signal(null); loading = signal(true); error = signal(null); + reportModalOpen = signal(false); // Font Awesome icons faGlobe = faGlobe; @@ -382,6 +433,7 @@ export class ProfileComponent implements OnInit { faTwitch = faTwitch; faYoutube = faYoutube; faDiscord = faDiscord; + faFlag = faFlag; ngOnInit(): void { const identifier = this.route.snapshot.paramMap.get('identifier'); @@ -412,4 +464,34 @@ export class ProfileComponent implements OnInit { day: 'numeric' }); } + + /** + * Determine whether to show the report button. + * Only show if the user is authenticated and viewing someone else's profile. + */ + showReportButton(): boolean { + const currentUser = this.authService.user(); + const profileData = this.profile(); + + if (!currentUser || !profileData) { + return false; + } + + // Don't show report button on your own profile + return currentUser.id !== profileData.id; + } + + /** + * Open the report modal. + */ + openReportModal(): void { + this.reportModalOpen.set(true); + } + + /** + * Close the report modal. + */ + closeReportModal(): void { + this.reportModalOpen.set(false); + } } diff --git a/apps/frontend/src/app/components/report-modal/report-modal.component.ts b/apps/frontend/src/app/components/report-modal/report-modal.component.ts new file mode 100644 index 0000000..9cff1a9 --- /dev/null +++ b/apps/frontend/src/app/components/report-modal/report-modal.component.ts @@ -0,0 +1,371 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, inject, signal, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ReportService } from '../../services/report.service'; +import { ToastService } from '../../services/toast.service'; +import { ReportReason } from '@library/shared-types'; + +@Component({ + selector: 'app-report-modal', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` + + `, + styles: [` + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + } + + .modal-card { + background: var(--card-background, #1a1a2e); + border-radius: 12px; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 2px solid rgba(155, 89, 182, 0.3); + } + + .modal-header h2 { + margin: 0; + color: var(--accent-colour, #9b59b6); + font-size: 1.5rem; + } + + .close-button { + background: none; + border: none; + font-size: 2rem; + color: var(--text-colour, #e0e0e0); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + line-height: 1; + transition: color 0.2s ease; + } + + .close-button:hover { + color: var(--accent-colour, #9b59b6); + } + + .modal-body { + padding: 1.5rem; + } + + .report-info { + margin: 0 0 1.5rem 0; + color: var(--text-colour, #e0e0e0); + line-height: 1.6; + } + + .report-info strong { + color: var(--accent-colour, #9b59b6); + } + + .form-group { + margin-bottom: 1.5rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-colour, #e0e0e0); + font-weight: 500; + } + + .form-control { + width: 100%; + padding: 0.75rem; + background: rgba(155, 89, 182, 0.1); + border: 2px solid rgba(155, 89, 182, 0.3); + border-radius: 8px; + color: var(--text-colour, #e0e0e0); + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s ease; + } + + .form-control:focus { + outline: none; + border-color: var(--accent-colour, #9b59b6); + } + + .form-control:invalid:not(:focus):not(:placeholder-shown) { + border-color: var(--error-colour, #c41e3a); + } + + textarea.form-control { + resize: vertical; + min-height: 120px; + } + + select.form-control { + cursor: pointer; + appearance: none; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1.5rem; + padding-right: 2.5rem; + } + + select.form-control option { + background: #2d2d2d; + color: #e0e0e0; + padding: 0.5rem; + } + + .character-count { + margin-top: 0.5rem; + font-size: 0.875rem; + color: var(--text-muted, #a0a0a0); + text-align: right; + } + + .character-count.invalid { + color: var(--error-colour, #c41e3a); + } + + .error-text { + color: var(--error-colour, #c41e3a); + margin-left: 0.5rem; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 2px solid rgba(155, 89, 182, 0.3); + } + + .button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .button-secondary { + background: rgba(155, 89, 182, 0.2); + color: var(--text-colour, #e0e0e0); + border: 2px solid rgba(155, 89, 182, 0.3); + } + + .button-secondary:hover:not(:disabled) { + background: rgba(155, 89, 182, 0.3); + border-color: var(--accent-colour, #9b59b6); + } + + .button-danger { + background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%); + color: white; + } + + .button-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4); + } + `] +}) +export class ReportModalComponent { + private reportService = inject(ReportService); + private toastService = inject(ToastService); + + // Inputs + reportedUserId = input.required(); + reportedUsername = input.required(); + + // Outputs + closeModal = output(); + + // State + selectedReason = signal(''); + details = signal(''); + submitting = signal(false); + + // Expose enum for template + ReportReason = ReportReason; + + /** + * Check if details are invalid (less than 10 characters or more than 1000). + */ + detailsInvalid(): boolean { + const length = this.details().length; + return (length > 0 && length < 10) || length > 1000; + } + + /** + * Handle overlay click to close modal. + */ + onOverlayClick(event: MouseEvent): void { + if ((event.target as HTMLElement).classList.contains('modal-overlay')) { + this.onClose(); + } + } + + /** + * Close the modal. + */ + onClose(): void { + this.closeModal.emit(); + } + + /** + * Submit the report. + */ + onSubmit(): void { + // Validate form + if (!this.selectedReason() || this.detailsInvalid()) { + this.toastService.error('Please fill in all required fields correctly'); + return; + } + + this.submitting.set(true); + + this.reportService.createReport( + this.reportedUserId(), + this.selectedReason(), + this.details() + ).subscribe({ + next: () => { + this.toastService.success('Report submitted successfully'); + this.onClose(); + }, + error: (err: { status?: number; error?: { error?: string } }) => { + console.error('Error submitting report:', err); + // Check if it's a conflict error (duplicate pending report) + if (err.status === 409 && err.error?.error) { + this.toastService.error(err.error.error); + } else { + this.toastService.error('Failed to submit report. Please try again.'); + } + this.submitting.set(false); + } + }); + } +} diff --git a/apps/frontend/src/app/services/report.service.ts b/apps/frontend/src/app/services/report.service.ts new file mode 100644 index 0000000..52b4198 --- /dev/null +++ b/apps/frontend/src/app/services/report.service.ts @@ -0,0 +1,74 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { + ProfileReportWithUsers, + CreateReportDto, + ReportStatus +} from '@library/shared-types'; + +@Injectable({ + providedIn: 'root' +}) +export class ReportService { + private api = inject(ApiService); + + /** + * Create a new profile report. + * + * @param reportedUserId - The ID of the user being reported + * @param reason - The reason for the report + * @param details - Additional details about the report + * @returns Observable of the created report + */ + createReport(reportedUserId: string, reason: string, details: string): Observable { + const dto: CreateReportDto = { + reportedUserId, + reason: reason as CreateReportDto['reason'], + details + }; + return this.api.post('/reports', dto); + } + + /** + * Get all reports (admin only). Optionally filter by status. + * + * @param status - Optional status to filter by + * @returns Observable of all matching reports + */ + getAllReports(status?: ReportStatus): Observable { + const url = status ? `/reports?status=${status}` : '/reports'; + return this.api.get(url); + } + + /** + * Get a specific report by ID (admin only). + * + * @param id - The report ID + * @returns Observable of the report + */ + getReportById(id: string): Observable { + return this.api.get(`/reports/${id}`); + } + + /** + * Update a report's status and review notes (admin only). + * + * @param id - The report ID + * @param status - The new status + * @param reviewNotes - Optional review notes + * @returns Observable of the updated report + */ + updateReport(id: string, status: ReportStatus, reviewNotes?: string): Observable { + return this.api.put(`/reports/${id}`, { + status, + reviewNotes + }); + } +} diff --git a/shared-types/src/lib/report.types.ts b/shared-types/src/lib/report.types.ts new file mode 100644 index 0000000..985a915 --- /dev/null +++ b/shared-types/src/lib/report.types.ts @@ -0,0 +1,60 @@ +export enum ReportReason { + INAPPROPRIATE_CONTENT = "INAPPROPRIATE_CONTENT", + HARASSMENT = "HARASSMENT", + SPAM = "SPAM", + IMPERSONATION = "IMPERSONATION", + OFFENSIVE_NAME = "OFFENSIVE_NAME", + MALICIOUS_LINKS = "MALICIOUS_LINKS", + OTHER = "OTHER", +} + +export enum ReportStatus { + PENDING = "PENDING", + REVIEWED = "REVIEWED", + DISMISSED = "DISMISSED", + ACTION_TAKEN = "ACTION_TAKEN", +} + +export interface ProfileReport { + id: string; + reportedUserId: string; + reporterId: string; + reason: ReportReason; + details: string; + status: ReportStatus; + reviewedBy?: string; + reviewNotes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface ProfileReportWithUsers extends ProfileReport { + reportedUser: { + id: string; + username: string; + displayName?: string; + avatar?: string; + }; + reporter: { + id: string; + username: string; + displayName?: string; + avatar?: string; + }; + reviewer?: { + id: string; + username: string; + displayName?: string; + }; +} + +export interface CreateReportDto { + reportedUserId: string; + reason: ReportReason; + details: string; +} + +export interface UpdateReportDto { + status: ReportStatus; + reviewNotes?: string; +}