diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9f8043e..d7a77b8 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -176,46 +176,77 @@ enum MangaStatus { RETIRED } +enum PrimaryBadge { + STAFF + MOD + VIP + DISCORD +} + model User { id String @id @default(auto()) @map("_id") @db.ObjectId discordId String @unique username String email String @unique avatar String? + slug String? + displayName String? + bio String? + profilePublic Boolean @default(true) + primaryBadge PrimaryBadge? + website String? + discordServer String? + bluesky String? + github String? + linkedin String? + twitch String? + youtube String? isAdmin Boolean @default(false) isBanned Boolean @default(false) inDiscord Boolean @default(false) 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[] - suggestions Suggestion[] - likes Like[] - refreshTokens RefreshToken[] + comments Comment[] + suggestions Suggestion[] + likes Like[] + refreshTokens RefreshToken[] + reportsMade ProfileReport[] @relation("Reporter") + reportsReceived ProfileReport[] @relation("ReportedUser") + reportsReviewed ProfileReport[] @relation("Reviewer") + commentReportsMade CommentReport[] @relation("CommentReporter") + commentReportsReviewed CommentReport[] @relation("CommentReviewer") + userAchievements UserAchievement[] + + @@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 { @@ -249,6 +280,7 @@ enum AuditAction { RATE_LIMIT_EXCEEDED CSRF_VALIDATION_FAILED UNAUTHORIZED_ACCESS + ACHIEVEMENT_UNLOCKED } enum AuditCategory { @@ -316,3 +348,77 @@ model RefreshToken { @@index([userId]) @@index([expiresAt]) } + +enum ReportReason { + INAPPROPRIATE_CONTENT + HARASSMENT + SPAM + IMPERSONATION + OFFENSIVE_NAME + MALICIOUS_LINKS + OTHER +} + +enum ReportStatus { + PENDING + REVIEWED + DISMISSED + ACTION_TAKEN +} + +model ProfileReport { + id String @id @default(auto()) @map("_id") @db.ObjectId + reportedUserId String @db.ObjectId + reportedUser User @relation("ReportedUser", fields: [reportedUserId], references: [id]) + reporterId String @db.ObjectId + reporter User @relation("Reporter", fields: [reporterId], references: [id]) + reason ReportReason + details String + status ReportStatus @default(PENDING) + reviewedBy String? @db.ObjectId + reviewer User? @relation("Reviewer", fields: [reviewedBy], references: [id]) + reviewNotes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([reportedUserId]) + @@index([reporterId]) + @@index([status]) +} + +model CommentReport { + id String @id @default(auto()) @map("_id") @db.ObjectId + reportedCommentId String @db.ObjectId + reportedComment Comment @relation(fields: [reportedCommentId], references: [id], onDelete: Cascade) + 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]) +} + +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 new file mode 100644 index 0000000..06cef1d --- /dev/null +++ b/api/src/app/routes/comment-reports/index.ts @@ -0,0 +1,152 @@ +/** + * @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, 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) => { + 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, + ); + + // 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); + }, + ); +}; + +export default commentReportsRoutes; diff --git a/api/src/app/routes/comments/index.ts b/api/src/app/routes/comments/index.ts new file mode 100644 index 0000000..a0893a6 --- /dev/null +++ b/api/src/app/routes/comments/index.ts @@ -0,0 +1,72 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { FastifyPluginAsync } from "fastify"; +import { Comment, AuditAction, AuditCategory } from "@library/shared-types"; +import { CommentService } from "../../services/comment.service"; +import { AuditService } from "../../services/audit.service"; +import { adminGuard } from "../../middleware/admin-guard"; + +interface UpdateCommentBody { + content: string; +} + +const commentsRoutes: FastifyPluginAsync = async (app) => { + const commentService = new CommentService(); + + // Admin: Update any comment by ID + app.put<{ Params: { id: string }; Body: UpdateCommentBody; Reply: Comment | { error: string } }>( + "/:id", + { + preValidation: [app.authenticate, adminGuard], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + const { content } = request.body; + + const existingComment = await commentService.getCommentById(id); + if (!existingComment) { + return reply.code(404).send({ error: "Comment not found" }); + } + + const comment = await commentService.updateComment(id, content); + await AuditService.logFromRequest(request, { + action: AuditAction.commentUpdate, + category: AuditCategory.admin, + details: `Admin updated comment ${id}`, + }); + return comment; + } + ); + + // Admin: Delete any comment by ID + app.delete<{ Params: { id: string }; Reply: { success: boolean } | { error: string } }>( + "/:id", + { + preValidation: [app.authenticate, adminGuard], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + + const existingComment = await commentService.getCommentById(id); + if (!existingComment) { + return reply.code(404).send({ error: "Comment not found" }); + } + + await commentService.deleteComment(id); + await AuditService.logFromRequest(request, { + action: AuditAction.commentDelete, + category: AuditCategory.admin, + details: `Admin deleted comment ${id}`, + }); + return { success: true }; + } + ); +}; + +export default commentsRoutes; 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 9cdfc6f..104db17 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) { @@ -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 new file mode 100644 index 0000000..48f968a --- /dev/null +++ b/api/src/app/routes/reports/index.ts @@ -0,0 +1,151 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import type { FastifyPluginAsync } from "fastify"; +import type { + CreateReportDto, + ProfileReportWithUsers, + ReportStatus, + UpdateReportDto, +} from "@library/shared-types"; +import { ReportReason, 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) => { + const reportService = new ReportService(); + + // Create a new report (authenticated users) + fastify.post<{ + Body: CreateReportDto; + Reply: ProfileReportWithUsers | { error: string }; + }>( + "/", + { + preValidation: [fastify.authenticate], + schema: { + body: { + type: "object", + required: ["reportedUserId", "reason", "details"], + properties: { + reportedUserId: { type: "string" }, + reason: { + type: "string", + enum: Object.values(ReportReason), + }, + details: { type: "string", minLength: 10, maxLength: 1000 }, + }, + }, + }, + }, + async (request, reply) => { + try { + const report = await reportService.createReport( + request.user.id, + request.body, + ); + return reply.status(201).send(report); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("already have a pending report") + ) { + return reply.status(409).send({ error: error.message }); + } + throw error; + } + }, + ); + + // Get all reports (admin only) + fastify.get<{ + Querystring: { status?: ReportStatus }; + Reply: ProfileReportWithUsers[]; + }>( + "/", + { + preValidation: [fastify.authenticate, adminGuard], + schema: { + querystring: { + type: "object", + properties: { + status: { type: "string" }, + }, + }, + }, + }, + async (request, reply) => { + const reports = await reportService.getAllReports( + request.query.status, + ); + return reply.send(reports); + }, + ); + + // Get a single report by ID (admin only) + fastify.get<{ + Params: { id: string }; + Reply: ProfileReportWithUsers | { error: string }; + }>( + "/:id", + { + preValidation: [fastify.authenticate, adminGuard], + }, + async (request, reply) => { + const report = await reportService.getReportById(request.params.id); + + if (!report) { + return reply.status(404).send({ error: "Report not found" }); + } + + return reply.send(report); + }, + ); + + // Update a report (admin only) + fastify.put<{ + Params: { id: string }; + Body: UpdateReportDto; + Reply: ProfileReportWithUsers; + }>( + "/:id", + { + preValidation: [fastify.authenticate, adminGuard], + schema: { + body: { + type: "object", + required: ["status"], + properties: { + status: { type: "string" }, + reviewNotes: { type: "string", maxLength: 1000 }, + }, + }, + }, + }, + async (request, reply) => { + const report = await reportService.updateReport( + request.params.id, + request.user.id, + request.body, + ); + + // 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); + }, + ); +}; + +export default reportsRoutes; 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/routes/users/index.ts b/api/src/app/routes/users/index.ts index 710f765..fc94a67 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -5,11 +5,56 @@ */ 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"; +interface UpdateUserSettingsBody { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; + primaryBadge?: PrimaryBadge; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; +} + +interface UserProfileResponse { + id: string; + username: string; + displayName?: string; + avatar?: string; + bio?: string; + slug?: string; + primaryBadge?: PrimaryBadge; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: 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 +68,108 @@ 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, + primaryBadge: profile.primaryBadge, + website: profile.website, + discordServer: profile.discordServer, + bluesky: profile.bluesky, + github: profile.github, + linkedin: profile.linkedin, + twitch: profile.twitch, + youtube: profile.youtube, + 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", { @@ -87,6 +234,64 @@ const usersRoutes: FastifyPluginAsync = async (app) => { return user; } ); + + app.post<{ Params: { id: string }; Reply: User | { error: string } }>( + "/:id/make-private", + { + preValidation: [app.authenticate, adminGuard], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + const user = await userService.updateUserSettings(id, { profilePublic: false }); + if (!user) { + return reply.code(404).send({ error: "User not found" }); + } + + await AuditService.logFromRequest(request, { + action: AuditAction.entryUpdate, + category: AuditCategory.admin, + targetUserId: id, + details: `Admin made profile private for user: ${user.username}`, + }); + + return user; + } + ); + + app.put<{ Params: { id: string }; Body: UpdateUserSettingsBody; Reply: User | { error: string } }>( + "/:id", + { + preValidation: [app.authenticate, adminGuard], + preHandler: [app.csrfProtection], + }, + async (request, reply) => { + const { id } = request.params; + 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 !== id) { + return reply.code(400).send({ error: "Slug already taken" }); + } + } + + const updatedUser = await userService.updateUserSettings(id, updates); + if (!updatedUser) { + return reply.code(404).send({ error: "User not found" }); + } + + await AuditService.logFromRequest(request, { + action: AuditAction.entryUpdate, + category: AuditCategory.admin, + targetUserId: id, + details: `Admin updated profile for user: ${updatedUser.username}`, + }); + + return updatedUser; + } + ); }; export default usersRoutes; 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/auth.service.ts b/api/src/app/services/auth.service.ts index 010473d..f1ca503 100644 --- a/api/src/app/services/auth.service.ts +++ b/api/src/app/services/auth.service.ts @@ -71,6 +71,15 @@ 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, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -167,6 +176,15 @@ 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, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, @@ -217,6 +235,15 @@ 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, + website: dbUser.website || undefined, + discordServer: dbUser.discordServer || undefined, + bluesky: dbUser.bluesky || undefined, + github: dbUser.github || undefined, + linkedin: dbUser.linkedin || undefined, isAdmin: dbUser.isAdmin, isBanned: dbUser.isBanned, inDiscord: dbUser.inDiscord, 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..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"; @@ -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, @@ -60,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, @@ -71,6 +77,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 +86,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 +123,7 @@ export class CommentService { userId, gameId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -134,7 +141,7 @@ export class CommentService { userId, bookId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -152,7 +159,7 @@ export class CommentService { userId, musicId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -160,10 +167,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 +186,7 @@ export class CommentService { userId, artId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -187,10 +194,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 +213,7 @@ export class CommentService { userId, showId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -214,10 +221,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 +240,7 @@ export class CommentService { userId, mangaId, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } @@ -256,7 +263,7 @@ export class CommentService { content: sanitizedContent, rawContent: content, }, - include: { user: true }, + include: { user: true, reports: true }, }); return this.mapComment(comment); } 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/report.service.ts b/api/src/app/services/report.service.ts new file mode 100644 index 0000000..775518b --- /dev/null +++ b/api/src/app/services/report.service.ts @@ -0,0 +1,312 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { + ReportStatus as PrismaReportStatus, + ReportReason as PrismaReportReason, +} from "@prisma/client"; +import type { + CreateReportDto, + ProfileReportWithUsers, + ReportStatus, + UpdateReportDto, +} from "@library/shared-types"; +import { ReportReason } from "@library/shared-types"; +import { prisma } from "../lib/prisma.js"; + +export class ReportService { + private prisma = prisma; + + /** + * Convert Prisma ReportReason to shared-types ReportReason + */ + private toPrismaReportReason(reason: ReportReason): PrismaReportReason { + return reason as unknown as PrismaReportReason; + } + + /** + * Convert Prisma ReportStatus to shared-types ReportStatus + */ + private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus { + return status as unknown as PrismaReportStatus; + } + + /** + * Convert Prisma enum back to shared-types enum + */ + private fromPrismaReportReason(reason: PrismaReportReason): ReportReason { + return reason as unknown as ReportReason; + } + + /** + * Convert Prisma enum back to shared-types enum + */ + private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus { + return status as unknown as ReportStatus; + } + + /** + * Create a new profile report. + * + * @param reporterId - The ID of the user making the report + * @param createDto - The report details + * @returns The created report + * @throws Error if user already has a pending report for this profile + */ + async createReport( + reporterId: string, + createDto: CreateReportDto, + ): Promise { + // Check if user already has a pending report for this profile + const existingReport = await this.prisma.profileReport.findFirst({ + where: { + reporterId, + reportedUserId: createDto.reportedUserId, + status: PrismaReportStatus.PENDING, + }, + }); + + if (existingReport) { + throw new Error( + "You already have a pending report for this profile. Please wait for it to be reviewed.", + ); + } + + // Check if user has reached the limit of pending reports (5 max) + const pendingReportsCount = await this.prisma.profileReport.count({ + where: { + reporterId, + status: PrismaReportStatus.PENDING, + }, + }); + + if (pendingReportsCount >= 5) { + throw new Error( + "You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.", + ); + } + + const report = await this.prisma.profileReport.create({ + data: { + reporterId, + reportedUserId: createDto.reportedUserId, + reason: this.toPrismaReportReason(createDto.reason), + details: createDto.details, + }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + }, + }); + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + }; + } + + /** + * Get all reports (admin only). Optionally filter by status. + * + * @param status - Optional status filter + * @returns All reports matching the filter + */ + async getAllReports( + status?: ReportStatus, + ): Promise { + const reports = await this.prisma.profileReport.findMany({ + where: status ? { status: this.toPrismaReportStatus(status) } : undefined, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return reports.map((report) => ({ + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + })); + } + + /** + * Get a single report by ID (admin only). + * + * @param id - The report ID + * @returns The report or null + */ + async getReportById(id: string): Promise { + const report = await this.prisma.profileReport.findUnique({ + where: { id }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); + + if (!report) { + return null; + } + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } + + /** + * Update a report's status and review notes (admin only). + * + * @param id - The report ID + * @param reviewerId - The ID of the admin reviewing the report + * @param updateDto - The update details + * @returns The updated report + */ + async updateReport( + id: string, + reviewerId: string, + updateDto: UpdateReportDto, + ): Promise { + const report = await this.prisma.profileReport.update({ + where: { id }, + data: { + status: this.toPrismaReportStatus(updateDto.status), + reviewNotes: updateDto.reviewNotes, + reviewedBy: reviewerId, + }, + include: { + reportedUser: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reporter: { + select: { + id: true, + username: true, + displayName: true, + avatar: true, + }, + }, + reviewer: { + select: { + id: true, + username: true, + displayName: true, + }, + }, + }, + }); + + return { + id: report.id, + reportedUserId: report.reportedUserId, + reporterId: report.reporterId, + reason: this.fromPrismaReportReason(report.reason), + details: report.details, + status: this.fromPrismaReportStatus(report.status), + reviewedBy: report.reviewedBy ?? undefined, + reviewNotes: report.reviewNotes ?? undefined, + createdAt: report.createdAt, + updatedAt: report.updatedAt, + reportedUser: report.reportedUser, + reporter: report.reporter, + reviewer: report.reviewer ?? undefined, + }; + } +} diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 6d825aa..ece6fcf 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -4,8 +4,9 @@ * @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"; export class UserService { private prisma = prisma; @@ -21,6 +22,18 @@ 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -45,6 +58,18 @@ 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -66,6 +91,18 @@ 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -87,6 +124,18 @@ 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, isAdmin: user.isAdmin, isBanned: user.isBanned, inDiscord: user.inDiscord, @@ -104,4 +153,179 @@ 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, + 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; + primaryBadge?: PrimaryBadge; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; + } + ): 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, + primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined, + website: user.website || undefined, + discordServer: user.discordServer || undefined, + bluesky: user.bluesky || undefined, + github: user.github || undefined, + linkedin: user.linkedin || undefined, + twitch: user.twitch || undefined, + youtube: user.youtube || undefined, + 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; + primaryBadge?: PrimaryBadge | null; + website?: string | null; + discordServer?: string | null; + bluesky?: string | null; + github?: string | null; + linkedin?: string | null; + twitch?: string | null; + youtube?: string | null; + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + profilePublic: boolean; + createdAt: Date; + achievementPoints: number; + 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, + primaryBadge: user.primaryBadge as PrimaryBadge, + website: user.website, + discordServer: user.discordServer, + bluesky: user.bluesky, + github: user.github, + linkedin: user.linkedin, + twitch: user.twitch, + youtube: user.youtube, + isStaff: user.isStaff, + isMod: user.isMod, + isVip: user.isVip, + inDiscord: user.inDiscord, + profilePublic: user.profilePublic, + createdAt: user.createdAt, + achievementPoints: user.achievementPoints, + 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-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/app.routes.ts b/apps/frontend/src/app/app.routes.ts index 9e77a19..139b7ad 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -41,6 +41,10 @@ export const appRoutes: Route[] = [ path: 'admin/suggestions', loadComponent: () => import('./components/admin/admin-suggestions.component').then(m => m.AdminSuggestionsComponent) }, + { + path: 'admin/reports', + loadComponent: () => import('./components/admin-reports/admin-reports.component').then(m => m.AdminReportsComponent) + }, { path: 'my-suggestions', loadComponent: () => import('./components/my-suggestions/my-suggestions.component').then(m => m.MySuggestionsComponent) @@ -49,6 +53,18 @@ 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: '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/admin-reports/admin-reports.component.ts b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts new file mode 100644 index 0000000..fa840b4 --- /dev/null +++ b/apps/frontend/src/app/components/admin-reports/admin-reports.component.ts @@ -0,0 +1,1883 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { ReportService } from '../../services/report.service'; +import { CommentReportService } from '../../services/comment-report.service'; +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, PrimaryBadge } from '@library/shared-types'; + +@Component({ + selector: 'app-admin-reports', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+
+

Reports

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

Loading reports...

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

{{ report.details }}

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

{{ report.reviewNotes }}

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

No profile reports found.

+
+ } + } @else { + @for (report of filteredCommentReports(); track report.id) { +
+ +
+ + +
+ + {{ formatStatus(report.status) }} + + + {{ formatDate(report.createdAt) }} + +
+
+ + +
+
+ Comment: +

{{ truncateComment(report.reportedComment.content) }}

+
+
+ Reason: {{ formatReason(report.reason) }} +
+
+ Details: +

{{ report.details }}

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

{{ report.reviewNotes }}

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

No comment reports found.

+
+ } + } +
+ } +
+ + + @if (reviewingProfileReport()) { + + } + + + @if (reviewingCommentReport()) { + + } + + + @if (editingProfile()) { + + } +
+ `, + styles: [` + .admin-reports-wrapper { + position: relative; + } + + .admin-reports-container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + } + + .header-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + } + + h1 { + color: var(--witch-purple); + margin: 0; + font-size: 2rem; + } + + .report-type-toggle { + display: flex; + gap: 0.5rem; + background: var(--witch-lavender); + padding: 0.25rem; + border-radius: 12px; + } + + .toggle-btn { + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + border-radius: 8px; + color: var(--witch-mauve); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + } + + .toggle-btn:hover { + color: var(--witch-purple); + } + + .toggle-btn.active { + background: var(--witch-purple); + color: var(--witch-moon); + font-weight: 600; + } + + .toggle-btn:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + .loading, .error, .empty-state { + text-align: center; + padding: 3rem 2rem; + color: var(--witch-mauve); + font-size: 1.1rem; + } + + .error { + color: var(--witch-rose); + } + + /* Filter Tabs */ + .filter-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 2rem; + flex-wrap: wrap; + border-bottom: 2px solid var(--witch-lavender); + padding-bottom: 0.5rem; + } + + .tab { + padding: 0.75rem 1.5rem; + background: transparent; + border: none; + border-radius: 8px 8px 0 0; + color: var(--witch-mauve); + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + position: relative; + } + + .tab:hover { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .tab.active { + background: var(--witch-purple); + color: var(--witch-moon); + font-weight: 600; + } + + .tab:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + /* Reports List */ + .reports-list { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .report-card { + background: var(--witch-moon); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 12px var(--witch-shadow); + transition: all 0.3s; + } + + .report-card:hover { + box-shadow: 0 6px 16px var(--witch-shadow); + transform: translateY(-2px); + } + + /* Report Header */ + .report-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--witch-lavender); + flex-wrap: wrap; + gap: 1rem; + } + + .user-info { + display: flex; + gap: 2rem; + flex-wrap: wrap; + flex: 1; + } + + .user-section h3 { + font-size: 0.85rem; + color: var(--witch-mauve); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .user-details { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid var(--witch-lavender); + } + + .avatar-placeholder { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--witch-purple); + color: var(--witch-moon); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1rem; + border: 2px solid var(--witch-lavender); + } + + .user-names { + display: flex; + flex-direction: column; + gap: 0.15rem; + } + + .username { + font-weight: 600; + color: var(--witch-purple); + font-size: 0.95rem; + } + + .display-name { + font-size: 0.85rem; + color: var(--witch-mauve); + } + + .report-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + } + + .status-badge { + padding: 0.4rem 0.9rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .status-badge.pending { + background: #ffd93d; + color: #1a1a1a; + } + + .status-badge.reviewed { + background: #6ab7ff; + color: #1a1a1a; + } + + .status-badge.dismissed { + background: #b0b0b0; + color: #1a1a1a; + } + + .status-badge.action-taken { + background: #6bcf7f; + color: #1a1a1a; + } + + .report-date { + font-size: 0.85rem; + color: var(--witch-mauve); + } + + /* Report Content */ + .report-content { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.5rem; + } + + .reason, .details, .review-notes { + color: var(--witch-purple); + } + + .reason strong, .details strong, .review-notes strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + .details p, .review-notes p { + color: var(--witch-mauve); + line-height: 1.6; + margin: 0; + } + + .reviewer { + display: block; + margin-top: 0.5rem; + color: var(--witch-mauve); + font-style: italic; + } + + .comment-content-preview { + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + margin-bottom: 1rem; + } + + .comment-content-preview strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + .comment-text { + color: var(--witch-mauve); + line-height: 1.6; + margin: 0; + word-wrap: break-word; + } + + .comment-full-content { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + } + + .comment-full-content strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + /* Report Actions */ + .report-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + } + + .btn { + padding: 0.65rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + } + + .btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 8px var(--witch-shadow); + } + + .btn:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-review { + background: var(--witch-purple); + color: var(--witch-moon); + } + + .btn-review:hover { + background: var(--witch-mauve); + } + + /* Modal */ + .modal-overlay { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: 100vh !important; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999 !important; + padding: 1rem; + margin: 0 !important; + transform: none !important; + } + + .modal-content { + background: var(--witch-moon); + border-radius: 16px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 8px 32px var(--witch-shadow); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid var(--witch-lavender); + } + + .modal-header h2 { + color: var(--witch-purple); + margin: 0; + font-size: 1.5rem; + } + + .modal-close { + background: none; + border: none; + font-size: 2rem; + color: var(--witch-mauve); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.3s; + } + + .modal-close:hover { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .modal-close:focus { + outline: 2px solid var(--witch-purple); + outline-offset: 2px; + } + + .modal-body { + padding: 1.5rem; + } + + /* Review Summary */ + .review-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + } + + .summary-item { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .summary-item strong { + color: var(--witch-purple); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .summary-item span { + color: var(--witch-mauve); + font-size: 0.95rem; + } + + .details-section { + margin-bottom: 1.5rem; + } + + .details-section strong { + display: block; + margin-bottom: 0.5rem; + color: var(--witch-purple); + font-weight: 600; + } + + .details-text { + color: var(--witch-mauve); + line-height: 1.6; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + margin: 0; + } + + /* Admin Actions */ + .admin-actions { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--witch-lavender); + border-radius: 8px; + } + + .admin-actions strong { + display: block; + margin-bottom: 0.75rem; + color: var(--witch-purple); + font-weight: 600; + } + + .action-buttons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + } + + .btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; + } + + .btn-warning { + background: #f59e0b; + color: white; + } + + .btn-warning:hover:not(:disabled) { + background: #d97706; + } + + .btn-danger { + background: #ef4444; + color: white; + } + + .btn-danger:hover:not(:disabled) { + background: #dc2626; + } + + /* Review Form */ + .review-form { + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .form-group label { + color: var(--witch-purple); + font-weight: 600; + font-size: 0.95rem; + } + + .form-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); + border-radius: 8px; + background: var(--witch-moon); + color: var(--witch-purple); + font-size: 1rem; + font-family: inherit; + transition: all 0.3s; + } + + .form-control:focus { + outline: none; + border-color: var(--witch-purple); + box-shadow: 0 0 0 3px rgba(155, 89, 182, 0.2); + } + + select.form-control { + cursor: pointer; + } + + select.form-control option { + background: var(--witch-moon); + color: var(--witch-purple); + } + + textarea.form-control { + resize: vertical; + min-height: 100px; + } + + .modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--witch-lavender); + } + + .btn-secondary { + background: var(--witch-lavender); + color: var(--witch-purple); + } + + .btn-secondary:hover:not(:disabled) { + background: var(--witch-mauve); + color: var(--witch-moon); + } + + .btn-primary { + background: var(--witch-purple); + color: var(--witch-moon); + } + + .btn-primary:hover:not(:disabled) { + background: var(--witch-mauve); + } + + /* Responsive Design */ + @media (max-width: 768px) { + .admin-reports-container { + padding: 1rem; + } + + h1 { + font-size: 1.5rem; + } + + .filter-tabs { + flex-direction: column; + } + + .tab { + text-align: left; + } + + .report-header { + flex-direction: column; + } + + .user-info { + flex-direction: column; + gap: 1rem; + } + + .report-meta { + align-items: flex-start; + } + + .review-summary { + grid-template-columns: 1fr; + } + + .modal-actions { + flex-direction: column-reverse; + } + + .btn { + width: 100%; + } + } + `] +}) +export class AdminReportsComponent implements OnInit { + private reportService = inject(ReportService); + private commentReportService = inject(CommentReportService); + private commentsService = inject(CommentsService); + private userService = inject(UserService); + private authService = inject(AuthService); + private toastService = inject(ToastService); + private router = inject(Router); + + // Make ReportStatus and PrimaryBadge accessible in template + protected readonly ReportStatus = ReportStatus; + protected readonly PrimaryBadge = PrimaryBadge; + + reportType = signal<'profile' | 'comment'>('profile'); + allProfileReports = signal([]); + allCommentReports = signal([]); + loading = signal(true); + error = signal(null); + activeFilter = signal<'all' | ReportStatus>('all'); + + reviewingProfileReport = signal(null); + reviewingCommentReport = signal(null); + editingProfile = signal<{ userId: string; username: string; profile: any } | null>(null); + submitting = signal(false); + + reviewForm = { + status: ReportStatus.PENDING, + reviewNotes: '' + }; + + profileEditForm = { + displayName: '', + slug: '', + bio: '', + profilePublic: true, + primaryBadge: undefined as PrimaryBadge | undefined, + website: '', + discordServer: '', + bluesky: '', + github: '', + linkedin: '', + twitch: '', + youtube: '' + }; + + filteredProfileReports = computed(() => { + const filter = this.activeFilter(); + if (filter === 'all') { + return this.allProfileReports(); + } + return this.allProfileReports().filter(report => report.status === filter); + }); + + filteredCommentReports = computed(() => { + const filter = this.activeFilter(); + if (filter === 'all') { + return this.allCommentReports(); + } + return this.allCommentReports().filter(report => report.status === filter); + }); + + pendingCount = computed(() => { + if (this.reportType() === 'profile') { + return this.allProfileReports().filter(r => r.status === ReportStatus.PENDING).length; + } + return this.allCommentReports().filter(r => r.status === ReportStatus.PENDING).length; + }); + + reviewedCount = computed(() => { + if (this.reportType() === 'profile') { + return this.allProfileReports().filter(r => r.status === ReportStatus.REVIEWED).length; + } + return this.allCommentReports().filter(r => r.status === ReportStatus.REVIEWED).length; + }); + + dismissedCount = computed(() => { + if (this.reportType() === 'profile') { + return this.allProfileReports().filter(r => r.status === ReportStatus.DISMISSED).length; + } + return this.allCommentReports().filter(r => r.status === ReportStatus.DISMISSED).length; + }); + + actionTakenCount = computed(() => { + if (this.reportType() === 'profile') { + return this.allProfileReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length; + } + return this.allCommentReports().filter(r => r.status === ReportStatus.ACTION_TAKEN).length; + }); + + ngOnInit(): void { + if (!this.authService.isAdmin()) { + this.router.navigate(['/']); + return; + } + + this.loadReports(); + } + + private loadReports(): void { + this.loading.set(true); + this.error.set(null); + + if (this.reportType() === 'profile') { + this.reportService.getAllReports().subscribe({ + next: (reports) => { + this.allProfileReports.set(reports); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to load profile reports'); + this.loading.set(false); + this.toastService.error('Failed to load profile reports'); + } + }); + } else { + this.commentReportService.getAllReports().subscribe({ + next: (reports) => { + this.allCommentReports.set(reports); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message ?? 'Failed to load comment reports'); + this.loading.set(false); + this.toastService.error('Failed to load comment reports'); + } + }); + } + } + + setReportType(type: 'profile' | 'comment'): void { + this.reportType.set(type); + this.activeFilter.set('all'); + this.loadReports(); + } + + setFilter(filter: 'all' | ReportStatus): void { + this.activeFilter.set(filter); + } + + openProfileReviewModal(report: ProfileReportWithUsers): void { + this.reviewingProfileReport.set(report); + this.reviewForm = { + status: report.status, + reviewNotes: report.reviewNotes || '' + }; + } + + closeProfileReviewModal(): void { + if (!this.submitting()) { + this.reviewingProfileReport.set(null); + this.reviewForm = { + status: ReportStatus.PENDING, + reviewNotes: '' + }; + } + } + + submitProfileReview(): void { + const report = this.reviewingProfileReport(); + if (!report) { + return; + } + + this.submitting.set(true); + + this.reportService.updateReport( + report.id, + this.reviewForm.status, + this.reviewForm.reviewNotes || undefined + ).subscribe({ + next: (updatedReport) => { + this.allProfileReports.update(reports => + reports.map(r => r.id === updatedReport.id ? updatedReport : r) + ); + this.toastService.success('Profile report updated successfully'); + this.submitting.set(false); + this.closeProfileReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update profile report'); + this.submitting.set(false); + } + }); + } + + openCommentReviewModal(report: CommentReportWithDetails): void { + this.reviewingCommentReport.set(report); + this.reviewForm = { + status: report.status, + reviewNotes: report.reviewNotes || '' + }; + } + + closeCommentReviewModal(): void { + if (!this.submitting()) { + this.reviewingCommentReport.set(null); + this.reviewForm = { + status: ReportStatus.PENDING, + reviewNotes: '' + }; + } + } + + submitCommentReview(): void { + const report = this.reviewingCommentReport(); + if (!report) { + return; + } + + this.submitting.set(true); + + this.commentReportService.updateReport(report.id, { + status: this.reviewForm.status, + reviewNotes: this.reviewForm.reviewNotes || undefined + }).subscribe({ + next: (updatedReport) => { + this.allCommentReports.update(reports => + reports.map(r => r.id === updatedReport.id ? updatedReport : r) + ); + this.toastService.success('Comment report updated successfully'); + this.submitting.set(false); + this.closeCommentReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update comment report'); + this.submitting.set(false); + } + }); + } + + truncateComment(content: string, maxLength = 200): string { + const stripped = content.replace(/<[^>]*>/g, ''); + if (stripped.length <= maxLength) { + return stripped; + } + return stripped.substring(0, maxLength) + '...'; + } + + formatStatus(status: ReportStatus): string { + const statusMap: Record = { + [ReportStatus.PENDING]: 'Pending', + [ReportStatus.REVIEWED]: 'Reviewed', + [ReportStatus.DISMISSED]: 'Dismissed', + [ReportStatus.ACTION_TAKEN]: 'Action Taken' + }; + return statusMap[status]; + } + + formatReason(reason: ReportReason): string { + const reasonMap: Record = { + [ReportReason.INAPPROPRIATE_CONTENT]: 'Inappropriate Content', + [ReportReason.HARASSMENT]: 'Harassment', + [ReportReason.SPAM]: 'Spam', + [ReportReason.IMPERSONATION]: 'Impersonation', + [ReportReason.OFFENSIVE_NAME]: 'Offensive Name', + [ReportReason.MALICIOUS_LINKS]: 'Malicious Links', + [ReportReason.OTHER]: 'Other' + }; + return reasonMap[reason]; + } + + formatDate(date: Date): string { + return new Date(date).toLocaleString('en-GB', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + + editComment(report: CommentReportWithDetails): void { + const commentId = report.reportedComment.id; + const currentContent = report.reportedComment.rawContent || report.reportedComment.content; + const newContent = prompt('Edit comment content:', currentContent); + + if (newContent !== null && newContent.trim() !== '') { + this.commentsService.adminUpdateComment(commentId, newContent).subscribe({ + next: () => { + this.toastService.success('Comment updated successfully'); + this.loadReports(); + this.closeCommentReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update comment'); + } + }); + } + } + + deleteComment(report: CommentReportWithDetails): void { + const commentId = report.reportedComment.id; + const commentAuthor = report.reportedComment.user.username; + const confirmed = confirm(`Are you sure you want to delete this comment by ${commentAuthor}?`); + + if (confirmed) { + this.commentsService.adminDeleteComment(commentId).subscribe({ + next: () => { + this.toastService.success('Comment deleted successfully'); + this.loadReports(); + this.closeCommentReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to delete comment'); + } + }); + } + } + + editProfile(report: ProfileReportWithUsers): void { + const userId = report.reportedUser.id; + const username = report.reportedUser.username; + + // Load the full profile first to get current values + this.userService.getProfile(userId).subscribe({ + next: (profile) => { + // 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 + primaryBadge: profile.primaryBadge || undefined, + website: profile.website || '', + discordServer: profile.discordServer || '', + bluesky: profile.bluesky || '', + github: profile.github || '', + linkedin: profile.linkedin || '', + twitch: profile.twitch || '', + youtube: profile.youtube || '' + }; + + // Open the edit modal + this.editingProfile.set({ userId, username, profile }); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to load profile'); + } + }); + } + + closeProfileEditModal(): void { + this.editingProfile.set(null); + this.profileEditForm = { + displayName: '', + slug: '', + bio: '', + profilePublic: true, + primaryBadge: undefined as PrimaryBadge | undefined, + 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; + const confirmed = confirm(`Are you sure you want to make ${username}'s profile private?`); + + if (confirmed) { + this.userService.makeProfilePrivate(userId).subscribe({ + next: () => { + this.toastService.success(`${username}'s profile has been made private`); + this.loadReports(); + this.closeProfileReviewModal(); + }, + error: (err) => { + this.toastService.error(err.message ?? 'Failed to update profile privacy'); + } + }); + } + } +} 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 3ce88d0..cb015c9 100644 --- a/apps/frontend/src/app/components/art/art-gallery.component.ts +++ b/apps/frontend/src/app/components/art/art-gallery.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 { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from '@library/shared-types'; @Component({ selector: 'app-art-gallery', standalone: true, - imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent], + imports: [CommonModule, FormsModule, PaginationComponent, LikeButtonComponent, CommentDisplayComponent], template: `
@@ -468,56 +469,11 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from } } - @if (commentsLoading()[art.id]) { -
Loading comments...
- } @else { - @for (comment of comments()[art.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!
- } - } +
}
@@ -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..bcdbb50 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/comment-display/comment-display.component.ts b/apps/frontend/src/app/components/comment-display/comment-display.component.ts new file mode 100644 index 0000000..781ee5a --- /dev/null +++ b/apps/frontend/src/app/components/comment-display/comment-display.component.ts @@ -0,0 +1,317 @@ +/** + * @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 { 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'; + +@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.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 + } + } + {{ 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); + + // 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(); + + 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.edit.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..430c4f9 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/header/header.component.ts b/apps/frontend/src/app/components/header/header.component.ts index 6801e87..97db707 100644 --- a/apps/frontend/src/app/components/header/header.component.ts +++ b/apps/frontend/src/app/components/header/header.component.ts @@ -50,6 +50,9 @@ import { ApiService } from '../../services/api.service'; } @if (showDropdown()) { 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..a6eafe3 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..4ccad38 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/profile/profile.component.ts b/apps/frontend/src/app/components/profile/profile.component.ts new file mode 100644 index 0000000..d3251ba --- /dev/null +++ b/apps/frontend/src/app/components/profile/profile.component.ts @@ -0,0 +1,667 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, inject, signal, OnInit } from '@angular/core'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons'; +import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons'; +import { UserService, UserProfileResponse } from '../../services/user.service'; +import { AchievementService } from '../../services/achievement.service'; +import { ToastService } from '../../services/toast.service'; +import { AuthService } from '../../services/auth.service'; +import { ReportModalComponent } from '../report-modal/report-modal.component'; +import { PrimaryBadge, AchievementProgress } from '@library/shared-types'; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [CommonModule, FontAwesomeModule, ReportModalComponent, RouterModule], + template: ` +
+ @if (loading()) { +
Loading profile...
+ } @else if (error()) { +
{{ error() }}
+ } @else if (profile()) { +
+
+ @if (profile()?.avatar) { + + } @else { +
+ {{ profile()!.username[0]?.toUpperCase() }} +
+ } +
+

{{ profile()!.displayName || profile()!.username }}

+ @if (profile()!.displayName) { +

\@{{ profile()!.username }}

+ } + @if (profile()!.slug) { +

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

+ } +
+ @if (showReportButton()) { + + } +
+ + @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 + } +
+ } + + @if (profile()!.bio) { +
+

{{ profile()!.bio }}

+
+ } + + @if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) { + + } + +
+

Activity Statistics

+
+
+ {{ profile()!.stats.suggestionsCount }} + Suggestions +
+
+ {{ profile()!.stats.suggestionsAcceptedCount }} + Accepted +
+
+ {{ profile()!.stats.likesCount }} + Likes +
+
+ {{ profile()!.stats.commentsCount }} + Comments +
+ @if (profile()!.achievementPoints > 0) { +
+ {{ profile()!.achievementPoints }} + 🏆 Achievement Points +
+ } +
+
+ + @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
+
+
+ } +
+
+ } + + +
+ } + + @if (reportModalOpen()) { + + } +
+ `, + styles: [` + .profile-container { + max-width: 800px; + margin: 2rem auto; + padding: 0 1rem; + } + + .loading, .error { + text-align: center; + padding: 2rem; + font-size: 1.2rem; + } + + .error { + color: var(--error-colour, #c41e3a); + } + + .profile-card { + background: var(--card-background, #1a1a2e); + border-radius: 12px; + padding: 2rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + } + + .profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 1.5rem; + position: relative; + } + + .profile-avatar { + width: 100px; + height: 100px; + border-radius: 50%; + border: 3px solid var(--accent-colour, #9b59b6); + } + + .profile-avatar-placeholder { + width: 100px; + height: 100px; + border-radius: 50%; + background: var(--accent-colour, #9b59b6); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + font-weight: bold; + color: white; + } + + .profile-info { + flex: 1; + } + + .profile-username { + margin: 0; + font-size: 2rem; + color: var(--text-colour, #e0e0e0); + } + + .profile-handle { + margin: 0.25rem 0; + color: var(--text-muted, #a0a0a0); + font-size: 1.1rem; + } + + .profile-slug { + margin: 0.25rem 0; + color: var(--text-muted, #a0a0a0); + 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; + gap: 0.5rem; + margin-bottom: 1.5rem; + } + + .badge { + padding: 0.4rem 0.8rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; + } + + .badge-staff { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .badge-mod { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + color: white; + } + + .badge-vip { + background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%); + color: white; + } + + .badge-member { + background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); + color: #333; + } + + .bio-section { + margin-bottom: 1.5rem; + padding: 1rem; + background: rgba(155, 89, 182, 0.1); + border-radius: 8px; + } + + .bio-text { + margin: 0; + color: var(--text-colour, #e0e0e0); + line-height: 1.6; + white-space: pre-wrap; + } + + .social-links-section { + margin-bottom: 1.5rem; + } + + .social-links-section h2 { + margin: 0 0 1rem 0; + color: var(--accent-colour, #9b59b6); + font-size: 1.5rem; + } + + .social-links { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + } + + .social-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: rgba(155, 89, 182, 0.2); + border: 1px solid rgba(155, 89, 182, 0.3); + border-radius: 8px; + color: var(--text-colour, #e0e0e0); + text-decoration: none; + transition: all 0.2s ease; + } + + .social-link:hover { + background: rgba(155, 89, 182, 0.3); + border-color: var(--accent-colour, #9b59b6); + transform: translateY(-2px); + } + + .social-link fa-icon { + font-size: 1.3rem; + width: 1.5rem; + } + + .social-link fa-icon ::ng-deep svg { + width: 1.3rem; + height: 1.3rem; + } + + .social-link .label { + font-weight: 500; + } + + .stats-section { + margin-bottom: 1.5rem; + } + + .stats-section h2, .achievements-section h2 { + margin: 0 0 1rem 0; + color: var(--accent-colour, #9b59b6); + font-size: 1.5rem; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + } + + .stat-card { + text-align: center; + padding: 1rem; + background: rgba(155, 89, 182, 0.2); + border-radius: 8px; + border: 1px solid rgba(155, 89, 182, 0.3); + } + + .stat-value { + display: block; + font-size: 2rem; + font-weight: bold; + color: var(--accent-colour, #9b59b6); + } + + .stat-label { + display: block; + font-size: 0.9rem; + color: var(--text-muted, #a0a0a0); + margin-top: 0.25rem; + } + + .footer-section { + text-align: center; + padding-top: 1rem; + border-top: 1px solid rgba(155, 89, 182, 0.3); + } + + .member-since { + margin: 0; + 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); + + // Expose PrimaryBadge enum for template + readonly PrimaryBadge = PrimaryBadge; + + // Font Awesome icons + faGlobe = faGlobe; + faGithub = faGithub; + faCloud = faCloud; + faLinkedin = faLinkedin; + faTwitch = faTwitch; + faYoutube = faYoutube; + faDiscord = faDiscord; + faFlag = faFlag; + + ngOnInit(): void { + const identifier = this.route.snapshot.paramMap.get('identifier'); + if (!identifier) { + this.error.set('No user identifier provided'); + this.loading.set(false); + return; + } + + this.userService.getProfile(identifier).subscribe({ + 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); + this.error.set('Failed to load profile'); + this.loading.set(false); + this.toastService.error('Failed to load profile'); + } + }); + } + + formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + 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; + } + + /** + * 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. + */ + 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..c1fd79f --- /dev/null +++ b/apps/frontend/src/app/components/report-modal/report-modal.component.ts @@ -0,0 +1,398 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +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'; + +@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 commentReportService = inject(CommentReportService); + private toastService = inject(ToastService); + + // Inputs + reportType = input.required<'profile' | 'comment'>(); + targetId = input.required(); // userId for profile, commentId for comment + reportedUsername = input(''); // Only used for profile reports + + // Outputs + closeModal = output(); + + // State + selectedReason = signal(''); + details = signal(''); + submitting = signal(false); + + // Computed values + modalTitle = computed(() => { + return this.reportType() === 'profile' ? 'Report Profile' : 'Report Comment'; + }); + + reportInfo = computed(() => { + if (this.reportType() === 'profile') { + return `You are reporting ${this.reportedUsername()}. Please provide a reason and details for this report.`; + } else { + return 'You are reporting this comment. Please provide a reason and details for this report.'; + } + }); + + // 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); + + if (this.reportType() === 'profile') { + this.reportService.createReport( + this.targetId(), + this.selectedReason(), + this.details() + ).subscribe(this.getSubscribeHandlers()); + } else { + this.commentReportService.createReport({ + reportedCommentId: this.targetId(), + reason: this.selectedReason() as ReportReason, + details: this.details(), + }).subscribe(this.getSubscribeHandlers()); + } + } + + private getSubscribeHandlers() { + return { + 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 or rate limit) + 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/components/settings/settings.component.ts b/apps/frontend/src/app/components/settings/settings.component.ts new file mode 100644 index 0000000..8b7a369 --- /dev/null +++ b/apps/frontend/src/app/components/settings/settings.component.ts @@ -0,0 +1,491 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, inject, signal, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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, PrimaryBadge } from '@library/shared-types'; + +@Component({ + selector: 'app-settings', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+

