generated from nhcarrigan/template
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
This commit is contained in:
@@ -205,6 +205,9 @@ model User {
|
||||
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])
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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<ProfileReportWithUsers> {
|
||||
// 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<ProfileReportWithUsers[]> {
|
||||
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<ProfileReportWithUsers | null> {
|
||||
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<ProfileReportWithUsers> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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: `
|
||||
<div class="admin-reports-container">
|
||||
<h1>Profile Reports</h1>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading" role="status" aria-live="polite">
|
||||
<p>Loading reports...</p>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<div class="error" role="alert" aria-live="assertive">
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Filter Tabs -->
|
||||
<div class="filter-tabs" role="tablist" aria-label="Report status filters">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
[class.active]="activeFilter() === 'all'"
|
||||
[attr.aria-selected]="activeFilter() === 'all'"
|
||||
(click)="setFilter('all')"
|
||||
>
|
||||
All ({{ allReports().length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
[class.active]="activeFilter() === ReportStatus.PENDING"
|
||||
[attr.aria-selected]="activeFilter() === ReportStatus.PENDING"
|
||||
(click)="setFilter(ReportStatus.PENDING)"
|
||||
>
|
||||
Pending ({{ pendingCount() }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
[class.active]="activeFilter() === ReportStatus.REVIEWED"
|
||||
[attr.aria-selected]="activeFilter() === ReportStatus.REVIEWED"
|
||||
(click)="setFilter(ReportStatus.REVIEWED)"
|
||||
>
|
||||
Reviewed ({{ reviewedCount() }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
[class.active]="activeFilter() === ReportStatus.DISMISSED"
|
||||
[attr.aria-selected]="activeFilter() === ReportStatus.DISMISSED"
|
||||
(click)="setFilter(ReportStatus.DISMISSED)"
|
||||
>
|
||||
Dismissed ({{ dismissedCount() }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
[class.active]="activeFilter() === ReportStatus.ACTION_TAKEN"
|
||||
[attr.aria-selected]="activeFilter() === ReportStatus.ACTION_TAKEN"
|
||||
(click)="setFilter(ReportStatus.ACTION_TAKEN)"
|
||||
>
|
||||
Action Taken ({{ actionTakenCount() }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reports List -->
|
||||
<div class="reports-list" role="region" aria-label="Reports list">
|
||||
@for (report of filteredReports(); track report.id) {
|
||||
<div class="report-card">
|
||||
<!-- Report Header -->
|
||||
<div class="report-header">
|
||||
<div class="user-info">
|
||||
<div class="user-section">
|
||||
<h3>Reported User</h3>
|
||||
<div class="user-details">
|
||||
@if (report.reportedUser.avatar) {
|
||||
<img
|
||||
[src]="report.reportedUser.avatar"
|
||||
[alt]="report.reportedUser.username + ' avatar'"
|
||||
class="avatar"
|
||||
/>
|
||||
} @else {
|
||||
<div class="avatar-placeholder" [attr.aria-label]="report.reportedUser.username + ' avatar'">
|
||||
{{ report.reportedUser.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<div class="user-names">
|
||||
<span class="username">{{ report.reportedUser.username }}</span>
|
||||
@if (report.reportedUser.displayName) {
|
||||
<span class="display-name">({{ report.reportedUser.displayName }})</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-section">
|
||||
<h3>Reporter</h3>
|
||||
<div class="user-details">
|
||||
@if (report.reporter.avatar) {
|
||||
<img
|
||||
[src]="report.reporter.avatar"
|
||||
[alt]="report.reporter.username + ' avatar'"
|
||||
class="avatar"
|
||||
/>
|
||||
} @else {
|
||||
<div class="avatar-placeholder" [attr.aria-label]="report.reporter.username + ' avatar'">
|
||||
{{ report.reporter.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
<div class="user-names">
|
||||
<span class="username">{{ report.reporter.username }}</span>
|
||||
@if (report.reporter.displayName) {
|
||||
<span class="display-name">({{ report.reporter.displayName }})</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-meta">
|
||||
<span
|
||||
class="status-badge"
|
||||
[class.pending]="report.status === 'PENDING'"
|
||||
[class.reviewed]="report.status === 'REVIEWED'"
|
||||
[class.dismissed]="report.status === 'DISMISSED'"
|
||||
[class.action-taken]="report.status === 'ACTION_TAKEN'"
|
||||
[attr.aria-label]="'Status: ' + formatStatus(report.status)"
|
||||
>
|
||||
{{ formatStatus(report.status) }}
|
||||
</span>
|
||||
<span class="report-date">
|
||||
{{ formatDate(report.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Content -->
|
||||
<div class="report-content">
|
||||
<div class="reason">
|
||||
<strong>Reason:</strong> {{ formatReason(report.reason) }}
|
||||
</div>
|
||||
<div class="details">
|
||||
<strong>Details:</strong>
|
||||
<p>{{ report.details }}</p>
|
||||
</div>
|
||||
@if (report.reviewNotes) {
|
||||
<div class="review-notes">
|
||||
<strong>Review Notes:</strong>
|
||||
<p>{{ report.reviewNotes }}</p>
|
||||
@if (report.reviewer) {
|
||||
<small class="reviewer">
|
||||
Reviewed by {{ report.reviewer.username }}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Report Actions -->
|
||||
<div class="report-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-review"
|
||||
(click)="openReviewModal(report)"
|
||||
[attr.aria-label]="'Review report for ' + report.reportedUser.username"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">
|
||||
<p>No reports found.</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Review Modal -->
|
||||
@if (reviewingReport()) {
|
||||
<div
|
||||
class="modal-overlay"
|
||||
(click)="closeReviewModal()"
|
||||
(keydown.escape)="closeReviewModal()"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div
|
||||
class="modal-content"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
role="document"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Review Report</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="modal-close"
|
||||
(click)="closeReviewModal()"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Report Summary -->
|
||||
<div class="review-summary">
|
||||
<div class="summary-item">
|
||||
<strong>Reported User:</strong>
|
||||
<span>{{ reviewingReport()!.reportedUser.username }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reporter:</strong>
|
||||
<span>{{ reviewingReport()!.reporter.username }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reason:</strong>
|
||||
<span>{{ formatReason(reviewingReport()!.reason) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<strong>Reported On:</strong>
|
||||
<span>{{ formatDate(reviewingReport()!.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-section">
|
||||
<strong>Details:</strong>
|
||||
<p class="details-text">{{ reviewingReport()!.details }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Review Form -->
|
||||
<form (ngSubmit)="submitReview()" class="review-form">
|
||||
<div class="form-group">
|
||||
<label for="status">Update Status</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
[(ngModel)]="reviewForm.status"
|
||||
class="form-control"
|
||||
required
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="PENDING">Pending</option>
|
||||
<option value="REVIEWED">Reviewed</option>
|
||||
<option value="DISMISSED">Dismissed</option>
|
||||
<option value="ACTION_TAKEN">Action Taken</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reviewNotes">Review Notes (Optional)</label>
|
||||
<textarea
|
||||
id="reviewNotes"
|
||||
name="reviewNotes"
|
||||
[(ngModel)]="reviewForm.reviewNotes"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Add any notes about your review..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
(click)="closeReviewModal()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
{{ submitting() ? 'Submitting...' : 'Submit Review' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
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<ProfileReportWithUsers[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
activeFilter = signal<'all' | ReportStatus>('all');
|
||||
|
||||
reviewingReport = signal<ProfileReportWithUsers | null>(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, string> = {
|
||||
[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, string> = {
|
||||
[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'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="profile-container">
|
||||
@if (loading()) {
|
||||
@@ -42,6 +44,17 @@ import { ToastService } from '../../services/toast.service';
|
||||
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (showReportButton()) {
|
||||
<button
|
||||
class="report-button"
|
||||
(click)="openReportModal()"
|
||||
[attr.aria-label]="'Report ' + profile()!.username + ' profile'"
|
||||
type="button"
|
||||
>
|
||||
<fa-icon [icon]="faFlag"></fa-icon>
|
||||
<span>Report</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="badges-section">
|
||||
@@ -142,6 +155,14 @@ import { ToastService } from '../../services/toast.service';
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (reportModalOpen()) {
|
||||
<app-report-modal
|
||||
[reportedUserId]="profile()!.id"
|
||||
[reportedUsername]="profile()!.displayName || profile()!.username"
|
||||
(closeModal)="closeReportModal()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
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<UserProfileResponse | null>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
<div class="modal-overlay" (click)="onOverlayClick($event)" (keydown.escape)="closeModal.emit()" tabindex="-1">
|
||||
<div class="modal-card" role="dialog" aria-labelledby="modal-title" aria-modal="true">
|
||||
<div class="modal-header">
|
||||
<h2 id="modal-title">Report Profile</h2>
|
||||
<button
|
||||
class="close-button"
|
||||
(click)="onClose()"
|
||||
aria-label="Close modal"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="report-info">
|
||||
You are reporting <strong>{{ reportedUsername() }}</strong>.
|
||||
Please provide a reason and details for this report.
|
||||
</p>
|
||||
|
||||
<form (ngSubmit)="onSubmit()" #reportForm="ngForm">
|
||||
<div class="form-group">
|
||||
<label for="reason">Reason *</label>
|
||||
<select
|
||||
id="reason"
|
||||
name="reason"
|
||||
[(ngModel)]="selectedReason"
|
||||
required
|
||||
class="form-control"
|
||||
aria-required="true"
|
||||
>
|
||||
<option value="" disabled>Select a reason</option>
|
||||
<option [value]="ReportReason.INAPPROPRIATE_CONTENT">Inappropriate Content</option>
|
||||
<option [value]="ReportReason.HARASSMENT">Harassment</option>
|
||||
<option [value]="ReportReason.SPAM">Spam</option>
|
||||
<option [value]="ReportReason.IMPERSONATION">Impersonation</option>
|
||||
<option [value]="ReportReason.OFFENSIVE_NAME">Offensive Name</option>
|
||||
<option [value]="ReportReason.MALICIOUS_LINKS">Malicious Links</option>
|
||||
<option [value]="ReportReason.OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="details">Details *</label>
|
||||
<textarea
|
||||
id="details"
|
||||
name="details"
|
||||
[(ngModel)]="details"
|
||||
required
|
||||
minlength="10"
|
||||
maxlength="1000"
|
||||
rows="5"
|
||||
class="form-control"
|
||||
placeholder="Please provide specific details about why you are reporting this profile (10-1000 characters)"
|
||||
aria-required="true"
|
||||
[attr.aria-invalid]="detailsInvalid()"
|
||||
></textarea>
|
||||
<div class="character-count" [class.invalid]="detailsInvalid()">
|
||||
{{ details().length }} / 1000 characters
|
||||
@if (details().length < 10 && details().length > 0) {
|
||||
<span class="error-text">(minimum 10 characters)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="button button-secondary"
|
||||
(click)="onClose()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="button button-danger"
|
||||
[disabled]="!reportForm.valid || submitting()"
|
||||
>
|
||||
@if (submitting()) {
|
||||
<span>Submitting...</span>
|
||||
} @else {
|
||||
<span>Submit Report</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
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,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23e0e0e0"><path d="M7 10l5 5 5-5z"/></svg>');
|
||||
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<string>();
|
||||
reportedUsername = input.required<string>();
|
||||
|
||||
// Outputs
|
||||
closeModal = output<void>();
|
||||
|
||||
// State
|
||||
selectedReason = signal<string>('');
|
||||
details = signal<string>('');
|
||||
submitting = signal<boolean>(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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<ProfileReportWithUsers> {
|
||||
const dto: CreateReportDto = {
|
||||
reportedUserId,
|
||||
reason: reason as CreateReportDto['reason'],
|
||||
details
|
||||
};
|
||||
return this.api.post<ProfileReportWithUsers>('/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<ProfileReportWithUsers[]> {
|
||||
const url = status ? `/reports?status=${status}` : '/reports';
|
||||
return this.api.get<ProfileReportWithUsers[]>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific report by ID (admin only).
|
||||
*
|
||||
* @param id - The report ID
|
||||
* @returns Observable of the report
|
||||
*/
|
||||
getReportById(id: string): Observable<ProfileReportWithUsers> {
|
||||
return this.api.get<ProfileReportWithUsers>(`/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<ProfileReportWithUsers> {
|
||||
return this.api.put<ProfileReportWithUsers>(`/reports/${id}`, {
|
||||
status,
|
||||
reviewNotes
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user