From 34c7ca8ba27e4c4f7d21a6559f7e87f8c677b633 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 17:27:35 -0800 Subject: [PATCH 01/17] feat: implement user profiles and settings Add comprehensive user profile system allowing users to showcase their activity and customize their profiles. Database Changes: - Added profile fields to User model: slug, displayName, bio, profilePublic - Added index on slug field for efficient lookups API Changes: - Added GET /users/me endpoint to fetch current user - Added PUT /users/me endpoint to update user settings - Added GET /users/profile/:identifier endpoint for public profiles - Updated UserService with profile methods and statistics - Modified AuthService to include profile fields in user responses Frontend Changes: - Created ProfileComponent to display user profiles with stats - Created SettingsComponent for profile customization - Added profile and settings routes - Updated header dropdown menu with profile links - Enhanced UserService with profile methods - Added updateUser method to AuthService Features: - Custom profile slugs for clean URLs - Display names separate from usernames - User bios (up to 500 characters) - Public/private profile toggle - Activity statistics (suggestions, likes, comments, acceptance rate) - Badge display (Staff, Mod, VIP, Discord Member) - Beautiful witch-themed styling Closes #45 --- api/prisma/schema.prisma | 6 + api/src/app/routes/users/index.ts | 123 +++++++ api/src/app/services/auth.service.ts | 12 + api/src/app/services/user.service.ts | 150 ++++++++ apps/frontend/src/app/app.routes.ts | 8 + .../app/components/header/header.component.ts | 2 + .../components/profile/profile.component.ts | 304 ++++++++++++++++ .../components/settings/settings.component.ts | 324 ++++++++++++++++++ .../frontend/src/app/services/auth.service.ts | 4 + .../frontend/src/app/services/user.service.ts | 41 +++ shared-types/src/lib/auth.types.ts | 26 +- 11 files changed, 989 insertions(+), 11 deletions(-) create mode 100644 apps/frontend/src/app/components/profile/profile.component.ts create mode 100644 apps/frontend/src/app/components/settings/settings.component.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9f8043e..77b0191 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -182,6 +182,10 @@ model User { username String email String @unique avatar String? + slug String? + displayName String? + bio String? + profilePublic Boolean @default(true) isAdmin Boolean @default(false) isBanned Boolean @default(false) inDiscord Boolean @default(false) @@ -194,6 +198,8 @@ model User { suggestions Suggestion[] likes Like[] refreshTokens RefreshToken[] + + @@index([slug], map: "User_slug_key") } model Comment { diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts index 710f765..390c958 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -10,6 +10,35 @@ import { UserService } from "../../services/user.service"; import { AuditService } from "../../services/audit.service"; import { adminGuard } from "../../middleware/admin-guard"; +interface UpdateUserSettingsBody { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; +} + +interface UserProfileResponse { + id: string; + username: string; + displayName?: string; + avatar?: string; + bio?: string; + slug?: string; + badges: { + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + }; + stats: { + suggestionsCount: number; + suggestionsAcceptedCount: number; + likesCount: number; + commentsCount: number; + }; + createdAt: Date; +} + const usersRoutes: FastifyPluginAsync = async (app) => { const userService = new UserService(); @@ -23,6 +52,100 @@ const usersRoutes: FastifyPluginAsync = async (app) => { } ); + app.get<{ Reply: User }>( + "/me", + { + preValidation: [app.authenticate], + }, + async (request) => { + const currentUser = request.user as { id: string }; + const user = await userService.getUserById(currentUser.id); + if (!user) { + throw new Error("User not found"); + } + return user; + } + ); + + app.put<{ Body: UpdateUserSettingsBody; Reply: User | { error: string } }>( + "/me", + { + preValidation: [app.authenticate], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const currentUser = request.user as { id: string }; + const updates = request.body; + + // If slug is being updated, check if it's unique + if (updates.slug) { + const existingUser = await userService.getUserBySlug(updates.slug); + if (existingUser && existingUser.id !== currentUser.id) { + return reply.code(400).send({ error: "Slug already taken" }); + } + } + + const updatedUser = await userService.updateUserSettings( + currentUser.id, + updates + ); + + if (!updatedUser) { + return reply.code(404).send({ error: "User not found" }); + } + + return updatedUser; + } + ); + + app.get<{ + Params: { identifier: string }; + Reply: UserProfileResponse | { error: string }; + }>( + "/profile/:identifier", + async (request, reply) => { + const { identifier } = request.params; + + try { + const profile = await userService.getUserProfile(identifier); + + if (!profile) { + return reply.code(404).send({ error: "User not found" }); + } + + if (!profile.profilePublic) { + // Check if the requesting user is viewing their own profile + const currentUser = request.user as { id: string } | undefined; + if (!currentUser || currentUser.id !== profile.id) { + return reply + .code(403) + .send({ error: "This profile is private" }); + } + } + + return { + id: profile.id, + username: profile.username, + displayName: profile.displayName, + avatar: profile.avatar, + bio: profile.bio, + slug: profile.slug, + badges: { + isStaff: profile.isStaff, + isMod: profile.isMod, + isVip: profile.isVip, + inDiscord: profile.inDiscord, + }, + stats: profile.stats, + createdAt: profile.createdAt, + }; + } catch (error) { + console.error("Error fetching profile:", error); + return reply.code(500).send({ error: "Failed to fetch profile" }); + } + } + ); + app.get<{ Params: { id: string }; Reply: User | null }>( "/:id", { diff --git a/api/src/app/services/auth.service.ts b/api/src/app/services/auth.service.ts index 010473d..10ad88d 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -71,6 +71,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -167,6 +171,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -217,6 +225,10 @@ export class AuthService { username: dbUser.username, email: dbUser.email, avatar: dbUser.avatar || undefined, + slug: dbUser.slug || undefined, + displayName: dbUser.displayName || undefined, + bio: dbUser.bio || undefined, + profilePublic: dbUser.profilePublic, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 6d825aa..ad6009d 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -6,6 +6,7 @@ import { User } from "@library/shared-types"; import { prisma } from "../lib/prisma"; +import { SuggestionStatus } from "@prisma/client"; export class UserService { private prisma = prisma; @@ -21,6 +22,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -45,6 +50,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -66,6 +75,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -87,6 +100,10 @@ export class UserService { username: user.username, email: user.email, avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -104,4 +121,137 @@ export class UserService { return user?.isBanned ?? false; } + + async getUserBySlug(slug: string): Promise { + const user = await this.prisma.user.findFirst({ + where: { slug }, + }); + + if (!user) { + return null; + } + + return { + id: user.id, + discordId: user.discordId, + username: user.username, + email: user.email, + avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, + isAdmin: user.isAdmin, + isBanned: user.isBanned, + inDiscord: user.inDiscord, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + }; + } + + async updateUserSettings( + id: string, + updates: { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; + } + ): Promise { + const user = await this.prisma.user.update({ + where: { id }, + data: updates, + }); + + return { + id: user.id, + discordId: user.discordId, + username: user.username, + email: user.email, + avatar: user.avatar || undefined, + slug: user.slug || undefined, + displayName: user.displayName || undefined, + bio: user.bio || undefined, + profilePublic: user.profilePublic, + isAdmin: user.isAdmin, + isBanned: user.isBanned, + inDiscord: user.inDiscord, + isVip: user.isVip, + isMod: user.isMod, + isStaff: user.isStaff, + }; + } + + async getUserProfile(identifier: string): Promise<{ + id: string; + username: string; + displayName?: string | null; + avatar?: string | null; + bio?: string | null; + slug?: string | null; + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + profilePublic: boolean; + createdAt: Date; + stats: { + suggestionsCount: number; + suggestionsAcceptedCount: number; + likesCount: number; + commentsCount: number; + }; + } | null> { + // Try to find by slug first, then by id if it's a valid ObjectId + const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier); + + const whereConditions = isValidObjectId + ? [{ slug: identifier }, { id: identifier }] + : [{ slug: identifier }]; + + const user = await this.prisma.user.findFirst({ + where: { + OR: whereConditions, + }, + include: { + suggestions: { + select: { id: true, status: true }, + }, + likes: { + select: { id: true }, + }, + comments: { + select: { id: true }, + }, + }, + }); + + if (!user) { + return null; + } + + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatar: user.avatar, + bio: user.bio, + slug: user.slug, + isStaff: user.isStaff, + isMod: user.isMod, + isVip: user.isVip, + inDiscord: user.inDiscord, + profilePublic: user.profilePublic, + createdAt: user.createdAt, + stats: { + suggestionsCount: user.suggestions.length, + suggestionsAcceptedCount: user.suggestions.filter( + (suggestion) => suggestion.status === SuggestionStatus.ACCEPTED + ).length, + likesCount: user.likes.length, + commentsCount: user.comments.length, + }, + }; + } } diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9e77a19..87dcca1 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -49,6 +49,14 @@ export const appRoutes: Route[] = [ path: 'my-likes', loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent) }, + { + path: 'profile/:identifier', + loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent) + }, + { + path: 'settings', + loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 6801e87..05931ad 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -50,6 +50,8 @@ import { ApiService } from '../../services/api.service'; } @if (showDropdown()) { } + + @if (reportModalOpen()) { + + } `, styles: [` @@ -173,6 +194,7 @@ import { ToastService } from '../../services/toast.service'; align-items: center; gap: 1.5rem; margin-bottom: 1.5rem; + position: relative; } .profile-avatar { @@ -217,6 +239,33 @@ import { ToastService } from '../../services/toast.service'; font-size: 0.9rem; } + .report-button { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .report-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4); + } + + .report-button fa-icon { + font-size: 1rem; + } + .badges-section { display: flex; flex-wrap: wrap; @@ -369,10 +418,12 @@ export class ProfileComponent implements OnInit { private route = inject(ActivatedRoute); private userService = inject(UserService); private toastService = inject(ToastService); + private authService = inject(AuthService); profile = signal(null); loading = signal(true); error = signal(null); + reportModalOpen = signal(false); // Font Awesome icons faGlobe = faGlobe; @@ -382,6 +433,7 @@ export class ProfileComponent implements OnInit { faTwitch = faTwitch; faYoutube = faYoutube; faDiscord = faDiscord; + faFlag = faFlag; ngOnInit(): void { const identifier = this.route.snapshot.paramMap.get('identifier'); @@ -412,4 +464,34 @@ export class ProfileComponent implements OnInit { day: 'numeric' }); } + + /** + * Determine whether to show the report button. + * Only show if the user is authenticated and viewing someone else's profile. + */ + showReportButton(): boolean { + const currentUser = this.authService.user(); + const profileData = this.profile(); + + if (!currentUser || !profileData) { + return false; + } + + // Don't show report button on your own profile + return currentUser.id !== profileData.id; + } + + /** + * Open the report modal. + */ + openReportModal(): void { + this.reportModalOpen.set(true); + } + + /** + * Close the report modal. + */ + closeReportModal(): void { + this.reportModalOpen.set(false); + } } diff --git a/apps/frontend/src/app/components/report-modal/report-modal.component.ts b/apps/frontend/src/app/components/report-modal/report-modal.component.ts new file mode 100644 index 0000000..9cff1a9 --- /dev/null +++ b/apps/frontend/src/app/components/report-modal/report-modal.component.ts @@ -0,0 +1,371 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, inject, signal, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { ReportService } from '../../services/report.service'; +import { ToastService } from '../../services/toast.service'; +import { ReportReason } from '@library/shared-types'; + +@Component({ + selector: 'app-report-modal', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` + + `, + styles: [` + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + } + + .modal-card { + background: var(--card-background, #1a1a2e); + border-radius: 12px; + width: 100%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 2px solid rgba(155, 89, 182, 0.3); + } + + .modal-header h2 { + margin: 0; + color: var(--accent-colour, #9b59b6); + font-size: 1.5rem; + } + + .close-button { + background: none; + border: none; + font-size: 2rem; + color: var(--text-colour, #e0e0e0); + cursor: pointer; + padding: 0; + width: 2rem; + height: 2rem; + line-height: 1; + transition: color 0.2s ease; + } + + .close-button:hover { + color: var(--accent-colour, #9b59b6); + } + + .modal-body { + padding: 1.5rem; + } + + .report-info { + margin: 0 0 1.5rem 0; + color: var(--text-colour, #e0e0e0); + line-height: 1.6; + } + + .report-info strong { + color: var(--accent-colour, #9b59b6); + } + + .form-group { + margin-bottom: 1.5rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-colour, #e0e0e0); + font-weight: 500; + } + + .form-control { + width: 100%; + padding: 0.75rem; + background: rgba(155, 89, 182, 0.1); + border: 2px solid rgba(155, 89, 182, 0.3); + border-radius: 8px; + color: var(--text-colour, #e0e0e0); + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s ease; + } + + .form-control:focus { + outline: none; + border-color: var(--accent-colour, #9b59b6); + } + + .form-control:invalid:not(:focus):not(:placeholder-shown) { + border-color: var(--error-colour, #c41e3a); + } + + textarea.form-control { + resize: vertical; + min-height: 120px; + } + + select.form-control { + cursor: pointer; + appearance: none; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + background-repeat: no-repeat; + background-position: right 0.75rem center; + background-size: 1.5rem; + padding-right: 2.5rem; + } + + select.form-control option { + background: #2d2d2d; + color: #e0e0e0; + padding: 0.5rem; + } + + .character-count { + margin-top: 0.5rem; + font-size: 0.875rem; + color: var(--text-muted, #a0a0a0); + text-align: right; + } + + .character-count.invalid { + color: var(--error-colour, #c41e3a); + } + + .error-text { + color: var(--error-colour, #c41e3a); + margin-left: 0.5rem; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 2px solid rgba(155, 89, 182, 0.3); + } + + .button { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .button-secondary { + background: rgba(155, 89, 182, 0.2); + color: var(--text-colour, #e0e0e0); + border: 2px solid rgba(155, 89, 182, 0.3); + } + + .button-secondary:hover:not(:disabled) { + background: rgba(155, 89, 182, 0.3); + border-color: var(--accent-colour, #9b59b6); + } + + .button-danger { + background: linear-gradient(135deg, #c41e3a 0%, #e74c3c 100%); + color: white; + } + + .button-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(196, 30, 58, 0.4); + } + `] +}) +export class ReportModalComponent { + private reportService = inject(ReportService); + private toastService = inject(ToastService); + + // Inputs + reportedUserId = input.required(); + reportedUsername = input.required(); + + // Outputs + closeModal = output(); + + // State + selectedReason = signal(''); + details = signal(''); + submitting = signal(false); + + // Expose enum for template + ReportReason = ReportReason; + + /** + * Check if details are invalid (less than 10 characters or more than 1000). + */ + detailsInvalid(): boolean { + const length = this.details().length; + return (length > 0 && length < 10) || length > 1000; + } + + /** + * Handle overlay click to close modal. + */ + onOverlayClick(event: MouseEvent): void { + if ((event.target as HTMLElement).classList.contains('modal-overlay')) { + this.onClose(); + } + } + + /** + * Close the modal. + */ + onClose(): void { + this.closeModal.emit(); + } + + /** + * Submit the report. + */ + onSubmit(): void { + // Validate form + if (!this.selectedReason() || this.detailsInvalid()) { + this.toastService.error('Please fill in all required fields correctly'); + return; + } + + this.submitting.set(true); + + this.reportService.createReport( + this.reportedUserId(), + this.selectedReason(), + this.details() + ).subscribe({ + next: () => { + this.toastService.success('Report submitted successfully'); + this.onClose(); + }, + error: (err: { status?: number; error?: { error?: string } }) => { + console.error('Error submitting report:', err); + // Check if it's a conflict error (duplicate pending report) + if (err.status === 409 && err.error?.error) { + this.toastService.error(err.error.error); + } else { + this.toastService.error('Failed to submit report. Please try again.'); + } + this.submitting.set(false); + } + }); + } +} diff --git a/apps/frontend/src/app/services/report.service.ts b/apps/frontend/src/app/services/report.service.ts new file mode 100644 index 0000000..52b4198 --- /dev/null +++ b/apps/frontend/src/app/services/report.service.ts @@ -0,0 +1,74 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from './api.service'; +import type { + ProfileReportWithUsers, + CreateReportDto, + ReportStatus +} from '@library/shared-types'; + +@Injectable({ + providedIn: 'root' +}) +export class ReportService { + private api = inject(ApiService); + + /** + * Create a new profile report. + * + * @param reportedUserId - The ID of the user being reported + * @param reason - The reason for the report + * @param details - Additional details about the report + * @returns Observable of the created report + */ + createReport(reportedUserId: string, reason: string, details: string): Observable { + const dto: CreateReportDto = { + reportedUserId, + reason: reason as CreateReportDto['reason'], + details + }; + return this.api.post('/reports', dto); + } + + /** + * Get all reports (admin only). Optionally filter by status. + * + * @param status - Optional status to filter by + * @returns Observable of all matching reports + */ + getAllReports(status?: ReportStatus): Observable { + const url = status ? `/reports?status=${status}` : '/reports'; + return this.api.get(url); + } + + /** + * Get a specific report by ID (admin only). + * + * @param id - The report ID + * @returns Observable of the report + */ + getReportById(id: string): Observable { + return this.api.get(`/reports/${id}`); + } + + /** + * Update a report's status and review notes (admin only). + * + * @param id - The report ID + * @param status - The new status + * @param reviewNotes - Optional review notes + * @returns Observable of the updated report + */ + updateReport(id: string, status: ReportStatus, reviewNotes?: string): Observable { + return this.api.put(`/reports/${id}`, { + status, + reviewNotes + }); + } +} diff --git a/shared-types/src/lib/report.types.ts b/shared-types/src/lib/report.types.ts new file mode 100644 index 0000000..985a915 --- /dev/null +++ b/shared-types/src/lib/report.types.ts @@ -0,0 +1,60 @@ +export enum ReportReason { + INAPPROPRIATE_CONTENT = "INAPPROPRIATE_CONTENT", + HARASSMENT = "HARASSMENT", + SPAM = "SPAM", + IMPERSONATION = "IMPERSONATION", + OFFENSIVE_NAME = "OFFENSIVE_NAME", + MALICIOUS_LINKS = "MALICIOUS_LINKS", + OTHER = "OTHER", +} + +export enum ReportStatus { + PENDING = "PENDING", + REVIEWED = "REVIEWED", + DISMISSED = "DISMISSED", + ACTION_TAKEN = "ACTION_TAKEN", +} + +export interface ProfileReport { + id: string; + reportedUserId: string; + reporterId: string; + reason: ReportReason; + details: string; + status: ReportStatus; + reviewedBy?: string; + reviewNotes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface ProfileReportWithUsers extends ProfileReport { + reportedUser: { + id: string; + username: string; + displayName?: string; + avatar?: string; + }; + reporter: { + id: string; + username: string; + displayName?: string; + avatar?: string; + }; + reviewer?: { + id: string; + username: string; + displayName?: string; + }; +} + +export interface CreateReportDto { + reportedUserId: string; + reason: ReportReason; + details: string; +} + +export interface UpdateReportDto { + status: ReportStatus; + reviewNotes?: string; +} -- 2.52.0 From 8f569e0bb4421532ff358a014f6d1de2eb6d784d Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 18:38:16 -0800 Subject: [PATCH 04/17] chore: add missing pieces for profile reporting and fix formatting - Added Reports link to admin dropdown menu - Fixed log route path (changed from '/log' to '/') - Exported report types from shared-types index - Fixed whitespace alignment in auth and game types - Fixed formatting in e2e test files (newlines, comment style) --- api/src/app/routes/log/index.ts | 2 +- apps/frontend-e2e/src/e2e/app.cy.ts | 2 +- apps/frontend-e2e/src/support/app.po.ts | 7 +++- apps/frontend-e2e/src/support/commands.ts | 22 ++++++---- apps/frontend-e2e/src/support/e2e.ts | 2 +- .../app/components/header/header.component.ts | 1 + apps/frontend/src/app/services/index.ts | 3 +- shared-types/src/index.ts | 1 + shared-types/src/lib/auth.types.ts | 42 +++++++++---------- shared-types/src/lib/game.types.ts | 16 +++---- 10 files changed, 56 insertions(+), 42 deletions(-) diff --git a/api/src/app/routes/log/index.ts b/api/src/app/routes/log/index.ts index 9cdfc6f..2c57468 100644 --- a/api/src/app/routes/log/index.ts +++ b/api/src/app/routes/log/index.ts @@ -19,7 +19,7 @@ interface LogBody { } export default async function (fastify: FastifyInstance) { - fastify.post('/log', async function (request: FastifyRequest<{ Body: LogBody }>) { + fastify.post('/', async function (request: FastifyRequest<{ Body: LogBody }>) { const { level, message, context, error } = request.body; if (level === 'error' && error) { diff --git a/apps/frontend-e2e/src/e2e/app.cy.ts b/apps/frontend-e2e/src/e2e/app.cy.ts index 257c02f..7423a9b 100644 --- a/apps/frontend-e2e/src/e2e/app.cy.ts +++ b/apps/frontend-e2e/src/e2e/app.cy.ts @@ -18,4 +18,4 @@ describe("frontend-e2e", () => { // Function helper example, see `../support/app.po.ts` file getGreeting().contains(/Welcome/); }); -}); \ No newline at end of file +}); diff --git a/apps/frontend-e2e/src/support/app.po.ts b/apps/frontend-e2e/src/support/app.po.ts index 91b038c..66ec6f2 100644 --- a/apps/frontend-e2e/src/support/app.po.ts +++ b/apps/frontend-e2e/src/support/app.po.ts @@ -4,4 +4,9 @@ * @license Naomi's Public License */ -export const getGreeting = (): Cypress.Chainable => cy.get("h1"); \ No newline at end of file +/** + * + */ +export const getGreeting = (): Cypress.Chainable => { + return cy.get("h1"); +}; diff --git a/apps/frontend-e2e/src/support/commands.ts b/apps/frontend-e2e/src/support/commands.ts index e233901..ccfbaae 100644 --- a/apps/frontend-e2e/src/support/commands.ts +++ b/apps/frontend-e2e/src/support/commands.ts @@ -13,14 +13,14 @@ * * For more comprehensive examples of custom * commands please read more here: - * https://on.cypress.io/custom-commands + * https://on.cypress.io/custom-commands. */ // eslint-disable-next-line @typescript-eslint/no-namespace -- Required for Cypress type extensions declare namespace Cypress { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Subject is required for type definition interface Chainable { - login: (email: string, password: string) => void; + login: (email: string, password: string)=> void; } } @@ -30,11 +30,17 @@ Cypress.Commands.add("login", (email, password) => { console.log("Custom command example: Login", email, password); }); -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +/* + * -- This is a child command -- + * Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) + */ -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +/* + * -- This is a dual command -- + * Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) + */ -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) \ No newline at end of file +/* + * -- This will overwrite an existing command -- + * Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) + */ diff --git a/apps/frontend-e2e/src/support/e2e.ts b/apps/frontend-e2e/src/support/e2e.ts index 618fc37..bb04d5c 100644 --- a/apps/frontend-e2e/src/support/e2e.ts +++ b/apps/frontend-e2e/src/support/e2e.ts @@ -16,7 +16,7 @@ * 'supportFile' configuration option. * * You can read more here: - * https://on.cypress.io/configuration + * https://on.cypress.io/configuration. */ // Import commands.ts using ES2015 syntax: diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 05931ad..16a7b00 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -60,6 +60,7 @@ import { ApiService } from '../../services/api.service'; Users Audit Suggestions + Reports } diff --git a/apps/frontend/src/app/services/index.ts b/apps/frontend/src/app/services/index.ts index 05aa660..de8afdf 100644 --- a/apps/frontend/src/app/services/index.ts +++ b/apps/frontend/src/app/services/index.ts @@ -8,4 +8,5 @@ export { ApiService } from './api.service'; export { AuthService } from './auth.service'; export { BooksService } from './books.service'; export { GamesService } from './games.service'; -export { MusicService } from './music.service'; \ No newline at end of file +export { MusicService } from './music.service'; +export { ReportService } from './report.service'; \ No newline at end of file diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 66519bd..69b4d61 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -13,5 +13,6 @@ export * from "./lib/game.types"; export type * from "./lib/like.types"; export * from "./lib/manga.types"; export * from "./lib/music.types"; +export * from "./lib/report.types"; export * from "./lib/show.types"; export * from "./lib/suggestion.types"; diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index 03e0b66..a80b044 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -5,28 +5,28 @@ */ interface User { - id: string; - email: string; - username: string; - avatar?: string; - slug?: string; - displayName?: string; - bio?: string; - profilePublic: boolean; - website?: string; + id: string; + email: string; + username: string; + avatar?: string; + slug?: string; + displayName?: string; + bio?: string; + profilePublic: boolean; + website?: string; discordServer?: string; - bluesky?: string; - github?: string; - linkedin?: string; - twitch?: string; - youtube?: string; - discordId: string; - isAdmin: boolean; - isBanned: boolean; - inDiscord: boolean; - isVip: boolean; - isMod: boolean; - isStaff: boolean; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; + discordId: string; + isAdmin: boolean; + isBanned: boolean; + inDiscord: boolean; + isVip: boolean; + isMod: boolean; + isStaff: boolean; } interface JwtPayload { diff --git a/shared-types/src/lib/game.types.ts b/shared-types/src/lib/game.types.ts index 842f1d5..38949ac 100644 --- a/shared-types/src/lib/game.types.ts +++ b/shared-types/src/lib/game.types.ts @@ -32,16 +32,16 @@ interface Game { } interface CreateGameDto { - title: string; - platform?: string; - status: GameStatus; + title: string; + platform?: string; + status: GameStatus; dateStarted?: Date; dateFinished?: Date; - rating?: number; - notes?: string; - coverImage?: string; - tags?: Array; - links?: Array; + rating?: number; + notes?: string; + coverImage?: string; + tags?: Array; + links?: Array; } interface UpdateGameDto extends Partial { -- 2.52.0 From c514849f1266611163909d8aaaaf46cff1d5b75e Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 19:06:49 -0800 Subject: [PATCH 05/17] feat: implement comment reporting system Added comprehensive comment reporting infrastructure similar to profile reporting. API Changes: - New CommentReport model in Prisma schema with relations to User and Comment - CommentReportService with CRUD operations, duplicate prevention, and rate limiting - API routes at /comment-reports for creating and managing comment reports - Updated CommentService to include hasPendingReports flag for all comments Frontend Changes: - Created shared CommentDisplayComponent for reusable comment display with report button - Updated ReportModalComponent to handle both profile and comment reports - CommentReportService for API communication - Integrated CommentDisplayComponent into games-list component - Comments with pending reports show "[comment pending admin review]" message Features: - Users can report comments they didn't write - Duplicate prevention (one pending report per comment per user) - Rate limiting (5 pending reports maximum per user) - Admins can review and action comment reports - Comments are hidden during review to prevent abuse Remaining Work: - Need to integrate CommentDisplayComponent into remaining media components (books, music, art, shows, manga) - Need to extend admin-reports page to display comment reports alongside profile reports --- api/prisma/schema.prisma | 63 ++- api/src/app/routes/comment-reports/index.ts | 140 +++++++ .../app/services/comment-report.service.ts | 369 ++++++++++++++++++ api/src/app/services/comment.service.ts | 46 ++- .../comment-display.component.ts | 310 +++++++++++++++ .../components/games/games-list.component.ts | 71 ++-- .../components/profile/profile.component.ts | 3 +- .../report-modal/report-modal.component.ts | 53 ++- .../app/services/comment-report.service.ts | 50 +++ shared-types/src/lib/comment.types.ts | 27 +- shared-types/src/lib/report.types.ts | 50 +++ 11 files changed, 1068 insertions(+), 114 deletions(-) create mode 100644 api/src/app/routes/comment-reports/index.ts create mode 100644 api/src/app/services/comment-report.service.ts create mode 100644 apps/frontend/src/app/components/comment-display/comment-display.component.ts create mode 100644 apps/frontend/src/app/services/comment-report.service.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 6437f61..f6bed6d 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -205,33 +205,36 @@ model User { suggestions Suggestion[] likes Like[] refreshTokens RefreshToken[] - reportsMade ProfileReport[] @relation("Reporter") - reportsReceived ProfileReport[] @relation("ReportedUser") - reportsReviewed ProfileReport[] @relation("Reviewer") + reportsMade ProfileReport[] @relation("Reporter") + reportsReceived ProfileReport[] @relation("ReportedUser") + reportsReviewed ProfileReport[] @relation("Reviewer") + commentReportsMade CommentReport[] @relation("CommentReporter") + commentReportsReviewed CommentReport[] @relation("CommentReviewer") @@index([slug], map: "User_slug_key") } model Comment { - id String @id @default(auto()) @map("_id") @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId content String rawContent String? - userId String @db.ObjectId - user User @relation(fields: [userId], references: [id]) - gameId String? @db.ObjectId - game Game? @relation(fields: [gameId], references: [id]) - bookId String? @db.ObjectId - book Book? @relation(fields: [bookId], references: [id]) - musicId String? @db.ObjectId - music Music? @relation(fields: [musicId], references: [id]) - artId String? @db.ObjectId - art Art? @relation(fields: [artId], references: [id]) - showId String? @db.ObjectId - show Show? @relation(fields: [showId], references: [id]) - mangaId String? @db.ObjectId - manga Manga? @relation(fields: [mangaId], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + gameId String? @db.ObjectId + game Game? @relation(fields: [gameId], references: [id]) + bookId String? @db.ObjectId + book Book? @relation(fields: [bookId], references: [id]) + musicId String? @db.ObjectId + music Music? @relation(fields: [musicId], references: [id]) + artId String? @db.ObjectId + art Art? @relation(fields: [artId], references: [id]) + showId String? @db.ObjectId + show Show? @relation(fields: [showId], references: [id]) + mangaId String? @db.ObjectId + manga Manga? @relation(fields: [mangaId], references: [id]) + reports CommentReport[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model AuditLog { @@ -369,3 +372,23 @@ model ProfileReport { @@index([reporterId]) @@index([status]) } + +model CommentReport { + id String @id @default(auto()) @map("_id") @db.ObjectId + reportedCommentId String @db.ObjectId + reportedComment Comment @relation(fields: [reportedCommentId], references: [id]) + reporterId String @db.ObjectId + reporter User @relation("CommentReporter", fields: [reporterId], references: [id]) + reason ReportReason + details String + status ReportStatus @default(PENDING) + reviewedBy String? @db.ObjectId + reviewer User? @relation("CommentReviewer", fields: [reviewedBy], references: [id]) + reviewNotes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([reportedCommentId]) + @@index([reporterId]) + @@index([status]) +} diff --git a/api/src/app/routes/comment-reports/index.ts b/api/src/app/routes/comment-reports/index.ts new file mode 100644 index 0000000..a784ff0 --- /dev/null +++ b/api/src/app/routes/comment-reports/index.ts @@ -0,0 +1,140 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { FastifyPluginAsync } from "fastify"; +import type { + CreateCommentReportDto, + CommentReportWithDetails, + ReportStatus, + UpdateCommentReportDto, +} from "@library/shared-types"; +import { ReportReason } from "@library/shared-types"; + +import { CommentReportService } from "../../services/comment-report.service.js"; +import { adminGuard } from "../../middleware/admin-guard.js"; + +const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { + const commentReportService = new CommentReportService(); + + // Create a new comment report (authenticated users) + fastify.post<{ + Body: CreateCommentReportDto; + Reply: CommentReportWithDetails | { error: string }; + }>( + "/", + { + preValidation: [fastify.authenticate], + schema: { + body: { + type: "object", + required: ["reportedCommentId", "reason", "details"], + properties: { + reportedCommentId: { type: "string" }, + reason: { + type: "string", + enum: Object.values(ReportReason), + }, + details: { type: "string", minLength: 10, maxLength: 1000 }, + }, + }, + }, + }, + async (request, reply) => { + try { + const report = await commentReportService.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") || + error.message.includes("maximum number of pending reports")) + ) { + return reply.status(409).send({ error: error.message }); + } + throw error; + } + }, + ); + + // Get all comment reports (admin only) + fastify.get<{ + Querystring: { status?: ReportStatus }; + Reply: CommentReportWithDetails[]; + }>( + "/", + { + preValidation: [fastify.authenticate, adminGuard], + schema: { + querystring: { + type: "object", + properties: { + status: { type: "string" }, + }, + }, + }, + }, + async (request, reply) => { + const reports = await commentReportService.getAllReports( + request.query.status, + ); + return reply.send(reports); + }, + ); + + // Get a single comment report by ID (admin only) + fastify.get<{ + Params: { id: string }; + Reply: CommentReportWithDetails | { error: string }; + }>( + "/:id", + { + preValidation: [fastify.authenticate, adminGuard], + }, + async (request, reply) => { + const report = await commentReportService.getReportById(request.params.id); + + if (!report) { + return reply.status(404).send({ error: "Report not found" }); + } + + return reply.send(report); + }, + ); + + // Update a comment report (admin only) + fastify.put<{ + Params: { id: string }; + Body: UpdateCommentReportDto; + Reply: CommentReportWithDetails; + }>( + "/: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 commentReportService.updateReport( + request.params.id, + request.user.id, + request.body, + ); + return reply.send(report); + }, + ); +}; + +export default commentReportsRoutes; diff --git a/api/src/app/services/comment-report.service.ts b/api/src/app/services/comment-report.service.ts new file mode 100644 index 0000000..8f0b6a2 --- /dev/null +++ b/api/src/app/services/comment-report.service.ts @@ -0,0 +1,369 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + ReportStatus as PrismaReportStatus, + ReportReason as PrismaReportReason, +} from "@prisma/client"; +import type { + CreateCommentReportDto, + CommentReportWithDetails, + ReportStatus, + UpdateCommentReportDto, +} from "@library/shared-types"; +import { ReportReason } from "@library/shared-types"; +import { prisma } from "../lib/prisma.js"; + +export class CommentReportService { + 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 comment 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 comment + */ + async createReport( + reporterId: string, + createDto: CreateCommentReportDto, + ): Promise { + // Check if user already has a pending report for this comment + const existingReport = await this.prisma.commentReport.findFirst({ + where: { + reporterId, + reportedCommentId: createDto.reportedCommentId, + status: PrismaReportStatus.PENDING, + }, + }); + + if (existingReport) { + throw new Error( + "You already have a pending report for this comment. Please wait for it to be reviewed.", + ); + } + + // Check if user has reached the limit of pending reports (5 max) + const pendingReportsCount = await this.prisma.commentReport.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.commentReport.create({ + data: { + reporterId, + reportedCommentId: createDto.reportedCommentId, + reason: this.toPrismaReportReason(createDto.reason), + details: createDto.details, + }, + include: { + reportedComment: { + include: { + user: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }); + + return { + id: report.id, + reportedCommentId: report.reportedCommentId, + 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, + reportedComment: { + id: report.reportedComment.id, + content: report.reportedComment.content, + rawContent: report.reportedComment.rawContent ?? undefined, + userId: report.reportedComment.userId, + user: report.reportedComment.user, + }, + reporter: report.reporter, + }; + } + + /** + * Get all comment reports (admin only). Optionally filter by status. + * + * @param status - Optional status filter + * @returns All reports matching the filter + */ + async getAllReports( + status?: ReportStatus, + ): Promise { + const reports = await this.prisma.commentReport.findMany({ + where: status ? { status: this.toPrismaReportStatus(status) } : undefined, + include: { + reportedComment: { + include: { + user: { + 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, + reportedCommentId: report.reportedCommentId, + 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, + reportedComment: { + id: report.reportedComment.id, + content: report.reportedComment.content, + rawContent: report.reportedComment.rawContent ?? undefined, + userId: report.reportedComment.userId, + user: report.reportedComment.user, + }, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + })); + } + + /** + * Get a single comment report by ID (admin only). + * + * @param id - The report ID + * @returns The report or null + */ + async getReportById(id: string): Promise { + const report = await this.prisma.commentReport.findUnique({ + where: { id }, + include: { + reportedComment: { + include: { + user: { + 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, + reportedCommentId: report.reportedCommentId, + 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, + reportedComment: { + id: report.reportedComment.id, + content: report.reportedComment.content, + rawContent: report.reportedComment.rawContent ?? undefined, + userId: report.reportedComment.userId, + user: report.reportedComment.user, + }, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } + + /** + * Update a comment 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: UpdateCommentReportDto, + ): Promise { + const report = await this.prisma.commentReport.update({ + where: { id }, + data: { + status: this.toPrismaReportStatus(updateDto.status), + reviewNotes: updateDto.reviewNotes, + reviewedBy: reviewerId, + }, + include: { + reportedComment: { + include: { + user: { + 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, + reportedCommentId: report.reportedCommentId, + 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, + reportedComment: { + id: report.reportedComment.id, + content: report.reportedComment.content, + rawContent: report.reportedComment.rawContent ?? undefined, + userId: report.reportedComment.userId, + user: report.reportedComment.user, + }, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } + + /** + * Check if a comment has any pending reports. + * + * @param commentId - The comment ID + * @returns True if the comment has pending reports + */ + async hasPendingReports(commentId: string): Promise { + const count = await this.prisma.commentReport.count({ + where: { + reportedCommentId: commentId, + status: PrismaReportStatus.PENDING, + }, + }); + + return count > 0; + } +} diff --git a/api/src/app/services/comment.service.ts b/api/src/app/services/comment.service.ts index cc3f24d..d47ce0a 100644 --- a/api/src/app/services/comment.service.ts +++ b/api/src/app/services/comment.service.ts @@ -50,7 +50,12 @@ export class CommentService { }); } - private mapComment(comment: any): Comment { + private async mapComment(comment: any): Promise { + // Check if comment has pending reports + const hasPendingReports = comment.reports + ? comment.reports.some((report: any) => report.status === "PENDING") + : false; + return { id: comment.id, content: comment.content, @@ -71,6 +76,7 @@ export class CommentService { artId: comment.artId || undefined, showId: comment.showId || undefined, mangaId: comment.mangaId || undefined, + hasPendingReports, createdAt: comment.createdAt, updatedAt: comment.updatedAt, }; @@ -79,28 +85,28 @@ export class CommentService { async getCommentsForGame(gameId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { gameId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async getCommentsForBook(bookId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { bookId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async getCommentsForMusic(musicId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { musicId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForGame( @@ -116,7 +122,7 @@ export class CommentService { userId, gameId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -134,7 +140,7 @@ export class CommentService { userId, bookId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -152,7 +158,7 @@ export class CommentService { userId, musicId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -160,10 +166,10 @@ export class CommentService { async getCommentsForArt(artId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { artId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForArt( @@ -179,7 +185,7 @@ export class CommentService { userId, artId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -187,10 +193,10 @@ export class CommentService { async getCommentsForShow(showId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { showId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForShow( @@ -206,7 +212,7 @@ export class CommentService { userId, showId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -214,10 +220,10 @@ export class CommentService { async getCommentsForManga(mangaId: string): Promise { const comments = await this.prisma.comment.findMany({ where: { mangaId }, - include: { user: true }, + include: { user: true, reports: true }, orderBy: { createdAt: "desc" }, }); - return comments.map((c) => this.mapComment(c)); + return Promise.all(comments.map((c) => this.mapComment(c))); } async createCommentForManga( @@ -233,7 +239,7 @@ export class CommentService { userId, mangaId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -256,7 +262,7 @@ export class CommentService { content: sanitizedContent, rawContent: content, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } diff --git a/apps/frontend/src/app/components/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts new file mode 100644 index 0000000..879aa51 --- /dev/null +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -0,0 +1,310 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Component, Input, Output, EventEmitter, signal, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import type { Comment } from '@library/shared-types'; +import { AuthService } from '../../services/auth.service'; +import { SanitizeService } from '../../services/sanitize.service'; +import { ReportModalComponent } from '../report-modal/report-modal.component'; + +@Component({ + selector: 'app-comment-display', + standalone: true, + imports: [CommonModule, FormsModule, ReportModalComponent], + template: ` +
+ @if (comments().length > 0) { + @for (comment of comments(); track comment.id) { +
+
+ @if (comment.user.avatar) { + + } + {{ comment.user.username }} + @if (comment.user.inDiscord) { + Discord + } + @if (comment.user.isVip) { + VIP + } + @if (comment.user.isMod) { + Mod + } + @if (comment.user.isStaff) { + Staff + } + {{ formatDate(comment.createdAt) }} + @if (canEditComment(comment)) { + + } + @if (canDeleteComment(comment)) { + + } + @if (canReportComment(comment)) { + + } +
+ @if (editingCommentId() === comment.id) { +
+ +
+ + +
+
+ } @else { + @if (comment.hasPendingReports) { +
[comment pending admin review]
+ } @else { +
+ } + } +
+ } + } @else { +
No comments yet. Be the first to comment!
+ } +
+ + @if (showReportModal()) { + + } + `, + styles: [` + .comments-section { + margin-top: 1rem; + } + + .comment { + background: #f9fafb; + padding: 0.75rem; + border-radius: 0.375rem; + margin-bottom: 0.75rem; + } + + .comment-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; + } + + .comment-avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + } + + .comment-author { + font-weight: 600; + color: #1f2937; + } + + .discord-badge, + .vip-badge, + .mod-badge, + .staff-badge { + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + } + + .discord-badge { + background: #5865f2; + color: white; + } + + .vip-badge { + background: #fbbf24; + color: #78350f; + } + + .mod-badge { + background: #10b981; + color: white; + } + + .staff-badge { + background: #8b5cf6; + color: white; + } + + .comment-date { + font-size: 0.75rem; + color: #6b7280; + } + + .comment-content { + font-size: 0.9rem; + color: #4b5563; + } + + .comment-pending-review { + font-size: 0.9rem; + color: #9b59b6; + font-style: italic; + padding: 0.5rem; + background: #f3e8ff; + border-radius: 0.25rem; + } + + .comment-edit-form { + margin-top: 0.5rem; + } + + .comment-edit-form textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid #d1d5db; + border-radius: 0.375rem; + font-family: inherit; + resize: vertical; + } + + .comment-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + } + + .no-comments { + text-align: center; + color: #6b7280; + padding: 2rem; + font-style: italic; + } + + .btn { + padding: 0.25rem 0.75rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s; + } + + .btn-xs { + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + } + + .btn-primary { + background: #3b82f6; + color: white; + } + + .btn-primary:hover { + background: #2563eb; + } + + .btn-secondary { + background: #6b7280; + color: white; + } + + .btn-secondary:hover { + background: #4b5563; + } + + .btn-danger { + background: #ef4444; + color: white; + } + + .btn-danger:hover { + background: #dc2626; + } + + .btn-warning { + background: #f59e0b; + color: white; + } + + .btn-warning:hover { + background: #d97706; + } + `] +}) +export class CommentDisplayComponent { + private readonly authService = inject(AuthService); + readonly sanitizeService = inject(SanitizeService); + + @Input({ required: true }) comments = signal([]); + @Output() onEdit = new EventEmitter<{ commentId: string; content: string }>(); + @Output() onDelete = new EventEmitter(); + + editingCommentId = signal(null); + editCommentContent = ''; + showReportModal = signal(false); + reportingCommentId = signal(''); + + formatDate(date: Date | string): string { + const d = new Date(date); + return d.toLocaleDateString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + canEditComment(comment: Comment): boolean { + const user = this.authService.user(); + if (!user) return false; + return comment.userId === user.id || this.authService.isAdmin(); + } + + canDeleteComment(comment: Comment): boolean { + const user = this.authService.user(); + if (!user) return false; + return comment.userId === user.id || this.authService.isAdmin(); + } + + canReportComment(comment: Comment): boolean { + const user = this.authService.user(); + if (!user) return false; + // Users can report comments they didn't write (but not their own) + return comment.userId !== user.id; + } + + startEdit(comment: Comment): void { + this.editingCommentId.set(comment.id); + this.editCommentContent = comment.rawContent ?? comment.content; + } + + saveEdit(commentId: string): void { + this.onEdit.emit({ commentId, content: this.editCommentContent }); + this.cancelEdit(); + } + + cancelEdit(): void { + this.editingCommentId.set(null); + this.editCommentContent = ''; + } + + openReportModal(comment: Comment): void { + this.reportingCommentId.set(comment.id); + this.showReportModal.set(true); + } + + closeReportModal(): void { + this.showReportModal.set(false); + this.reportingCommentId.set(''); + } +} diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index 4c6ee2b..c592498 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.component'; import { LikeButtonComponent } from '../shared/like-button.component'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-games-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -608,52 +609,11 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti @if (commentsLoading()[game.id]) {
Loading comments...
} @else { - @for (comment of comments()[game.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } + }
} @@ -1739,6 +1699,23 @@ export class GamesListComponent implements OnInit { }); } + handleCommentEdit(gameId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnGame(gameId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [gameId]: (this.comments()[gameId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(gameId: string) { + return signal(this.comments()[gameId] || []); + } + // Suggestion methods toggleSuggestForm() { this.showSuggestForm.update(v => !v); diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index c877d23..62fb84e 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -158,7 +158,8 @@ import { ReportModalComponent } from '../report-modal/report-modal.component'; @if (reportModalOpen()) { diff --git a/apps/frontend/src/app/components/report-modal/report-modal.component.ts b/apps/frontend/src/app/components/report-modal/report-modal.component.ts index 9cff1a9..c1fd79f 100644 --- a/apps/frontend/src/app/components/report-modal/report-modal.component.ts +++ b/apps/frontend/src/app/components/report-modal/report-modal.component.ts @@ -4,10 +4,11 @@ * @author Naomi Carrigan */ -import { Component, inject, signal, input, output } from '@angular/core'; +import { Component, inject, signal, input, output, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ReportService } from '../../services/report.service'; +import { CommentReportService } from '../../services/comment-report.service'; import { ToastService } from '../../services/toast.service'; import { ReportReason } from '@library/shared-types'; @@ -19,7 +20,7 @@ import { ReportReason } from '@library/shared-types'; }
@@ -1615,4 +1571,21 @@ export class ArtGalleryComponent implements OnInit { toggleFilters() { this.showFilters.update(v => !v); } + + handleCommentEdit(artId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnArt(artId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [artId]: (this.comments()[artId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(artId: string) { + return signal(this.comments()[artId] || []); + } } diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 3bfaa06..0af45ac 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.component'; import { LikeButtonComponent } from '../shared/like-button.component'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-books-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -655,52 +656,11 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti @if (commentsLoading()[book.id]) {
Loading comments...
} @else { - @for (comment of comments()[book.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } + }
} @@ -1982,4 +1942,21 @@ export class BooksListComponent implements OnInit { alert('Failed to submit suggestion. Please try again.'); } } + + handleCommentEdit(bookId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnBook(bookId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [bookId]: (this.comments()[bookId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(bookId: string) { + return signal(this.comments()[bookId] || []); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 4030f30..58c9ecf 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.component'; import { LikeButtonComponent } from '../shared/like-button.component'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-manga-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -610,56 +611,11 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion } } - @if (commentsLoading()[manga.id]) { -
Loading comments...
- } @else { - @for (comment of comments()[manga.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } - } +
}
@@ -1780,4 +1736,21 @@ export class MangaListComponent implements OnInit { alert('Failed to submit suggestion. Please try again.'); } } + + handleCommentEdit(mangaId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnManga(mangaId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [mangaId]: (this.comments()[mangaId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(mangaId: string) { + return signal(this.comments()[mangaId] || []); + } } diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index a5ceeeb..1c91be4 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.component'; import { LikeButtonComponent } from '../shared/like-button.component'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-music-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -686,56 +687,11 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, } } - @if (commentsLoading()[music.id]) { -
Loading comments...
- } @else { - @for (comment of comments()[music.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } - } +
}
@@ -2014,4 +1970,21 @@ export class MusicListComponent implements OnInit { alert('Failed to submit suggestion. Please try again.'); } } + + handleCommentEdit(musicId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnMusic(musicId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [musicId]: (this.comments()[musicId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(musicId: string) { + return signal(this.comments()[musicId] || []); + } } \ No newline at end of file diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index 1f6268e..ce41b07 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -14,12 +14,13 @@ import { SanitizeService } from '../../services/sanitize.service'; import { SuggestionService } from '../../services/suggestion.service'; import { PaginationComponent } from '../shared/pagination.component'; import { LikeButtonComponent } from '../shared/like-button.component'; +import { CommentDisplayComponent } from '../comment-display/comment-display.component'; import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-shows-list', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -604,56 +605,11 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg } } - @if (commentsLoading()[show.id]) { -
Loading comments...
- } @else { - @for (comment of comments()[show.id] || []; track comment.id) { -
-
- @if (comment.user.avatar) { - - } - {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff - } - {{ formatDate(comment.createdAt) }} - @if (canEditComment(comment)) { - - } - @if (canDeleteComment(comment)) { - - } -
- @if (editingCommentId() === comment.id) { -
- -
- - -
-
- } @else { -
- } -
- } @empty { -
No comments yet. Be the first to comment!
- } - } +
}
@@ -1783,4 +1739,21 @@ export class ShowsListComponent implements OnInit { alert('Failed to submit suggestion. Please try again.'); } } + + handleCommentEdit(showId: string, event: { commentId: string; content: string }) { + this.commentsService.updateCommentOnShow(showId, event.commentId, event.content).subscribe({ + next: (updatedComment) => { + this.comments.set({ + ...this.comments(), + [showId]: (this.comments()[showId] || []).map(c => + c.id === event.commentId ? updatedComment : c + ) + }); + } + }); + } + + getCommentsSignal(showId: string) { + return signal(this.comments()[showId] || []); + } } -- 2.52.0 From e728968fc9f3aa6f1d1ccdf18ee44371199ffaf9 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 19:17:50 -0800 Subject: [PATCH 07/17] feat: add comment report management to admin interface Updated the admin-reports component to handle both profile and comment reports: - Added report type toggle to switch between profile and comment reports - Duplicated report display logic for comment reports - Comment reports show the comment content with truncation - Added separate review modals for profile and comment reports - Comment reports display comment author instead of reported user - Maintains all existing functionality for profile reports Co-Authored-By: Claude Sonnet 4.5 --- .../admin-reports/admin-reports.component.ts | 743 ++++++++++++++---- 1 file changed, 588 insertions(+), 155 deletions(-) diff --git a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts index 4363a86..148e964 100644 --- a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -9,17 +9,39 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { ReportService } from '../../services/report.service'; +import { CommentReportService } from '../../services/comment-report.service'; import { AuthService } from '../../services/auth.service'; import { ToastService } from '../../services/toast.service'; -import { ProfileReportWithUsers, ReportStatus, ReportReason } from '@library/shared-types'; +import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason } from '@library/shared-types'; @Component({ selector: 'app-admin-reports', standalone: true, imports: [CommonModule, FormsModule], template: ` -
-

Profile Reports

+
+
+
+

Reports

+
+ + +
+
@if (loading()) {
@@ -40,7 +62,7 @@ import { ProfileReportWithUsers, ReportStatus, ReportReason } from '@library/sha [attr.aria-selected]="activeFilter() === 'all'" (click)="setFilter('all')" > - All ({{ allReports().length }}) + All ({{ reportType() === 'profile' ? allProfileReports().length : allCommentReports().length }})
+ } @empty { +
+

No profile reports found.

+
+ } + } @else { + @for (report of filteredCommentReports(); track report.id) { +
+ +
+ -
- } @empty { -
-

No reports found.

-
+ } @empty { +
+

No comment reports found.

+
+ } }
} +
- - @if (reviewingReport()) { + + @if (reviewingProfileReport()) { } + + + @if (editingProfile()) { + + }
`, styles: [` @@ -1137,6 +1332,29 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR font-size: 0.95rem; } + .form-help { + font-size: 0.85rem; + color: var(--witch-mauve); + font-style: italic; + } + + .form-section { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--witch-lavender); + } + + .form-section:last-of-type { + border-bottom: none; + padding-bottom: 0; + } + + .form-section h3 { + color: var(--witch-purple); + margin-bottom: 1rem; + font-size: 1.1rem; + } + .form-control { padding: 0.75rem; border: 2px solid var(--witch-lavender); @@ -1256,6 +1474,7 @@ export class AdminReportsComponent implements OnInit { reviewingProfileReport = signal(null); reviewingCommentReport = signal(null); + editingProfile = signal<{ userId: string; username: string; profile: any } | null>(null); submitting = signal(false); reviewForm = { @@ -1263,6 +1482,20 @@ export class AdminReportsComponent implements OnInit { reviewNotes: '' }; + profileEditForm = { + displayName: '', + slug: '', + bio: '', + profilePublic: true, + website: '', + discordServer: '', + bluesky: '', + github: '', + linkedin: '', + twitch: '', + youtube: '' + }; + filteredProfileReports = computed(() => { const filter = this.activeFilter(); if (filter === 'all') { @@ -1534,20 +1767,23 @@ export class AdminReportsComponent implements OnInit { // Load the full profile first to get current values this.userService.getProfile(userId).subscribe({ next: (profile) => { - const newBio = prompt(`Edit bio for ${username}:`, profile.bio || ''); + // Populate the edit form with current values + this.profileEditForm = { + displayName: profile.displayName || '', + slug: profile.slug || '', + bio: profile.bio || '', + profilePublic: true, // We'll get this from the full user object if needed + website: profile.website || '', + discordServer: profile.discordServer || '', + bluesky: profile.bluesky || '', + github: profile.github || '', + linkedin: profile.linkedin || '', + twitch: profile.twitch || '', + youtube: profile.youtube || '' + }; - if (newBio !== null) { - this.userService.adminUpdateUser(userId, { bio: newBio }).subscribe({ - next: () => { - this.toastService.success('Profile updated successfully'); - this.loadReports(); - this.closeProfileReviewModal(); - }, - error: (err) => { - this.toastService.error(err.message ?? 'Failed to update profile'); - } - }); - } + // Open the edit modal + this.editingProfile.set({ userId, username, profile }); }, error: (err) => { this.toastService.error(err.message ?? 'Failed to load profile'); @@ -1555,6 +1791,43 @@ export class AdminReportsComponent implements OnInit { }); } + closeProfileEditModal(): void { + this.editingProfile.set(null); + this.profileEditForm = { + displayName: '', + slug: '', + bio: '', + profilePublic: true, + website: '', + discordServer: '', + bluesky: '', + github: '', + linkedin: '', + twitch: '', + youtube: '' + }; + } + + saveProfileEdit(): void { + const editing = this.editingProfile(); + if (!editing) return; + + this.submitting.set(true); + this.userService.adminUpdateUser(editing.userId, this.profileEditForm).subscribe({ + next: () => { + this.toastService.success('Profile updated successfully'); + this.loadReports(); + this.closeProfileEditModal(); + this.closeProfileReviewModal(); + this.submitting.set(false); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update profile'); + this.submitting.set(false); + } + }); + } + makeProfilePrivate(report: ProfileReportWithUsers): void { const userId = report.reportedUser.id; const username = report.reportedUser.username; -- 2.52.0 From 9d965808a7c8d029b212ca20c06670ea05ac9d9f Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 20:28:23 -0800 Subject: [PATCH 12/17] feat: add primary badge selection for user profiles Implements #49 - Allow users to select one primary badge to display on their profile instead of showing all badges at once. Changes: - Add PrimaryBadge enum to Prisma schema and shared types (STAFF, MOD, VIP, DISCORD) - Add primaryBadge field to User model and all user interfaces - Update settings component with badge selection dropdown - Only show badges the user actually has in the dropdown - Update profile component to display only selected badge (or all if none selected) - Add primaryBadge to admin profile edit modal - Update API routes and services to handle primaryBadge - Export PrimaryBadge enum from shared-types (not just as type) Additional fixes: - Fix Angular output naming: rename onEdit/onDelete to edit/delete - Update all parent components using comment-display outputs - Add type casting for Prisma PrimaryBadge enum to shared-types enum --- api/prisma/schema.prisma | 8 ++++ api/src/app/routes/users/index.ts | 5 ++- api/src/app/services/user.service.ts | 11 ++++- .../admin-reports/admin-reports.component.ts | 33 +++++++++++++- .../components/art/art-gallery.component.ts | 4 +- .../components/books/books-list.component.ts | 4 +- .../comment-display.component.ts | 8 ++-- .../components/games/games-list.component.ts | 4 +- .../components/manga/manga-list.component.ts | 4 +- .../components/music/music-list.component.ts | 4 +- .../components/profile/profile.component.ts | 43 ++++++++++++++----- .../components/settings/settings.component.ts | 42 ++++++++++++++++-- .../components/shows/shows-list.component.ts | 4 +- .../frontend/src/app/services/user.service.ts | 4 +- shared-types/src/index.ts | 2 +- shared-types/src/lib/auth.types.ts | 11 +++++ 16 files changed, 155 insertions(+), 36 deletions(-) diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 581605f..2e12dd0 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -176,6 +176,13 @@ enum MangaStatus { RETIRED } +enum PrimaryBadge { + STAFF + MOD + VIP + DISCORD +} + model User { id String @id @default(auto()) @map("_id") @db.ObjectId discordId String @unique @@ -186,6 +193,7 @@ model User { displayName String? bio String? profilePublic Boolean @default(true) + primaryBadge PrimaryBadge? website String? discordServer String? bluesky String? diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts index c7283bc..fc94a67 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -5,7 +5,7 @@ */ import { FastifyPluginAsync } from "fastify"; -import { User, AuditAction, AuditCategory } from "@library/shared-types"; +import { User, AuditAction, AuditCategory, PrimaryBadge } from "@library/shared-types"; import { UserService } from "../../services/user.service"; import { AuditService } from "../../services/audit.service"; import { adminGuard } from "../../middleware/admin-guard"; @@ -15,6 +15,7 @@ interface UpdateUserSettingsBody { displayName?: string; bio?: string; profilePublic?: boolean; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; @@ -31,6 +32,7 @@ interface UserProfileResponse { avatar?: string; bio?: string; slug?: string; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; @@ -144,6 +146,7 @@ const usersRoutes: FastifyPluginAsync = async (app) => { avatar: profile.avatar, bio: profile.bio, slug: profile.slug, + primaryBadge: profile.primaryBadge, website: profile.website, discordServer: profile.discordServer, bluesky: profile.bluesky, diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 7540c6f..68c3dcf 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -4,7 +4,7 @@ * @author Naomi Carrigan */ -import { User } from "@library/shared-types"; +import { User, PrimaryBadge } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import { SuggestionStatus } from "@prisma/client"; @@ -26,6 +26,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -61,6 +62,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -93,6 +95,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -125,6 +128,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -169,6 +173,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -192,6 +197,7 @@ export class UserService { displayName?: string; bio?: string; profilePublic?: boolean; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; @@ -216,6 +222,7 @@ export class UserService { displayName: user.displayName || undefined, bio: user.bio || undefined, profilePublic: user.profilePublic, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, website: user.website || undefined, discordServer: user.discordServer || undefined, bluesky: user.bluesky || undefined, @@ -239,6 +246,7 @@ export class UserService { avatar?: string | null; bio?: string | null; slug?: string | null; + primaryBadge?: PrimaryBadge | null; website?: string | null; discordServer?: string | null; bluesky?: string | null; @@ -294,6 +302,7 @@ export class UserService { avatar: user.avatar, bio: user.bio, slug: user.slug, + primaryBadge: user.primaryBadge as PrimaryBadge, website: user.website, discordServer: user.discordServer, bluesky: user.bluesky, diff --git a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts index 65d21a4..9edffab 100644 --- a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -14,7 +14,7 @@ import { CommentsService } from '../../services/comments.service'; import { UserService } from '../../services/user.service'; import { AuthService } from '../../services/auth.service'; import { ToastService } from '../../services/toast.service'; -import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason } from '@library/shared-types'; +import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason, PrimaryBadge } from '@library/shared-types'; @Component({ selector: 'app-admin-reports', @@ -674,6 +674,31 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR > {{ (profileEditForm.bio.length || 0) }} / 500 characters
+ +
+ + + Choose one badge to display on profile, or show all +
@@ -1462,8 +1487,9 @@ export class AdminReportsComponent implements OnInit { private toastService = inject(ToastService); private router = inject(Router); - // Make ReportStatus accessible in template + // Make ReportStatus and PrimaryBadge accessible in template protected readonly ReportStatus = ReportStatus; + protected readonly PrimaryBadge = PrimaryBadge; reportType = signal<'profile' | 'comment'>('profile'); allProfileReports = signal([]); @@ -1487,6 +1513,7 @@ export class AdminReportsComponent implements OnInit { slug: '', bio: '', profilePublic: true, + primaryBadge: undefined as PrimaryBadge | undefined, website: '', discordServer: '', bluesky: '', @@ -1773,6 +1800,7 @@ export class AdminReportsComponent implements OnInit { slug: profile.slug || '', bio: profile.bio || '', profilePublic: true, // We'll get this from the full user object if needed + primaryBadge: profile.primaryBadge || undefined, website: profile.website || '', discordServer: profile.discordServer || '', bluesky: profile.bluesky || '', @@ -1798,6 +1826,7 @@ export class AdminReportsComponent implements OnInit { slug: '', bio: '', profilePublic: true, + primaryBadge: undefined as PrimaryBadge | undefined, website: '', discordServer: '', bluesky: '', diff --git a/apps/frontend/src/app/components/art/art-gallery.component.ts b/apps/frontend/src/app/components/art/art-gallery.component.ts index fbaefe0..cb015c9 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.component.ts @@ -471,8 +471,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
} diff --git a/apps/frontend/src/app/components/books/books-list.component.ts b/apps/frontend/src/app/components/books/books-list.component.ts index 0af45ac..bcdbb50 100644 --- a/apps/frontend/src/app/components/books/books-list.component.ts +++ b/apps/frontend/src/app/components/books/books-list.component.ts @@ -658,8 +658,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti } @else { } diff --git a/apps/frontend/src/app/components/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts index 879aa51..4a9f3dd 100644 --- a/apps/frontend/src/app/components/comment-display/comment-display.component.ts +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -42,7 +42,7 @@ import { ReportModalComponent } from '../report-modal/report-modal.component'; } @if (canDeleteComment(comment)) { - + } @if (canReportComment(comment)) { @@ -245,8 +245,8 @@ export class CommentDisplayComponent { readonly sanitizeService = inject(SanitizeService); @Input({ required: true }) comments = signal([]); - @Output() onEdit = new EventEmitter<{ commentId: string; content: string }>(); - @Output() onDelete = new EventEmitter(); + @Output() edit = new EventEmitter<{ commentId: string; content: string }>(); + @Output() delete = new EventEmitter(); editingCommentId = signal(null); editCommentContent = ''; @@ -289,7 +289,7 @@ export class CommentDisplayComponent { } saveEdit(commentId: string): void { - this.onEdit.emit({ commentId, content: this.editCommentContent }); + this.edit.emit({ commentId, content: this.editCommentContent }); this.cancelEdit(); } diff --git a/apps/frontend/src/app/components/games/games-list.component.ts b/apps/frontend/src/app/components/games/games-list.component.ts index c592498..430c4f9 100644 --- a/apps/frontend/src/app/components/games/games-list.component.ts +++ b/apps/frontend/src/app/components/games/games-list.component.ts @@ -611,8 +611,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti } @else { } diff --git a/apps/frontend/src/app/components/manga/manga-list.component.ts b/apps/frontend/src/app/components/manga/manga-list.component.ts index 58c9ecf..a6eafe3 100644 --- a/apps/frontend/src/app/components/manga/manga-list.component.ts +++ b/apps/frontend/src/app/components/manga/manga-list.component.ts @@ -613,8 +613,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion } diff --git a/apps/frontend/src/app/components/music/music-list.component.ts b/apps/frontend/src/app/components/music/music-list.component.ts index 1c91be4..4ccad38 100644 --- a/apps/frontend/src/app/components/music/music-list.component.ts +++ b/apps/frontend/src/app/components/music/music-list.component.ts @@ -689,8 +689,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment, } diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index 62fb84e..3d3eeb5 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -14,6 +14,7 @@ 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'; +import { PrimaryBadge } from '@library/shared-types'; @Component({ selector: 'app-profile', @@ -58,17 +59,34 @@ import { ReportModalComponent } from '../report-modal/report-modal.component';
- @if (profile()!.badges.isStaff) { - Staff - } - @if (profile()!.badges.isMod) { - Moderator - } - @if (profile()!.badges.isVip) { - VIP - } - @if (profile()!.badges.inDiscord) { - Discord Member + @if (profile()!.primaryBadge) { + + @if (profile()!.primaryBadge === PrimaryBadge.STAFF && profile()!.badges.isStaff) { + Staff + } + @if (profile()!.primaryBadge === PrimaryBadge.MOD && profile()!.badges.isMod) { + Moderator + } + @if (profile()!.primaryBadge === PrimaryBadge.VIP && profile()!.badges.isVip) { + VIP + } + @if (profile()!.primaryBadge === PrimaryBadge.DISCORD && profile()!.badges.inDiscord) { + Discord Member + } + } @else { + + @if (profile()!.badges.isStaff) { + Staff + } + @if (profile()!.badges.isMod) { + Moderator + } + @if (profile()!.badges.isVip) { + VIP + } + @if (profile()!.badges.inDiscord) { + Discord Member + } }
@@ -426,6 +444,9 @@ export class ProfileComponent implements OnInit { error = signal(null); reportModalOpen = signal(false); + // Expose PrimaryBadge enum for template + readonly PrimaryBadge = PrimaryBadge; + // Font Awesome icons faGlobe = faGlobe; faGithub = faGithub; diff --git a/apps/frontend/src/app/components/settings/settings.component.ts b/apps/frontend/src/app/components/settings/settings.component.ts index 49a6b22..1f6b490 100644 --- a/apps/frontend/src/app/components/settings/settings.component.ts +++ b/apps/frontend/src/app/components/settings/settings.component.ts @@ -10,7 +10,7 @@ import { FormsModule } from '@angular/forms'; import { UserService, UpdateUserSettingsRequest } from '../../services/user.service'; import { AuthService } from '../../services/auth.service'; import { ToastService } from '../../services/toast.service'; -import { User } from '@library/shared-types'; +import { User, PrimaryBadge } from '@library/shared-types'; @Component({ selector: 'app-settings', @@ -69,6 +69,30 @@ import { User } from '@library/shared-types'; > {{ (formData.bio?.length || 0) }} / 500 characters + +
+ + + Choose one badge to display on your profile, or show all +
@@ -264,7 +288,8 @@ import { User } from '@library/shared-types'; } .form-group input[type="text"], - .form-group textarea { + .form-group textarea, + .form-group select { width: 100%; padding: 0.75rem; border: 1px solid rgba(155, 89, 182, 0.5); @@ -275,8 +300,13 @@ import { User } from '@library/shared-types'; font-family: inherit; } + .form-group select { + cursor: pointer; + } + .form-group input[type="text"]:focus, - .form-group textarea:focus { + .form-group textarea:focus, + .form-group select:focus { outline: none; border-color: var(--accent-colour, #9b59b6); box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3); @@ -375,11 +405,15 @@ export class SettingsComponent implements OnInit { loading = signal(true); saving = signal(false); + // Expose PrimaryBadge enum for template + readonly PrimaryBadge = PrimaryBadge; + formData: UpdateUserSettingsRequest & { bio?: string } = { displayName: '', slug: '', bio: '', profilePublic: true, + primaryBadge: undefined, website: '', discordServer: '', bluesky: '', @@ -398,6 +432,7 @@ export class SettingsComponent implements OnInit { slug: userData.slug || '', bio: userData.bio || '', profilePublic: userData.profilePublic ?? true, + primaryBadge: userData.primaryBadge || undefined, website: userData.website || '', discordServer: userData.discordServer || '', bluesky: userData.bluesky || '', @@ -424,6 +459,7 @@ export class SettingsComponent implements OnInit { slug: this.formData.slug || undefined, bio: this.formData.bio || undefined, profilePublic: this.formData.profilePublic, + primaryBadge: this.formData.primaryBadge || undefined, website: this.formData.website || undefined, discordServer: this.formData.discordServer || undefined, bluesky: this.formData.bluesky || undefined, diff --git a/apps/frontend/src/app/components/shows/shows-list.component.ts b/apps/frontend/src/app/components/shows/shows-list.component.ts index ce41b07..24910d1 100644 --- a/apps/frontend/src/app/components/shows/shows-list.component.ts +++ b/apps/frontend/src/app/components/shows/shows-list.component.ts @@ -607,8 +607,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
} diff --git a/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts index 45f67d9..8b4f75f 100644 --- a/apps/frontend/src/app/services/user.service.ts +++ b/apps/frontend/src/app/services/user.service.ts @@ -7,7 +7,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { ApiService } from './api.service'; -import { User } from '@library/shared-types'; +import { User, PrimaryBadge } from '@library/shared-types'; export interface UserProfileResponse { id: string; @@ -16,6 +16,7 @@ export interface UserProfileResponse { avatar?: string; bio?: string; slug?: string; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; @@ -43,6 +44,7 @@ export interface UpdateUserSettingsRequest { displayName?: string; bio?: string; profilePublic?: boolean; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 69b4d61..ec404b3 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -5,7 +5,7 @@ */ export type * from "./lib/art.types"; export * from "./lib/audit.types"; -export type * from "./lib/auth.types"; +export * from "./lib/auth.types"; export * from "./lib/book.types"; export type * from "./lib/comment.types"; export type * from "./lib/common.types"; diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index a80b044..8fda2c2 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -4,6 +4,15 @@ * @author Naomi Carrigan */ +/* eslint-disable @typescript-eslint/naming-convention -- Prisma enum values use UPPER_CASE */ +enum PrimaryBadge { + STAFF = "STAFF", + MOD = "MOD", + VIP = "VIP", + DISCORD = "DISCORD", +} +/* eslint-enable @typescript-eslint/naming-convention */ + interface User { id: string; email: string; @@ -13,6 +22,7 @@ interface User { displayName?: string; bio?: string; profilePublic: boolean; + primaryBadge?: PrimaryBadge; website?: string; discordServer?: string; bluesky?: string; @@ -47,4 +57,5 @@ interface AuthResponse { user: User; } +export { PrimaryBadge }; export type { AuthResponse, JwtPayload, User }; -- 2.52.0 From 18cfe16d8770c6af9398ded87c69289446303900 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 20:31:24 -0800 Subject: [PATCH 13/17] feat: display primary badge in comments Extend the primary badge selection feature to comments, so users see only their selected badge next to their comments (or all badges if none selected). Changes: - Add primaryBadge field to CommentUser interface - Update comment service mapComment to include primaryBadge - Update comment-display component to show primary badge logic - Import and expose PrimaryBadge enum in comment-display component - Use same conditional logic as profile (show primary or all badges) --- api/src/app/services/comment.service.ts | 3 +- .../comment-display.component.ts | 43 ++++++++++++++----- shared-types/src/lib/comment.types.ts | 17 +++++--- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/api/src/app/services/comment.service.ts b/api/src/app/services/comment.service.ts index d47ce0a..8113af9 100644 --- a/api/src/app/services/comment.service.ts +++ b/api/src/app/services/comment.service.ts @@ -4,7 +4,7 @@ * @author Naomi Carrigan */ -import { Comment, CreateCommentDto } from "@library/shared-types"; +import { Comment, CreateCommentDto, PrimaryBadge } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import createDOMPurify from "dompurify"; import { JSDOM } from "jsdom"; @@ -65,6 +65,7 @@ export class CommentService { id: comment.user.id, username: comment.user.username, avatar: comment.user.avatar || undefined, + primaryBadge: (comment.user.primaryBadge as PrimaryBadge) || undefined, inDiscord: comment.user.inDiscord, isVip: comment.user.isVip, isMod: comment.user.isMod, diff --git a/apps/frontend/src/app/components/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts index 4a9f3dd..5008691 100644 --- a/apps/frontend/src/app/components/comment-display/comment-display.component.ts +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -7,6 +7,7 @@ import { Component, Input, Output, EventEmitter, signal, inject } from '@angular import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import type { Comment } from '@library/shared-types'; +import { PrimaryBadge } from '@library/shared-types'; import { AuthService } from '../../services/auth.service'; import { SanitizeService } from '../../services/sanitize.service'; import { ReportModalComponent } from '../report-modal/report-modal.component'; @@ -25,17 +26,34 @@ import { ReportModalComponent } from '../report-modal/report-modal.component'; } {{ comment.user.username }} - @if (comment.user.inDiscord) { - Discord - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isStaff) { - Staff + @if (comment.user.primaryBadge) { + + @if (comment.user.primaryBadge === PrimaryBadge.STAFF && comment.user.isStaff) { + Staff + } + @if (comment.user.primaryBadge === PrimaryBadge.MOD && comment.user.isMod) { + Mod + } + @if (comment.user.primaryBadge === PrimaryBadge.VIP && comment.user.isVip) { + VIP + } + @if (comment.user.primaryBadge === PrimaryBadge.DISCORD && comment.user.inDiscord) { + Discord + } + } @else { + + @if (comment.user.isStaff) { + Staff + } + @if (comment.user.isMod) { + Mod + } + @if (comment.user.isVip) { + VIP + } + @if (comment.user.inDiscord) { + Discord + } } {{ formatDate(comment.createdAt) }} @if (canEditComment(comment)) { @@ -244,6 +262,9 @@ export class CommentDisplayComponent { private readonly authService = inject(AuthService); readonly sanitizeService = inject(SanitizeService); + // Expose PrimaryBadge enum for template + readonly PrimaryBadge = PrimaryBadge; + @Input({ required: true }) comments = signal([]); @Output() edit = new EventEmitter<{ commentId: string; content: string }>(); @Output() delete = new EventEmitter(); diff --git a/shared-types/src/lib/comment.types.ts b/shared-types/src/lib/comment.types.ts index 1f80e21..212e2ed 100644 --- a/shared-types/src/lib/comment.types.ts +++ b/shared-types/src/lib/comment.types.ts @@ -4,14 +4,17 @@ * @author Naomi Carrigan */ +import { PrimaryBadge } from "./auth.types"; + interface CommentUser { - id: string; - username: string; - avatar?: string; - inDiscord?: boolean; - isVip?: boolean; - isMod?: boolean; - isStaff?: boolean; + id: string; + username: string; + avatar?: string; + primaryBadge?: PrimaryBadge; + inDiscord?: boolean; + isVip?: boolean; + isMod?: boolean; + isStaff?: boolean; } interface Comment { -- 2.52.0 From 6e93ea140f9e77a5eda572e667eed91a10443b5d Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 20:36:42 -0800 Subject: [PATCH 14/17] fix: hide all badges when no primary badge is selected Update the badge display logic so that if a user hasn't selected a primary badge, no badges are shown at all (instead of showing all badges). Changes: - Remove else clause from profile badge section (only show badges-section div if primaryBadge is set) - Remove else clause from comment badge display - Update help text from "show all" to "hide all" in settings - Update help text in admin profile edit modal to match - Clarify that badge selection applies to both profile and comments --- .../admin-reports/admin-reports.component.ts | 4 ++-- .../comment-display.component.ts | 14 ------------ .../components/profile/profile.component.ts | 22 ++++--------------- .../components/settings/settings.component.ts | 4 ++-- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts index 9edffab..000affc 100644 --- a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -683,7 +683,7 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR [(ngModel)]="profileEditForm.primaryBadge" class="form-control" > - + @if (editingProfile()?.profile?.badges.isStaff) { } @@ -697,7 +697,7 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR } - Choose one badge to display on profile, or show all + Choose one badge to display on profile and comments diff --git a/apps/frontend/src/app/components/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts index 5008691..781ee5a 100644 --- a/apps/frontend/src/app/components/comment-display/comment-display.component.ts +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -40,20 +40,6 @@ import { ReportModalComponent } from '../report-modal/report-modal.component'; @if (comment.user.primaryBadge === PrimaryBadge.DISCORD && comment.user.inDiscord) { Discord } - } @else { - - @if (comment.user.isStaff) { - Staff - } - @if (comment.user.isMod) { - Mod - } - @if (comment.user.isVip) { - VIP - } - @if (comment.user.inDiscord) { - Discord - } } {{ formatDate(comment.createdAt) }} @if (canEditComment(comment)) { diff --git a/apps/frontend/src/app/components/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts index 3d3eeb5..f51f8f9 100644 --- a/apps/frontend/src/app/components/profile/profile.component.ts +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -58,8 +58,8 @@ import { PrimaryBadge } from '@library/shared-types'; } -
- @if (profile()!.primaryBadge) { + @if (profile()!.primaryBadge) { +
@if (profile()!.primaryBadge === PrimaryBadge.STAFF && profile()!.badges.isStaff) { Staff @@ -73,22 +73,8 @@ import { PrimaryBadge } from '@library/shared-types'; @if (profile()!.primaryBadge === PrimaryBadge.DISCORD && profile()!.badges.inDiscord) { Discord Member } - } @else { - - @if (profile()!.badges.isStaff) { - Staff - } - @if (profile()!.badges.isMod) { - Moderator - } - @if (profile()!.badges.isVip) { - VIP - } - @if (profile()!.badges.inDiscord) { - Discord Member - } - } -
+
+ } @if (profile()!.bio) {
diff --git a/apps/frontend/src/app/components/settings/settings.component.ts b/apps/frontend/src/app/components/settings/settings.component.ts index 1f6b490..0acb59b 100644 --- a/apps/frontend/src/app/components/settings/settings.component.ts +++ b/apps/frontend/src/app/components/settings/settings.component.ts @@ -77,7 +77,7 @@ import { User, PrimaryBadge } from '@library/shared-types'; name="primaryBadge" [(ngModel)]="formData.primaryBadge" > - + @if (user()!.isStaff) { } @@ -91,7 +91,7 @@ import { User, PrimaryBadge } from '@library/shared-types'; } - Choose one badge to display on your profile, or show all + Choose one badge to display on your profile and comments
-- 2.52.0 From 1f62d64ace882d5d3882607fa014911cfdc5fee4 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 20:42:06 -0800 Subject: [PATCH 15/17] fix: improve dropdown option contrast for readability Add explicit background and text color styling to select option elements to fix poor contrast that made dropdown options illegible. Changes: - Add option styling to settings component select dropdown - Add option styling to admin reports component select dropdown - Set background to match form background color - Set text color to match standard text color - Ensures dropdown options are readable on all backgrounds --- .../app/components/admin-reports/admin-reports.component.ts | 5 +++++ .../src/app/components/settings/settings.component.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts index 000affc..fa840b4 100644 --- a/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -1401,6 +1401,11 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR cursor: pointer; } + select.form-control option { + background: var(--witch-moon); + color: var(--witch-purple); + } + textarea.form-control { resize: vertical; min-height: 100px; diff --git a/apps/frontend/src/app/components/settings/settings.component.ts b/apps/frontend/src/app/components/settings/settings.component.ts index 0acb59b..8b7a369 100644 --- a/apps/frontend/src/app/components/settings/settings.component.ts +++ b/apps/frontend/src/app/components/settings/settings.component.ts @@ -304,6 +304,11 @@ import { User, PrimaryBadge } from '@library/shared-types'; cursor: pointer; } + .form-group select option { + background: #1a1a2e; + color: var(--text-colour, #e0e0e0); + } + .form-group input[type="text"]:focus, .form-group textarea:focus, .form-group select:focus { -- 2.52.0 From 3da648544e84367e01ba87bab7b8b1a1de4a1b78 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 22:02:10 -0800 Subject: [PATCH 16/17] feat: implement comprehensive achievement system with 62 achievements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete achievement system with gamification features across all user interactions. **Database & Types:** - Add UserAchievement model to track user progress and earned achievements - Add achievement-related fields to User model (achievementPoints, currentStreak, lastStreakCheck) - Add ACHIEVEMENT_UNLOCKED audit action type - Define 62 achievements as TypeScript constants across 5 categories **Achievement Categories:** - Suggestions (15): First suggestion through ultimate curator milestones - Likes (12): First like through legendary fan milestones - Comments (12): First comment through review legend milestones - Engagement (15): Login streaks and total activity tracking - Reports (8): Valid reports and accuracy tracking **Backend Implementation:** - Create AchievementService with comprehensive checking logic - Add achievement route with 6 API endpoints - Integrate achievement checking into all user interaction points: - Suggestions (create + accept) - Likes (toggle) - Comments (all 6 media types) - Login streaks - Reports (profile + comment) - Update UserService to include achievement points in profiles **Frontend Implementation:** - Create AchievementService for API communication - Create achievements page showing all 62 achievements with: - Category filtering (All, Suggestions, Likes, Comments, Engagement, Reports) - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Earned date display for completed achievements - Add "Recent Achievements" section to user profiles - Add "🏆 Achievements" link to header navigation - Only show "View All" link on own profile **Technical Features:** - Real-time achievement checking on user actions - Progress tracking to avoid recalculation - Points system for gamification - Tier-based gradient styling - Leaderboard-ready architecture Resolves #48 Co-Authored-By: Hikari --- api/prisma/schema.prisma | 22 + api/src/app/routes/achievements/index.ts | 122 +++ api/src/app/routes/art/index.ts | 12 +- api/src/app/routes/auth/index.ts | 12 +- api/src/app/routes/books/index.ts | 12 +- api/src/app/routes/comment-reports/index.ts | 14 +- api/src/app/routes/games/index.ts | 12 +- api/src/app/routes/log/index.ts | 5 +- api/src/app/routes/manga/index.ts | 12 +- api/src/app/routes/music/index.ts | 12 +- api/src/app/routes/reports/index.ts | 14 +- api/src/app/routes/shows/index.ts | 12 +- api/src/app/routes/suggestions/index.ts | 27 +- api/src/app/services/achievement.service.ts | 772 ++++++++++++++++++ api/src/app/services/like.service.ts | 11 +- api/src/app/services/user.service.ts | 2 + apps/frontend/src/app/app.routes.ts | 4 + .../achievements/achievements.component.ts | 437 ++++++++++ .../app/components/header/header.component.ts | 1 + .../components/profile/profile.component.ts | 170 +++- .../src/app/services/achievement.service.ts | 63 ++ .../frontend/src/app/services/user.service.ts | 1 + shared-types/src/index.ts | 2 + shared-types/src/lib/achievement.constants.ts | 644 +++++++++++++++ shared-types/src/lib/achievement.types.ts | 70 ++ shared-types/src/lib/audit.types.ts | 1 + 26 files changed, 2449 insertions(+), 17 deletions(-) create mode 100644 api/src/app/routes/achievements/index.ts create mode 100644 api/src/app/services/achievement.service.ts create mode 100644 apps/frontend/src/app/components/achievements/achievements.component.ts create mode 100644 apps/frontend/src/app/services/achievement.service.ts create mode 100644 shared-types/src/lib/achievement.constants.ts create mode 100644 shared-types/src/lib/achievement.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2e12dd0..d7a77b8 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -207,6 +207,9 @@ model User { isVip Boolean @default(false) isMod Boolean @default(false) isStaff Boolean @default(false) + achievementPoints Int @default(0) + currentStreak Int @default(0) + lastStreakCheck DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -218,6 +221,7 @@ model User { reportsReviewed ProfileReport[] @relation("Reviewer") commentReportsMade CommentReport[] @relation("CommentReporter") commentReportsReviewed CommentReport[] @relation("CommentReviewer") + userAchievements UserAchievement[] @@index([slug], map: "User_slug_key") } @@ -276,6 +280,7 @@ enum AuditAction { RATE_LIMIT_EXCEEDED CSRF_VALIDATION_FAILED UNAUTHORIZED_ACCESS + ACHIEVEMENT_UNLOCKED } enum AuditCategory { @@ -400,3 +405,20 @@ model CommentReport { @@index([reporterId]) @@index([status]) } + +model UserAchievement { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + achievementKey String + progress Int @default(0) + earned Boolean @default(false) + earnedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, achievementKey]) + @@index([userId]) + @@index([achievementKey]) + @@index([earned]) +} diff --git a/api/src/app/routes/achievements/index.ts b/api/src/app/routes/achievements/index.ts new file mode 100644 index 0000000..41d9a66 --- /dev/null +++ b/api/src/app/routes/achievements/index.ts @@ -0,0 +1,122 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyPluginAsync } from "fastify"; +import { + ACHIEVEMENT_LIST, + ACHIEVEMENTS, + AchievementProgress, + UserAchievementSummary, +} from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; + +const achievementsRoutes: FastifyPluginAsync = async (app) => { + const achievementService = new AchievementService(); + + /** + * Get all achievement definitions (public route). + */ + app.get("/definitions", async () => { + return ACHIEVEMENT_LIST; + }); + + /** + * Get a specific achievement definition by key (public route). + */ + app.get<{ Params: { key: string } }>( + "/definitions/:key", + async (request, reply) => { + const { key } = request.params; + const achievement = ACHIEVEMENTS[key]; + + if (!achievement) { + return reply.notFound("Achievement not found"); + } + + return achievement; + }, + ); + + /** + * Get current user's achievement summary (authenticated users). + */ + app.get<{ Reply: UserAchievementSummary }>( + "/summary", + { + preValidation: [app.authenticate], + }, + async (request) => { + const userId = request.user.id; + const summary = await achievementService.getUserAchievementSummary( + userId, + ); + return summary; + }, + ); + + /** + * Get current user's achievement progress (authenticated users). + */ + app.get<{ Reply: AchievementProgress[] }>( + "/progress", + { + preValidation: [app.authenticate], + }, + async (request) => { + const userId = request.user.id; + const progress = await achievementService.getUserAchievementProgress( + userId, + ); + return progress; + }, + ); + + /** + * Get another user's achievement summary by ID (authenticated users). + */ + app.get<{ Params: { userId: string }; Reply: UserAchievementSummary }>( + "/users/:userId/summary", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const { userId } = request.params; + + try { + const summary = await achievementService.getUserAchievementSummary( + userId, + ); + return summary; + } catch (error) { + return reply.notFound("User not found"); + } + }, + ); + + /** + * Get another user's achievement progress by ID (authenticated users). + */ + app.get<{ Params: { userId: string }; Reply: AchievementProgress[] }>( + "/users/:userId/progress", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const { userId } = request.params; + + try { + const progress = await achievementService.getUserAchievementProgress( + userId, + ); + return progress; + } catch (error) { + return reply.notFound("User not found"); + } + }, + ); +}; + +export default achievementsRoutes; diff --git a/api/src/app/routes/art/index.ts b/api/src/app/routes/art/index.ts index 3c24820..befe459 100644 --- a/api/src/app/routes/art/index.ts +++ b/api/src/app/routes/art/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { ArtService } from "../../services/art.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const artRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to art`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index 3bdadb9..f13d76e 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -1,7 +1,8 @@ import { FastifyPluginAsync } from "fastify"; import { AuthService } from "../../services/auth.service"; import { AuditService } from "../../services/audit.service"; -import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; +import { AuthResponse, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; const authRoutes: FastifyPluginAsync = async (app) => { const authService = new AuthService(app); @@ -92,6 +93,15 @@ const authRoutes: FastifyPluginAsync = async (app) => { success: true, }, request); + // Update login streak and check engagement achievements + const achievementService = new AchievementService(); + await achievementService.updateLoginStreak(user.id); + await achievementService.checkAchievements( + user.id, + AchievementCategory.Engagement, + request + ); + // Set signed cookies and redirect to frontend reply .setCookie("auth-token", accessToken, { diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index f22cfa2..2aea0fe 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { BookService } from "../../services/book.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const booksRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to book`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/comment-reports/index.ts b/api/src/app/routes/comment-reports/index.ts index a784ff0..06cef1d 100644 --- a/api/src/app/routes/comment-reports/index.ts +++ b/api/src/app/routes/comment-reports/index.ts @@ -10,9 +10,10 @@ import type { ReportStatus, UpdateCommentReportDto, } from "@library/shared-types"; -import { ReportReason } from "@library/shared-types"; +import { ReportReason, AchievementCategory } from "@library/shared-types"; import { CommentReportService } from "../../services/comment-report.service.js"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard.js"; const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { @@ -132,6 +133,17 @@ const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { request.user.id, request.body, ); + + // Check for report achievements for the original reporter + if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") { + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + report.reporterId, + AchievementCategory.Report, + request + ); + } + return reply.send(report); }, ); diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index 53e2e15..2d00886 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { GameService } from "../../services/game.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -125,6 +126,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to game`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/log/index.ts b/api/src/app/routes/log/index.ts index 2c57468..104db17 100644 --- a/api/src/app/routes/log/index.ts +++ b/api/src/app/routes/log/index.ts @@ -30,9 +30,10 @@ export default async function (fastify: FastifyInstance) { } await logger.error(context || 'Frontend', errorObj); } else if (level === 'error') { - await logger.log('warn', `[Frontend Error] ${message}`); + await logger.error('Frontend', new Error(message)); } else { - await logger.log(level, `[Frontend] ${message}`); + const logMessage = context ? `[${context}] ${message}` : message; + await logger.log(level, logMessage); } return { success: true }; diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts index 3909744..3391b16 100644 --- a/api/src/app/routes/manga/index.ts +++ b/api/src/app/routes/manga/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { MangaService } from "../../services/manga.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -118,6 +119,15 @@ const mangaRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to manga`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/music/index.ts b/api/src/app/routes/music/index.ts index 5f63553..d903627 100644 --- a/api/src/app/routes/music/index.ts +++ b/api/src/app/routes/music/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { MusicService } from "../../services/music.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const musicRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to music`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/reports/index.ts b/api/src/app/routes/reports/index.ts index 8fb3cdc..48f968a 100644 --- a/api/src/app/routes/reports/index.ts +++ b/api/src/app/routes/reports/index.ts @@ -10,9 +10,10 @@ import type { ReportStatus, UpdateReportDto, } from "@library/shared-types"; -import { ReportReason } from "@library/shared-types"; +import { ReportReason, AchievementCategory } from "@library/shared-types"; import { ReportService } from "../../services/report.service.js"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard.js"; const reportsRoutes: FastifyPluginAsync = async (fastify) => { @@ -131,6 +132,17 @@ const reportsRoutes: FastifyPluginAsync = async (fastify) => { request.user.id, request.body, ); + + // Check for report achievements for the original reporter + if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") { + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + report.reporterId, + AchievementCategory.Report, + request + ); + } + return reply.send(report); }, ); diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts index a41c422..7676370 100644 --- a/api/src/app/routes/shows/index.ts +++ b/api/src/app/routes/shows/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { ShowService } from "../../services/show.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -118,6 +119,15 @@ const showsRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to show`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts index 569deb4..fe97a04 100644 --- a/api/src/app/routes/suggestions/index.ts +++ b/api/src/app/routes/suggestions/index.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from "fastify"; import { SuggestionService } from "../../services/suggestion.service"; import { AuditService } from "../../services/audit.service"; -import { AuditAction, AuditCategory } from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; +import { AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import type { SuggestionStatus, SuggestionEntity, @@ -93,6 +94,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( @@ -123,6 +132,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements for the user who made the suggestion + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + suggestion.userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( @@ -154,6 +171,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements for the user who made the suggestion + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + suggestion.userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( diff --git a/api/src/app/services/achievement.service.ts b/api/src/app/services/achievement.service.ts new file mode 100644 index 0000000..5c26f2f --- /dev/null +++ b/api/src/app/services/achievement.service.ts @@ -0,0 +1,772 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyRequest } from "fastify"; +import { + ACHIEVEMENTS, + ACHIEVEMENT_LIST, + AchievementCategory, + AchievementDefinition, + AchievementProgress, + AuditAction, + AuditCategory, + UserAchievementSummary, +} from "@library/shared-types"; + +import { prisma } from "../lib/prisma"; +import { AuditService } from "./audit.service"; + +export class AchievementService { + /** + * Check and award achievements for a user after an action. + * Returns list of newly earned achievements. + */ + async checkAchievements( + userId: string, + category: AchievementCategory, + req: FastifyRequest, + ): Promise { + const relevantAchievements = ACHIEVEMENT_LIST.filter( + (ach) => ach.category === category, + ); + const newlyEarned: AchievementDefinition[] = []; + + for (const achievement of relevantAchievements) { + const userAchievement = await prisma.userAchievement.findUnique({ + where: { + userId_achievementKey: { + userId, + achievementKey: achievement.key, + }, + }, + }); + + // Skip already earned achievements + if (userAchievement?.earned) { + continue; + } + + // Check if achievement is now earned + const earned = await this.checkAchievementCondition(userId, achievement); + + if (earned) { + await this.awardAchievement(userId, achievement, req); + newlyEarned.push(achievement); + } + } + + return newlyEarned; + } + + /** + * Award an achievement to a user. + */ + private async awardAchievement( + userId: string, + achievement: AchievementDefinition, + req: FastifyRequest, + ): Promise { + await prisma.userAchievement.upsert({ + where: { + userId_achievementKey: { + userId, + achievementKey: achievement.key, + }, + }, + create: { + userId, + achievementKey: achievement.key, + progress: 100, + earned: true, + earnedAt: new Date(), + }, + update: { + earned: true, + earnedAt: new Date(), + progress: 100, + }, + }); + + // Update user's achievement points + await prisma.user.update({ + where: { id: userId }, + data: { + achievementPoints: { + increment: achievement.points, + }, + }, + }); + + // Log the achievement unlock + await AuditService.logFromRequest(req, { + action: AuditAction.achievementUnlocked, + category: AuditCategory.content, + details: `Unlocked achievement: ${achievement.title}`, + }); + } + + /** + * Check if a specific achievement condition is met. + */ + private async checkAchievementCondition( + userId: string, + achievement: AchievementDefinition, + ): Promise { + switch (achievement.category) { + case AchievementCategory.Suggestion: + return await this.checkSuggestionAchievement(userId, achievement); + case AchievementCategory.Like: + return await this.checkLikeAchievement(userId, achievement); + case AchievementCategory.Comment: + return await this.checkCommentAchievement(userId, achievement); + case AchievementCategory.Engagement: + return await this.checkEngagementAchievement(userId, achievement); + case AchievementCategory.Report: + return await this.checkReportAchievement(userId, achievement); + default: + return false; + } + } + + /** + * Check suggestion-based achievements. + */ + private async checkSuggestionAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total suggestions) + if ( + achievement.key.startsWith("suggestion_first_steps") || + achievement.key.startsWith("suggestion_contributor") || + achievement.key.startsWith("suggestion_dedicated") || + achievement.key.startsWith("suggestion_master") || + achievement.key.startsWith("suggestion_legend") + ) { + const count = await prisma.suggestion.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Accepted suggestion achievements + if (achievement.key.startsWith("suggestion_quality")) { + const count = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + return count >= (requirements.count ?? 0); + } + + // Approved achievement (first accepted) + if (achievement.key === "suggestion_approved") { + const count = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + return count >= 1; + } + + // Acceptance rate achievements + if (achievement.key.startsWith("suggestion_acceptance")) { + const total = await prisma.suggestion.count({ + where: { userId }, + }); + const accepted = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + + if (total < (requirements.count ?? 0)) { + return false; + } + + const rate = accepted / total; + return rate >= (requirements.rate ?? 0); + } + + // Diversity achievement (all media types) + if (achievement.key === "suggestion_renaissance") { + const types = await prisma.suggestion.groupBy({ + by: ["entityType"], + where: { + userId, + status: "ACCEPTED", + }, + }); + return types.length >= 6; + } + + // Enthusiast (5 in one day) + if (achievement.key === "suggestion_enthusiast") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const count = await prisma.suggestion.count({ + where: { + userId, + createdAt: { + gte: today, + lt: tomorrow, + }, + }, + }); + return count >= 5; + } + + return false; + } + + /** + * Check like-based achievements. + */ + private async checkLikeAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total likes) + if ( + achievement.key.startsWith("like_first") || + achievement.key.startsWith("like_enthusiast") || + achievement.key.startsWith("like_fan") || + achievement.key.startsWith("like_super") || + achievement.key.startsWith("like_mega") || + achievement.key.startsWith("like_legendary") + ) { + const count = await prisma.like.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Media-specific achievements + if (achievement.key === "like_book_lover") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "book", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_gamer") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "game", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_cinephile") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "show", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_music") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "music", + }, + }); + return count >= 50; + } + + // Diversity achievement + if (achievement.key === "like_diverse") { + const types = await prisma.like.groupBy({ + by: ["entityType"], + where: { userId }, + }); + return types.length >= 6; + } + + // Binge liker (20+ in one day) + if (achievement.key === "like_binge") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const count = await prisma.like.count({ + where: { + userId, + createdAt: { + gte: today, + lt: tomorrow, + }, + }, + }); + return count >= 20; + } + + return false; + } + + /** + * Check comment-based achievements. + */ + private async checkCommentAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total comments) + if ( + achievement.key.startsWith("comment_first") || + achievement.key.startsWith("comment_reviewer") || + achievement.key.startsWith("comment_critic") || + achievement.key.startsWith("comment_expert") || + achievement.key.startsWith("comment_master") || + achievement.key.startsWith("comment_legend") + ) { + const count = await prisma.comment.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Length-based achievements + if (achievement.key === "comment_detailed") { + const count = await prisma.comment.count({ + where: { + userId, + content: { + // MongoDB doesn't have a direct length check, so we'll fetch and check + }, + }, + }); + + // Fetch all comments and check length + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 500); + return longComments.length >= 10; + } + + if (achievement.key === "comment_essay") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 1000); + return longComments.length >= 5; + } + + if (achievement.key === "comment_novel") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 2000); + return longComments.length >= 3; + } + + // Thoughtful reviewer (50 different items) + if (achievement.key === "comment_thoughtful") { + // Count unique entity IDs + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { + bookId: true, + gameId: true, + showId: true, + mangaId: true, + musicId: true, + artId: true, + }, + }); + + const uniqueItems = new Set(); + comments.forEach((c) => { + const entityId = + c.bookId ?? + c.gameId ?? + c.showId ?? + c.mangaId ?? + c.musicId ?? + c.artId; + if (entityId) { + uniqueItems.add(entityId); + } + }); + + return uniqueItems.size >= 50; + } + + // Diversity achievement + if (achievement.key === "comment_diverse") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { + bookId: true, + gameId: true, + showId: true, + mangaId: true, + musicId: true, + artId: true, + }, + }); + + const types = new Set(); + comments.forEach((c) => { + if (c.bookId) { + types.add("book"); + } + if (c.gameId) { + types.add("game"); + } + if (c.showId) { + types.add("show"); + } + if (c.mangaId) { + types.add("manga"); + } + if (c.musicId) { + types.add("music"); + } + if (c.artId) { + types.add("art"); + } + }); + + return types.size >= 6; + } + + return false; + } + + /** + * Check engagement-based achievements. + */ + private async checkEngagementAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + currentStreak: true, + createdAt: true, + }, + }); + + if (!user) { + return false; + } + + // Welcome achievement + if (achievement.key === "engagement_welcome") { + return true; // Awarded on first login + } + + // Streak-based achievements + if (achievement.key.startsWith("engagement_streak")) { + return user.currentStreak >= (achievement.requirements.streak ?? 0); + } + + // Triple threat (suggestion, like, comment in same day) + if (achievement.key === "engagement_triple_threat") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const [hasSuggestion, hasLike, hasComment] = await Promise.all([ + prisma.suggestion.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + prisma.like.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + prisma.comment.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + ]); + + return hasSuggestion > 0 && hasLike > 0 && hasComment > 0; + } + + // Power user (triple threat 10 times) - would need special tracking + // Early adopter achievements + if ( + achievement.key === "engagement_early_adopter" || + achievement.key === "engagement_founding_100" || + achievement.key === "engagement_founding_1000" + ) { + const userRank = await prisma.user.count({ + where: { + createdAt: { + lt: user.createdAt, + }, + }, + }); + + if (achievement.key === "engagement_early_adopter") { + return userRank < 10; + } + if (achievement.key === "engagement_founding_100") { + return userRank < 100; + } + if (achievement.key === "engagement_founding_1000") { + return userRank < 1000; + } + } + + // Account age achievements + if (achievement.key.startsWith("engagement_veteran")) { + const daysSinceCreation = Math.floor( + (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24), + ); + return daysSinceCreation >= (achievement.requirements.dayRange ?? 0); + } + + return false; + } + + /** + * Check report-based achievements. + */ + private async checkReportAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count ACTION_TAKEN reports + if ( + achievement.key.startsWith("report_watchful") || + achievement.key.startsWith("report_guardian") || + achievement.key.startsWith("report_protector") || + achievement.key.startsWith("report_vigilant") + ) { + const profileReports = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const commentReports = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + return profileReports + commentReports >= (requirements.count ?? 0); + } + + // Accuracy achievements + if (achievement.key.startsWith("report_accuracy")) { + const totalProfileReports = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: { + not: "PENDING", + }, + }, + }); + + const totalCommentReports = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: { + not: "PENDING", + }, + }, + }); + + const total = totalProfileReports + totalCommentReports; + + if (total < (requirements.count ?? 10)) { + return false; + } + + const actionTakenProfile = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const actionTakenComment = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const actionTaken = actionTakenProfile + actionTakenComment; + const rate = actionTaken / total; + + return rate >= (requirements.rate ?? 0); + } + + // Volume achievement (total reports) + if (achievement.key === "report_volume") { + const profileReports = await prisma.profileReport.count({ + where: { reporterId: userId }, + }); + + const commentReports = await prisma.commentReport.count({ + where: { reporterId: userId }, + }); + + return profileReports + commentReports >= 50; + } + + return false; + } + + /** + * Get a user's achievement progress and summary. + */ + async getUserAchievementSummary( + userId: string, + ): Promise { + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + orderBy: { earnedAt: "desc" }, + }); + + const earnedAchievements = userAchievements.filter((ua) => ua.earned); + const totalPoints = earnedAchievements.reduce((sum, ua) => { + const definition = ACHIEVEMENTS[ua.achievementKey]; + return sum + (definition?.points ?? 0); + }, 0); + + const recentAchievements: AchievementProgress[] = earnedAchievements + .slice(0, 5) + .map((ua) => ({ + definition: ACHIEVEMENTS[ua.achievementKey], + progress: ua.progress, + earned: ua.earned, + earnedAt: ua.earnedAt ?? undefined, + })); + + // Progress by category + const progressByCategory = Object.values(AchievementCategory).map( + (category) => { + const categoryAchievements = ACHIEVEMENT_LIST.filter( + (a) => a.category === category, + ); + const earned = earnedAchievements.filter( + (ua) => ACHIEVEMENTS[ua.achievementKey]?.category === category, + ).length; + + return { + category, + earned, + total: categoryAchievements.length, + }; + }, + ); + + return { + totalPoints, + totalEarned: earnedAchievements.length, + recentAchievements, + progressByCategory, + }; + } + + /** + * Get all achievements with progress for a user. + */ + async getUserAchievementProgress( + userId: string, + ): Promise { + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + }); + + const progressMap = new Map( + userAchievements.map((ua) => [ua.achievementKey, ua]), + ); + + return ACHIEVEMENT_LIST.map((definition) => { + const userAchievement = progressMap.get(definition.key); + return { + definition, + progress: userAchievement?.progress ?? 0, + earned: userAchievement?.earned ?? false, + earnedAt: userAchievement?.earnedAt ?? undefined, + }; + }); + } + + /** + * Update login streak for a user. + */ + async updateLoginStreak(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + currentStreak: true, + lastStreakCheck: true, + }, + }); + + if (!user) { + return; + } + + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const lastCheck = user.lastStreakCheck; + const isConsecutive = + lastCheck && + lastCheck >= yesterday && + lastCheck < new Date(now.setHours(0, 0, 0, 0)); + + await prisma.user.update({ + where: { id: userId }, + data: { + currentStreak: isConsecutive ? user.currentStreak + 1 : 1, + lastStreakCheck: new Date(), + }, + }); + } +} diff --git a/api/src/app/services/like.service.ts b/api/src/app/services/like.service.ts index 7875836..a8aafcd 100644 --- a/api/src/app/services/like.service.ts +++ b/api/src/app/services/like.service.ts @@ -7,8 +7,9 @@ import type { FastifyRequest } from 'fastify'; import { prisma } from '../lib/prisma'; import { AuditService } from './audit.service'; +import { AchievementService } from './achievement.service'; import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types'; -import { AuditAction, AuditCategory } from '@library/shared-types'; +import { AuditAction, AuditCategory, AchievementCategory } from '@library/shared-types'; export class LikeService { async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise { @@ -59,6 +60,14 @@ export class LikeService { details: `Liked ${entityType}` }); + // Check for like achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Like, + req + ); + const count = await this.getLikeCount(entityType, entityId); return { liked: true, count }; } diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 68c3dcf..ece6fcf 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -260,6 +260,7 @@ export class UserService { inDiscord: boolean; profilePublic: boolean; createdAt: Date; + achievementPoints: number; stats: { suggestionsCount: number; suggestionsAcceptedCount: number; @@ -316,6 +317,7 @@ export class UserService { inDiscord: user.inDiscord, profilePublic: user.profilePublic, createdAt: user.createdAt, + achievementPoints: user.achievementPoints, stats: { suggestionsCount: user.suggestions.length, suggestionsAcceptedCount: user.suggestions.filter( diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index a8c1e6e..139b7ad 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -61,6 +61,10 @@ export const appRoutes: Route[] = [ path: 'settings', loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent) }, + { + path: 'achievements', + loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/achievements/achievements.component.ts b/apps/frontend/src/app/components/achievements/achievements.component.ts new file mode 100644 index 0000000..8ef4087 --- /dev/null +++ b/apps/frontend/src/app/components/achievements/achievements.component.ts @@ -0,0 +1,437 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + AchievementCategory, + AchievementProgress, + AchievementTier, +} from '@library/shared-types'; +import { AchievementService } from '../../services/achievement.service'; + +@Component({ + selector: 'app-achievements', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

🏆 Achievements

+

Track your progress across all achievement categories

+ + @if (totalPoints() > 0) { +
+ Total Points: + {{ totalPoints() }} +
+ } +
+ +
+ + + + + + +
+ + @if (loading()) { +
Loading achievements...
+ } @else if (error()) { +
{{ error() }}
+ } @else { +
+ @for (achievement of filteredAchievements(); track achievement.definition.key) { +
+ +
+ {{ achievement.definition.icon }} +
+ +
+

+ {{ achievement.definition.title }} + @if (achievement.earned) { + + } +

+ +

+ {{ achievement.definition.description }} +

+ + + + @if (!achievement.earned && achievement.progress > 0) { +
+
+
+
{{ achievement.progress }}% complete
+ } + + @if (achievement.earned && achievement.earnedAt) { +
+ Earned {{ formatDate(achievement.earnedAt) }} +
+ } +
+
+ } +
+ } +
+ `, + styles: [` + .achievements-container { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; + } + + .achievements-header { + text-align: center; + margin-bottom: 2rem; + } + + .achievements-header h1 { + font-size: 2.5rem; + margin-bottom: 0.5rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .subtitle { + color: #a0aec0; + font-size: 1.1rem; + } + + .total-points { + margin-top: 1rem; + padding: 1rem 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 12px; + display: inline-block; + color: white; + font-size: 1.2rem; + font-weight: bold; + } + + .points-label { + margin-right: 0.5rem; + } + + .points-value { + font-size: 1.5rem; + } + + .filter-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 2rem; + } + + .filter-buttons button { + padding: 0.75rem 1.5rem; + background: #2d3748; + color: #cbd5e0; + border: 2px solid #4a5568; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + font-size: 1rem; + } + + .filter-buttons button:hover { + background: #4a5568; + border-color: #667eea; + } + + .filter-buttons button.active { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-color: #667eea; + color: white; + } + + .achievements-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; + } + + .achievement-card { + background: #2d3748; + border-radius: 12px; + padding: 1.5rem; + display: flex; + gap: 1rem; + border: 2px solid #4a5568; + transition: all 0.3s; + } + + .achievement-card.earned { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); + border-color: #667eea; + } + + .achievement-card.earned:hover { + transform: translateY(-4px); + box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3); + } + + .achievement-card.locked { + opacity: 0.6; + } + + .achievement-icon { + font-size: 3rem; + flex-shrink: 0; + } + + .achievement-content { + flex: 1; + } + + .achievement-title { + font-size: 1.25rem; + margin-bottom: 0.5rem; + color: #e2e8f0; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .earned-check { + color: #48bb78; + font-size: 1.5rem; + } + + .achievement-description { + color: #a0aec0; + margin-bottom: 1rem; + line-height: 1.5; + } + + .achievement-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + } + + .achievement-tier { + padding: 0.25rem 0.75rem; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 600; + text-transform: uppercase; + } + + .achievement-tier[data-tier="bronze"] { + background: linear-gradient(135deg, #cd7f32 0%, #b87333 100%); + color: white; + } + + .achievement-tier[data-tier="silver"] { + background: linear-gradient(135deg, #c0c0c0 0%, #a8a8a8 100%); + color: #2d3748; + } + + .achievement-tier[data-tier="gold"] { + background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%); + color: #2d3748; + } + + .achievement-tier[data-tier="platinum"] { + background: linear-gradient(135deg, #e5e4e2 0%, #bdb8af 100%); + color: #2d3748; + } + + .achievement-tier[data-tier="diamond"] { + background: linear-gradient(135deg, #b9f2ff 0%, #7ec8e3 100%); + color: #2d3748; + } + + .achievement-points { + color: #667eea; + font-weight: 600; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #4a5568; + border-radius: 4px; + overflow: hidden; + margin: 0.5rem 0; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); + transition: width 0.3s; + } + + .progress-text { + font-size: 0.875rem; + color: #a0aec0; + text-align: right; + } + + .earned-date { + font-size: 0.875rem; + color: #48bb78; + margin-top: 0.5rem; + } + + .loading, .error { + text-align: center; + padding: 3rem; + font-size: 1.2rem; + color: #a0aec0; + } + + .error { + color: #fc8181; + } + `], +}) +export class AchievementsComponent implements OnInit { + private readonly achievementService = inject(AchievementService); + + achievements = signal([]); + selectedCategory = signal(null); + loading = signal(true); + error = signal(null); + + // Expose AchievementCategory enum for template + readonly AchievementCategory = AchievementCategory; + + ngOnInit(): void { + this.loadAchievements(); + } + + private loadAchievements(): void { + this.achievementService.getCurrentUserProgress().subscribe({ + next: (achievements) => { + this.achievements.set(achievements); + this.loading.set(false); + }, + error: (err) => { + this.error.set('Failed to load achievements'); + this.loading.set(false); + console.error('Error loading achievements:', err); + }, + }); + } + + filteredAchievements(): AchievementProgress[] { + const category = this.selectedCategory(); + if (!category) { + return this.achievements(); + } + return this.achievements().filter( + (a) => a.definition.category === category, + ); + } + + selectCategory(category: AchievementCategory | null): void { + this.selectedCategory.set(category); + } + + totalAchievements(): number { + return this.achievements().length; + } + + totalEarned(): number { + return this.achievements().filter((a) => a.earned).length; + } + + totalPoints(): number { + return this.achievements() + .filter((a) => a.earned) + .reduce((sum, a) => sum + a.definition.points, 0); + } + + getCategoryCount(category: AchievementCategory): string { + const total = this.achievements().filter( + (a) => a.definition.category === category, + ).length; + const earned = this.achievements().filter( + (a) => a.definition.category === category && a.earned, + ).length; + return `${earned}/${total}`; + } + + getTierLabel(tier: AchievementTier): string { + return tier; + } + + formatDate(date: Date): string { + const d = new Date(date); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return 'today'; + } + if (diffDays === 1) { + return 'yesterday'; + } + if (diffDays < 7) { + return `${diffDays} days ago`; + } + if (diffDays < 30) { + const weeks = Math.floor(diffDays / 7); + return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; + } + if (diffDays < 365) { + const months = Math.floor(diffDays / 30); + return `${months} ${months === 1 ? 'month' : 'months'} ago`; + } + const years = Math.floor(diffDays / 365); + return `${years} ${years === 1 ? 'year' : 'years'} ago`; + } +} diff --git a/apps/frontend/src/app/components/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 16a7b00..97db707 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -52,6 +52,7 @@ import { ApiService } from '../../services/api.service'; + @if (recentAchievements().length > 0) { +
+
+

Recent Achievements

+ @if (isOwnProfile()) { + View All → + } +
+
+ @for (achievement of recentAchievements(); track achievement.definition.key) { +
+
{{ achievement.definition.icon }}
+
+
{{ achievement.definition.title }}
+
{{ achievement.definition.points }} pts
+
+
+ } +
+
+ } + @@ -372,7 +401,7 @@ import { PrimaryBadge } from '@library/shared-types'; margin-bottom: 1.5rem; } - .stats-section h2 { + .stats-section h2, .achievements-section h2 { margin: 0 0 1rem 0; color: var(--accent-colour, #9b59b6); font-size: 1.5rem; @@ -417,15 +446,114 @@ import { PrimaryBadge } from '@library/shared-types'; color: var(--text-muted, #a0a0a0); font-size: 0.9rem; } + + .achievement-points-card { + background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%); + border: 1px solid rgba(102, 126, 234, 0.5); + } + + .achievements-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(155, 89, 182, 0.3); + } + + .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .view-all-link { + color: var(--accent-colour, #9b59b6); + text-decoration: none; + font-size: 0.9rem; + transition: opacity 0.2s; + } + + .view-all-link:hover { + opacity: 0.8; + } + + .achievements-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + } + + .achievement-badge { + background: var(--card-background, #16213e); + border-radius: 8px; + padding: 1rem; + display: flex; + align-items: center; + gap: 0.75rem; + border: 2px solid transparent; + transition: all 0.2s; + } + + .achievement-badge[data-tier="bronze"] { + border-color: #cd7f32; + } + + .achievement-badge[data-tier="silver"] { + border-color: #c0c0c0; + } + + .achievement-badge[data-tier="gold"] { + border-color: #ffd700; + } + + .achievement-badge[data-tier="platinum"] { + border-color: #e5e4e2; + } + + .achievement-badge[data-tier="diamond"] { + border-color: #b9f2ff; + } + + .achievement-badge:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(155, 89, 182, 0.3); + } + + .achievement-icon { + font-size: 2rem; + flex-shrink: 0; + } + + .achievement-info { + flex: 1; + min-width: 0; + } + + .achievement-title { + font-weight: 600; + color: var(--text-colour, #ffffff); + font-size: 1rem; + line-height: 1.3; + word-wrap: break-word; + } + + .achievement-points { + color: var(--text-colour, #ffffff); + opacity: 0.8; + font-size: 0.85rem; + margin-top: 0.25rem; + font-weight: 500; + } `] }) export class ProfileComponent implements OnInit { private route = inject(ActivatedRoute); private userService = inject(UserService); + private achievementService = inject(AchievementService); private toastService = inject(ToastService); private authService = inject(AuthService); profile = signal(null); + recentAchievements = signal([]); loading = signal(true); error = signal(null); reportModalOpen = signal(false); @@ -455,6 +583,26 @@ export class ProfileComponent implements OnInit { next: (profileData: UserProfileResponse) => { this.profile.set(profileData); this.loading.set(false); + + // Load recent achievements + this.achievementService.getUserProgress(profileData.id).subscribe({ + next: (achievements) => { + // Get earned achievements sorted by earned date, take top 6 + const earned = achievements + .filter(a => a.earned && a.earnedAt) + .sort((a, b) => { + const dateA = a.earnedAt ? new Date(a.earnedAt).getTime() : 0; + const dateB = b.earnedAt ? new Date(b.earnedAt).getTime() : 0; + return dateB - dateA; + }) + .slice(0, 6); + this.recentAchievements.set(earned); + }, + error: (err) => { + console.error('Error loading achievements:', err); + // Don't show error toast for achievements failure + } + }); }, error: (err: Error) => { console.error('Error loading profile:', err); @@ -489,6 +637,20 @@ export class ProfileComponent implements OnInit { return currentUser.id !== profileData.id; } + /** + * Determine whether viewing your own profile. + */ + isOwnProfile(): boolean { + const currentUser = this.authService.user(); + const profileData = this.profile(); + + if (!currentUser || !profileData) { + return false; + } + + return currentUser.id === profileData.id; + } + /** * Open the report modal. */ diff --git a/apps/frontend/src/app/services/achievement.service.ts b/apps/frontend/src/app/services/achievement.service.ts new file mode 100644 index 0000000..978a98e --- /dev/null +++ b/apps/frontend/src/app/services/achievement.service.ts @@ -0,0 +1,63 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Injectable, inject } from '@angular/core'; +import { + AchievementDefinition, + AchievementProgress, + UserAchievementSummary, +} from '@library/shared-types'; +import { Observable } from 'rxjs'; + +import { ApiService } from './api.service'; + +@Injectable({ + providedIn: 'root', +}) +export class AchievementService { + private readonly api = inject(ApiService); + + /** + * Get all achievement definitions. + */ + getAchievementDefinitions(): Observable { + return this.api.get('/achievements/definitions'); + } + + /** + * Get a specific achievement definition by key. + */ + getAchievementDefinition(key: string): Observable { + return this.api.get(`/achievements/definitions/${key}`); + } + + /** + * Get current user's achievement summary. + */ + getCurrentUserSummary(): Observable { + return this.api.get('/achievements/summary'); + } + + /** + * Get current user's achievement progress. + */ + getCurrentUserProgress(): Observable { + return this.api.get('/achievements/progress'); + } + + /** + * Get another user's achievement summary. + */ + getUserSummary(userId: string): Observable { + return this.api.get(`/achievements/users/${userId}/summary`); + } + + /** + * Get another user's achievement progress. + */ + getUserProgress(userId: string): Observable { + return this.api.get(`/achievements/users/${userId}/progress`); + } +} diff --git a/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts index 8b4f75f..bc9c72b 100644 --- a/apps/frontend/src/app/services/user.service.ts +++ b/apps/frontend/src/app/services/user.service.ts @@ -24,6 +24,7 @@ export interface UserProfileResponse { linkedin?: string; twitch?: string; youtube?: string; + achievementPoints: number; badges: { isStaff: boolean; isMod: boolean; diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index ec404b3..dc596c9 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -3,6 +3,8 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +export * from "./lib/achievement.constants"; +export * from "./lib/achievement.types"; export type * from "./lib/art.types"; export * from "./lib/audit.types"; export * from "./lib/auth.types"; diff --git a/shared-types/src/lib/achievement.constants.ts b/shared-types/src/lib/achievement.constants.ts new file mode 100644 index 0000000..63b3fe7 --- /dev/null +++ b/shared-types/src/lib/achievement.constants.ts @@ -0,0 +1,644 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + AchievementCategory, + AchievementDefinition, + AchievementTier, +} from "./achievement.types"; + +export const ACHIEVEMENTS: Record = { + // ========== SUGGESTION ACHIEVEMENTS (15) ========== + suggestion_first_steps: { + key: "suggestion_first_steps", + title: "First Steps", + description: "Submit your first 10 suggestions to the library", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Bronze, + icon: "🌱", + points: 50, + requirements: { count: 10 }, + }, + suggestion_contributor: { + key: "suggestion_contributor", + title: "Contributor", + description: "Submit 50 suggestions to the library", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Silver, + icon: "📝", + points: 100, + requirements: { count: 50 }, + }, + suggestion_dedicated: { + key: "suggestion_dedicated", + title: "Dedicated", + description: "Submit 100 suggestions to the library", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Gold, + icon: "⭐", + points: 250, + requirements: { count: 100 }, + }, + suggestion_master: { + key: "suggestion_master", + title: "Master Curator", + description: "Submit 250 suggestions to the library", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Platinum, + icon: "💎", + points: 500, + requirements: { count: 250 }, + }, + suggestion_legend: { + key: "suggestion_legend", + title: "Legend", + description: "Submit 500 suggestions to the library", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Diamond, + icon: "👑", + points: 1000, + requirements: { count: 500 }, + }, + suggestion_approved: { + key: "suggestion_approved", + title: "Approved!", + description: "Have your first suggestion accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Bronze, + icon: "✅", + points: 50, + requirements: { count: 1 }, + }, + suggestion_quality_5: { + key: "suggestion_quality_5", + title: "Quality Curator", + description: "Have 5 suggestions accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Silver, + icon: "🌟", + points: 100, + requirements: { count: 5 }, + }, + suggestion_quality_10: { + key: "suggestion_quality_10", + title: "Elite Curator", + description: "Have 10 suggestions accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Gold, + icon: "🏆", + points: 200, + requirements: { count: 10 }, + }, + suggestion_quality_25: { + key: "suggestion_quality_25", + title: "Master Curator", + description: "Have 25 suggestions accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Platinum, + icon: "💫", + points: 400, + requirements: { count: 25 }, + }, + suggestion_quality_50: { + key: "suggestion_quality_50", + title: "Grand Master", + description: "Have 50 suggestions accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Platinum, + icon: "🎖️", + points: 700, + requirements: { count: 50 }, + }, + suggestion_quality_100: { + key: "suggestion_quality_100", + title: "Ultimate Curator", + description: "Have 100 suggestions accepted", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Diamond, + icon: "👸", + points: 1500, + requirements: { count: 100 }, + }, + suggestion_acceptance_75: { + key: "suggestion_acceptance_75", + title: "Quality Over Quantity", + description: "Maintain a 75%+ acceptance rate (minimum 20 suggestions)", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Gold, + icon: "🎯", + points: 300, + requirements: { rate: 0.75, count: 20 }, + }, + suggestion_acceptance_100: { + key: "suggestion_acceptance_100", + title: "Perfect Record", + description: "Achieve 100% acceptance rate (minimum 10 suggestions)", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Platinum, + icon: "✨", + points: 500, + requirements: { rate: 1.0, count: 10 }, + }, + suggestion_renaissance: { + key: "suggestion_renaissance", + title: "Renaissance Person", + description: "Submit accepted suggestions across all 6 media types", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Gold, + icon: "🌈", + points: 400, + requirements: { diversity: true }, + }, + suggestion_enthusiast: { + key: "suggestion_enthusiast", + title: "Suggestion Enthusiast", + description: "Submit 5 suggestions in one day", + category: AchievementCategory.Suggestion, + tier: AchievementTier.Silver, + icon: "🔥", + points: 150, + requirements: { count: 5, dayRange: 1 }, + }, + + // ========== LIKE ACHIEVEMENTS (12) ========== + like_first: { + key: "like_first", + title: "First Like", + description: "Like your first item in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Bronze, + icon: "❤️", + points: 25, + requirements: { count: 1 }, + }, + like_enthusiast: { + key: "like_enthusiast", + title: "Enthusiast", + description: "Like 25 items in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Bronze, + icon: "💕", + points: 50, + requirements: { count: 25 }, + }, + like_fan: { + key: "like_fan", + title: "Fan", + description: "Like 100 items in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "💖", + points: 100, + requirements: { count: 100 }, + }, + like_super_fan: { + key: "like_super_fan", + title: "Super Fan", + description: "Like 250 items in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Gold, + icon: "💝", + points: 250, + requirements: { count: 250 }, + }, + like_mega_fan: { + key: "like_mega_fan", + title: "Mega Fan", + description: "Like 500 items in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Platinum, + icon: "💗", + points: 500, + requirements: { count: 500 }, + }, + like_legendary: { + key: "like_legendary", + title: "Legendary Fan", + description: "Like 1000 items in the library", + category: AchievementCategory.Like, + tier: AchievementTier.Diamond, + icon: "💞", + points: 1000, + requirements: { count: 1000 }, + }, + like_book_lover: { + key: "like_book_lover", + title: "Book Lover", + description: "Like 50 books", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "📚", + points: 100, + requirements: { count: 50 }, + }, + like_gamer: { + key: "like_gamer", + title: "Gamer", + description: "Like 50 games", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "🎮", + points: 100, + requirements: { count: 50 }, + }, + like_cinephile: { + key: "like_cinephile", + title: "Cinephile", + description: "Like 50 shows/films", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "🎬", + points: 100, + requirements: { count: 50 }, + }, + like_music: { + key: "like_music", + title: "Music Enthusiast", + description: "Like 50 music albums", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "🎵", + points: 100, + requirements: { count: 50 }, + }, + like_diverse: { + key: "like_diverse", + title: "Diverse Taste", + description: "Like items from all 6 media types", + category: AchievementCategory.Like, + tier: AchievementTier.Gold, + icon: "🌍", + points: 200, + requirements: { diversity: true }, + }, + like_binge: { + key: "like_binge", + title: "Binge Liker", + description: "Like 20+ items in one day", + category: AchievementCategory.Like, + tier: AchievementTier.Silver, + icon: "⚡", + points: 150, + requirements: { count: 20, dayRange: 1 }, + }, + + // ========== COMMENT ACHIEVEMENTS (12) ========== + comment_first: { + key: "comment_first", + title: "First Thoughts", + description: "Leave your first comment in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Bronze, + icon: "💬", + points: 25, + requirements: { count: 1 }, + }, + comment_reviewer: { + key: "comment_reviewer", + title: "Reviewer", + description: "Leave 10 comments in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Bronze, + icon: "✍️", + points: 50, + requirements: { count: 10 }, + }, + comment_critic: { + key: "comment_critic", + title: "Critic", + description: "Leave 50 comments in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Silver, + icon: "🗣️", + points: 100, + requirements: { count: 50 }, + }, + comment_expert: { + key: "comment_expert", + title: "Expert Critic", + description: "Leave 100 comments in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Gold, + icon: "🎭", + points: 250, + requirements: { count: 100 }, + }, + comment_master: { + key: "comment_master", + title: "Master Reviewer", + description: "Leave 250 comments in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Platinum, + icon: "📖", + points: 500, + requirements: { count: 250 }, + }, + comment_legend: { + key: "comment_legend", + title: "Review Legend", + description: "Leave 500 comments in the library", + category: AchievementCategory.Comment, + tier: AchievementTier.Diamond, + icon: "🏅", + points: 1000, + requirements: { count: 500 }, + }, + comment_detailed: { + key: "comment_detailed", + title: "Detailed Critic", + description: "Write 10 comments with 500+ characters", + category: AchievementCategory.Comment, + tier: AchievementTier.Silver, + icon: "📝", + points: 150, + requirements: { count: 10 }, + }, + comment_essay: { + key: "comment_essay", + title: "Essay Writer", + description: "Write 5 comments with 1000+ characters", + category: AchievementCategory.Comment, + tier: AchievementTier.Gold, + icon: "📄", + points: 300, + requirements: { count: 5 }, + }, + comment_novel: { + key: "comment_novel", + title: "Novel Writer", + description: "Write 3 comments with 2000+ characters", + category: AchievementCategory.Comment, + tier: AchievementTier.Platinum, + icon: "📚", + points: 500, + requirements: { count: 3 }, + }, + comment_thoughtful: { + key: "comment_thoughtful", + title: "Thoughtful Reviewer", + description: "Comment on 50 different items", + category: AchievementCategory.Comment, + tier: AchievementTier.Silver, + icon: "💭", + points: 150, + requirements: { uniqueItems: 50 }, + }, + comment_diverse: { + key: "comment_diverse", + title: "Well-Rounded Critic", + description: "Comment on all 6 media types", + category: AchievementCategory.Comment, + tier: AchievementTier.Gold, + icon: "🎨", + points: 250, + requirements: { diversity: true }, + }, + comment_first_to_comment: { + key: "comment_first_to_comment", + title: "Discussion Starter", + description: "Be the first to comment on 10 items", + category: AchievementCategory.Comment, + tier: AchievementTier.Silver, + icon: "🗨️", + points: 200, + requirements: { count: 10 }, + }, + + // ========== ENGAGEMENT ACHIEVEMENTS (15) ========== + engagement_welcome: { + key: "engagement_welcome", + title: "Welcome!", + description: "Complete your profile setup", + category: AchievementCategory.Engagement, + tier: AchievementTier.Bronze, + icon: "👋", + points: 25, + requirements: {}, + }, + engagement_streak_7: { + key: "engagement_streak_7", + title: "Daily Visitor", + description: "Login for 7 days in a row", + category: AchievementCategory.Engagement, + tier: AchievementTier.Bronze, + icon: "🔥", + points: 100, + requirements: { streak: 7 }, + }, + engagement_streak_30: { + key: "engagement_streak_30", + title: "Dedicated", + description: "Login for 30 days in a row", + category: AchievementCategory.Engagement, + tier: AchievementTier.Silver, + icon: "💪", + points: 250, + requirements: { streak: 30 }, + }, + engagement_streak_100: { + key: "engagement_streak_100", + title: "Committed", + description: "Login for 100 days in a row", + category: AchievementCategory.Engagement, + tier: AchievementTier.Gold, + icon: "🏆", + points: 500, + requirements: { streak: 100 }, + }, + engagement_streak_365: { + key: "engagement_streak_365", + title: "Unstoppable", + description: "Login for 365 days in a row", + category: AchievementCategory.Engagement, + tier: AchievementTier.Diamond, + icon: "⚡", + points: 2000, + requirements: { streak: 365 }, + }, + engagement_triple_threat: { + key: "engagement_triple_threat", + title: "Triple Threat", + description: "Submit a suggestion, like an item, and leave a comment in the same day", + category: AchievementCategory.Engagement, + tier: AchievementTier.Silver, + icon: "🎯", + points: 200, + requirements: { dayRange: 1 }, + }, + engagement_power_user: { + key: "engagement_power_user", + title: "Power User", + description: "Achieve Triple Threat 10 times", + category: AchievementCategory.Engagement, + tier: AchievementTier.Platinum, + icon: "💫", + points: 750, + requirements: { count: 10 }, + }, + engagement_early_adopter: { + key: "engagement_early_adopter", + title: "Early Adopter", + description: "Be among the first 10 users to join", + category: AchievementCategory.Engagement, + tier: AchievementTier.Diamond, + icon: "🌟", + points: 1000, + requirements: {}, + }, + engagement_founding_100: { + key: "engagement_founding_100", + title: "Founding Member", + description: "Be among the first 100 users to join", + category: AchievementCategory.Engagement, + tier: AchievementTier.Gold, + icon: "🎖️", + points: 500, + requirements: {}, + }, + engagement_founding_1000: { + key: "engagement_founding_1000", + title: "Pioneer", + description: "Be among the first 1000 users to join", + category: AchievementCategory.Engagement, + tier: AchievementTier.Silver, + icon: "🚀", + points: 200, + requirements: {}, + }, + engagement_veteran_30: { + key: "engagement_veteran_30", + title: "Veteran", + description: "Have an account for 30 days", + category: AchievementCategory.Engagement, + tier: AchievementTier.Bronze, + icon: "⏰", + points: 50, + requirements: { dayRange: 30 }, + }, + engagement_veteran_180: { + key: "engagement_veteran_180", + title: "Seasoned", + description: "Have an account for 6 months", + category: AchievementCategory.Engagement, + tier: AchievementTier.Silver, + icon: "📅", + points: 150, + requirements: { dayRange: 180 }, + }, + engagement_veteran_365: { + key: "engagement_veteran_365", + title: "Longstanding", + description: "Have an account for 1 year", + category: AchievementCategory.Engagement, + tier: AchievementTier.Gold, + icon: "🎂", + points: 300, + requirements: { dayRange: 365 }, + }, + engagement_veteran_730: { + key: "engagement_veteran_730", + title: "Elder", + description: "Have an account for 2 years", + category: AchievementCategory.Engagement, + tier: AchievementTier.Platinum, + icon: "🗓️", + points: 600, + requirements: { dayRange: 730 }, + }, + engagement_veteran_1825: { + key: "engagement_veteran_1825", + title: "Legend", + description: "Have an account for 5 years", + category: AchievementCategory.Engagement, + tier: AchievementTier.Diamond, + icon: "🌠", + points: 1500, + requirements: { dayRange: 1825 }, + }, + + // ========== REPORT ACHIEVEMENTS (8) ========== + report_watchful: { + key: "report_watchful", + title: "Watchful Eye", + description: "Submit your first valid report (ACTION_TAKEN)", + category: AchievementCategory.Report, + tier: AchievementTier.Bronze, + icon: "👀", + points: 50, + requirements: { count: 1 }, + }, + report_guardian: { + key: "report_guardian", + title: "Guardian", + description: "Have 5 reports result in ACTION_TAKEN", + category: AchievementCategory.Report, + tier: AchievementTier.Silver, + icon: "🛡️", + points: 100, + requirements: { count: 5 }, + }, + report_protector: { + key: "report_protector", + title: "Protector", + description: "Have 10 reports result in ACTION_TAKEN", + category: AchievementCategory.Report, + tier: AchievementTier.Gold, + icon: "⚔️", + points: 250, + requirements: { count: 10 }, + }, + report_vigilant: { + key: "report_vigilant", + title: "Vigilant Protector", + description: "Have 25 reports result in ACTION_TAKEN", + category: AchievementCategory.Report, + tier: AchievementTier.Platinum, + icon: "🔰", + points: 500, + requirements: { count: 25 }, + }, + report_accuracy_80: { + key: "report_accuracy_80", + title: "Sharp Eye", + description: "Maintain 80%+ ACTION_TAKEN rate (minimum 10 reports)", + category: AchievementCategory.Report, + tier: AchievementTier.Gold, + icon: "🎯", + points: 300, + requirements: { rate: 0.8, count: 10 }, + }, + report_accuracy_90: { + key: "report_accuracy_90", + title: "Eagle Eye", + description: "Maintain 90%+ ACTION_TAKEN rate (minimum 10 reports)", + category: AchievementCategory.Report, + tier: AchievementTier.Platinum, + icon: "🦅", + points: 500, + requirements: { rate: 0.9, count: 10 }, + }, + report_consistent: { + key: "report_consistent", + title: "Consistent Guardian", + description: "Submit at least one report weekly for 4 weeks", + category: AchievementCategory.Report, + tier: AchievementTier.Silver, + icon: "📆", + points: 200, + requirements: { count: 4 }, + }, + report_volume: { + key: "report_volume", + title: "Dedicated Reporter", + description: "Submit 50 reports (any status)", + category: AchievementCategory.Report, + tier: AchievementTier.Silver, + icon: "📝", + points: 150, + requirements: { count: 50 }, + }, +}; + +export const ACHIEVEMENT_LIST = Object.values(ACHIEVEMENTS); diff --git a/shared-types/src/lib/achievement.types.ts b/shared-types/src/lib/achievement.types.ts new file mode 100644 index 0000000..06968b7 --- /dev/null +++ b/shared-types/src/lib/achievement.types.ts @@ -0,0 +1,70 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +export enum AchievementCategory { + Suggestion = "SUGGESTION", + Like = "LIKE", + Comment = "COMMENT", + Engagement = "ENGAGEMENT", + Report = "REPORT", +} + +export enum AchievementTier { + Bronze = "BRONZE", + Silver = "SILVER", + Gold = "GOLD", + Platinum = "PLATINUM", + Diamond = "DIAMOND", +} + +export interface AchievementRequirements { + count?: number; + rate?: number; + streak?: number; + diversity?: boolean; + uniqueItems?: number; + dayRange?: number; +} + +export interface AchievementDefinition { + key: string; + title: string; + description: string; + category: AchievementCategory; + tier: AchievementTier; + icon: string; + points: number; + requirements: AchievementRequirements; +} + +export interface UserAchievement { + id: string; + userId: string; + achievementKey: string; + progress: number; + earned: boolean; + earnedAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface AchievementProgress { + definition: AchievementDefinition; + progress: number; + earned: boolean; + earnedAt?: Date; +} + +export interface UserAchievementSummary { + totalPoints: number; + totalEarned: number; + recentAchievements: AchievementProgress[]; + progressByCategory: { + category: AchievementCategory; + earned: number; + total: number; + }[]; +} diff --git a/shared-types/src/lib/audit.types.ts b/shared-types/src/lib/audit.types.ts index e0d708d..bc4547b 100644 --- a/shared-types/src/lib/audit.types.ts +++ b/shared-types/src/lib/audit.types.ts @@ -21,6 +21,7 @@ enum AuditAction { rateLimitExceeded = "RATE_LIMIT_EXCEEDED", csrfValidationFailed = "CSRF_VALIDATION_FAILED", unauthorizedAccess = "UNAUTHORIZED_ACCESS", + achievementUnlocked = "ACHIEVEMENT_UNLOCKED", } enum AuditCategory { -- 2.52.0 From 198fe240f7031c81f5386b22aeeeeb7fef2075b1 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Thu, 19 Feb 2026 22:17:08 -0800 Subject: [PATCH 17/17] test: add profilePublic field to User test fixtures Updates all User test objects in auth.types.spec.ts to include the profilePublic field that was added to the User interface. Co-Authored-By: Claude Sonnet 4.5 --- shared-types/test/auth.types.spec.ts | 88 +++++++++++++++------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/shared-types/test/auth.types.spec.ts b/shared-types/test/auth.types.spec.ts index 3354dae..0720be9 100644 --- a/shared-types/test/auth.types.spec.ts +++ b/shared-types/test/auth.types.spec.ts @@ -10,30 +10,32 @@ describe("auth Types", () => { describe("user interface", () => { it("should accept valid user objects", () => { const userWithAvatar: User = { - avatar: "https://example.com/avatar.png", - discordId: "discord123", - email: "test@example.com", - id: "user123", - inDiscord: true, - isAdmin: true, - isBanned: false, - isMod: true, - isStaff: true, - isVip: false, - username: "testuser", + avatar: "https://example.com/avatar.png", + discordId: "discord123", + email: "test@example.com", + id: "user123", + inDiscord: true, + isAdmin: true, + isBanned: false, + isMod: true, + isStaff: true, + isVip: false, + profilePublic: true, + username: "testuser", }; const userWithoutAvatar: User = { - discordId: "discord456", - email: "another@example.com", - id: "user456", - inDiscord: false, - isAdmin: false, - isBanned: false, - isMod: false, - isStaff: false, - isVip: true, - username: "anotheruser", + discordId: "discord456", + email: "another@example.com", + id: "user456", + inDiscord: false, + isAdmin: false, + isBanned: false, + isMod: false, + isStaff: false, + isVip: true, + profilePublic: false, + username: "anotheruser", }; expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png"); @@ -44,16 +46,17 @@ describe("auth Types", () => { it("should handle all boolean flags correctly", () => { const bannedUser: User = { - discordId: "discord789", - email: "banned@example.com", - id: "banned123", - inDiscord: false, - isAdmin: false, - isBanned: true, - isMod: false, - isStaff: false, - isVip: false, - username: "banneduser", + discordId: "discord789", + email: "banned@example.com", + id: "banned123", + inDiscord: false, + isAdmin: false, + isBanned: true, + isMod: false, + isStaff: false, + isVip: false, + profilePublic: false, + username: "banneduser", }; expect(bannedUser.isBanned).toBeTruthy(); @@ -97,17 +100,18 @@ describe("auth Types", () => { const authResponse: AuthResponse = { accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", user: { - avatar: "https://example.com/avatar.png", - discordId: "discord123", - email: "test@example.com", - id: "user123", - inDiscord: true, - isAdmin: false, - isBanned: false, - isMod: false, - isStaff: false, - isVip: false, - username: "testuser", + avatar: "https://example.com/avatar.png", + discordId: "discord123", + email: "test@example.com", + id: "user123", + inDiscord: true, + isAdmin: false, + isBanned: false, + isMod: false, + isStaff: false, + isVip: false, + profilePublic: true, + username: "testuser", }, }; -- 2.52.0