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

Merged
naomi merged 17 commits from feat/user-profiles into main 2026-02-19 22:21:18 -08:00
26 changed files with 2449 additions and 17 deletions
Showing only changes of commit 3da648544e - Show all commits
+22
View File
@@ -207,6 +207,9 @@ model User {
isVip Boolean @default(false) isVip Boolean @default(false)
isMod Boolean @default(false) isMod Boolean @default(false)
isStaff Boolean @default(false) isStaff Boolean @default(false)
achievementPoints Int @default(0)
currentStreak Int @default(0)
lastStreakCheck DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
comments Comment[] comments Comment[]
@@ -218,6 +221,7 @@ model User {
reportsReviewed ProfileReport[] @relation("Reviewer") reportsReviewed ProfileReport[] @relation("Reviewer")
commentReportsMade CommentReport[] @relation("CommentReporter") commentReportsMade CommentReport[] @relation("CommentReporter")
commentReportsReviewed CommentReport[] @relation("CommentReviewer") commentReportsReviewed CommentReport[] @relation("CommentReviewer")
userAchievements UserAchievement[]
@@index([slug], map: "User_slug_key") @@index([slug], map: "User_slug_key")
} }
@@ -276,6 +280,7 @@ enum AuditAction {
RATE_LIMIT_EXCEEDED RATE_LIMIT_EXCEEDED
CSRF_VALIDATION_FAILED CSRF_VALIDATION_FAILED
UNAUTHORIZED_ACCESS UNAUTHORIZED_ACCESS
ACHIEVEMENT_UNLOCKED
} }
enum AuditCategory { enum AuditCategory {
@@ -400,3 +405,20 @@ model CommentReport {
@@index([reporterId]) @@index([reporterId])
@@index([status]) @@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])
}
+122
View File
@@ -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;
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { ArtService } from "../../services/art.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -139,6 +140,15 @@ const artRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to art`, details: `Added comment to art`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+11 -1
View File
@@ -1,7 +1,8 @@
import { FastifyPluginAsync } from "fastify"; import { FastifyPluginAsync } from "fastify";
import { AuthService } from "../../services/auth.service"; import { AuthService } from "../../services/auth.service";
import { AuditService } from "../../services/audit.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 authRoutes: FastifyPluginAsync = async (app) => {
const authService = new AuthService(app); const authService = new AuthService(app);
@@ -92,6 +93,15 @@ const authRoutes: FastifyPluginAsync = async (app) => {
success: true, success: true,
}, request); }, 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 // Set signed cookies and redirect to frontend
reply reply
.setCookie("auth-token", accessToken, { .setCookie("auth-token", accessToken, {
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { BookService } from "../../services/book.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -139,6 +140,15 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to book`, details: `Added comment to book`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+13 -1
View File
@@ -10,9 +10,10 @@ import type {
ReportStatus, ReportStatus,
UpdateCommentReportDto, UpdateCommentReportDto,
} from "@library/shared-types"; } 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 { CommentReportService } from "../../services/comment-report.service.js";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard.js"; import { adminGuard } from "../../middleware/admin-guard.js";
const commentReportsRoutes: FastifyPluginAsync = async (fastify) => { const commentReportsRoutes: FastifyPluginAsync = async (fastify) => {
@@ -132,6 +133,17 @@ const commentReportsRoutes: FastifyPluginAsync = async (fastify) => {
request.user.id, request.user.id,
request.body, 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); return reply.send(report);
}, },
); );
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { GameService } from "../../services/game.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -125,6 +126,15 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to game`, details: `Added comment to game`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+3 -2
View File
@@ -30,9 +30,10 @@ export default async function (fastify: FastifyInstance) {
} }
await logger.error(context || 'Frontend', errorObj); await logger.error(context || 'Frontend', errorObj);
} else if (level === 'error') { } else if (level === 'error') {
await logger.log('warn', `[Frontend Error] ${message}`); await logger.error('Frontend', new Error(message));
} else { } else {
await logger.log(level, `[Frontend] ${message}`); const logMessage = context ? `[${context}] ${message}` : message;
await logger.log(level, logMessage);
} }
return { success: true }; return { success: true };
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { MangaService } from "../../services/manga.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -118,6 +119,15 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to manga`, details: `Added comment to manga`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { MusicService } from "../../services/music.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -139,6 +140,15 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to music`, details: `Added comment to music`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+13 -1
View File
@@ -10,9 +10,10 @@ import type {
ReportStatus, ReportStatus,
UpdateReportDto, UpdateReportDto,
} from "@library/shared-types"; } 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 { ReportService } from "../../services/report.service.js";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard.js"; import { adminGuard } from "../../middleware/admin-guard.js";
const reportsRoutes: FastifyPluginAsync = async (fastify) => { const reportsRoutes: FastifyPluginAsync = async (fastify) => {
@@ -131,6 +132,17 @@ const reportsRoutes: FastifyPluginAsync = async (fastify) => {
request.user.id, request.user.id,
request.body, 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); return reply.send(report);
}, },
); );
+11 -1
View File
@@ -5,10 +5,11 @@
*/ */
import { FastifyPluginAsync } from "fastify"; 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 { ShowService } from "../../services/show.service";
import { CommentService } from "../../services/comment.service"; import { CommentService } from "../../services/comment.service";
import { AuditService } from "../../services/audit.service"; import { AuditService } from "../../services/audit.service";
import { AchievementService } from "../../services/achievement.service";
import { adminGuard } from "../../middleware/admin-guard"; import { adminGuard } from "../../middleware/admin-guard";
import { bannedGuard } from "../../middleware/banned-guard"; import { bannedGuard } from "../../middleware/banned-guard";
@@ -118,6 +119,15 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
resourceId: id, resourceId: id,
details: `Added comment to show`, details: `Added comment to show`,
}); });
// Check for comment achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Comment,
request
);
return comment; return comment;
} }
); );
+26 -1
View File
@@ -1,7 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { SuggestionService } from "../../services/suggestion.service"; import { SuggestionService } from "../../services/suggestion.service";
import { AuditService } from "../../services/audit.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 { import type {
SuggestionStatus, SuggestionStatus,
SuggestionEntity, SuggestionEntity,
@@ -93,6 +94,14 @@ export default async function (app: FastifyInstance): Promise<void> {
success: true, success: true,
}); });
// Check for suggestion achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Suggestion,
request
);
reply.send(suggestion); reply.send(suggestion);
} catch (error) { } catch (error) {
return reply.badRequest( return reply.badRequest(
@@ -123,6 +132,14 @@ export default async function (app: FastifyInstance): Promise<void> {
success: true, 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); reply.send(suggestion);
} catch (error) { } catch (error) {
return reply.badRequest( return reply.badRequest(
@@ -154,6 +171,14 @@ export default async function (app: FastifyInstance): Promise<void> {
success: true, 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); reply.send(suggestion);
} catch (error) { } catch (error) {
return reply.badRequest( return reply.badRequest(
+772
View File
@@ -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<AchievementDefinition[]> {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<string>();
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<string>();
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<boolean> {
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<boolean> {
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<UserAchievementSummary> {
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<AchievementProgress[]> {
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<void> {
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(),
},
});
}
}
+10 -1
View File
@@ -7,8 +7,9 @@
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
import { AchievementService } from './achievement.service';
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types'; 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 { export class LikeService {
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> { async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
@@ -59,6 +60,14 @@ export class LikeService {
details: `Liked ${entityType}` 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); const count = await this.getLikeCount(entityType, entityId);
return { liked: true, count }; return { liked: true, count };
} }
+2
View File
@@ -260,6 +260,7 @@ export class UserService {
inDiscord: boolean; inDiscord: boolean;
profilePublic: boolean; profilePublic: boolean;
createdAt: Date; createdAt: Date;
achievementPoints: number;
stats: { stats: {
suggestionsCount: number; suggestionsCount: number;
suggestionsAcceptedCount: number; suggestionsAcceptedCount: number;
@@ -316,6 +317,7 @@ export class UserService {
inDiscord: user.inDiscord, inDiscord: user.inDiscord,
profilePublic: user.profilePublic, profilePublic: user.profilePublic,
createdAt: user.createdAt, createdAt: user.createdAt,
achievementPoints: user.achievementPoints,
stats: { stats: {
suggestionsCount: user.suggestions.length, suggestionsCount: user.suggestions.length,
suggestionsAcceptedCount: user.suggestions.filter( suggestionsAcceptedCount: user.suggestions.filter(
+4
View File
@@ -61,6 +61,10 @@ export const appRoutes: Route[] = [
path: 'settings', path: 'settings',
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent) loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
}, },
{
path: 'achievements',
loadComponent: () => import('./components/achievements/achievements.component').then(m => m.AchievementsComponent)
},
{ {
path: '**', path: '**',
redirectTo: '' redirectTo: ''
@@ -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: `
<div class="achievements-container">
<div class="achievements-header">
<h1>🏆 Achievements</h1>
<p class="subtitle">Track your progress across all achievement categories</p>
@if (totalPoints() > 0) {
<div class="total-points">
<span class="points-label">Total Points:</span>
<span class="points-value">{{ totalPoints() }}</span>
</div>
}
</div>
<div class="filter-buttons">
<button
[class.active]="selectedCategory() === null"
(click)="selectCategory(null)">
All ({{ totalEarned() }}/{{ totalAchievements() }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Suggestion"
(click)="selectCategory(AchievementCategory.Suggestion)">
📝 Suggestions ({{ getCategoryCount(AchievementCategory.Suggestion) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Like"
(click)="selectCategory(AchievementCategory.Like)">
❤️ Likes ({{ getCategoryCount(AchievementCategory.Like) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Comment"
(click)="selectCategory(AchievementCategory.Comment)">
💬 Comments ({{ getCategoryCount(AchievementCategory.Comment) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Engagement"
(click)="selectCategory(AchievementCategory.Engagement)">
🎯 Engagement ({{ getCategoryCount(AchievementCategory.Engagement) }})
</button>
<button
[class.active]="selectedCategory() === AchievementCategory.Report"
(click)="selectCategory(AchievementCategory.Report)">
🛡️ Reports ({{ getCategoryCount(AchievementCategory.Report) }})
</button>
</div>
@if (loading()) {
<div class="loading">Loading achievements...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else {
<div class="achievements-grid">
@for (achievement of filteredAchievements(); track achievement.definition.key) {
<div
class="achievement-card"
[class.earned]="achievement.earned"
[class.locked]="!achievement.earned"
[attr.data-tier]="achievement.definition.tier.toLowerCase()">
<div class="achievement-icon">
{{ achievement.definition.icon }}
</div>
<div class="achievement-content">
<h3 class="achievement-title">
{{ achievement.definition.title }}
@if (achievement.earned) {
<span class="earned-check">✓</span>
}
</h3>
<p class="achievement-description">
{{ achievement.definition.description }}
</p>
<div class="achievement-footer">
<span class="achievement-tier" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
{{ getTierLabel(achievement.definition.tier) }}
</span>
<span class="achievement-points">{{ achievement.definition.points }} pts</span>
</div>
@if (!achievement.earned && achievement.progress > 0) {
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="achievement.progress"></div>
</div>
<div class="progress-text">{{ achievement.progress }}% complete</div>
}
@if (achievement.earned && achievement.earnedAt) {
<div class="earned-date">
Earned {{ formatDate(achievement.earnedAt) }}
</div>
}
</div>
</div>
}
</div>
}
</div>
`,
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<AchievementProgress[]>([]);
selectedCategory = signal<AchievementCategory | null>(null);
loading = signal(true);
error = signal<string | null>(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`;
}
}
@@ -52,6 +52,7 @@ import { ApiService } from '../../services/api.service';
<div class="dropdown-menu"> <div class="dropdown-menu">
<a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a> <a [routerLink]="['/profile', user.slug || user.id]" class="dropdown-item" (click)="closeDropdown()">My Profile</a>
<a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a> <a routerLink="/settings" class="dropdown-item" (click)="closeDropdown()">Settings</a>
<a routerLink="/achievements" class="dropdown-item" (click)="closeDropdown()">🏆 Achievements</a>
@if (!user.isAdmin) { @if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a> <a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
} }
@@ -5,21 +5,22 @@
*/ */
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons'; import { faGlobe, faCloud, faFlag } from '@fortawesome/free-solid-svg-icons';
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons'; import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
import { UserService, UserProfileResponse } from '../../services/user.service'; import { UserService, UserProfileResponse } from '../../services/user.service';
import { AchievementService } from '../../services/achievement.service';
import { ToastService } from '../../services/toast.service'; import { ToastService } from '../../services/toast.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { ReportModalComponent } from '../report-modal/report-modal.component'; import { ReportModalComponent } from '../report-modal/report-modal.component';
import { PrimaryBadge } from '@library/shared-types'; import { PrimaryBadge, AchievementProgress } from '@library/shared-types';
@Component({ @Component({
selector: 'app-profile', selector: 'app-profile',
standalone: true, standalone: true,
imports: [CommonModule, FontAwesomeModule, ReportModalComponent], imports: [CommonModule, FontAwesomeModule, ReportModalComponent, RouterModule],
template: ` template: `
<div class="profile-container"> <div class="profile-container">
@if (loading()) { @if (loading()) {
@@ -151,9 +152,37 @@ import { PrimaryBadge } from '@library/shared-types';
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span> <span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
<span class="stat-label">Comments</span> <span class="stat-label">Comments</span>
</div> </div>
@if (profile()!.achievementPoints > 0) {
<div class="stat-card achievement-points-card">
<span class="stat-value">{{ profile()!.achievementPoints }}</span>
<span class="stat-label">🏆 Achievement Points</span>
</div>
}
</div> </div>
</div> </div>
@if (recentAchievements().length > 0) {
<div class="achievements-section">
<div class="section-header">
<h2>Recent Achievements</h2>
@if (isOwnProfile()) {
<a routerLink="/achievements" class="view-all-link">View All →</a>
}
</div>
<div class="achievements-grid">
@for (achievement of recentAchievements(); track achievement.definition.key) {
<div class="achievement-badge" [attr.data-tier]="achievement.definition.tier.toLowerCase()">
<div class="achievement-icon">{{ achievement.definition.icon }}</div>
<div class="achievement-info">
<div class="achievement-title">{{ achievement.definition.title }}</div>
<div class="achievement-points">{{ achievement.definition.points }} pts</div>
</div>
</div>
}
</div>
</div>
}
<div class="footer-section"> <div class="footer-section">
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p> <p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
</div> </div>
@@ -372,7 +401,7 @@ import { PrimaryBadge } from '@library/shared-types';
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.stats-section h2 { .stats-section h2, .achievements-section h2 {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6); color: var(--accent-colour, #9b59b6);
font-size: 1.5rem; font-size: 1.5rem;
@@ -417,15 +446,114 @@ import { PrimaryBadge } from '@library/shared-types';
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
font-size: 0.9rem; 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 { export class ProfileComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private userService = inject(UserService); private userService = inject(UserService);
private achievementService = inject(AchievementService);
private toastService = inject(ToastService); private toastService = inject(ToastService);
private authService = inject(AuthService); private authService = inject(AuthService);
profile = signal<UserProfileResponse | null>(null); profile = signal<UserProfileResponse | null>(null);
recentAchievements = signal<AchievementProgress[]>([]);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
reportModalOpen = signal(false); reportModalOpen = signal(false);
@@ -455,6 +583,26 @@ export class ProfileComponent implements OnInit {
next: (profileData: UserProfileResponse) => { next: (profileData: UserProfileResponse) => {
this.profile.set(profileData); this.profile.set(profileData);
this.loading.set(false); 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) => { error: (err: Error) => {
console.error('Error loading profile:', err); console.error('Error loading profile:', err);
@@ -489,6 +637,20 @@ export class ProfileComponent implements OnInit {
return currentUser.id !== profileData.id; 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. * Open the report modal.
*/ */
@@ -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<AchievementDefinition[]> {
return this.api.get<AchievementDefinition[]>('/achievements/definitions');
}
/**
* Get a specific achievement definition by key.
*/
getAchievementDefinition(key: string): Observable<AchievementDefinition> {
return this.api.get<AchievementDefinition>(`/achievements/definitions/${key}`);
}
/**
* Get current user's achievement summary.
*/
getCurrentUserSummary(): Observable<UserAchievementSummary> {
return this.api.get<UserAchievementSummary>('/achievements/summary');
}
/**
* Get current user's achievement progress.
*/
getCurrentUserProgress(): Observable<AchievementProgress[]> {
return this.api.get<AchievementProgress[]>('/achievements/progress');
}
/**
* Get another user's achievement summary.
*/
getUserSummary(userId: string): Observable<UserAchievementSummary> {
return this.api.get<UserAchievementSummary>(`/achievements/users/${userId}/summary`);
}
/**
* Get another user's achievement progress.
*/
getUserProgress(userId: string): Observable<AchievementProgress[]> {
return this.api.get<AchievementProgress[]>(`/achievements/users/${userId}/progress`);
}
}
@@ -24,6 +24,7 @@ export interface UserProfileResponse {
linkedin?: string; linkedin?: string;
twitch?: string; twitch?: string;
youtube?: string; youtube?: string;
achievementPoints: number;
badges: { badges: {
isStaff: boolean; isStaff: boolean;
isMod: boolean; isMod: boolean;
+2
View File
@@ -3,6 +3,8 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
export * from "./lib/achievement.constants";
export * from "./lib/achievement.types";
export type * from "./lib/art.types"; export type * from "./lib/art.types";
export * from "./lib/audit.types"; export * from "./lib/audit.types";
export * from "./lib/auth.types"; export * from "./lib/auth.types";
@@ -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<string, AchievementDefinition> = {
// ========== 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);
+70
View File
@@ -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;
}[];
}
+1
View File
@@ -21,6 +21,7 @@ enum AuditAction {
rateLimitExceeded = "RATE_LIMIT_EXCEEDED", rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
csrfValidationFailed = "CSRF_VALIDATION_FAILED", csrfValidationFailed = "CSRF_VALIDATION_FAILED",
unauthorizedAccess = "UNAUTHORIZED_ACCESS", unauthorizedAccess = "UNAUTHORIZED_ACCESS",
achievementUnlocked = "ACHIEVEMENT_UNLOCKED",
} }
enum AuditCategory { enum AuditCategory {