From 3da648544e84367e01ba87bab7b8b1a1de4a1b78 Mon Sep 17 00:00:00 2001 From: Hikari Date: Thu, 19 Feb 2026 22:02:10 -0800 Subject: [PATCH] feat: implement comprehensive achievement system with 62 achievements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete achievement system with gamification features across all user interactions. **Database & Types:** - Add UserAchievement model to track user progress and earned achievements - Add achievement-related fields to User model (achievementPoints, currentStreak, lastStreakCheck) - Add ACHIEVEMENT_UNLOCKED audit action type - Define 62 achievements as TypeScript constants across 5 categories **Achievement Categories:** - Suggestions (15): First suggestion through ultimate curator milestones - Likes (12): First like through legendary fan milestones - Comments (12): First comment through review legend milestones - Engagement (15): Login streaks and total activity tracking - Reports (8): Valid reports and accuracy tracking **Backend Implementation:** - Create AchievementService with comprehensive checking logic - Add achievement route with 6 API endpoints - Integrate achievement checking into all user interaction points: - Suggestions (create + accept) - Likes (toggle) - Comments (all 6 media types) - Login streaks - Reports (profile + comment) - Update UserService to include achievement points in profiles **Frontend Implementation:** - Create AchievementService for API communication - Create achievements page showing all 62 achievements with: - Category filtering (All, Suggestions, Likes, Comments, Engagement, Reports) - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Earned date display for completed achievements - Add "Recent Achievements" section to user profiles - Add "🏆 Achievements" link to header navigation - Only show "View All" link on own profile **Technical Features:** - Real-time achievement checking on user actions - Progress tracking to avoid recalculation - Points system for gamification - Tier-based gradient styling - Leaderboard-ready architecture Resolves #48 Co-Authored-By: Hikari --- api/prisma/schema.prisma | 22 + api/src/app/routes/achievements/index.ts | 122 +++ api/src/app/routes/art/index.ts | 12 +- api/src/app/routes/auth/index.ts | 12 +- api/src/app/routes/books/index.ts | 12 +- api/src/app/routes/comment-reports/index.ts | 14 +- api/src/app/routes/games/index.ts | 12 +- api/src/app/routes/log/index.ts | 5 +- api/src/app/routes/manga/index.ts | 12 +- api/src/app/routes/music/index.ts | 12 +- api/src/app/routes/reports/index.ts | 14 +- api/src/app/routes/shows/index.ts | 12 +- api/src/app/routes/suggestions/index.ts | 27 +- api/src/app/services/achievement.service.ts | 772 ++++++++++++++++++ api/src/app/services/like.service.ts | 11 +- api/src/app/services/user.service.ts | 2 + apps/frontend/src/app/app.routes.ts | 4 + .../achievements/achievements.component.ts | 437 ++++++++++ .../app/components/header/header.component.ts | 1 + .../components/profile/profile.component.ts | 170 +++- .../src/app/services/achievement.service.ts | 63 ++ .../frontend/src/app/services/user.service.ts | 1 + shared-types/src/index.ts | 2 + shared-types/src/lib/achievement.constants.ts | 644 +++++++++++++++ shared-types/src/lib/achievement.types.ts | 70 ++ shared-types/src/lib/audit.types.ts | 1 + 26 files changed, 2449 insertions(+), 17 deletions(-) create mode 100644 api/src/app/routes/achievements/index.ts create mode 100644 api/src/app/services/achievement.service.ts create mode 100644 apps/frontend/src/app/components/achievements/achievements.component.ts create mode 100644 apps/frontend/src/app/services/achievement.service.ts create mode 100644 shared-types/src/lib/achievement.constants.ts create mode 100644 shared-types/src/lib/achievement.types.ts diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2e12dd0..d7a77b8 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -207,6 +207,9 @@ model User { isVip Boolean @default(false) isMod Boolean @default(false) isStaff Boolean @default(false) + achievementPoints Int @default(0) + currentStreak Int @default(0) + lastStreakCheck DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt comments Comment[] @@ -218,6 +221,7 @@ model User { reportsReviewed ProfileReport[] @relation("Reviewer") commentReportsMade CommentReport[] @relation("CommentReporter") commentReportsReviewed CommentReport[] @relation("CommentReviewer") + userAchievements UserAchievement[] @@index([slug], map: "User_slug_key") } @@ -276,6 +280,7 @@ enum AuditAction { RATE_LIMIT_EXCEEDED CSRF_VALIDATION_FAILED UNAUTHORIZED_ACCESS + ACHIEVEMENT_UNLOCKED } enum AuditCategory { @@ -400,3 +405,20 @@ model CommentReport { @@index([reporterId]) @@index([status]) } + +model UserAchievement { + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + user User @relation(fields: [userId], references: [id]) + achievementKey String + progress Int @default(0) + earned Boolean @default(false) + earnedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, achievementKey]) + @@index([userId]) + @@index([achievementKey]) + @@index([earned]) +} diff --git a/api/src/app/routes/achievements/index.ts b/api/src/app/routes/achievements/index.ts new file mode 100644 index 0000000..41d9a66 --- /dev/null +++ b/api/src/app/routes/achievements/index.ts @@ -0,0 +1,122 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyPluginAsync } from "fastify"; +import { + ACHIEVEMENT_LIST, + ACHIEVEMENTS, + AchievementProgress, + UserAchievementSummary, +} from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; + +const achievementsRoutes: FastifyPluginAsync = async (app) => { + const achievementService = new AchievementService(); + + /** + * Get all achievement definitions (public route). + */ + app.get("/definitions", async () => { + return ACHIEVEMENT_LIST; + }); + + /** + * Get a specific achievement definition by key (public route). + */ + app.get<{ Params: { key: string } }>( + "/definitions/:key", + async (request, reply) => { + const { key } = request.params; + const achievement = ACHIEVEMENTS[key]; + + if (!achievement) { + return reply.notFound("Achievement not found"); + } + + return achievement; + }, + ); + + /** + * Get current user's achievement summary (authenticated users). + */ + app.get<{ Reply: UserAchievementSummary }>( + "/summary", + { + preValidation: [app.authenticate], + }, + async (request) => { + const userId = request.user.id; + const summary = await achievementService.getUserAchievementSummary( + userId, + ); + return summary; + }, + ); + + /** + * Get current user's achievement progress (authenticated users). + */ + app.get<{ Reply: AchievementProgress[] }>( + "/progress", + { + preValidation: [app.authenticate], + }, + async (request) => { + const userId = request.user.id; + const progress = await achievementService.getUserAchievementProgress( + userId, + ); + return progress; + }, + ); + + /** + * Get another user's achievement summary by ID (authenticated users). + */ + app.get<{ Params: { userId: string }; Reply: UserAchievementSummary }>( + "/users/:userId/summary", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const { userId } = request.params; + + try { + const summary = await achievementService.getUserAchievementSummary( + userId, + ); + return summary; + } catch (error) { + return reply.notFound("User not found"); + } + }, + ); + + /** + * Get another user's achievement progress by ID (authenticated users). + */ + app.get<{ Params: { userId: string }; Reply: AchievementProgress[] }>( + "/users/:userId/progress", + { + preValidation: [app.authenticate], + }, + async (request, reply) => { + const { userId } = request.params; + + try { + const progress = await achievementService.getUserAchievementProgress( + userId, + ); + return progress; + } catch (error) { + return reply.notFound("User not found"); + } + }, + ); +}; + +export default achievementsRoutes; diff --git a/api/src/app/routes/art/index.ts b/api/src/app/routes/art/index.ts index 3c24820..befe459 100644 --- a/api/src/app/routes/art/index.ts +++ b/api/src/app/routes/art/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Art, CreateArtDto, UpdateArtDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { ArtService } from "../../services/art.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const artRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to art`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index 3bdadb9..f13d76e 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -1,7 +1,8 @@ import { FastifyPluginAsync } from "fastify"; import { AuthService } from "../../services/auth.service"; import { AuditService } from "../../services/audit.service"; -import { AuthResponse, AuditAction, AuditCategory } from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; +import { AuthResponse, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; const authRoutes: FastifyPluginAsync = async (app) => { const authService = new AuthService(app); @@ -92,6 +93,15 @@ const authRoutes: FastifyPluginAsync = async (app) => { success: true, }, request); + // Update login streak and check engagement achievements + const achievementService = new AchievementService(); + await achievementService.updateLoginStreak(user.id); + await achievementService.checkAchievements( + user.id, + AchievementCategory.Engagement, + request + ); + // Set signed cookies and redirect to frontend reply .setCookie("auth-token", accessToken, { diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index f22cfa2..2aea0fe 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Book, CreateBookDto, UpdateBookDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { BookService } from "../../services/book.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const booksRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to book`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/comment-reports/index.ts b/api/src/app/routes/comment-reports/index.ts index a784ff0..06cef1d 100644 --- a/api/src/app/routes/comment-reports/index.ts +++ b/api/src/app/routes/comment-reports/index.ts @@ -10,9 +10,10 @@ import type { ReportStatus, UpdateCommentReportDto, } from "@library/shared-types"; -import { ReportReason } from "@library/shared-types"; +import { ReportReason, AchievementCategory } from "@library/shared-types"; import { CommentReportService } from "../../services/comment-report.service.js"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard.js"; const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { @@ -132,6 +133,17 @@ const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { request.user.id, request.body, ); + + // Check for report achievements for the original reporter + if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") { + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + report.reporterId, + AchievementCategory.Report, + request + ); + } + return reply.send(report); }, ); diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index 53e2e15..2d00886 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Game, CreateGameDto, UpdateGameDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { GameService } from "../../services/game.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -125,6 +126,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to game`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/log/index.ts b/api/src/app/routes/log/index.ts index 2c57468..104db17 100644 --- a/api/src/app/routes/log/index.ts +++ b/api/src/app/routes/log/index.ts @@ -30,9 +30,10 @@ export default async function (fastify: FastifyInstance) { } await logger.error(context || 'Frontend', errorObj); } else if (level === 'error') { - await logger.log('warn', `[Frontend Error] ${message}`); + await logger.error('Frontend', new Error(message)); } else { - await logger.log(level, `[Frontend] ${message}`); + const logMessage = context ? `[${context}] ${message}` : message; + await logger.log(level, logMessage); } return { success: true }; diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts index 3909744..3391b16 100644 --- a/api/src/app/routes/manga/index.ts +++ b/api/src/app/routes/manga/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Manga, CreateMangaDto, UpdateMangaDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { MangaService } from "../../services/manga.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -118,6 +119,15 @@ const mangaRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to manga`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/music/index.ts b/api/src/app/routes/music/index.ts index 5f63553..d903627 100644 --- a/api/src/app/routes/music/index.ts +++ b/api/src/app/routes/music/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Music, CreateMusicDto, UpdateMusicDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { MusicService } from "../../services/music.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -139,6 +140,15 @@ const musicRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to music`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/reports/index.ts b/api/src/app/routes/reports/index.ts index 8fb3cdc..48f968a 100644 --- a/api/src/app/routes/reports/index.ts +++ b/api/src/app/routes/reports/index.ts @@ -10,9 +10,10 @@ import type { ReportStatus, UpdateReportDto, } from "@library/shared-types"; -import { ReportReason } from "@library/shared-types"; +import { ReportReason, AchievementCategory } from "@library/shared-types"; import { ReportService } from "../../services/report.service.js"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard.js"; const reportsRoutes: FastifyPluginAsync = async (fastify) => { @@ -131,6 +132,17 @@ const reportsRoutes: FastifyPluginAsync = async (fastify) => { request.user.id, request.body, ); + + // Check for report achievements for the original reporter + if (report.status === "ACTION_TAKEN" || report.status === "DISMISSED") { + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + report.reporterId, + AchievementCategory.Report, + request + ); + } + return reply.send(report); }, ); diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts index a41c422..7676370 100644 --- a/api/src/app/routes/shows/index.ts +++ b/api/src/app/routes/shows/index.ts @@ -5,10 +5,11 @@ */ import { FastifyPluginAsync } from "fastify"; -import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory } from "@library/shared-types"; +import { Show, CreateShowDto, UpdateShowDto, Comment, CreateCommentDto, AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import { ShowService } from "../../services/show.service"; import { CommentService } from "../../services/comment.service"; import { AuditService } from "../../services/audit.service"; +import { AchievementService } from "../../services/achievement.service"; import { adminGuard } from "../../middleware/admin-guard"; import { bannedGuard } from "../../middleware/banned-guard"; @@ -118,6 +119,15 @@ const showsRoutes: FastifyPluginAsync = async (app) => { resourceId: id, details: `Added comment to show`, }); + + // Check for comment achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Comment, + request + ); + return comment; } ); diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts index 569deb4..fe97a04 100644 --- a/api/src/app/routes/suggestions/index.ts +++ b/api/src/app/routes/suggestions/index.ts @@ -1,7 +1,8 @@ import type { FastifyInstance } from "fastify"; import { SuggestionService } from "../../services/suggestion.service"; import { AuditService } from "../../services/audit.service"; -import { AuditAction, AuditCategory } from "@library/shared-types"; +import { AchievementService } from "../../services/achievement.service"; +import { AuditAction, AuditCategory, AchievementCategory } from "@library/shared-types"; import type { SuggestionStatus, SuggestionEntity, @@ -93,6 +94,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( @@ -123,6 +132,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements for the user who made the suggestion + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + suggestion.userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( @@ -154,6 +171,14 @@ export default async function (app: FastifyInstance): Promise { success: true, }); + // Check for suggestion achievements for the user who made the suggestion + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + suggestion.userId, + AchievementCategory.Suggestion, + request + ); + reply.send(suggestion); } catch (error) { return reply.badRequest( diff --git a/api/src/app/services/achievement.service.ts b/api/src/app/services/achievement.service.ts new file mode 100644 index 0000000..5c26f2f --- /dev/null +++ b/api/src/app/services/achievement.service.ts @@ -0,0 +1,772 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import type { FastifyRequest } from "fastify"; +import { + ACHIEVEMENTS, + ACHIEVEMENT_LIST, + AchievementCategory, + AchievementDefinition, + AchievementProgress, + AuditAction, + AuditCategory, + UserAchievementSummary, +} from "@library/shared-types"; + +import { prisma } from "../lib/prisma"; +import { AuditService } from "./audit.service"; + +export class AchievementService { + /** + * Check and award achievements for a user after an action. + * Returns list of newly earned achievements. + */ + async checkAchievements( + userId: string, + category: AchievementCategory, + req: FastifyRequest, + ): Promise { + const relevantAchievements = ACHIEVEMENT_LIST.filter( + (ach) => ach.category === category, + ); + const newlyEarned: AchievementDefinition[] = []; + + for (const achievement of relevantAchievements) { + const userAchievement = await prisma.userAchievement.findUnique({ + where: { + userId_achievementKey: { + userId, + achievementKey: achievement.key, + }, + }, + }); + + // Skip already earned achievements + if (userAchievement?.earned) { + continue; + } + + // Check if achievement is now earned + const earned = await this.checkAchievementCondition(userId, achievement); + + if (earned) { + await this.awardAchievement(userId, achievement, req); + newlyEarned.push(achievement); + } + } + + return newlyEarned; + } + + /** + * Award an achievement to a user. + */ + private async awardAchievement( + userId: string, + achievement: AchievementDefinition, + req: FastifyRequest, + ): Promise { + await prisma.userAchievement.upsert({ + where: { + userId_achievementKey: { + userId, + achievementKey: achievement.key, + }, + }, + create: { + userId, + achievementKey: achievement.key, + progress: 100, + earned: true, + earnedAt: new Date(), + }, + update: { + earned: true, + earnedAt: new Date(), + progress: 100, + }, + }); + + // Update user's achievement points + await prisma.user.update({ + where: { id: userId }, + data: { + achievementPoints: { + increment: achievement.points, + }, + }, + }); + + // Log the achievement unlock + await AuditService.logFromRequest(req, { + action: AuditAction.achievementUnlocked, + category: AuditCategory.content, + details: `Unlocked achievement: ${achievement.title}`, + }); + } + + /** + * Check if a specific achievement condition is met. + */ + private async checkAchievementCondition( + userId: string, + achievement: AchievementDefinition, + ): Promise { + switch (achievement.category) { + case AchievementCategory.Suggestion: + return await this.checkSuggestionAchievement(userId, achievement); + case AchievementCategory.Like: + return await this.checkLikeAchievement(userId, achievement); + case AchievementCategory.Comment: + return await this.checkCommentAchievement(userId, achievement); + case AchievementCategory.Engagement: + return await this.checkEngagementAchievement(userId, achievement); + case AchievementCategory.Report: + return await this.checkReportAchievement(userId, achievement); + default: + return false; + } + } + + /** + * Check suggestion-based achievements. + */ + private async checkSuggestionAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total suggestions) + if ( + achievement.key.startsWith("suggestion_first_steps") || + achievement.key.startsWith("suggestion_contributor") || + achievement.key.startsWith("suggestion_dedicated") || + achievement.key.startsWith("suggestion_master") || + achievement.key.startsWith("suggestion_legend") + ) { + const count = await prisma.suggestion.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Accepted suggestion achievements + if (achievement.key.startsWith("suggestion_quality")) { + const count = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + return count >= (requirements.count ?? 0); + } + + // Approved achievement (first accepted) + if (achievement.key === "suggestion_approved") { + const count = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + return count >= 1; + } + + // Acceptance rate achievements + if (achievement.key.startsWith("suggestion_acceptance")) { + const total = await prisma.suggestion.count({ + where: { userId }, + }); + const accepted = await prisma.suggestion.count({ + where: { + userId, + status: "ACCEPTED", + }, + }); + + if (total < (requirements.count ?? 0)) { + return false; + } + + const rate = accepted / total; + return rate >= (requirements.rate ?? 0); + } + + // Diversity achievement (all media types) + if (achievement.key === "suggestion_renaissance") { + const types = await prisma.suggestion.groupBy({ + by: ["entityType"], + where: { + userId, + status: "ACCEPTED", + }, + }); + return types.length >= 6; + } + + // Enthusiast (5 in one day) + if (achievement.key === "suggestion_enthusiast") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const count = await prisma.suggestion.count({ + where: { + userId, + createdAt: { + gte: today, + lt: tomorrow, + }, + }, + }); + return count >= 5; + } + + return false; + } + + /** + * Check like-based achievements. + */ + private async checkLikeAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total likes) + if ( + achievement.key.startsWith("like_first") || + achievement.key.startsWith("like_enthusiast") || + achievement.key.startsWith("like_fan") || + achievement.key.startsWith("like_super") || + achievement.key.startsWith("like_mega") || + achievement.key.startsWith("like_legendary") + ) { + const count = await prisma.like.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Media-specific achievements + if (achievement.key === "like_book_lover") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "book", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_gamer") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "game", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_cinephile") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "show", + }, + }); + return count >= 50; + } + + if (achievement.key === "like_music") { + const count = await prisma.like.count({ + where: { + userId, + entityType: "music", + }, + }); + return count >= 50; + } + + // Diversity achievement + if (achievement.key === "like_diverse") { + const types = await prisma.like.groupBy({ + by: ["entityType"], + where: { userId }, + }); + return types.length >= 6; + } + + // Binge liker (20+ in one day) + if (achievement.key === "like_binge") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const count = await prisma.like.count({ + where: { + userId, + createdAt: { + gte: today, + lt: tomorrow, + }, + }, + }); + return count >= 20; + } + + return false; + } + + /** + * Check comment-based achievements. + */ + private async checkCommentAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count-based achievements (total comments) + if ( + achievement.key.startsWith("comment_first") || + achievement.key.startsWith("comment_reviewer") || + achievement.key.startsWith("comment_critic") || + achievement.key.startsWith("comment_expert") || + achievement.key.startsWith("comment_master") || + achievement.key.startsWith("comment_legend") + ) { + const count = await prisma.comment.count({ + where: { userId }, + }); + return count >= (requirements.count ?? 0); + } + + // Length-based achievements + if (achievement.key === "comment_detailed") { + const count = await prisma.comment.count({ + where: { + userId, + content: { + // MongoDB doesn't have a direct length check, so we'll fetch and check + }, + }, + }); + + // Fetch all comments and check length + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 500); + return longComments.length >= 10; + } + + if (achievement.key === "comment_essay") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 1000); + return longComments.length >= 5; + } + + if (achievement.key === "comment_novel") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { content: true }, + }); + + const longComments = comments.filter((c) => c.content.length >= 2000); + return longComments.length >= 3; + } + + // Thoughtful reviewer (50 different items) + if (achievement.key === "comment_thoughtful") { + // Count unique entity IDs + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { + bookId: true, + gameId: true, + showId: true, + mangaId: true, + musicId: true, + artId: true, + }, + }); + + const uniqueItems = new Set(); + comments.forEach((c) => { + const entityId = + c.bookId ?? + c.gameId ?? + c.showId ?? + c.mangaId ?? + c.musicId ?? + c.artId; + if (entityId) { + uniqueItems.add(entityId); + } + }); + + return uniqueItems.size >= 50; + } + + // Diversity achievement + if (achievement.key === "comment_diverse") { + const comments = await prisma.comment.findMany({ + where: { userId }, + select: { + bookId: true, + gameId: true, + showId: true, + mangaId: true, + musicId: true, + artId: true, + }, + }); + + const types = new Set(); + comments.forEach((c) => { + if (c.bookId) { + types.add("book"); + } + if (c.gameId) { + types.add("game"); + } + if (c.showId) { + types.add("show"); + } + if (c.mangaId) { + types.add("manga"); + } + if (c.musicId) { + types.add("music"); + } + if (c.artId) { + types.add("art"); + } + }); + + return types.size >= 6; + } + + return false; + } + + /** + * Check engagement-based achievements. + */ + private async checkEngagementAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + currentStreak: true, + createdAt: true, + }, + }); + + if (!user) { + return false; + } + + // Welcome achievement + if (achievement.key === "engagement_welcome") { + return true; // Awarded on first login + } + + // Streak-based achievements + if (achievement.key.startsWith("engagement_streak")) { + return user.currentStreak >= (achievement.requirements.streak ?? 0); + } + + // Triple threat (suggestion, like, comment in same day) + if (achievement.key === "engagement_triple_threat") { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const [hasSuggestion, hasLike, hasComment] = await Promise.all([ + prisma.suggestion.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + prisma.like.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + prisma.comment.count({ + where: { + userId, + createdAt: { gte: today, lt: tomorrow }, + }, + }), + ]); + + return hasSuggestion > 0 && hasLike > 0 && hasComment > 0; + } + + // Power user (triple threat 10 times) - would need special tracking + // Early adopter achievements + if ( + achievement.key === "engagement_early_adopter" || + achievement.key === "engagement_founding_100" || + achievement.key === "engagement_founding_1000" + ) { + const userRank = await prisma.user.count({ + where: { + createdAt: { + lt: user.createdAt, + }, + }, + }); + + if (achievement.key === "engagement_early_adopter") { + return userRank < 10; + } + if (achievement.key === "engagement_founding_100") { + return userRank < 100; + } + if (achievement.key === "engagement_founding_1000") { + return userRank < 1000; + } + } + + // Account age achievements + if (achievement.key.startsWith("engagement_veteran")) { + const daysSinceCreation = Math.floor( + (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24), + ); + return daysSinceCreation >= (achievement.requirements.dayRange ?? 0); + } + + return false; + } + + /** + * Check report-based achievements. + */ + private async checkReportAchievement( + userId: string, + achievement: AchievementDefinition, + ): Promise { + const { requirements } = achievement; + + // Count ACTION_TAKEN reports + if ( + achievement.key.startsWith("report_watchful") || + achievement.key.startsWith("report_guardian") || + achievement.key.startsWith("report_protector") || + achievement.key.startsWith("report_vigilant") + ) { + const profileReports = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const commentReports = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + return profileReports + commentReports >= (requirements.count ?? 0); + } + + // Accuracy achievements + if (achievement.key.startsWith("report_accuracy")) { + const totalProfileReports = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: { + not: "PENDING", + }, + }, + }); + + const totalCommentReports = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: { + not: "PENDING", + }, + }, + }); + + const total = totalProfileReports + totalCommentReports; + + if (total < (requirements.count ?? 10)) { + return false; + } + + const actionTakenProfile = await prisma.profileReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const actionTakenComment = await prisma.commentReport.count({ + where: { + reporterId: userId, + status: "ACTION_TAKEN", + }, + }); + + const actionTaken = actionTakenProfile + actionTakenComment; + const rate = actionTaken / total; + + return rate >= (requirements.rate ?? 0); + } + + // Volume achievement (total reports) + if (achievement.key === "report_volume") { + const profileReports = await prisma.profileReport.count({ + where: { reporterId: userId }, + }); + + const commentReports = await prisma.commentReport.count({ + where: { reporterId: userId }, + }); + + return profileReports + commentReports >= 50; + } + + return false; + } + + /** + * Get a user's achievement progress and summary. + */ + async getUserAchievementSummary( + userId: string, + ): Promise { + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + orderBy: { earnedAt: "desc" }, + }); + + const earnedAchievements = userAchievements.filter((ua) => ua.earned); + const totalPoints = earnedAchievements.reduce((sum, ua) => { + const definition = ACHIEVEMENTS[ua.achievementKey]; + return sum + (definition?.points ?? 0); + }, 0); + + const recentAchievements: AchievementProgress[] = earnedAchievements + .slice(0, 5) + .map((ua) => ({ + definition: ACHIEVEMENTS[ua.achievementKey], + progress: ua.progress, + earned: ua.earned, + earnedAt: ua.earnedAt ?? undefined, + })); + + // Progress by category + const progressByCategory = Object.values(AchievementCategory).map( + (category) => { + const categoryAchievements = ACHIEVEMENT_LIST.filter( + (a) => a.category === category, + ); + const earned = earnedAchievements.filter( + (ua) => ACHIEVEMENTS[ua.achievementKey]?.category === category, + ).length; + + return { + category, + earned, + total: categoryAchievements.length, + }; + }, + ); + + return { + totalPoints, + totalEarned: earnedAchievements.length, + recentAchievements, + progressByCategory, + }; + } + + /** + * Get all achievements with progress for a user. + */ + async getUserAchievementProgress( + userId: string, + ): Promise { + const userAchievements = await prisma.userAchievement.findMany({ + where: { userId }, + }); + + const progressMap = new Map( + userAchievements.map((ua) => [ua.achievementKey, ua]), + ); + + return ACHIEVEMENT_LIST.map((definition) => { + const userAchievement = progressMap.get(definition.key); + return { + definition, + progress: userAchievement?.progress ?? 0, + earned: userAchievement?.earned ?? false, + earnedAt: userAchievement?.earnedAt ?? undefined, + }; + }); + } + + /** + * Update login streak for a user. + */ + async updateLoginStreak(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + currentStreak: true, + lastStreakCheck: true, + }, + }); + + if (!user) { + return; + } + + const now = new Date(); + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const lastCheck = user.lastStreakCheck; + const isConsecutive = + lastCheck && + lastCheck >= yesterday && + lastCheck < new Date(now.setHours(0, 0, 0, 0)); + + await prisma.user.update({ + where: { id: userId }, + data: { + currentStreak: isConsecutive ? user.currentStreak + 1 : 1, + lastStreakCheck: new Date(), + }, + }); + } +} diff --git a/api/src/app/services/like.service.ts b/api/src/app/services/like.service.ts index 7875836..a8aafcd 100644 --- a/api/src/app/services/like.service.ts +++ b/api/src/app/services/like.service.ts @@ -7,8 +7,9 @@ import type { FastifyRequest } from 'fastify'; import { prisma } from '../lib/prisma'; import { AuditService } from './audit.service'; +import { AchievementService } from './achievement.service'; import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types'; -import { AuditAction, AuditCategory } from '@library/shared-types'; +import { AuditAction, AuditCategory, AchievementCategory } from '@library/shared-types'; export class LikeService { async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise { @@ -59,6 +60,14 @@ export class LikeService { details: `Liked ${entityType}` }); + // Check for like achievements + const achievementService = new AchievementService(); + await achievementService.checkAchievements( + userId, + AchievementCategory.Like, + req + ); + const count = await this.getLikeCount(entityType, entityId); return { liked: true, count }; } diff --git a/api/src/app/services/user.service.ts b/api/src/app/services/user.service.ts index 68c3dcf..ece6fcf 100644 --- a/api/src/app/services/user.service.ts +++ b/api/src/app/services/user.service.ts @@ -260,6 +260,7 @@ export class UserService { inDiscord: boolean; profilePublic: boolean; createdAt: Date; + achievementPoints: number; stats: { suggestionsCount: number; suggestionsAcceptedCount: number; @@ -316,6 +317,7 @@ export class UserService { inDiscord: user.inDiscord, profilePublic: user.profilePublic, createdAt: user.createdAt, + achievementPoints: user.achievementPoints, stats: { suggestionsCount: user.suggestions.length, suggestionsAcceptedCount: user.suggestions.filter( diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index a8c1e6e..139b7ad 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -61,6 +61,10 @@ export const appRoutes: Route[] = [ path: 'settings', loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent) }, + { + path: 'achievements', + loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent) + }, { path: '**', redirectTo: '' diff --git a/apps/frontend/src/app/components/achievements/achievements.component.ts b/apps/frontend/src/app/components/achievements/achievements.component.ts new file mode 100644 index 0000000..8ef4087 --- /dev/null +++ b/apps/frontend/src/app/components/achievements/achievements.component.ts @@ -0,0 +1,437 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { + AchievementCategory, + AchievementProgress, + AchievementTier, +} from '@library/shared-types'; +import { AchievementService } from '../../services/achievement.service'; + +@Component({ + selector: 'app-achievements', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

🏆 Achievements

+

Track your progress across all achievement categories

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

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

+ +

+ {{ achievement.definition.description }} +

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

Recent Achievements

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