feat: implement user profiles with achievements and primary badge system #58

Merged
naomi merged 17 commits from feat/user-profiles into main 2026-02-19 22:21:18 -08:00
9 changed files with 2056 additions and 6 deletions
Showing only changes of commit d797d38ddd - Show all commits
+40
View File
@@ -205,6 +205,9 @@ model User {
suggestions Suggestion[] suggestions Suggestion[]
likes Like[] likes Like[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
reportsMade ProfileReport[] @relation("Reporter")
reportsReceived ProfileReport[] @relation("ReportedUser")
reportsReviewed ProfileReport[] @relation("Reviewer")
@@index([slug], map: "User_slug_key") @@index([slug], map: "User_slug_key")
} }
@@ -329,3 +332,40 @@ model RefreshToken {
@@index([userId]) @@index([userId])
@@index([expiresAt]) @@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])
}
+139
View File
@@ -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;
+312
View File
@@ -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,
};
}
}
+4
View File
@@ -41,6 +41,10 @@ export const appRoutes: Route[] = [
path: 'admin/suggestions', path: 'admin/suggestions',
loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent) 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', path: 'my-suggestions',
loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) 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 { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; 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 { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
import { UserService, UserProfileResponse } from '../../services/user.service'; import { UserService, UserProfileResponse } from '../../services/user.service';
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth.service';
import { ReportModalComponent } from '../report-modal/report-modal.component';
@Component({ @Component({
selector: 'app-profile', selector: 'app-profile',
standalone: true, standalone: true,
imports: [CommonModule, FontAwesomeModule], imports: [CommonModule, FontAwesomeModule, ReportModalComponent],
template: ` template: `
<div class="profile-container"> <div class="profile-container">
@if (loading()) { @if (loading()) {
@@ -42,6 +44,17 @@ import { ToastService } from '../../services/toast.service';
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p> <p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
} }
</div> </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>
<div class="badges-section"> <div class="badges-section">
@@ -142,6 +155,14 @@ import { ToastService } from '../../services/toast.service';
</div> </div>
</div> </div>
} }
@if (reportModalOpen()) {
<app-report-modal
[reportedUserId]="profile()!.id"
[reportedUsername]="profile()!.displayName || profile()!.username"
(closeModal)="closeReportModal()"
/>
}
</div> </div>
`, `,
styles: [` styles: [`
@@ -173,6 +194,7 @@ import { ToastService } from '../../services/toast.service';
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
position: relative;
} }
.profile-avatar { .profile-avatar {
@@ -217,6 +239,33 @@ import { ToastService } from '../../services/toast.service';
font-size: 0.9rem; 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 { .badges-section {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -369,10 +418,12 @@ export class ProfileComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private userService = inject(UserService); private userService = inject(UserService);
private toastService = inject(ToastService); private toastService = inject(ToastService);
private authService = inject(AuthService);
profile = signal<UserProfileResponse | null>(null); profile = signal<UserProfileResponse | null>(null);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
reportModalOpen = signal(false);
// Font Awesome icons // Font Awesome icons
faGlobe = faGlobe; faGlobe = faGlobe;
@@ -382,6 +433,7 @@ export class ProfileComponent implements OnInit {
faTwitch = faTwitch; faTwitch = faTwitch;
faYoutube = faYoutube; faYoutube = faYoutube;
faDiscord = faDiscord; faDiscord = faDiscord;
faFlag = faFlag;
ngOnInit(): void { ngOnInit(): void {
const identifier = this.route.snapshot.paramMap.get('identifier'); const identifier = this.route.snapshot.paramMap.get('identifier');
@@ -412,4 +464,34 @@ export class ProfileComponent implements OnInit {
day: 'numeric' 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
});
}
}
+60
View File
@@ -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;
}