Profile Settings

+ + @if (loading()) { +
Loading settings...
+ } @else if (user()) { +
+
+

Profile Information

+ +
+ + + This will be shown instead of your username +
+ +
+ + + + Your profile will be at: library.nhcarrigan.com/profile/{{ formData.slug || 'your-slug' }} + + Only lowercase letters, numbers, and hyphens allowed +
+ +
+ + + {{ (formData.bio?.length || 0) }} / 500 characters +
+ +
+ + + Choose one badge to display on your profile and comments +
+
+ +
+

Social Links

+ +
+ + + Your personal website or portfolio (must start with http:// or https://) +
+ +
+ + + Just your GitHub username (alphanumeric and hyphens, 1-39 characters) +
+ +
+ + + Your full Bluesky handle (e.g., username.bsky.social) +
+ +
+ + + Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters) +
+ +
+ + + Just your Twitch username (alphanumeric and underscores, 4-25 characters) +
+ +
+ + + Your YouTube handle (@username) or channel ID (UC...) +
+ +
+ + + Just your Discord server invite code (alphanumeric, 2-32 characters) +
+
+ +
+

Privacy

+ +
+ + + When disabled, only you can view your profile + +
+
+ +
+

Account Information

+
+ Username: {{ user()!.username }} +
+
+ Email: {{ user()!.email }} +
+ @if (user()!.avatar) { +
+ Avatar: + Avatar +
+ } + + Username, email, and avatar are managed through Discord and cannot be changed here + +
+ +
+ +
+
+ } +
+ `, + styles: [` + .settings-container { + max-width: 700px; + margin: 2rem auto; + padding: 0 1rem; + } + + h1 { + color: var(--accent-colour, #9b59b6); + margin-bottom: 2rem; + } + + .loading { + text-align: center; + padding: 2rem; + font-size: 1.2rem; + } + + .settings-form { + background: var(--card-background, #1a1a2e); + border-radius: 12px; + padding: 2rem; + } + + .form-section { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid rgba(155, 89, 182, 0.3); + } + + .form-section:last-of-type { + border-bottom: none; + } + + .form-section h2 { + color: var(--accent-colour, #9b59b6); + font-size: 1.3rem; + margin-bottom: 1rem; + } + + .form-group { + margin-bottom: 1.5rem; + } + + .form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-colour, #e0e0e0); + font-weight: 600; + } + + .form-group input[type="text"], + .form-group textarea, + .form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid rgba(155, 89, 182, 0.5); + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); + color: var(--text-colour, #e0e0e0); + font-size: 1rem; + font-family: inherit; + } + + .form-group select { + 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 { + outline: none; + border-color: var(--accent-colour, #9b59b6); + box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3); + } + + .form-group input[type="text"]:invalid:not(:focus):not(:placeholder-shown), + .form-group textarea:invalid:not(:focus):not(:placeholder-shown) { + border-color: var(--error-colour, #c41e3a); + } + + .form-group input[type="text"]:valid:not(:placeholder-shown), + .form-group textarea:valid:not(:placeholder-shown) { + border-color: rgba(46, 204, 113, 0.5); + } + + .form-group textarea { + resize: vertical; + min-height: 100px; + } + + .form-help { + display: block; + margin-top: 0.25rem; + color: var(--text-muted, #a0a0a0); + font-size: 0.85rem; + } + + .checkbox-group label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; + } + + .checkbox-group input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + } + + .info-item { + margin-bottom: 1rem; + color: var(--text-colour, #e0e0e0); + } + + .info-item strong { + color: var(--accent-colour, #9b59b6); + } + + .avatar-preview { + width: 50px; + height: 50px; + border-radius: 50%; + margin-left: 0.5rem; + vertical-align: middle; + } + + .form-actions { + display: flex; + justify-content: flex-end; + padding-top: 1rem; + } + + .btn { + padding: 0.75rem 2rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + } + + .btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + `] +}) +export class SettingsComponent implements OnInit { + private userService = inject(UserService); + private authService = inject(AuthService); + private toastService = inject(ToastService); + + user = signal(null); + 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: '', + github: '', + linkedin: '', + twitch: '', + youtube: '' + }; + + ngOnInit(): void { + this.userService.getMe().subscribe({ + next: (userData: User) => { + this.user.set(userData); + this.formData = { + displayName: userData.displayName || '', + slug: userData.slug || '', + bio: userData.bio || '', + profilePublic: userData.profilePublic ?? true, + primaryBadge: userData.primaryBadge || undefined, + website: userData.website || '', + discordServer: userData.discordServer || '', + bluesky: userData.bluesky || '', + github: userData.github || '', + linkedin: userData.linkedin || '', + twitch: userData.twitch || '', + youtube: userData.youtube || '' + }; + this.loading.set(false); + }, + error: (err: Error) => { + console.error('Error loading user profile:', err); + this.toastService.error('Failed to load profile'); + this.loading.set(false); + } + }); + } + + saveSettings(): void { + this.saving.set(true); + + const updates: UpdateUserSettingsRequest = { + displayName: this.formData.displayName || undefined, + 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, + github: this.formData.github || undefined, + linkedin: this.formData.linkedin || undefined, + twitch: this.formData.twitch || undefined, + youtube: this.formData.youtube || undefined + }; + + this.userService.updateSettings(updates).subscribe({ + next: (updatedUser: User) => { + this.user.set(updatedUser); + this.authService.updateUser(updatedUser); + this.saving.set(false); + this.toastService.success('Settings saved successfully!'); + }, + error: (err: Error) => { + console.error('Error saving settings:', err); + this.toastService.error('Failed to save settings'); + this.saving.set(false); + } + }); + } +} 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..24910d1 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] || []); + } } 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/auth.service.ts b/apps/frontend/src/app/services/auth.service.ts index ec7c2c4..0372820 100644 --- a/apps/frontend/src/app/services/auth.service.ts +++ b/apps/frontend/src/app/services/auth.service.ts @@ -123,6 +123,10 @@ export class AuthService { return this.user() !== null; } + updateUser(user: User): void { + this.currentUser.set(user); + } + isAdmin(): boolean { return this.user()?.isAdmin === true; } diff --git a/apps/frontend/src/app/services/comment-report.service.ts b/apps/frontend/src/app/services/comment-report.service.ts new file mode 100644 index 0000000..89abac2 --- /dev/null +++ b/apps/frontend/src/app/services/comment-report.service.ts @@ -0,0 +1,50 @@ +/** + * @copyright NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import type { + CommentReportWithDetails, + CreateCommentReportDto, + UpdateCommentReportDto, + ReportStatus, +} from '@library/shared-types'; +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root', +}) +export class CommentReportService { + private readonly http = inject(HttpClient); + private readonly apiUrl = `${environment.apiUrl}/comment-reports`; + + createReport( + dto: CreateCommentReportDto, + ): Observable { + return this.http.post(this.apiUrl, dto); + } + + getAllReports( + status?: ReportStatus, + ): Observable { + const params: Record = {}; + if (status) { + params['status'] = status; + } + return this.http.get(this.apiUrl, { params }); + } + + getReportById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + updateReport( + id: string, + dto: UpdateCommentReportDto, + ): Observable { + return this.http.put(`${this.apiUrl}/${id}`, dto); + } +} diff --git a/apps/frontend/src/app/services/comments.service.ts b/apps/frontend/src/app/services/comments.service.ts index 454bc6f..7dcc08b 100644 --- a/apps/frontend/src/app/services/comments.service.ts +++ b/apps/frontend/src/app/services/comments.service.ts @@ -111,4 +111,13 @@ export class CommentsService { updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable { return this.api.put(`/manga/${mangaId}/comments/${commentId}`, { content }); } + + // Admin methods - work with comment ID directly + adminUpdateComment(commentId: string, content: string): Observable { + return this.api.put(`/comments/${commentId}`, { content }); + } + + adminDeleteComment(commentId: string): Observable<{ success: boolean }> { + return this.api.delete<{ success: boolean }>(`/comments/${commentId}`); + } } 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/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/apps/frontend/src/app/services/user.service.ts b/apps/frontend/src/app/services/user.service.ts index b8995e6..bc9c72b 100644 --- a/apps/frontend/src/app/services/user.service.ts +++ b/apps/frontend/src/app/services/user.service.ts @@ -7,7 +7,53 @@ 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; + username: string; + displayName?: string; + avatar?: string; + bio?: string; + slug?: string; + primaryBadge?: PrimaryBadge; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; + achievementPoints: number; + badges: { + isStaff: boolean; + isMod: boolean; + isVip: boolean; + inDiscord: boolean; + }; + stats: { + suggestionsCount: number; + suggestionsAcceptedCount: number; + likesCount: number; + commentsCount: number; + }; + createdAt: Date; +} + +export interface UpdateUserSettingsRequest { + slug?: string; + displayName?: string; + bio?: string; + profilePublic?: boolean; + primaryBadge?: PrimaryBadge; + website?: string; + discordServer?: string; + bluesky?: string; + github?: string; + linkedin?: string; + twitch?: string; + youtube?: string; +} @Injectable({ providedIn: 'root' @@ -26,4 +72,24 @@ export class UserService { unbanUser(userId: string): Observable { return this.api.post(`/users/${userId}/unban`, {}); } + + getMe(): Observable { + return this.api.get('/users/me'); + } + + updateSettings(settings: UpdateUserSettingsRequest): Observable { + return this.api.put('/users/me', settings); + } + + getProfile(identifier: string): Observable { + return this.api.get(`/users/profile/${identifier}`); + } + + makeProfilePrivate(userId: string): Observable { + return this.api.post(`/users/${userId}/make-private`, {}); + } + + adminUpdateUser(userId: string, settings: UpdateUserSettingsRequest): Observable { + return this.api.put(`/users/${userId}`, settings); + } } diff --git a/package.json b/package.json index ac30da9..bd197b0 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "@fastify/rate-limit": "10.3.0", "@fastify/sensible": "6.0.4", "@fastify/static": "9.0.0", + "@fortawesome/angular-fontawesome": "4.0.0", + "@fortawesome/fontawesome-svg-core": "7.2.0", + "@fortawesome/free-brands-svg-icons": "7.2.0", + "@fortawesome/free-solid-svg-icons": "7.2.0", "@nhcarrigan/logger": "1.1.1", "@prisma/client": "6.19.2", "dompurify": "3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c258b9..4633e46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,18 @@ importers: '@fastify/static': specifier: 9.0.0 version: 9.0.0 + '@fortawesome/angular-fontawesome': + specifier: 4.0.0 + version: 4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2)) + '@fortawesome/fontawesome-svg-core': + specifier: 7.2.0 + version: 7.2.0 + '@fortawesome/free-brands-svg-icons': + specifier: 7.2.0 + version: 7.2.0 + '@fortawesome/free-solid-svg-icons': + specifier: 7.2.0 + version: 7.2.0 '@nhcarrigan/logger': specifier: 1.1.1 version: 1.1.1 @@ -1659,6 +1671,27 @@ packages: '@fastify/static@9.0.0': resolution: {integrity: sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==} + '@fortawesome/angular-fontawesome@4.0.0': + resolution: {integrity: sha512-TCqHqT5ovFY1A4RgMpoBUgS+RX3OVs39+CzHFgzDhbCPAopOa26J748TZJcuZwJAvGAk9tbWeVEmWuLByINAeg==} + peerDependencies: + '@angular/core': ^21.0.0 + + '@fortawesome/fontawesome-common-types@7.2.0': + resolution: {integrity: sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==} + engines: {node: '>=6'} + + '@fortawesome/fontawesome-svg-core@7.2.0': + resolution: {integrity: sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==} + engines: {node: '>=6'} + + '@fortawesome/free-brands-svg-icons@7.2.0': + resolution: {integrity: sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==} + engines: {node: '>=6'} + + '@fortawesome/free-solid-svg-icons@7.2.0': + resolution: {integrity: sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==} + engines: {node: '>=6'} + '@hapi/boom@10.0.1': resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} @@ -11651,6 +11684,26 @@ snapshots: fastq: 1.20.1 glob: 13.0.1 + '@fortawesome/angular-fontawesome@4.0.0(@angular/core@21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2))': + dependencies: + '@angular/core': 21.1.2(@angular/compiler@21.1.2)(rxjs@7.8.2) + '@fortawesome/fontawesome-svg-core': 7.2.0 + tslib: 2.8.1 + + '@fortawesome/fontawesome-common-types@7.2.0': {} + + '@fortawesome/fontawesome-svg-core@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + + '@fortawesome/free-brands-svg-icons@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + + '@fortawesome/free-solid-svg-icons@7.2.0': + dependencies: + '@fortawesome/fontawesome-common-types': 7.2.0 + '@hapi/boom@10.0.1': dependencies: '@hapi/hoek': 11.0.7 diff --git a/shared-types/src/index.ts b/shared-types/src/index.ts index 66519bd..dc596c9 100644 --- a/shared-types/src/index.ts +++ b/shared-types/src/index.ts @@ -3,9 +3,11 @@ * @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 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"; @@ -13,5 +15,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/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 { diff --git a/shared-types/src/lib/auth.types.ts b/shared-types/src/lib/auth.types.ts index bdef58f..8fda2c2 100644 --- a/shared-types/src/lib/auth.types.ts +++ b/shared-types/src/lib/auth.types.ts @@ -4,18 +4,39 @@ * @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; - username: string; - avatar?: string; - discordId: string; - isAdmin: boolean; - isBanned: boolean; - inDiscord: boolean; - isVip: boolean; - isMod: boolean; - isStaff: boolean; + id: string; + email: string; + username: string; + avatar?: string; + slug?: string; + displayName?: string; + bio?: string; + profilePublic: boolean; + primaryBadge?: PrimaryBadge; + 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; } interface JwtPayload { @@ -36,4 +57,5 @@ interface AuthResponse { user: User; } +export { PrimaryBadge }; export type { AuthResponse, JwtPayload, User }; diff --git a/shared-types/src/lib/comment.types.ts b/shared-types/src/lib/comment.types.ts index 3e4ecd1..212e2ed 100644 --- a/shared-types/src/lib/comment.types.ts +++ b/shared-types/src/lib/comment.types.ts @@ -4,30 +4,34 @@ * @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 { - id: string; - content: string; - rawContent?: string; - userId: string; - user: CommentUser; - gameId?: string; - bookId?: string; - musicId?: string; - artId?: string; - showId?: string; - mangaId?: string; - createdAt: Date; - updatedAt: Date; + id: string; + content: string; + rawContent?: string; + userId: string; + user: CommentUser; + gameId?: string; + bookId?: string; + musicId?: string; + artId?: string; + showId?: string; + mangaId?: string; + hasPendingReports?: boolean; + createdAt: Date; + updatedAt: Date; } interface CreateCommentDto { 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 { diff --git a/shared-types/src/lib/report.types.ts b/shared-types/src/lib/report.types.ts new file mode 100644 index 0000000..dca7a50 --- /dev/null +++ b/shared-types/src/lib/report.types.ts @@ -0,0 +1,110 @@ +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; +} + +export interface CommentReport { + id: string; + reportedCommentId: string; + reporterId: string; + reason: ReportReason; + details: string; + status: ReportStatus; + reviewedBy?: string; + reviewNotes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface CommentReportWithDetails extends CommentReport { + reportedComment: { + id: string; + content: string; + rawContent?: string; + userId: string; + user: { + 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 CreateCommentReportDto { + reportedCommentId: string; + reason: ReportReason; + details: string; +} + +export interface UpdateCommentReportDto { + status: ReportStatus; + reviewNotes?: string; +} 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", }, };