Compare commits

..

12 Commits

Author SHA1 Message Date
minori 680fee72db deps: update @nx/web to 23.0.1
Node.js CI / CI (pull_request) Failing after 19s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m49s
2026-07-04 07:05:36 -07:00
minori df79769dd0 deps: update @nx/web to 23.0.0
Node.js CI / CI (pull_request) Failing after 18s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m29s
2026-06-27 07:06:12 -07:00
minori 169d19434d deps: update @nx/web to 22.7.5
Node.js CI / CI (pull_request) Failing after 15s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m0s
2026-06-07 07:05:58 -07:00
minori 3d6031af72 deps: update @nx/web to 22.7.4
Node.js CI / CI (pull_request) Failing after 13s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m33s
2026-06-05 07:06:26 -07:00
minori f0f8509914 deps: update @nx/web to 22.7.3
Node.js CI / CI (pull_request) Failing after 15s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m27s
2026-06-02 07:05:45 -07:00
minori 825bb7369c deps: update @nx/web to 22.7.2
Node.js CI / CI (pull_request) Failing after 12s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m30s
2026-05-25 07:05:28 -07:00
minori e6bb4eb868 deps: update @nx/web to 22.7.1
Node.js CI / CI (pull_request) Failing after 12s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m34s
2026-05-09 07:10:42 -07:00
minori 4201f6b2e7 deps: update @nx/web to 22.7.0
Node.js CI / CI (pull_request) Failing after 14s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m28s
2026-05-05 17:45:00 -07:00
minori a14b82235f deps: update @nx/web to 22.6.5
Node.js CI / CI (pull_request) Failing after 14s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m20s
2026-04-21 07:05:09 -07:00
minori 029f3d7f43 deps: update @nx/web to 22.6.4
Node.js CI / CI (pull_request) Failing after 19s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 2m0s
2026-04-17 07:03:41 -07:00
minori e59d9ee7bd deps: update @nx/web to 22.5.0
Node.js CI / CI (pull_request) Failing after 8s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m4s
2026-02-20 07:14:16 -08:00
minori 16ef922d7b deps: update @nx/web to 22.4.5
Node.js CI / CI (pull_request) Failing after 14s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m34s
2026-02-14 07:09:25 -08:00
99 changed files with 1254 additions and 5757 deletions
+1 -5
View File
@@ -3,12 +3,8 @@ module.exports = {
preset: '../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', {
tsconfig: '<rootDir>/tsconfig.spec.json',
isolatedModules: true,
}],
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../coverage/api',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
};
-27
View File
@@ -24,9 +24,7 @@ model Game {
platform String?
status GameStatus
dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0)
notes String?
coverImage String?
@@ -41,7 +39,6 @@ enum GameStatus {
PLAYING
COMPLETED
BACKLOG
RETIRED
}
model Book {
@@ -51,7 +48,6 @@ model Book {
isbn String?
status BookStatus
dateAdded DateTime @default(now())
dateStarted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0)
notes String?
@@ -67,7 +63,6 @@ enum BookStatus {
READING
FINISHED
TO_READ
RETIRED
}
model Music {
@@ -77,9 +72,7 @@ model Music {
type MusicType
status MusicStatus
dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0)
notes String?
coverArt String?
@@ -100,7 +93,6 @@ enum MusicStatus {
LISTENING
COMPLETED
WANT_TO_LISTEN
RETIRED
}
model Art {
@@ -123,9 +115,7 @@ model Show {
type ShowType
status ShowStatus
dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0)
notes String?
coverImage String?
@@ -147,7 +137,6 @@ enum ShowStatus {
WATCHING
COMPLETED
WANT_TO_WATCH
RETIRED
}
model Manga {
@@ -156,9 +145,7 @@ model Manga {
author String
status MangaStatus
dateAdded DateTime @default(now())
dateStarted DateTime?
dateCompleted DateTime?
dateFinished DateTime?
rating Int? @db.Int @default(0)
notes String?
coverImage String?
@@ -173,7 +160,6 @@ enum MangaStatus {
READING
COMPLETED
WANT_TO_READ
RETIRED
}
model User {
@@ -182,17 +168,6 @@ model User {
username String
email String @unique
avatar String?
slug String?
displayName String?
bio String?
profilePublic Boolean @default(true)
website String?
discordServer String?
bluesky String?
github String?
linkedin String?
twitch String?
youtube String?
isAdmin Boolean @default(false)
isBanned Boolean @default(false)
inDiscord Boolean @default(false)
@@ -205,8 +180,6 @@ model User {
suggestions Suggestion[]
likes Like[]
refreshTokens RefreshToken[]
@@index([slug], map: "User_slug_key")
}
model Comment {
+1 -1
View File
@@ -15,6 +15,6 @@ describe('GET /', () => {
url: '/',
});
expect(response.json()).toEqual({ version: expect.any(String) });
expect(response.json()).toEqual({ message: 'Hello API' });
});
});
+4 -12
View File
@@ -13,8 +13,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
// Log CSRF validation failures
if (error.code === 'FST_CSRF_INVALID_TOKEN' || error.code === 'FST_CSRF_MISSING_SECRET') {
await AuditService.log({
action: AuditAction.csrfValidationFailed,
category: AuditCategory.security,
action: AuditAction.CSRF_VALIDATION_FAILED,
category: AuditCategory.SECURITY,
details: `CSRF validation failed: ${error.message}, URL: ${request.url}`,
success: false,
}, request).catch(() => {
@@ -25,8 +25,8 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
// Log unauthorized access attempts
if (error.statusCode === 401 || error.statusCode === 403) {
await AuditService.log({
action: AuditAction.unauthorizedAccess,
category: AuditCategory.security,
action: AuditAction.UNAUTHORIZED_ACCESS,
category: AuditCategory.SECURITY,
details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`,
success: false,
}, request).catch(() => {
@@ -57,13 +57,5 @@ export async function app(fastify: FastifyInstance, opts: AppOptions) {
fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'),
options: { ...opts, prefix: '/api' },
ignorePattern: /root\.ts$/,
});
// Register root route without prefix
fastify.register(AutoLoad, {
dir: path.join(__dirname, 'routes'),
options: { ...opts },
matchFilter: /root\.ts$/,
});
}
+1 -3
View File
@@ -82,9 +82,7 @@ const authPlugin: FastifyPluginAsync = async (app) => {
try {
await request.jwtVerify();
} catch (err) {
const error = new Error("Invalid token");
(error as any).statusCode = 401;
throw error;
throw app.httpErrors.unauthorized("Invalid token");
}
});
};
+2 -2
View File
@@ -17,8 +17,8 @@ const rateLimitPlugin: FastifyPluginAsync = async (app) => {
errorResponseBuilder: (request) => {
// Log rate limit exceeded event
AuditService.log({
action: AuditAction.rateLimitExceeded,
category: AuditCategory.security,
action: AuditAction.RATE_LIMIT_EXCEEDED,
category: AuditCategory.SECURITY,
details: `Rate limit exceeded for URL: ${request.url}`,
success: false,
}, request).catch(() => {
+12 -12
View File
@@ -46,8 +46,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const art = await artService.createArt(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: art.id,
details: `Created art: ${art.title}`,
@@ -74,8 +74,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const art = await artService.updateArt(id, request.body);
if (art) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Updated art: ${art.title}`,
@@ -98,8 +98,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await artService.deleteArt(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Deleted art with ID: ${id}`,
@@ -133,8 +133,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForArt(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Added comment to art`,
@@ -169,8 +169,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Updated comment ${commentId} on art`,
@@ -205,8 +205,8 @@ const artRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Deleted comment ${commentId} from art`,
+6 -6
View File
@@ -85,8 +85,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
// Log successful login
await AuditService.log({
action: AuditAction.login,
category: AuditCategory.auth,
action: AuditAction.LOGIN,
category: AuditCategory.AUTH,
userId: user.id,
details: `User ${user.username} logged in via Discord`,
success: true,
@@ -114,8 +114,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
} catch (error) {
// Log failed login attempt
await AuditService.log({
action: AuditAction.loginFailed,
category: AuditCategory.security,
action: AuditAction.LOGIN_FAILED,
category: AuditCategory.SECURITY,
details: error instanceof Error ? error.message : String(error),
success: false,
}, request);
@@ -229,8 +229,8 @@ const authRoutes: FastifyPluginAsync = async (app) => {
const user = request.user as { id?: string; username?: string };
if (user?.id) {
await AuditService.log({
action: AuditAction.logout,
category: AuditCategory.auth,
action: AuditAction.LOGOUT,
category: AuditCategory.AUTH,
userId: user.id,
details: `User ${user.username ?? "unknown"} logged out`,
success: true,
+12 -12
View File
@@ -46,8 +46,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const book = await bookService.createBook(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: book.id,
details: `Created book: ${book.title}`,
@@ -74,8 +74,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const book = await bookService.updateBook(id, request.body);
if (book) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Updated book: ${book.title}`,
@@ -98,8 +98,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await bookService.deleteBook(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Deleted book with ID: ${id}`,
@@ -133,8 +133,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForBook(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Added comment to book`,
@@ -169,8 +169,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Updated comment ${commentId} on book`,
@@ -205,8 +205,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Deleted comment ${commentId} from book`,
+12 -12
View File
@@ -40,8 +40,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const game = await gameService.createGame(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: game.id,
details: `Created game: ${game.title}`,
@@ -66,8 +66,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const game = await gameService.updateGame(id, request.body);
if (game) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Updated game: ${game.title}`,
@@ -88,8 +88,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await gameService.deleteGame(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Deleted game with ID: ${id}`,
@@ -119,8 +119,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForGame(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Added comment to game`,
@@ -153,8 +153,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Updated comment ${commentId} on game`,
@@ -187,8 +187,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Deleted comment ${commentId} from game`,
-40
View File
@@ -1,40 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { FastifyInstance, FastifyRequest } from 'fastify';
import { logger } from '../../utils/logger';
interface LogBody {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: string;
error?: {
name: string;
message: string;
stack?: string;
};
}
export default async function (fastify: FastifyInstance) {
fastify.post('/log', async function (request: FastifyRequest<{ Body: LogBody }>) {
const { level, message, context, error } = request.body;
if (level === 'error' && error) {
const errorObj = new Error(error.message);
errorObj.name = error.name;
if (error.stack) {
errorObj.stack = error.stack;
}
await logger.error(context || 'Frontend', errorObj);
} else if (level === 'error') {
await logger.log('warn', `[Frontend Error] ${message}`);
} else {
await logger.log(level, `[Frontend] ${message}`);
}
return { success: true };
});
}
+12 -12
View File
@@ -37,8 +37,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const manga = await mangaService.createManga(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: manga.id,
details: `Created manga: ${manga.title}`,
@@ -62,8 +62,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const manga = await mangaService.updateManga(id, request.body);
if (manga) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Updated manga: ${manga.title}`,
@@ -83,8 +83,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await mangaService.deleteManga(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Deleted manga with ID: ${id}`,
@@ -112,8 +112,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForManga(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Added comment to manga`,
@@ -145,8 +145,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Updated comment ${commentId} on manga`,
@@ -178,8 +178,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Deleted comment ${commentId} from manga`,
+12 -12
View File
@@ -46,8 +46,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const music = await musicService.createMusic(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: music.id,
details: `Created music: ${music.title}`,
@@ -74,8 +74,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const music = await musicService.updateMusic(id, request.body);
if (music) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Updated music: ${music.title}`,
@@ -98,8 +98,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await musicService.deleteMusic(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Deleted music with ID: ${id}`,
@@ -133,8 +133,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForMusic(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Added comment to music`,
@@ -169,8 +169,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Updated comment ${commentId} on music`,
@@ -205,8 +205,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Deleted comment ${commentId} from music`,
+1 -24
View File
@@ -1,30 +1,7 @@
import { FastifyInstance } from 'fastify';
import { readFileSync } from 'fs';
import { join } from 'path';
interface PackageJson {
version: string;
}
let cachedVersion: string | null = null;
function getVersion(): string {
if (cachedVersion) {
return cachedVersion;
}
try {
const packageJsonPath = join(process.cwd(), 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as PackageJson;
cachedVersion = packageJson.version;
return cachedVersion;
} catch {
return 'unknown';
}
}
export default async function (fastify: FastifyInstance) {
fastify.get('/', async function () {
return { version: getVersion() };
return { message: 'Hello API' };
});
}
+12 -12
View File
@@ -37,8 +37,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
async (request) => {
const show = await showService.createShow(request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: show.id,
details: `Created show: ${show.title}`,
@@ -62,8 +62,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const show = await showService.updateShow(id, request.body);
if (show) {
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.content,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Updated show: ${show.title}`,
@@ -83,8 +83,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const { id } = request.params;
await showService.deleteShow(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Deleted show with ID: ${id}`,
@@ -112,8 +112,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const userId = request.user.id;
const comment = await commentService.createCommentForShow(id, userId, request.body);
await AuditService.logFromRequest(request, {
action: AuditAction.commentCreate,
category: AuditCategory.content,
action: AuditAction.COMMENT_CREATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Added comment to show`,
@@ -145,8 +145,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.commentUpdate,
category: AuditCategory.content,
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Updated comment ${commentId} on show`,
@@ -178,8 +178,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.commentDelete,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.COMMENT_DELETE,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Deleted comment ${commentId} from show`,
+10 -10
View File
@@ -85,8 +85,8 @@ export default async function (app: FastifyInstance): Promise<void> {
);
await AuditService.logFromRequest(request, {
action: AuditAction.entryCreate,
category: AuditCategory.content,
action: AuditAction.ENTRY_CREATE,
category: AuditCategory.CONTENT,
resourceType: "Suggestion",
resourceId: suggestion.id,
details: `Created ${suggestion.entityType} suggestion: ${suggestion.title}`,
@@ -115,8 +115,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.acceptSuggestion(id);
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.admin,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.ADMIN,
resourceType: "Suggestion",
resourceId: suggestion.id,
details: `Accepted ${suggestion.entityType} suggestion: ${suggestion.title}`,
@@ -146,8 +146,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData);
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.admin,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.ADMIN,
resourceType: "Suggestion",
resourceId: suggestion.id,
details: `Accepted ${suggestion.entityType} suggestion with edits: ${suggestion.title}`,
@@ -177,8 +177,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.declineSuggestion(id, reason);
await AuditService.logFromRequest(request, {
action: AuditAction.entryUpdate,
category: AuditCategory.admin,
action: AuditAction.ENTRY_UPDATE,
category: AuditCategory.ADMIN,
resourceType: "Suggestion",
resourceId: suggestion.id,
details: `Declined ${suggestion.entityType} suggestion: ${suggestion.title}${reason ? ` (Reason: ${reason})` : ""}`,
@@ -209,8 +209,8 @@ export default async function (app: FastifyInstance): Promise<void> {
const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin);
await AuditService.logFromRequest(request, {
action: AuditAction.entryDelete,
category: isAdmin ? AuditCategory.admin : AuditCategory.content,
action: AuditAction.ENTRY_DELETE,
category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "Suggestion",
resourceId: suggestion.id,
details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`,
+4 -148
View File
@@ -10,49 +10,6 @@ import { UserService } from "../../services/user.service";
import { AuditService } from "../../services/audit.service";
import { adminGuard } from "../../middleware/admin-guard";
interface UpdateUserSettingsBody {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
interface UserProfileResponse {
id: string;
username: string;
displayName?: string;
avatar?: string;
bio?: string;
slug?: string;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
badges: {
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
};
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
createdAt: Date;
}
const usersRoutes: FastifyPluginAsync = async (app) => {
const userService = new UserService();
@@ -66,107 +23,6 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
}
);
app.get<{ Reply: User }>(
"/me",
{
preValidation: [app.authenticate],
},
async (request) => {
const currentUser = request.user as { id: string };
const user = await userService.getUserById(currentUser.id);
if (!user) {
throw new Error("User not found");
}
return user;
}
);
app.put<{ Body: UpdateUserSettingsBody; Reply: User | { error: string } }>(
"/me",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const currentUser = request.user as { id: string };
const updates = request.body;
// If slug is being updated, check if it's unique
if (updates.slug) {
const existingUser = await userService.getUserBySlug(updates.slug);
if (existingUser && existingUser.id !== currentUser.id) {
return reply.code(400).send({ error: "Slug already taken" });
}
}
const updatedUser = await userService.updateUserSettings(
currentUser.id,
updates
);
if (!updatedUser) {
return reply.code(404).send({ error: "User not found" });
}
return updatedUser;
}
);
app.get<{
Params: { identifier: string };
Reply: UserProfileResponse | { error: string };
}>(
"/profile/:identifier",
async (request, reply) => {
const { identifier } = request.params;
try {
const profile = await userService.getUserProfile(identifier);
if (!profile) {
return reply.code(404).send({ error: "User not found" });
}
if (!profile.profilePublic) {
// Check if the requesting user is viewing their own profile
const currentUser = request.user as { id: string } | undefined;
if (!currentUser || currentUser.id !== profile.id) {
return reply
.code(403)
.send({ error: "This profile is private" });
}
}
return {
id: profile.id,
username: profile.username,
displayName: profile.displayName,
avatar: profile.avatar,
bio: profile.bio,
slug: profile.slug,
website: profile.website,
discordServer: profile.discordServer,
bluesky: profile.bluesky,
github: profile.github,
linkedin: profile.linkedin,
twitch: profile.twitch,
youtube: profile.youtube,
badges: {
isStaff: profile.isStaff,
isMod: profile.isMod,
isVip: profile.isVip,
inDiscord: profile.inDiscord,
},
stats: profile.stats,
createdAt: profile.createdAt,
};
} catch (error) {
console.error("Error fetching profile:", error);
return reply.code(500).send({ error: "Failed to fetch profile" });
}
}
);
app.get<{ Params: { id: string }; Reply: User | null }>(
"/:id",
{
@@ -198,8 +54,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
}
await AuditService.logFromRequest(request, {
action: AuditAction.userBan,
category: AuditCategory.admin,
action: AuditAction.USER_BAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Banned user: ${user.username}`,
});
@@ -222,8 +78,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
}
await AuditService.logFromRequest(request, {
action: AuditAction.userUnban,
category: AuditCategory.admin,
action: AuditAction.USER_UNBAN,
category: AuditCategory.ADMIN,
targetUserId: id,
details: `Unbanned user: ${user.username}`,
});
+1 -1
View File
@@ -36,7 +36,7 @@ export const AuditService = {
request: FastifyRequest,
data: Omit<AuditLogData, "userId">
) {
const userId = ((request as any).user as { id?: string } | undefined)?.id;
const userId = (request.user as { id?: string } | undefined)?.id;
return this.log(
{
-27
View File
@@ -71,15 +71,6 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
@@ -176,15 +167,6 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
@@ -235,15 +217,6 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
-4
View File
@@ -24,7 +24,6 @@ export class BookService {
...book,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
@@ -47,7 +46,6 @@ export class BookService {
...book,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
@@ -71,7 +69,6 @@ export class BookService {
...book,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
@@ -98,7 +95,6 @@ export class BookService {
...book,
status: book.status as unknown as BookStatus,
dateAdded: book.dateAdded,
dateStarted: book.dateStarted || undefined,
dateFinished: book.dateFinished || undefined,
tags: book.tags ?? [],
links: book.links ?? [],
-8
View File
@@ -24,9 +24,7 @@ export class GameService {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
@@ -48,9 +46,7 @@ export class GameService {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
@@ -73,9 +69,7 @@ export class GameService {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
@@ -101,9 +95,7 @@ export class GameService {
...game,
status: game.status as unknown as GameStatus,
dateAdded: game.dateAdded,
dateStarted: game.dateStarted || undefined,
dateCompleted: game.dateCompleted || undefined,
dateFinished: game.dateFinished || undefined,
tags: game.tags ?? [],
links: game.links ?? [],
createdAt: game.createdAt,
+4 -4
View File
@@ -32,8 +32,8 @@ export class LikeService {
});
await AuditService.logFromRequest(req, {
action: AuditAction.unlike,
category: AuditCategory.content,
action: AuditAction.UNLIKE,
category: AuditCategory.CONTENT,
resourceType: entityType,
resourceId: entityId,
details: `Unliked ${entityType}`
@@ -52,8 +52,8 @@ export class LikeService {
});
await AuditService.logFromRequest(req, {
action: AuditAction.like,
category: AuditCategory.content,
action: AuditAction.LIKE,
category: AuditCategory.CONTENT,
resourceType: entityType,
resourceId: entityId,
details: `Liked ${entityType}`
-8
View File
@@ -21,9 +21,7 @@ export class MangaService {
...m,
status: m.status as unknown as MangaStatus,
dateAdded: m.dateAdded,
dateStarted: m.dateStarted || undefined,
dateCompleted: m.dateCompleted || undefined,
dateFinished: m.dateFinished || undefined,
tags: m.tags ?? [],
links: m.links ?? [],
createdAt: m.createdAt,
@@ -42,9 +40,7 @@ export class MangaService {
...manga,
status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt,
@@ -64,9 +60,7 @@ export class MangaService {
...manga,
status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt,
@@ -89,9 +83,7 @@ export class MangaService {
...manga,
status: manga.status as unknown as MangaStatus,
dateAdded: manga.dateAdded,
dateStarted: manga.dateStarted || undefined,
dateCompleted: manga.dateCompleted || undefined,
dateFinished: manga.dateFinished || undefined,
tags: manga.tags ?? [],
links: manga.links ?? [],
createdAt: manga.createdAt,
-8
View File
@@ -25,9 +25,7 @@ export class MusicService {
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt,
@@ -50,9 +48,7 @@ export class MusicService {
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt,
@@ -77,9 +73,7 @@ export class MusicService {
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt,
@@ -109,9 +103,7 @@ export class MusicService {
type: music.type as unknown as MusicType,
status: music.status as unknown as MusicStatus,
dateAdded: music.dateAdded,
dateStarted: music.dateStarted || undefined,
dateCompleted: music.dateCompleted || undefined,
dateFinished: music.dateFinished || undefined,
tags: music.tags ?? [],
links: music.links ?? [],
createdAt: music.createdAt,
-8
View File
@@ -22,9 +22,7 @@ export class ShowService {
type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt,
@@ -44,9 +42,7 @@ export class ShowService {
type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt,
@@ -68,9 +64,7 @@ export class ShowService {
type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt,
@@ -97,9 +91,7 @@ export class ShowService {
type: show.type as unknown as ShowType,
status: show.status as unknown as ShowStatus,
dateAdded: show.dateAdded,
dateStarted: show.dateStarted || undefined,
dateCompleted: show.dateCompleted || undefined,
dateFinished: show.dateFinished || undefined,
tags: show.tags ?? [],
links: show.links ?? [],
createdAt: show.createdAt,
-213
View File
@@ -6,7 +6,6 @@
import { User } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import { SuggestionStatus } from "@prisma/client";
export class UserService {
private prisma = prisma;
@@ -22,17 +21,6 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -57,17 +45,6 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -89,17 +66,6 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -121,17 +87,6 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -149,172 +104,4 @@ export class UserService {
return user?.isBanned ?? false;
}
async getUserBySlug(slug: string): Promise<User | null> {
const user = await this.prisma.user.findFirst({
where: { slug },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async updateUserSettings(
id: string,
updates: {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: updates,
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async getUserProfile(identifier: string): Promise<{
id: string;
username: string;
displayName?: string | null;
avatar?: string | null;
bio?: string | null;
slug?: string | null;
website?: string | null;
discordServer?: string | null;
bluesky?: string | null;
github?: string | null;
linkedin?: string | null;
twitch?: string | null;
youtube?: string | null;
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
profilePublic: boolean;
createdAt: Date;
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
} | null> {
// Try to find by slug first, then by id if it's a valid ObjectId
const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier);
const whereConditions = isValidObjectId
? [{ slug: identifier }, { id: identifier }]
: [{ slug: identifier }];
const user = await this.prisma.user.findFirst({
where: {
OR: whereConditions,
},
include: {
suggestions: {
select: { id: true, status: true },
},
likes: {
select: { id: true },
},
comments: {
select: { id: true },
},
},
});
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatar: user.avatar,
bio: user.bio,
slug: user.slug,
website: user.website,
discordServer: user.discordServer,
bluesky: user.bluesky,
github: user.github,
linkedin: user.linkedin,
twitch: user.twitch,
youtube: user.youtube,
isStaff: user.isStaff,
isMod: user.isMod,
isVip: user.isVip,
inDiscord: user.inDiscord,
profilePublic: user.profilePublic,
createdAt: user.createdAt,
stats: {
suggestionsCount: user.suggestions.length,
suggestionsAcceptedCount: user.suggestions.filter(
(suggestion) => suggestion.status === SuggestionStatus.ACCEPTED
).length,
likesCount: user.likes.length,
commentsCount: user.comments.length,
},
};
}
}
-9
View File
@@ -1,9 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger("Library", process.env.LOG_TOKEN ?? "");
+1 -34
View File
@@ -1,42 +1,9 @@
import Fastify from 'fastify';
import { app } from './app/app';
import { logger } from './app/utils/logger';
const host = process.env.HOST ?? 'localhost';
const port = process.env.PORT ? Number(process.env.PORT) : 12321;
// Global error handlers
process.on('uncaughtException', (error: Error) => {
void logger.error('Uncaught Exception', error);
process.exit(1);
});
process.on('unhandledRejection', (reason: unknown) => {
const error = reason instanceof Error ? reason : new Error(String(reason));
void logger.error('Unhandled Rejection', error);
process.exit(1);
});
process.on('warning', (warning: Error) => {
void logger.log('warn', `Process Warning: ${warning.name} - ${warning.message}`);
});
process.on('SIGTERM', () => {
void logger.log('info', 'SIGTERM signal received: closing HTTP server');
server.close(() => {
void logger.log('info', 'HTTP server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
void logger.log('info', 'SIGINT signal received: closing HTTP server');
server.close(() => {
void logger.log('info', 'HTTP server closed');
process.exit(0);
});
});
// Instantiate Fastify with some config
const server = Fastify({
logger: true,
@@ -52,6 +19,6 @@ server.listen({ port, host }, (err) => {
server.log.error(err);
process.exit(1);
} else {
void logger.log('info', `Server ready at http://${host}:${port}`);
console.log(`[ ready ] http://${host}:${port}`);
}
});
-47
View File
@@ -1,47 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
// Set required environment variables for tests
process.env.JWT_SECRET = 'test-secret';
process.env.DISCORD_CLIENT_ID = 'test-client-id';
process.env.DISCORD_CLIENT_SECRET = 'test-client-secret';
process.env.DOMAIN = 'http://localhost:3000';
process.env.API_URL = 'http://localhost:3000/api';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test';
process.env.BASE_URL = 'http://localhost:4200';
process.env.NODE_ENV = 'test';
// Mock ESM packages to avoid import issues in Jest
jest.mock('jsdom', () => ({
JSDOM: class {
window = {
document: {
createElement: jest.fn(() => ({})),
},
};
},
}));
jest.mock('marked', () => ({
marked: jest.fn((input: string) => `<p>${input}</p>`),
}));
jest.mock('dompurify', () => {
const mockDOMPurify = {
sanitize: jest.fn((input: string) => input),
addHook: jest.fn(),
};
const createDOMPurify = jest.fn(() => mockDOMPurify);
return createDOMPurify;
});
jest.mock('@nhcarrigan/logger', () => ({
Logger: class {
log = jest.fn().mockResolvedValue(undefined);
error = jest.fn().mockResolvedValue(undefined);
metric = jest.fn().mockResolvedValue(undefined);
},
}));
+1 -2
View File
@@ -10,7 +10,6 @@
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/test-setup.ts"
"src/**/*.test.ts"
]
}
-14
View File
@@ -2,7 +2,6 @@ import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
APP_INITIALIZER,
ErrorHandler,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http';
@@ -10,19 +9,12 @@ import { appRoutes } from './app.routes';
import { AuthService } from './services/auth.service';
import { AuthInterceptor } from './interceptors/auth.interceptor';
import { initializeAuth } from './initializers/auth.initializer';
import { GlobalErrorHandler } from './services/global-error-handler.service';
import { ConsoleLoggerService } from './services/console-logger.service';
import { initializeConsoleLogger } from './initializers/console-logger.initializer';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
{
provide: ErrorHandler,
useClass: GlobalErrorHandler
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
@@ -33,12 +25,6 @@ export const appConfig: ApplicationConfig = {
useFactory: initializeAuth,
deps: [AuthService],
multi: true
},
{
provide: APP_INITIALIZER,
useFactory: initializeConsoleLogger,
deps: [ConsoleLoggerService],
multi: true
}
],
};
-1
View File
@@ -3,4 +3,3 @@
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
<app-toast></app-toast>
-8
View File
@@ -49,14 +49,6 @@ export const appRoutes: Route[] = [
path: 'my-likes',
loadComponent: () => import('./components/my-likes/my-likes.component').then(m => m.MyLikesComponent)
},
{
path: 'profile/:identifier',
loadComponent: () => import('./components/profile/profile.component').then(m => m.ProfileComponent)
},
{
path: 'settings',
loadComponent: () => import('./components/settings/settings.component').then(m => m.SettingsComponent)
},
{
path: '**',
redirectTo: ''
+1 -2
View File
@@ -2,11 +2,10 @@ import { Component, inject, OnInit } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
import { ToastComponent } from './components/toast/toast.component';
import { AnalyticsService } from './services/analytics.service';
@Component({
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
imports: [RouterModule, HeaderComponent, FooterComponent],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.scss',
@@ -39,22 +39,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }})
</button>
<button
(click)="setFilter(SuggestionStatus.unreviewed)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
class="filter-btn pending"
>
Pending ({{ unreviewedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.accepted)"
[class.active]="statusFilter() === SuggestionStatus.accepted"
(click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
class="filter-btn accepted"
>
Accepted ({{ acceptedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.declined)"
[class.active]="statusFilter() === SuggestionStatus.declined"
(click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
class="filter-btn declined"
>
Declined ({{ declinedCount() }})
@@ -171,7 +171,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
</div>
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
<div class="decline-reason">
<strong>Decline reason:</strong> {{ suggestion.declineReason }}
</div>
@@ -180,7 +180,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
<div class="suggestion-footer">
<span class="date">Suggested on {{ formatDate(suggestion.createdAt) }}</span>
@if (suggestion.status === SuggestionStatus.unreviewed) {
@if (suggestion.status === SuggestionStatus.UNREVIEWED) {
<div class="actions">
<button (click)="acceptSuggestion(suggestion)" class="btn btn-accept">
Accept
@@ -206,8 +206,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
@if (showDeclineModal()) {
<div class="modal-overlay" (click)="closeDeclineModal()" (keyup.escape)="closeDeclineModal()" tabindex="0" role="button">
<div class="modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<div class="modal-overlay" (click)="closeDeclineModal()">
<div class="modal" (click)="$event.stopPropagation()">
<h3>Decline Suggestion</h3>
<p>Are you sure you want to decline "{{ decliningsuggestion()?.title }}"?</p>
<div class="form-group">
@@ -229,8 +229,8 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
@if (showEditModal()) {
<div class="modal-overlay" (click)="closeEditModal()" (keyup.escape)="closeEditModal()" tabindex="0" role="button">
<div class="modal edit-modal" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<div class="modal-overlay" (click)="closeEditModal()">
<div class="modal edit-modal" (click)="$event.stopPropagation()">
<h3>Review & Edit Before Accepting</h3>
<p>Review and edit the details before adding to your collection.</p>
@@ -248,7 +248,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</div>
@switch (editingSuggestion()!.entityType) {
@case (SuggestionEntity.book) {
@case ('BOOK') {
<div class="form-group">
<label for="edit-author">Author</label>
<input
@@ -269,7 +269,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case (SuggestionEntity.game) {
@case ('GAME') {
<div class="form-group">
<label for="edit-platform">Platform</label>
<input
@@ -280,7 +280,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case (SuggestionEntity.music) {
@case ('MUSIC') {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
@@ -300,7 +300,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select>
</div>
}
@case (SuggestionEntity.art) {
@case ('ART') {
<div class="form-group">
<label for="edit-artist">Artist</label>
<input
@@ -331,7 +331,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
>
</div>
}
@case (SuggestionEntity.show) {
@case ('SHOW') {
<div class="form-group">
<label for="edit-type">Type</label>
<select id="edit-type" [(ngModel)]="editedData.type" name="type" required>
@@ -342,7 +342,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
</select>
</div>
}
@case (SuggestionEntity.manga) {
@case ('MANGA') {
<div class="form-group">
<label for="edit-author">Author</label>
<input
@@ -366,7 +366,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
></textarea>
</div>
@if (editingSuggestion()!.entityType !== SuggestionEntity.art) {
@if (editingSuggestion()!.entityType !== 'ART') {
<div class="form-group">
<label for="edit-coverImage">Cover Image URL</label>
<input
@@ -727,11 +727,10 @@ export class AdminSuggestionsComponent implements OnInit {
pageSize = signal(25);
SuggestionStatus = SuggestionStatus;
SuggestionEntity = SuggestionEntity;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
@@ -789,20 +788,20 @@ export class AdminSuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string {
switch (status) {
case SuggestionStatus.unreviewed: return 'Pending';
case SuggestionStatus.accepted: return 'Accepted';
case SuggestionStatus.declined: return 'Declined';
case SuggestionStatus.UNREVIEWED: return 'Pending';
case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.DECLINED: return 'Declined';
}
}
getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) {
case SuggestionEntity.game: return '🎮';
case SuggestionEntity.book: return '📚';
case SuggestionEntity.music: return '🎵';
case SuggestionEntity.manga: return 'đź“–';
case SuggestionEntity.show: return '📺';
case SuggestionEntity.art: return '🎨';
case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.MANGA: return 'đź“–';
case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.ART: return '🎨';
}
}
@@ -823,39 +822,39 @@ export class AdminSuggestionsComponent implements OnInit {
// Add entity-specific data
switch (suggestion.entityType) {
case SuggestionEntity.book:
case 'BOOK':
const bookData = suggestion.bookData as any;
this.editedData.author = bookData?.author || '';
this.editedData.isbn = bookData?.isbn || '';
this.editedData.notes = bookData?.notes || '';
this.editedData.coverImage = bookData?.coverImage || '';
break;
case SuggestionEntity.game:
case 'GAME':
const gameData = suggestion.gameData as any;
this.editedData.platform = gameData?.platform || '';
this.editedData.notes = gameData?.notes || '';
this.editedData.coverImage = gameData?.coverImage || '';
break;
case SuggestionEntity.music:
case 'MUSIC':
const musicData = suggestion.musicData as any;
this.editedData.artist = musicData?.artist || '';
this.editedData.type = musicData?.type || 'ALBUM';
this.editedData.notes = musicData?.notes || '';
this.editedData.coverArt = musicData?.coverArt || '';
break;
case SuggestionEntity.art:
case 'ART':
const artData = suggestion.artData as any;
this.editedData.artist = artData?.artist || '';
this.editedData.description = artData?.description || '';
this.editedData.imageUrl = artData?.imageUrl || '';
break;
case SuggestionEntity.show:
case 'SHOW':
const showData = suggestion.showData as any;
this.editedData.type = showData?.type || 'TV_SERIES';
this.editedData.notes = showData?.notes || '';
this.editedData.coverImage = showData?.coverImage || '';
break;
case SuggestionEntity.manga:
case 'MANGA':
const mangaData = suggestion.mangaData as any;
this.editedData.author = mangaData?.author || '';
this.editedData.notes = mangaData?.notes || '';
@@ -105,7 +105,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -122,7 +123,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newArt.links; track link.url; let i = $index) {
<div class="link-item">
@@ -278,7 +280,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editArt.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -295,7 +298,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editArt.links; track link.url; let i = $index) {
<div class="link-item">
@@ -397,10 +401,6 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
[alt]="art.description || art.title"
class="art-image"
(click)="openLightbox(art)"
(keyup.enter)="openLightbox(art)"
(keyup.space)="openLightbox(art)"
tabindex="0"
role="button"
>
</div>
@@ -536,8 +536,8 @@ import { Art, CreateArtDto, UpdateArtDto, Comment, SuggestionEntity, Link } from
}
@if (lightboxArt()) {
<div class="lightbox" (click)="closeLightbox()" (keyup.escape)="closeLightbox()" tabindex="0" role="button">
<div class="lightbox-content" (click)="$event.stopPropagation()" (keyup)="$event.stopPropagation()" tabindex="-1">
<div class="lightbox" (click)="closeLightbox()">
<div class="lightbox-content" (click)="$event.stopPropagation()">
<button class="lightbox-close" (click)="closeLightbox()">&times;</button>
<img [src]="lightboxArt()!.imageUrl" [alt]="lightboxArt()!.description || lightboxArt()!.title">
<div class="lightbox-info">
@@ -1571,7 +1571,7 @@ export class ArtGalleryComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.art,
entityType: SuggestionEntity.ART,
title: this.suggestedArt.title,
artist: this.suggestedArt.artist,
imageUrl: this.suggestedArt.imageUrl,
@@ -79,30 +79,9 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newBook.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -147,7 +126,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newBook.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -164,7 +144,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newBook.links; track link.url; let i = $index) {
<div class="link-item">
@@ -241,30 +222,9 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
<option [value]="BookStatus.reading">Currently Reading</option>
<option [value]="BookStatus.finished">Finished</option>
<option [value]="BookStatus.toRead">To Read</option>
<option [value]="BookStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editBook.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editBook.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -309,7 +269,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editBook.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -326,7 +287,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editBook.links; track link.url; let i = $index) {
<div class="link-item">
@@ -459,7 +421,8 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
@if (showFilters()) {
<div class="advanced-filters">
<div class="filter-group">
<div class="tags-filter" aria-label="Filter by Tags">
<label>Filter by Tags:</label>
<div class="tags-filter">
@for (tag of allTags(); track tag) {
<label class="tag-checkbox">
<input
@@ -512,13 +475,6 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
>
To Read ({{ toReadCount() }})
</button>
<button
(click)="setFilter(BookStatus.retired)"
[class.active]="statusFilter() === BookStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -592,30 +548,12 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
</div>
}
@if (book.dateStarted) {
<p class="date-started">
Started: {{ formatDate(book.dateStarted) }}
</p>
}
@if (book.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(book.dateFinished) }}
</p>
}
@if (book.createdAt) {
<p class="date-added">
Added: {{ formatDate(book.createdAt) }}
</p>
}
@if (book.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(book.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(book)" class="btn btn-secondary btn-sm">
@@ -1051,10 +989,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
.date-finished {
font-size: 0.85rem;
color: var(--witch-plum);
margin-top: 0.5rem;
@@ -1482,7 +1417,6 @@ export class BooksListComponent implements OnInit {
readingCount = computed(() => this.books().filter(book => book.status === BookStatus.reading).length);
finishedCount = computed(() => this.books().filter(book => book.status === BookStatus.finished).length);
toReadCount = computed(() => this.books().filter(book => book.status === BookStatus.toRead).length);
retiredCount = computed(() => this.books().filter(book => book.status === BookStatus.retired).length);
// Get all unique tags from all books
allTags = computed(() => {
@@ -1533,13 +1467,11 @@ export class BooksListComponent implements OnInit {
totalFilteredBooks = computed(() => this.filteredBooks().length);
newBook: Partial<CreateBookDto> & { dateStarted?: Date; dateFinished?: Date } = {
newBook: Partial<CreateBookDto> = {
title: '',
author: '',
isbn: '',
status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1620,7 +1552,6 @@ export class BooksListComponent implements OnInit {
case BookStatus.reading: return 'Currently Reading';
case BookStatus.finished: return 'Finished';
case BookStatus.toRead: return 'To Read';
case BookStatus.retired: return 'Retired';
}
}
@@ -1637,8 +1568,6 @@ export class BooksListComponent implements OnInit {
author: '',
isbn: '',
status: BookStatus.toRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1705,8 +1634,6 @@ export class BooksListComponent implements OnInit {
author: this.newBook.author,
isbn: this.newBook.isbn,
status: this.newBook.status,
dateStarted: this.newBook.dateStarted ? new Date(this.newBook.dateStarted) : undefined,
dateFinished: this.newBook.dateFinished ? new Date(this.newBook.dateFinished) : undefined,
rating: this.newBook.rating,
notes: this.newBook.notes,
coverImage: this.newBook.coverImage,
@@ -1735,8 +1662,6 @@ export class BooksListComponent implements OnInit {
author: book.author,
isbn: book.isbn,
status: book.status,
dateStarted: book.dateStarted,
dateFinished: book.dateFinished,
rating: book.rating,
notes: book.notes,
coverImage: book.coverImage,
@@ -1765,13 +1690,7 @@ export class BooksListComponent implements OnInit {
const book = this.editingBook();
if (!book || !this.editBook.title || !this.editBook.author || !this.editBook.status) return;
const updateData = {
...this.editBook,
dateStarted: this.editBook.dateStarted ? new Date(this.editBook.dateStarted) : undefined,
dateFinished: this.editBook.dateFinished ? new Date(this.editBook.dateFinished) : undefined,
};
this.booksService.updateBook(book.id, updateData).subscribe(() => {
this.booksService.updateBook(book.id, this.editBook).subscribe(() => {
this.loadBooks();
this.cancelEdit();
});
@@ -1969,7 +1888,7 @@ export class BooksListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.book,
entityType: SuggestionEntity.BOOK,
title: this.suggestedBook.title,
author: this.suggestedBook.author,
isbn: this.suggestedBook.isbn,
@@ -67,30 +67,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -135,7 +114,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -152,7 +132,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -217,30 +198,9 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
<option [value]="GameStatus.playing">Currently Playing</option>
<option [value]="GameStatus.completed">Completed</option>
<option [value]="GameStatus.backlog">In Backlog</option>
<option [value]="GameStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editGame.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editGame.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -285,7 +245,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editGame.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -302,7 +263,8 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editGame.links; track link.url; let i = $index) {
<div class="link-item">
@@ -474,13 +436,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
>
Backlog ({{ backlogCount() }})
</button>
<button
(click)="setFilter(GameStatus.retired)"
[class.active]="statusFilter() === GameStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -549,26 +504,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
</div>
}
@if (game.dateStarted) {
<p class="date-started">
Started: {{ formatDate(game.dateStarted) }}
</p>
}
@if (game.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(game.dateFinished) }}
</p>
}
<p class="date-added">
Added: {{ formatDate(game.createdAt) }}
</p>
<p class="date-updated">
Updated: {{ formatDate(game.updatedAt) }}
</p>
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(game)" class="btn btn-secondary btn-sm">
@@ -923,15 +858,6 @@ import { Game, GameStatus, CreateGameDto, UpdateGameDto, Comment, SuggestionEnti
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1287,7 +1213,6 @@ export class GamesListComponent implements OnInit {
playingCount = computed(() => this.games().filter(game => game.status === GameStatus.playing).length);
completedCount = computed(() => this.games().filter(game => game.status === GameStatus.completed).length);
backlogCount = computed(() => this.games().filter(game => game.status === GameStatus.backlog).length);
retiredCount = computed(() => this.games().filter(game => game.status === GameStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1336,12 +1261,10 @@ export class GamesListComponent implements OnInit {
totalFilteredGames = computed(() => this.filteredGames().length);
newGame: Partial<CreateGameDto> & { dateStarted?: Date; dateFinished?: Date } = {
newGame: Partial<CreateGameDto> = {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1418,7 +1341,6 @@ export class GamesListComponent implements OnInit {
case GameStatus.playing: return 'Currently Playing';
case GameStatus.completed: return 'Completed';
case GameStatus.backlog: return 'In Backlog';
case GameStatus.retired: return 'Retired';
}
}
@@ -1434,8 +1356,6 @@ export class GamesListComponent implements OnInit {
title: '',
platform: '',
status: GameStatus.backlog,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1504,8 +1424,6 @@ export class GamesListComponent implements OnInit {
rating: this.newGame.rating,
notes: this.newGame.notes,
coverImage: this.newGame.coverImage,
dateStarted: this.newGame.dateStarted ? new Date(this.newGame.dateStarted) : undefined,
dateFinished: this.newGame.dateFinished ? new Date(this.newGame.dateFinished) : undefined,
tags: this.newGame.tags || [],
links: this.newGame.links || []
};
@@ -1530,8 +1448,6 @@ export class GamesListComponent implements OnInit {
title: game.title,
platform: game.platform,
status: game.status,
dateStarted: game.dateStarted,
dateFinished: game.dateFinished,
rating: game.rating,
notes: game.notes,
coverImage: game.coverImage,
@@ -1560,13 +1476,7 @@ export class GamesListComponent implements OnInit {
const game = this.editingGame();
if (!game || !this.editGame.title || !this.editGame.status) return;
const updateData: UpdateGameDto = {
...this.editGame,
dateStarted: this.editGame.dateStarted ? new Date(this.editGame.dateStarted) : undefined,
dateFinished: this.editGame.dateFinished ? new Date(this.editGame.dateFinished) : undefined
};
this.gamesService.updateGame(game.id, updateData).subscribe(() => {
this.gamesService.updateGame(game.id, this.editGame).subscribe(() => {
this.loadGames();
this.cancelEdit();
});
@@ -1763,7 +1673,7 @@ export class GamesListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.game,
entityType: SuggestionEntity.GAME,
title: this.suggestedGame.title,
platform: this.suggestedGame.platform,
notes: this.suggestedGame.notes,
@@ -35,36 +35,17 @@ import { ApiService } from '../../services/api.service';
<div class="auth-section">
@if (authService.user(); as user) {
<div class="user-menu">
@if (user.avatar) {
<img
[src]="user.avatar"
[alt]="user.username"
class="user-avatar"
(click)="toggleDropdown()"
(keyup.enter)="toggleDropdown()"
(keyup.space)="toggleDropdown()"
tabindex="0"
role="button"
/>
}
@if (showDropdown()) {
<div class="dropdown-menu">
<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>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="dropdown-item" (click)="closeDropdown()">My Suggestions</a>
}
<a routerLink="/my-likes" class="dropdown-item" (click)="closeDropdown()">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="dropdown-item" (click)="closeDropdown()">Users</a>
<a routerLink="/admin/audit" class="dropdown-item" (click)="closeDropdown()">Audit</a>
<a routerLink="/admin/suggestions" class="dropdown-item" (click)="closeDropdown()">Suggestions</a>
}
<button (click)="logout()" class="dropdown-item logout-btn">Logout</button>
</div>
}
</div>
<span class="welcome">Welcome, {{ user.username }}!</span>
@if (!user.isAdmin) {
<a routerLink="/my-suggestions" class="user-link">My Suggestions</a>
}
<a routerLink="/my-likes" class="user-link">My Likes</a>
@if (user.isAdmin) {
<a routerLink="/admin/users" class="admin-badge">Users</a>
<a routerLink="/admin/audit" class="admin-badge">Audit</a>
<a routerLink="/admin/suggestions" class="admin-badge">Suggestions</a>
}
<button (click)="logout()" class="btn btn-secondary">Logout</button>
} @else {
<button (click)="login()" class="btn btn-primary">Login with Discord</button>
}
@@ -141,75 +122,6 @@ import { ApiService } from '../../services/api.service';
color: var(--witch-lavender);
}
.user-menu {
position: relative;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid var(--witch-lavender);
transition: all 0.3s;
cursor: pointer;
}
.user-avatar:hover {
border-color: var(--witch-moon);
transform: scale(1.1);
}
.dropdown-menu {
position: absolute;
top: 50px;
right: 0;
background-color: var(--witch-purple);
border: 2px solid var(--witch-lavender);
border-radius: 8px;
padding: 0.5rem 0;
min-width: 180px;
box-shadow: 0 4px 12px var(--witch-shadow);
z-index: 1000;
animation: fadeIn 0.2s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.75rem 1rem;
color: var(--witch-lavender);
text-decoration: none;
background: none;
border: none;
text-align: left;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.dropdown-item:hover {
background-color: var(--witch-plum);
color: var(--witch-moon);
}
.logout-btn {
border-top: 1px solid var(--witch-lavender);
margin-top: 0.5rem;
padding-top: 0.75rem;
font-weight: 500;
}
.admin-badge {
background-color: var(--witch-rose);
color: var(--witch-moon);
@@ -278,7 +190,6 @@ export class HeaderComponent implements OnInit {
authService = inject(AuthService);
private apiService = inject(ApiService);
version = signal<string | null>(null);
showDropdown = signal<boolean>(false);
ngOnInit() {
this.apiService.get<{ version: string }>('/version').subscribe({
@@ -287,20 +198,11 @@ export class HeaderComponent implements OnInit {
});
}
toggleDropdown() {
this.showDropdown.update(v => !v);
}
closeDropdown() {
this.showDropdown.set(false);
}
login() {
this.authService.login();
}
logout() {
this.closeDropdown();
this.authService.logout().subscribe();
}
}
@@ -68,30 +68,9 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -136,7 +115,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -153,7 +133,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newManga.links; track link.url; let i = $index) {
<div class="link-item">
@@ -219,30 +200,9 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
<option [value]="MangaStatus.reading">Currently Reading</option>
<option [value]="MangaStatus.completed">Completed</option>
<option [value]="MangaStatus.wantToRead">Want to Read</option>
<option [value]="MangaStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editManga.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editManga.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -287,7 +247,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editManga.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -304,7 +265,8 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editManga.links; track link.url; let i = $index) {
<div class="link-item">
@@ -477,13 +439,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
>
Want to Read ({{ wantToReadCount() }})
</button>
<button
(click)="setFilter(MangaStatus.retired)"
[class.active]="statusFilter() === MangaStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -550,30 +505,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
</div>
}
@if (manga.dateStarted) {
<p class="date-started">
Started: {{ formatDate(manga.dateStarted) }}
</p>
}
@if (manga.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(manga.dateFinished) }}
</p>
}
@if (manga.createdAt) {
<p class="date-added">
Added: {{ formatDate(manga.createdAt) }}
</p>
}
@if (manga.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(manga.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(manga)" class="btn btn-secondary btn-sm">
@@ -930,15 +861,6 @@ import { Manga, MangaStatus, CreateMangaDto, UpdateMangaDto, Comment, Suggestion
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1294,7 +1216,6 @@ export class MangaListComponent implements OnInit {
readingCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.reading).length);
completedCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.completed).length);
wantToReadCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.wantToRead).length);
retiredCount = computed(() => this.mangaList().filter(m => m.status === MangaStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1343,12 +1264,10 @@ export class MangaListComponent implements OnInit {
totalFilteredManga = computed(() => this.filteredManga().length);
newManga: Partial<CreateMangaDto> & { dateStarted?: Date; dateFinished?: Date } = {
newManga: Partial<CreateMangaDto> = {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1425,7 +1344,6 @@ export class MangaListComponent implements OnInit {
case MangaStatus.reading: return 'Currently Reading';
case MangaStatus.completed: return 'Completed';
case MangaStatus.wantToRead: return 'Want to Read';
case MangaStatus.retired: return 'Retired';
}
}
@@ -1441,8 +1359,6 @@ export class MangaListComponent implements OnInit {
title: '',
author: '',
status: MangaStatus.wantToRead,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverImage: undefined,
@@ -1508,8 +1424,6 @@ export class MangaListComponent implements OnInit {
title: this.newManga.title,
author: this.newManga.author,
status: this.newManga.status,
dateStarted: this.newManga.dateStarted ? new Date(this.newManga.dateStarted) : undefined,
dateFinished: this.newManga.dateFinished ? new Date(this.newManga.dateFinished) : undefined,
rating: this.newManga.rating,
notes: this.newManga.notes,
coverImage: this.newManga.coverImage,
@@ -1537,8 +1451,6 @@ export class MangaListComponent implements OnInit {
title: manga.title,
author: manga.author,
status: manga.status,
dateStarted: manga.dateStarted,
dateFinished: manga.dateFinished,
rating: manga.rating,
notes: manga.notes,
coverImage: manga.coverImage,
@@ -1567,13 +1479,7 @@ export class MangaListComponent implements OnInit {
const manga = this.editingManga();
if (!manga || !this.editManga.title || !this.editManga.author || !this.editManga.status) return;
const updateData = {
...this.editManga,
dateStarted: this.editManga.dateStarted ? new Date(this.editManga.dateStarted) : undefined,
dateFinished: this.editManga.dateFinished ? new Date(this.editManga.dateFinished) : undefined,
};
this.mangaService.updateManga(manga.id, updateData).subscribe(() => {
this.mangaService.updateManga(manga.id, this.editManga).subscribe(() => {
this.loadManga();
this.cancelEdit();
});
@@ -1768,7 +1674,7 @@ export class MangaListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.manga,
entityType: SuggestionEntity.MANGA,
title: this.suggestedManga.title,
author: this.suggestedManga.author,
notes: this.suggestedManga.notes,
@@ -77,30 +77,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newMusic.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newMusic.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -145,7 +124,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newMusic.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -162,7 +142,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newMusic.links; track link.url; let i = $index) {
<div class="link-item">
@@ -237,30 +218,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
<option [value]="MusicStatus.listening">Currently Listening</option>
<option [value]="MusicStatus.completed">Completed</option>
<option [value]="MusicStatus.wantToListen">Want to Listen</option>
<option [value]="MusicStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editMusicData.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editMusicData.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -305,7 +265,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editMusicData.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -322,7 +283,8 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editMusicData.links; track link.url; let i = $index) {
<div class="link-item">
@@ -538,13 +500,6 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
>
Want to Listen ({{ wantToListenCount() }})
</button>
<button
(click)="setStatusFilter(MusicStatus.retired)"
[class.active]="statusFilter() === MusicStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
</div>
@@ -626,27 +581,9 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
</div>
}
@if (music.dateStarted) {
<p class="date-started">
Started: {{ formatDate(music.dateStarted) }}
</p>
}
@if (music.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(music.dateFinished) }}
</p>
}
@if (music.createdAt) {
<p class="date-added">
Added: {{ formatDate(music.createdAt) }}
</p>
}
@if (music.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(music.updatedAt) }}
@if (music.dateCompleted) {
<p class="date-completed">
Completed: {{ formatDate(music.dateCompleted) }}
</p>
}
@@ -1080,10 +1017,7 @@ import { Music, MusicStatus, MusicType, CreateMusicDto, UpdateMusicDto, Comment,
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
.date-completed {
font-size: 0.85rem;
color: var(--witch-plum);
margin-top: 0.5rem;
@@ -1501,7 +1435,6 @@ export class MusicListComponent implements OnInit {
listeningCount = computed(() => this.music().filter(m => m.status === MusicStatus.listening).length);
completedCount = computed(() => this.music().filter(m => m.status === MusicStatus.completed).length);
wantToListenCount = computed(() => this.music().filter(m => m.status === MusicStatus.wantToListen).length);
retiredCount = computed(() => this.music().filter(m => m.status === MusicStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1555,13 +1488,11 @@ export class MusicListComponent implements OnInit {
totalFilteredMusic = computed(() => this.filteredMusic().length);
newMusic: Partial<CreateMusicDto> & { dateStarted?: Date; dateFinished?: Date } = {
newMusic: Partial<CreateMusicDto> = {
title: '',
artist: '',
type: MusicType.album,
status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
tags: [],
@@ -1652,7 +1583,6 @@ export class MusicListComponent implements OnInit {
case MusicStatus.listening: return 'Currently Listening';
case MusicStatus.completed: return 'Completed';
case MusicStatus.wantToListen: return 'Want to Listen';
case MusicStatus.retired: return 'Retired';
}
}
@@ -1669,8 +1599,6 @@ export class MusicListComponent implements OnInit {
artist: '',
type: MusicType.album,
status: MusicStatus.wantToListen,
dateStarted: undefined,
dateFinished: undefined,
rating: undefined,
notes: '',
coverArt: undefined,
@@ -1737,8 +1665,6 @@ export class MusicListComponent implements OnInit {
artist: this.newMusic.artist,
type: this.newMusic.type,
status: this.newMusic.status,
dateStarted: this.newMusic.dateStarted ? new Date(this.newMusic.dateStarted) : undefined,
dateFinished: this.newMusic.dateFinished ? new Date(this.newMusic.dateFinished) : undefined,
rating: this.newMusic.rating,
notes: this.newMusic.notes,
coverArt: this.newMusic.coverArt,
@@ -1767,8 +1693,6 @@ export class MusicListComponent implements OnInit {
artist: music.artist,
type: music.type,
status: music.status,
dateStarted: music.dateStarted,
dateFinished: music.dateFinished,
rating: music.rating,
notes: music.notes,
coverArt: music.coverArt,
@@ -1797,13 +1721,7 @@ export class MusicListComponent implements OnInit {
const music = this.editingMusic();
if (!music || !this.editMusicData.title || !this.editMusicData.artist || !this.editMusicData.type || !this.editMusicData.status) return;
const updateData = {
...this.editMusicData,
dateStarted: this.editMusicData.dateStarted ? new Date(this.editMusicData.dateStarted) : undefined,
dateFinished: this.editMusicData.dateFinished ? new Date(this.editMusicData.dateFinished) : undefined,
};
this.musicService.updateMusic(music.id, updateData).subscribe(() => {
this.musicService.updateMusic(music.id, this.editMusicData).subscribe(() => {
this.loadMusic();
this.cancelEdit();
});
@@ -2001,7 +1919,7 @@ export class MusicListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.music,
entityType: SuggestionEntity.MUSIC,
title: this.suggestedMusic.title,
artist: this.suggestedMusic.artist,
type: this.suggestedMusic.type,
@@ -373,12 +373,12 @@ export class MyLikesComponent implements OnInit {
}
getItemTitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any;
const item = likedItem.item;
return item.title || item.name || 'Untitled';
}
getItemSubtitle(likedItem: LikedItemDto): string {
const item = likedItem.item as any;
const item = likedItem.item;
const type = likedItem.like.entityType;
switch (type) {
@@ -400,7 +400,7 @@ export class MyLikesComponent implements OnInit {
}
getItemImage(likedItem: LikedItemDto): string | null {
const item = likedItem.item as any;
const item = likedItem.item;
return item.coverImage || item.imageUrl || null;
}
@@ -43,22 +43,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
All ({{ suggestions().length }})
</button>
<button
(click)="setFilter(SuggestionStatus.unreviewed)"
[class.active]="statusFilter() === SuggestionStatus.unreviewed"
(click)="setFilter(SuggestionStatus.UNREVIEWED)"
[class.active]="statusFilter() === SuggestionStatus.UNREVIEWED"
class="filter-btn pending"
>
Pending ({{ unreviewedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.accepted)"
[class.active]="statusFilter() === SuggestionStatus.accepted"
(click)="setFilter(SuggestionStatus.ACCEPTED)"
[class.active]="statusFilter() === SuggestionStatus.ACCEPTED"
class="filter-btn accepted"
>
Accepted ({{ acceptedCount() }})
</button>
<button
(click)="setFilter(SuggestionStatus.declined)"
[class.active]="statusFilter() === SuggestionStatus.declined"
(click)="setFilter(SuggestionStatus.DECLINED)"
[class.active]="statusFilter() === SuggestionStatus.DECLINED"
class="filter-btn declined"
>
Declined ({{ declinedCount() }})
@@ -120,7 +120,7 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared-
}
</div>
@if (suggestion.status === SuggestionStatus.declined && suggestion.declineReason) {
@if (suggestion.status === SuggestionStatus.DECLINED && suggestion.declineReason) {
<div class="decline-reason">
<strong>Reason:</strong> {{ suggestion.declineReason }}
</div>
@@ -337,9 +337,9 @@ export class MySuggestionsComponent implements OnInit {
SuggestionStatus = SuggestionStatus;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.unreviewed).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.accepted).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.declined).length;
unreviewedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.UNREVIEWED).length;
acceptedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.ACCEPTED).length;
declinedCount = () => this.suggestions().filter(s => s.status === SuggestionStatus.DECLINED).length;
filteredSuggestions = computed(() => {
const filter = this.statusFilter();
@@ -397,20 +397,20 @@ export class MySuggestionsComponent implements OnInit {
getStatusLabel(status: SuggestionStatus): string {
switch (status) {
case SuggestionStatus.unreviewed: return 'Pending Review';
case SuggestionStatus.accepted: return 'Accepted';
case SuggestionStatus.declined: return 'Declined';
case SuggestionStatus.UNREVIEWED: return 'Pending Review';
case SuggestionStatus.ACCEPTED: return 'Accepted';
case SuggestionStatus.DECLINED: return 'Declined';
}
}
getEntityIcon(entityType: SuggestionEntity): string {
switch (entityType) {
case SuggestionEntity.game: return '🎮';
case SuggestionEntity.book: return '📚';
case SuggestionEntity.music: return '🎵';
case SuggestionEntity.manga: return 'đź“–';
case SuggestionEntity.show: return '📺';
case SuggestionEntity.art: return '🎨';
case SuggestionEntity.GAME: return '🎮';
case SuggestionEntity.BOOK: return '📚';
case SuggestionEntity.MUSIC: return '🎵';
case SuggestionEntity.MANGA: return 'đź“–';
case SuggestionEntity.SHOW: return '📺';
case SuggestionEntity.ART: return '🎨';
}
}
@@ -1,415 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faGlobe, faCloud } from '@fortawesome/free-solid-svg-icons';
import { faGithub, faLinkedin, faTwitch, faYoutube, faDiscord } from '@fortawesome/free-brands-svg-icons';
import { UserService, UserProfileResponse } from '../../services/user.service';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-profile',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
template: `
<div class="profile-container">
@if (loading()) {
<div class="loading">Loading profile...</div>
} @else if (error()) {
<div class="error">{{ error() }}</div>
} @else if (profile()) {
<div class="profile-card">
<div class="profile-header">
@if (profile()?.avatar) {
<img [src]="profile()!.avatar" [alt]="profile()!.username" class="profile-avatar" />
} @else {
<div class="profile-avatar-placeholder">
{{ profile()!.username[0]?.toUpperCase() }}
</div>
}
<div class="profile-info">
<h1 class="profile-username">{{ profile()!.displayName || profile()!.username }}</h1>
@if (profile()!.displayName) {
<p class="profile-handle">\@{{ profile()!.username }}</p>
}
@if (profile()!.slug) {
<p class="profile-slug">library.nhcarrigan.com/profile/{{ profile()!.slug }}</p>
}
</div>
</div>
<div class="badges-section">
@if (profile()!.badges.isStaff) {
<span class="badge badge-staff">Staff</span>
}
@if (profile()!.badges.isMod) {
<span class="badge badge-mod">Moderator</span>
}
@if (profile()!.badges.isVip) {
<span class="badge badge-vip">VIP</span>
}
@if (profile()!.badges.inDiscord) {
<span class="badge badge-member">Discord Member</span>
}
</div>
@if (profile()!.bio) {
<div class="bio-section">
<p class="bio-text">{{ profile()!.bio }}</p>
</div>
}
@if (profile()!.website || profile()!.github || profile()!.bluesky || profile()!.linkedin || profile()!.twitch || profile()!.youtube || profile()!.discordServer) {
<div class="social-links-section">
<h2>Social Links</h2>
<div class="social-links">
@if (profile()!.website) {
<a [href]="profile()!.website" target="_blank" rel="noopener noreferrer" class="social-link" title="Website">
<fa-icon [icon]="faGlobe" class="icon"></fa-icon>
<span class="label">Website</span>
</a>
}
@if (profile()!.github) {
<a [href]="'https://github.com/' + profile()!.github" target="_blank" rel="noopener noreferrer" class="social-link" title="GitHub">
<fa-icon [icon]="faGithub" class="icon"></fa-icon>
<span class="label">GitHub</span>
</a>
}
@if (profile()!.bluesky) {
<a [href]="'https://bsky.app/profile/' + profile()!.bluesky" target="_blank" rel="noopener noreferrer" class="social-link" title="Bluesky">
<fa-icon [icon]="faCloud" class="icon"></fa-icon>
<span class="label">Bluesky</span>
</a>
}
@if (profile()!.linkedin) {
<a [href]="'https://linkedin.com/in/' + profile()!.linkedin" target="_blank" rel="noopener noreferrer" class="social-link" title="LinkedIn">
<fa-icon [icon]="faLinkedin" class="icon"></fa-icon>
<span class="label">LinkedIn</span>
</a>
}
@if (profile()!.twitch) {
<a [href]="'https://twitch.tv/' + profile()!.twitch" target="_blank" rel="noopener noreferrer" class="social-link" title="Twitch">
<fa-icon [icon]="faTwitch" class="icon"></fa-icon>
<span class="label">Twitch</span>
</a>
}
@if (profile()!.youtube) {
<a [href]="'https://youtube.com/' + profile()!.youtube" target="_blank" rel="noopener noreferrer" class="social-link" title="YouTube">
<fa-icon [icon]="faYoutube" class="icon"></fa-icon>
<span class="label">YouTube</span>
</a>
}
@if (profile()!.discordServer) {
<a [href]="'https://discord.gg/' + profile()!.discordServer" target="_blank" rel="noopener noreferrer" class="social-link" title="Discord Server">
<fa-icon [icon]="faDiscord" class="icon"></fa-icon>
<span class="label">Discord</span>
</a>
}
</div>
</div>
}
<div class="stats-section">
<h2>Activity Statistics</h2>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsCount }}</span>
<span class="stat-label">Suggestions</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.suggestionsAcceptedCount }}</span>
<span class="stat-label">Accepted</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.likesCount }}</span>
<span class="stat-label">Likes</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ profile()!.stats.commentsCount }}</span>
<span class="stat-label">Comments</span>
</div>
</div>
</div>
<div class="footer-section">
<p class="member-since">Member since {{ formatDate(profile()!.createdAt) }}</p>
</div>
</div>
}
</div>
`,
styles: [`
.profile-container {
max-width: 800px;
margin: 2rem auto;
padding: 0 1rem;
}
.loading, .error {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.error {
color: var(--error-colour, #c41e3a);
}
.profile-card {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.profile-header {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid var(--accent-colour, #9b59b6);
}
.profile-avatar-placeholder {
width: 100px;
height: 100px;
border-radius: 50%;
background: var(--accent-colour, #9b59b6);
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: bold;
color: white;
}
.profile-info {
flex: 1;
}
.profile-username {
margin: 0;
font-size: 2rem;
color: var(--text-colour, #e0e0e0);
}
.profile-handle {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 1.1rem;
}
.profile-slug {
margin: 0.25rem 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
.badges-section {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.badge {
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-staff {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.badge-mod {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.badge-vip {
background: linear-gradient(135deg, #ffd89b 0%, #19547b 100%);
color: white;
}
.badge-member {
background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
color: #333;
}
.bio-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(155, 89, 182, 0.1);
border-radius: 8px;
}
.bio-text {
margin: 0;
color: var(--text-colour, #e0e0e0);
line-height: 1.6;
white-space: pre-wrap;
}
.social-links-section {
margin-bottom: 1.5rem;
}
.social-links-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.social-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: rgba(155, 89, 182, 0.2);
border: 1px solid rgba(155, 89, 182, 0.3);
border-radius: 8px;
color: var(--text-colour, #e0e0e0);
text-decoration: none;
transition: all 0.2s ease;
}
.social-link:hover {
background: rgba(155, 89, 182, 0.3);
border-color: var(--accent-colour, #9b59b6);
transform: translateY(-2px);
}
.social-link fa-icon {
font-size: 1.3rem;
width: 1.5rem;
}
.social-link fa-icon ::ng-deep svg {
width: 1.3rem;
height: 1.3rem;
}
.social-link .label {
font-weight: 500;
}
.stats-section {
margin-bottom: 1.5rem;
}
.stats-section h2 {
margin: 0 0 1rem 0;
color: var(--accent-colour, #9b59b6);
font-size: 1.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
}
.stat-card {
text-align: center;
padding: 1rem;
background: rgba(155, 89, 182, 0.2);
border-radius: 8px;
border: 1px solid rgba(155, 89, 182, 0.3);
}
.stat-value {
display: block;
font-size: 2rem;
font-weight: bold;
color: var(--accent-colour, #9b59b6);
}
.stat-label {
display: block;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
margin-top: 0.25rem;
}
.footer-section {
text-align: center;
padding-top: 1rem;
border-top: 1px solid rgba(155, 89, 182, 0.3);
}
.member-since {
margin: 0;
color: var(--text-muted, #a0a0a0);
font-size: 0.9rem;
}
`]
})
export class ProfileComponent implements OnInit {
private route = inject(ActivatedRoute);
private userService = inject(UserService);
private toastService = inject(ToastService);
profile = signal<UserProfileResponse | null>(null);
loading = signal(true);
error = signal<string | null>(null);
// Font Awesome icons
faGlobe = faGlobe;
faGithub = faGithub;
faCloud = faCloud;
faLinkedin = faLinkedin;
faTwitch = faTwitch;
faYoutube = faYoutube;
faDiscord = faDiscord;
ngOnInit(): void {
const identifier = this.route.snapshot.paramMap.get('identifier');
if (!identifier) {
this.error.set('No user identifier provided');
this.loading.set(false);
return;
}
this.userService.getProfile(identifier).subscribe({
next: (profileData: UserProfileResponse) => {
this.profile.set(profileData);
this.loading.set(false);
},
error: (err: Error) => {
console.error('Error loading profile:', err);
this.error.set('Failed to load profile');
this.loading.set(false);
this.toastService.error('Failed to load profile');
}
});
}
formatDate(date: Date | string): string {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
}
@@ -1,450 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { UserService, UpdateUserSettingsRequest } from '../../services/user.service';
import { AuthService } from '../../services/auth.service';
import { ToastService } from '../../services/toast.service';
import { User } from '@library/shared-types';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="settings-container">
<h1>Profile Settings</h1>
@if (loading()) {
<div class="loading">Loading settings...</div>
} @else if (user()) {
<form class="settings-form" (ngSubmit)="saveSettings()">
<div class="form-section">
<h2>Profile Information</h2>
<div class="form-group">
<label for="displayName">Display Name</label>
<input
type="text"
id="displayName"
name="displayName"
[(ngModel)]="formData.displayName"
placeholder="Your display name"
maxlength="50"
/>
<small class="form-help">This will be shown instead of your username</small>
</div>
<div class="form-group">
<label for="slug">Profile URL Slug</label>
<input
type="text"
id="slug"
name="slug"
[(ngModel)]="formData.slug"
placeholder="your-custom-url"
maxlength="30"
pattern="[a-z0-9-]+"
/>
<small class="form-help">
Your profile will be at: library.nhcarrigan.com/profile/{{ formData.slug || 'your-slug' }}
</small>
<small class="form-help">Only lowercase letters, numbers, and hyphens allowed</small>
</div>
<div class="form-group">
<label for="bio">Bio</label>
<textarea
id="bio"
name="bio"
[(ngModel)]="formData.bio"
placeholder="Tell us about yourself..."
rows="4"
maxlength="500"
></textarea>
<small class="form-help">{{ (formData.bio?.length || 0) }} / 500 characters</small>
</div>
</div>
<div class="form-section">
<h2>Social Links</h2>
<div class="form-group">
<label for="website">Website</label>
<input
type="text"
id="website"
name="website"
[(ngModel)]="formData.website"
placeholder="https://yourwebsite.com"
pattern="https?://.+"
/>
<small class="form-help">Your personal website or portfolio (must start with http:// or https://)</small>
</div>
<div class="form-group">
<label for="github">GitHub</label>
<input
type="text"
id="github"
name="github"
[(ngModel)]="formData.github"
placeholder="username"
pattern="[a-zA-Z0-9]([a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?"
/>
<small class="form-help">Just your GitHub username (alphanumeric and hyphens, 1-39 characters)</small>
</div>
<div class="form-group">
<label for="bluesky">Bluesky</label>
<input
type="text"
id="bluesky"
name="bluesky"
[(ngModel)]="formData.bluesky"
placeholder="username.bsky.social"
pattern="[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
/>
<small class="form-help">Your full Bluesky handle (e.g., username.bsky.social)</small>
</div>
<div class="form-group">
<label for="linkedin">LinkedIn</label>
<input
type="text"
id="linkedin"
name="linkedin"
[(ngModel)]="formData.linkedin"
placeholder="username"
pattern="[a-zA-Z0-9-]{3,100}"
/>
<small class="form-help">Just your LinkedIn username (alphanumeric and hyphens, 3-100 characters)</small>
</div>
<div class="form-group">
<label for="twitch">Twitch</label>
<input
type="text"
id="twitch"
name="twitch"
[(ngModel)]="formData.twitch"
placeholder="username"
pattern="[a-zA-Z0-9_]{4,25}"
/>
<small class="form-help">Just your Twitch username (alphanumeric and underscores, 4-25 characters)</small>
</div>
<div class="form-group">
<label for="youtube">YouTube</label>
<input
type="text"
id="youtube"
name="youtube"
[(ngModel)]="formData.youtube"
placeholder="@username or channel-id"
pattern="(@[a-zA-Z0-9_.-]{3,30}|UC[a-zA-Z0-9_-]{22})"
/>
<small class="form-help">Your YouTube handle (@username) or channel ID (UC...)</small>
</div>
<div class="form-group">
<label for="discordServer">Discord Server</label>
<input
type="text"
id="discordServer"
name="discordServer"
[(ngModel)]="formData.discordServer"
placeholder="invite-code"
pattern="[a-zA-Z0-9]{2,32}"
/>
<small class="form-help">Just your Discord server invite code (alphanumeric, 2-32 characters)</small>
</div>
</div>
<div class="form-section">
<h2>Privacy</h2>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
name="profilePublic"
[(ngModel)]="formData.profilePublic"
/>
<span>Make my profile public</span>
</label>
<small class="form-help">
When disabled, only you can view your profile
</small>
</div>
</div>
<div class="form-section">
<h2>Account Information</h2>
<div class="info-item">
<strong>Username:</strong> {{ user()!.username }}
</div>
<div class="info-item">
<strong>Email:</strong> {{ user()!.email }}
</div>
@if (user()!.avatar) {
<div class="info-item">
<strong>Avatar:</strong>
<img [src]="user()!.avatar" alt="Avatar" class="avatar-preview" />
</div>
}
<small class="form-help">
Username, email, and avatar are managed through Discord and cannot be changed here
</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" [disabled]="saving()">
{{ saving() ? 'Saving...' : 'Save Changes' }}
</button>
</div>
</form>
}
</div>
`,
styles: [`
.settings-container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 {
color: var(--accent-colour, #9b59b6);
margin-bottom: 2rem;
}
.loading {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
}
.settings-form {
background: var(--card-background, #1a1a2e);
border-radius: 12px;
padding: 2rem;
}
.form-section {
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(155, 89, 182, 0.3);
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section h2 {
color: var(--accent-colour, #9b59b6);
font-size: 1.3rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-colour, #e0e0e0);
font-weight: 600;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid rgba(155, 89, 182, 0.5);
border-radius: 8px;
background: rgba(0, 0, 0, 0.2);
color: var(--text-colour, #e0e0e0);
font-size: 1rem;
font-family: inherit;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-colour, #9b59b6);
box-shadow: 0 0 0 2px rgba(155, 89, 182, 0.3);
}
.form-group input[type="text"]:invalid:not(:focus):not(:placeholder-shown),
.form-group textarea:invalid:not(:focus):not(:placeholder-shown) {
border-color: var(--error-colour, #c41e3a);
}
.form-group input[type="text"]:valid:not(:placeholder-shown),
.form-group textarea:valid:not(:placeholder-shown) {
border-color: rgba(46, 204, 113, 0.5);
}
.form-group textarea {
resize: vertical;
min-height: 100px;
}
.form-help {
display: block;
margin-top: 0.25rem;
color: var(--text-muted, #a0a0a0);
font-size: 0.85rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.info-item {
margin-bottom: 1rem;
color: var(--text-colour, #e0e0e0);
}
.info-item strong {
color: var(--accent-colour, #9b59b6);
}
.avatar-preview {
width: 50px;
height: 50px;
border-radius: 50%;
margin-left: 0.5rem;
vertical-align: middle;
}
.form-actions {
display: flex;
justify-content: flex-end;
padding-top: 1rem;
}
.btn {
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`]
})
export class SettingsComponent implements OnInit {
private userService = inject(UserService);
private authService = inject(AuthService);
private toastService = inject(ToastService);
user = signal<User | null>(null);
loading = signal(true);
saving = signal(false);
formData: UpdateUserSettingsRequest & { bio?: string } = {
displayName: '',
slug: '',
bio: '',
profilePublic: true,
website: '',
discordServer: '',
bluesky: '',
github: '',
linkedin: '',
twitch: '',
youtube: ''
};
ngOnInit(): void {
this.userService.getMe().subscribe({
next: (userData: User) => {
this.user.set(userData);
this.formData = {
displayName: userData.displayName || '',
slug: userData.slug || '',
bio: userData.bio || '',
profilePublic: userData.profilePublic ?? true,
website: userData.website || '',
discordServer: userData.discordServer || '',
bluesky: userData.bluesky || '',
github: userData.github || '',
linkedin: userData.linkedin || '',
twitch: userData.twitch || '',
youtube: userData.youtube || ''
};
this.loading.set(false);
},
error: (err: Error) => {
console.error('Error loading user profile:', err);
this.toastService.error('Failed to load profile');
this.loading.set(false);
}
});
}
saveSettings(): void {
this.saving.set(true);
const updates: UpdateUserSettingsRequest = {
displayName: this.formData.displayName || undefined,
slug: this.formData.slug || undefined,
bio: this.formData.bio || undefined,
profilePublic: this.formData.profilePublic,
website: this.formData.website || undefined,
discordServer: this.formData.discordServer || undefined,
bluesky: this.formData.bluesky || undefined,
github: this.formData.github || undefined,
linkedin: this.formData.linkedin || undefined,
twitch: this.formData.twitch || undefined,
youtube: this.formData.youtube || undefined
};
this.userService.updateSettings(updates).subscribe({
next: (updatedUser: User) => {
this.user.set(updatedUser);
this.authService.updateUser(updatedUser);
this.saving.set(false);
this.toastService.success('Settings saved successfully!');
},
error: (err: Error) => {
console.error('Error saving settings:', err);
this.toastService.error('Failed to save settings');
this.saving.set(false);
}
});
}
}
@@ -66,30 +66,9 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="dateStarted">Date Started</label>
<input
type="date"
id="dateStarted"
[(ngModel)]="newShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="dateFinished">Date Finished</label>
<input
type="date"
id="dateFinished"
[(ngModel)]="newShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="rating">Rating (1-10)</label>
<input
@@ -134,7 +113,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of newShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -151,7 +131,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of newShow.links; track link.url; let i = $index) {
<div class="link-item">
@@ -215,30 +196,9 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
<option [value]="ShowStatus.watching">Currently Watching</option>
<option [value]="ShowStatus.completed">Completed</option>
<option [value]="ShowStatus.wantToWatch">Want to Watch</option>
<option [value]="ShowStatus.retired">Retired</option>
</select>
</div>
<div class="form-group">
<label for="edit-dateStarted">Date Started</label>
<input
type="date"
id="edit-dateStarted"
[(ngModel)]="editShow.dateStarted"
name="dateStarted"
>
</div>
<div class="form-group">
<label for="edit-dateFinished">Date Finished</label>
<input
type="date"
id="edit-dateFinished"
[(ngModel)]="editShow.dateFinished"
name="dateFinished"
>
</div>
<div class="form-group">
<label for="edit-rating">Rating (1-10)</label>
<input
@@ -283,7 +243,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
<div class="form-group">
<div class="tags-input-container" aria-label="Tags">
<label>Tags</label>
<div class="tags-input-container">
@for (tag of editShow.tags; track tag; let i = $index) {
<span class="tag">
{{ tag }}
@@ -300,7 +261,8 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
</div>
<div class="form-group" aria-label="External Links">
<div class="form-group">
<label>External Links</label>
<div class="links-list">
@for (link of editShow.links; track link.url; let i = $index) {
<div class="link-item">
@@ -471,13 +433,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
>
Want to Watch ({{ wantToWatchCount() }})
</button>
<button
(click)="setFilter(ShowStatus.retired)"
[class.active]="statusFilter() === ShowStatus.retired"
class="filter-btn"
>
Retired ({{ retiredCount() }})
</button>
</div>
@if (loading()) {
@@ -544,30 +499,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
</div>
}
@if (show.dateStarted) {
<p class="date-started">
Started: {{ formatDate(show.dateStarted) }}
</p>
}
@if (show.dateFinished) {
<p class="date-finished">
Finished: {{ formatDate(show.dateFinished) }}
</p>
}
@if (show.createdAt) {
<p class="date-added">
Added: {{ formatDate(show.createdAt) }}
</p>
}
@if (show.updatedAt) {
<p class="date-updated">
Updated: {{ formatDate(show.updatedAt) }}
</p>
}
@if (authService.isAdmin()) {
<div class="actions">
<button (click)="startEdit(show)" class="btn btn-secondary btn-sm">
@@ -923,15 +854,6 @@ import { Show, ShowStatus, ShowType, CreateShowDto, UpdateShowDto, Comment, Sugg
margin: 0.5rem 0;
}
.date-started,
.date-finished,
.date-added,
.date-updated {
font-size: 0.85rem;
color: #4b5563;
margin-top: 0.5rem;
}
.actions {
margin-top: 1rem;
}
@@ -1288,7 +1210,6 @@ export class ShowsListComponent implements OnInit {
watchingCount = computed(() => this.shows().filter(show => show.status === ShowStatus.watching).length);
completedCount = computed(() => this.shows().filter(show => show.status === ShowStatus.completed).length);
wantToWatchCount = computed(() => this.shows().filter(show => show.status === ShowStatus.wantToWatch).length);
retiredCount = computed(() => this.shows().filter(show => show.status === ShowStatus.retired).length);
allTags = computed(() => {
const tagsSet = new Set<string>();
@@ -1337,14 +1258,12 @@ export class ShowsListComponent implements OnInit {
totalFilteredShows = computed(() => this.filteredShows().length);
newShow: Partial<CreateShowDto> & { dateStarted?: Date; dateFinished?: Date } = {
newShow: Partial<CreateShowDto> = {
title: '',
type: ShowType.tvSeries,
status: ShowStatus.wantToWatch,
rating: undefined,
notes: '',
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
@@ -1419,7 +1338,6 @@ export class ShowsListComponent implements OnInit {
case ShowStatus.watching: return 'Currently Watching';
case ShowStatus.completed: return 'Completed';
case ShowStatus.wantToWatch: return 'Want to Watch';
case ShowStatus.retired: return 'Retired';
}
}
@@ -1447,8 +1365,6 @@ export class ShowsListComponent implements OnInit {
rating: undefined,
notes: '',
coverImage: undefined,
dateStarted: undefined,
dateFinished: undefined,
tags: [],
links: []
};
@@ -1511,8 +1427,6 @@ export class ShowsListComponent implements OnInit {
title: this.newShow.title,
type: this.newShow.type,
status: this.newShow.status,
dateStarted: this.newShow.dateStarted ? new Date(this.newShow.dateStarted) : undefined,
dateFinished: this.newShow.dateFinished ? new Date(this.newShow.dateFinished) : undefined,
rating: this.newShow.rating,
notes: this.newShow.notes,
coverImage: this.newShow.coverImage,
@@ -1540,8 +1454,6 @@ export class ShowsListComponent implements OnInit {
title: show.title,
type: show.type,
status: show.status,
dateStarted: show.dateStarted,
dateFinished: show.dateFinished,
rating: show.rating,
notes: show.notes,
coverImage: show.coverImage,
@@ -1570,13 +1482,7 @@ export class ShowsListComponent implements OnInit {
const show = this.editingShow();
if (!show || !this.editShow.title || !this.editShow.type || !this.editShow.status) return;
const updateData = {
...this.editShow,
dateStarted: this.editShow.dateStarted ? new Date(this.editShow.dateStarted) : undefined,
dateFinished: this.editShow.dateFinished ? new Date(this.editShow.dateFinished) : undefined,
};
this.showsService.updateShow(show.id, updateData).subscribe(() => {
this.showsService.updateShow(show.id, this.editShow).subscribe(() => {
this.loadShows();
this.cancelEdit();
});
@@ -1771,7 +1677,7 @@ export class ShowsListComponent implements OnInit {
try {
await this.suggestionService.createSuggestion({
entityType: SuggestionEntity.show,
entityType: SuggestionEntity.SHOW,
title: this.suggestedShow.title,
type: this.suggestedShow.type,
notes: this.suggestedShow.notes,
@@ -1,91 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
.toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
animation: slideIn 0.3s ease-out;
border: 2px solid;
background-color: var(--witch-purple);
color: var(--moon-white);
}
.toast-error {
border-color: #ff4444;
background-color: rgba(255, 68, 68, 0.4);
}
.toast-success {
border-color: #44ff88;
background-color: rgba(68, 255, 136, 0.4);
}
.toast-info {
border-color: var(--witch-lavender);
background-color: rgba(200, 162, 200, 0.4);
}
.toast-warning {
border-color: #ffaa44;
background-color: rgba(255, 170, 68, 0.4);
}
.toast-icon {
font-size: 20px;
flex-shrink: 0;
}
.toast-message {
flex: 1;
word-wrap: break-word;
}
.toast-close {
background: none;
border: none;
color: var(--moon-white);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.toast-close:hover {
transform: scale(1.2);
}
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@@ -1,29 +0,0 @@
<!--
@copyright 2026 NHCarrigan
@license Naomi's Public License
@author Naomi Carrigan
-->
<div class="toast-container">
@for (toast of toastService.toastList(); track toast.id) {
<div
class="toast toast-{{ toast.type }}"
(click)="toastService.remove(toast.id)"
(keyup.enter)="toastService.remove(toast.id)"
(keyup.space)="toastService.remove(toast.id)"
tabindex="0"
role="button"
>
<div class="toast-icon">
@switch (toast.type) {
@case ('error') { ❌ }
@case ('success') { âś… }
@case ('info') { ℹ️ }
@case ('warning') { ⚠️ }
}
</div>
<div class="toast-message">{{ toast.message }}</div>
<button class="toast-close" (click)="toastService.remove(toast.id)">Ă—</button>
</div>
}
</div>
@@ -1,18 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component, inject } from '@angular/core';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-toast',
standalone: true,
templateUrl: './toast.component.html',
styleUrls: ['./toast.component.css']
})
export class ToastComponent {
public toastService = inject(ToastService);
}
@@ -1,13 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ConsoleLoggerService } from '../services/console-logger.service';
export function initializeConsoleLogger(consoleLogger: ConsoleLoggerService) {
return () => {
consoleLogger.initialise();
};
}
@@ -16,17 +16,17 @@ import {
import { Observable, catchError, throwError, switchMap, BehaviorSubject, filter, take } from 'rxjs';
import { Router } from '@angular/router';
import { environment } from '../../environments/environment';
import { ToastService } from '../services/toast.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private router = inject(Router);
private http = inject(HttpClient);
private toast = inject(ToastService);
private isRefreshing = false;
private refreshTokenSubject = new BehaviorSubject<boolean | null>(null);
constructor(
private router: Router,
private http: HttpClient
) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Clone the request to add withCredentials
const authReq = request.clone({
@@ -38,9 +38,6 @@ export class AuthInterceptor implements HttpInterceptor {
if (error.status === 401 && !request.url.includes('/auth/refresh') && !request.url.includes('/auth/logout')) {
return this.handle401Error(authReq, next);
}
// Show toast for other HTTP errors
this.showErrorToast(error);
return throwError(() => error);
})
);
@@ -64,7 +61,6 @@ export class AuthInterceptor implements HttpInterceptor {
catchError((err) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(false);
this.toast.error('Your session has expired. Please log in again.');
this.router.navigate(['/']);
return throwError(() => err);
})
@@ -82,28 +78,4 @@ export class AuthInterceptor implements HttpInterceptor {
})
);
}
private showErrorToast(error: HttpErrorResponse): void {
let message = 'Something went wrong. Please try again.';
switch (error.status) {
case 400:
message = error.error?.message || 'Invalid request. Please check your input.';
break;
case 403:
message = 'You do not have permission to perform this action.';
break;
case 404:
message = 'Resource not found.';
break;
case 500:
message = 'Server error. Please try again later.';
break;
case 0:
message = 'Network error. Please check your connection.';
break;
}
this.toast.error(message);
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Art, CreateArtDto, UpdateArtDto } from '@library/shared-types';
providedIn: 'root'
})
export class ArtService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllArt(): Observable<Art[]> {
return this.api.get<Art[]>('/art');
+7 -39
View File
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, signal, inject } from '@angular/core';
import { Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, tap, catchError, switchMap, throwError, of } from 'rxjs';
import { ApiService } from './api.service';
@@ -16,14 +16,15 @@ import { HttpClient } from '@angular/common/http';
providedIn: 'root'
})
export class AuthService {
private api = inject(ApiService);
private router = inject(Router);
private http = inject(HttpClient);
private currentUser = signal<User | null>(null);
public readonly user = this.currentUser.asReadonly();
private refreshing = false;
private refreshInterval?: ReturnType<typeof setInterval>;
constructor(
private api: ApiService,
private router: Router,
private http: HttpClient
) {}
login(): void {
// Redirect to API login endpoint
@@ -34,7 +35,6 @@ export class AuthService {
return this.api.get<AuthResponse>('/auth/me').pipe(
tap(response => {
this.currentUser.set(response.user);
this.startRefreshTimer();
}),
catchError(error => {
if (error.status === 401) {
@@ -42,11 +42,9 @@ export class AuthService {
switchMap(() => this.api.get<AuthResponse>('/auth/me')),
tap(response => {
this.currentUser.set(response.user);
this.startRefreshTimer();
}),
catchError(() => {
this.currentUser.set(null);
this.stopRefreshTimer();
return throwError(() => error);
})
);
@@ -70,45 +68,20 @@ export class AuthService {
tap(response => {
this.currentUser.set(response.user);
this.refreshing = false;
this.startRefreshTimer();
}),
catchError(error => {
this.refreshing = false;
this.currentUser.set(null);
this.stopRefreshTimer();
return throwError(() => error);
})
);
}
private startRefreshTimer(): void {
this.stopRefreshTimer();
// Refresh token every 13 minutes (before 15-minute expiry)
const refreshIntervalMs = 13 * 60 * 1000;
this.refreshInterval = setInterval(() => {
this.refreshToken().subscribe({
error: (err) => {
console.error('Background token refresh failed:', err);
this.stopRefreshTimer();
}
});
}, refreshIntervalMs);
}
private stopRefreshTimer(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = undefined;
}
}
logout(): Observable<{ message: string }> {
return this.api.post<{ message: string }>('/auth/logout', {}).pipe(
tap(() => {
this.currentUser.set(null);
this.api.clearCsrfToken();
this.stopRefreshTimer();
this.router.navigate(['/']);
})
);
@@ -116,17 +89,12 @@ export class AuthService {
clearUser(): void {
this.currentUser.set(null);
this.stopRefreshTimer();
}
isAuthenticated(): boolean {
return this.user() !== null;
}
updateUser(user: User): void {
this.currentUser.set(user);
}
isAdmin(): boolean {
return this.user()?.isAdmin === true;
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Book, CreateBookDto, UpdateBookDto } from '@library/shared-types';
providedIn: 'root'
})
export class BooksService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllBooks(): Observable<Book[]> {
return this.api.get<Book[]>('/books');
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Comment, CreateCommentDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Comment, CreateCommentDto } from '@library/shared-types';
providedIn: 'root'
})
export class CommentsService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getCommentsForGame(gameId: string): Observable<Comment[]> {
return this.api.get<Comment[]>(`/games/${gameId}/comments`);
@@ -1,127 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environments/environment';
interface LogPayload {
level: 'debug' | 'info' | 'warn' | 'error';
message: string;
context?: string;
error?: {
name: string;
message: string;
stack?: string;
};
}
@Injectable({
providedIn: 'root'
})
export class ConsoleLoggerService {
private http = inject(HttpClient);
private originalConsole = {
log: console.log.bind(console),
error: console.error.bind(console),
warn: console.warn.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console)
};
/**
* Initialises the console override to pipe logs to the API.
*/
initialise(): void {
console.log = (...args: unknown[]) => {
this.originalConsole.log(...args);
this.sendLog('info', this.formatArgs(args));
};
console.info = (...args: unknown[]) => {
this.originalConsole.info(...args);
this.sendLog('info', this.formatArgs(args));
};
console.debug = (...args: unknown[]) => {
this.originalConsole.debug(...args);
this.sendLog('debug', this.formatArgs(args));
};
console.warn = (...args: unknown[]) => {
this.originalConsole.warn(...args);
this.sendLog('warn', this.formatArgs(args));
};
console.error = (...args: unknown[]) => {
this.originalConsole.error(...args);
// Check if the first argument is an Error object
if (args[0] instanceof Error) {
const error = args[0];
this.sendLog('error', error.message, 'Console', {
name: error.name,
message: error.message,
stack: error.stack
});
} else {
this.sendLog('error', this.formatArgs(args));
}
};
// Global error handlers
window.addEventListener('error', (event: ErrorEvent) => {
this.originalConsole.error('Uncaught Error:', event.error);
this.sendLog('error', event.message, 'Window Error', {
name: event.error?.name || 'Error',
message: event.message,
stack: event.error?.stack
});
});
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
this.originalConsole.error('Unhandled Promise Rejection:', event.reason);
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
this.sendLog('error', error.message, 'Unhandled Rejection', {
name: error.name,
message: error.message,
stack: error.stack
});
});
}
private formatArgs(args: unknown[]): string {
return args.map(arg => {
if (typeof arg === 'string') {
return arg;
}
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}`;
}
try {
return JSON.stringify(arg);
} catch {
return String(arg);
}
}).join(' ');
}
private sendLog(level: LogPayload['level'], message: string, context?: string, error?: LogPayload['error']): void {
const payload: LogPayload = {
level,
message,
context,
error
};
this.http.post(`${environment.apiUrl}/log`, payload).subscribe({
error: (err) => {
this.originalConsole.error('Failed to send log to API:', err);
}
});
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Game, CreateGameDto, UpdateGameDto } from '@library/shared-types';
providedIn: 'root'
})
export class GamesService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllGames(): Observable<Game[]> {
return this.api.get<Game[]>('/games');
@@ -1,43 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ErrorHandler, Injectable, inject } from '@angular/core';
import { ToastService } from './toast.service';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
private toast = inject(ToastService);
handleError(error: Error): void {
console.error('Global error caught:', error);
// Show user-friendly error message
const message = this.getUserFriendlyMessage(error);
this.toast.error(message);
}
private getUserFriendlyMessage(error: Error): string {
// Check for common error types
if (error.message.includes('Http failure')) {
return 'Network error. Please check your connection.';
}
if (error.message.includes('401') || error.message.includes('403')) {
return 'Your session has expired. Please refresh the page.';
}
if (error.message.includes('404')) {
return 'Resource not found.';
}
if (error.message.includes('500')) {
return 'Server error. Please try again later.';
}
// Generic error message
return 'Something went wrong. Please try again.';
}
}
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Manga, CreateMangaDto, UpdateMangaDto } from '@library/shared-types';
providedIn: 'root'
})
export class MangaService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllManga(): Observable<Manga[]> {
return this.api.get<Manga[]>('/manga');
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Music, CreateMusicDto, UpdateMusicDto } from '@library/shared-types';
providedIn: 'root'
})
export class MusicService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllMusic(): Observable<Music[]> {
return this.api.get<Music[]>('/music');
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Injectable, inject } from '@angular/core';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
@@ -13,8 +13,7 @@ import { Show, CreateShowDto, UpdateShowDto } from '@library/shared-types';
providedIn: 'root'
})
export class ShowsService {
private api = inject(ApiService);
constructor(private api: ApiService) {}
getAllShows(): Observable<Show[]> {
return this.api.get<Show[]>('/shows');
@@ -1,70 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable, signal } from '@angular/core';
export interface Toast {
id: number;
message: string;
type: 'error' | 'success' | 'info' | 'warning';
duration: number;
}
@Injectable({
providedIn: 'root'
})
export class ToastService {
private toasts = signal<Toast[]>([]);
public readonly toastList = this.toasts.asReadonly();
private nextId = 0;
/**
* Show an error toast notification.
*/
error(message: string, duration = 5000): void {
this.addToast(message, 'error', duration);
}
/**
* Show a success toast notification.
*/
success(message: string, duration = 3000): void {
this.addToast(message, 'success', duration);
}
/**
* Show an info toast notification.
*/
info(message: string, duration = 3000): void {
this.addToast(message, 'info', duration);
}
/**
* Show a warning toast notification.
*/
warning(message: string, duration = 4000): void {
this.addToast(message, 'warning', duration);
}
/**
* Remove a toast by ID.
*/
remove(id: number): void {
this.toasts.update(toasts => toasts.filter(t => t.id !== id));
}
private addToast(message: string, type: Toast['type'], duration: number): void {
const id = this.nextId++;
const toast: Toast = { id, message, type, duration };
this.toasts.update(toasts => [...toasts, toast]);
// Auto-remove after duration
setTimeout(() => {
this.remove(id);
}, duration);
}
}
@@ -9,49 +9,6 @@ import { Observable } from 'rxjs';
import { ApiService } from './api.service';
import { User } from '@library/shared-types';
export interface UserProfileResponse {
id: string;
username: string;
displayName?: string;
avatar?: string;
bio?: string;
slug?: string;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
badges: {
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
};
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
createdAt: Date;
}
export interface UpdateUserSettingsRequest {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
@Injectable({
providedIn: 'root'
})
@@ -69,16 +26,4 @@ export class UserService {
unbanUser(userId: string): Observable<User> {
return this.api.post<User>(`/users/${userId}/unban`, {});
}
getMe(): Observable<User> {
return this.api.get<User>('/users/me');
}
updateSettings(settings: UpdateUserSettingsRequest): Observable<User> {
return this.api.put<User>('/users/me', settings);
}
getProfile(identifier: string): Observable<UserProfileResponse> {
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
}
}
-24
View File
@@ -1,24 +0,0 @@
# Database
DATABASE_URL="op://Environment Variables - Naomi/Library/mongo url"
# JWT Secret
JWT_SECRET="op://Environment Variables - Naomi/Library/jwt secret"
# Discord OAuth
DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Library/discord client id"
DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Library/discord client secret"
# Admin Configuration
ADMIN_DISCORD_ID="op://Environment Variables - Naomi/Library/admin discord id"
# Discord Server
DISCORD_GUILD_ID="op://Environment Variables - Naomi/Library/discord server id"
SPONSOR_ROLE_ID="op://Environment Variables - Naomi/Library/sponsor role id"
MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
# Application URL
BASE_URL="op://Environment Variables - Naomi/Library/localhost url"
# Logger
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
+6 -1
View File
@@ -1,3 +1,8 @@
import nhcarrigan from '@nhcarrigan/eslint-config';
export default nhcarrigan;
export default [
...nhcarrigan,
{
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
},
];
+16 -22
View File
@@ -3,9 +3,8 @@
"version": "0.0.0",
"license": "MIT",
"scripts": {
"dev": "nx run-many --target=build --all && NODE_ENV=production op run --env-file=dev.env -- node dist/api/main.js",
"lint": "nx run-many --target=lint --all",
"build": "pnpm db:gen && nx run-many --target=build --all",
"build": "nx run-many --target=build --all",
"test": "nx run-many --target=test --all --passWithNoTests",
"build:frontend": "nx build frontend --configuration=production",
"build:api": "nx build api",
@@ -26,26 +25,21 @@
"@angular/platform-browser": "21.1.2",
"@angular/router": "21.1.2",
"@fastify/autoload": "6.0.3",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "11.0.0",
"@fastify/csrf-protection": "7.1.0",
"@fastify/helmet": "13.0.2",
"@fastify/jwt": "10.0.0",
"@fastify/oauth2": "8.1.2",
"@fastify/rate-limit": "10.3.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.0.0",
"@fastify/csrf-protection": "^7.1.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/oauth2": "^8.1.2",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "6.0.4",
"@fastify/static": "9.0.0",
"@fortawesome/angular-fontawesome": "4.0.0",
"@fortawesome/fontawesome-svg-core": "7.2.0",
"@fortawesome/free-brands-svg-icons": "7.2.0",
"@fortawesome/free-solid-svg-icons": "7.2.0",
"@nhcarrigan/logger": "1.1.1",
"@fastify/static": "^9.0.0",
"@prisma/client": "6.19.2",
"dompurify": "3.3.1",
"dompurify": "^3.3.1",
"fastify": "5.7.3",
"fastify-plugin": "5.0.1",
"jsdom": "28.0.0",
"marked": "17.0.1",
"jsdom": "^28.0.0",
"marked": "^17.0.1",
"rxjs": "7.8.2"
},
"devDependencies": {
@@ -65,16 +59,16 @@
"@nx/jest": "22.4.4",
"@nx/js": "22.4.4",
"@nx/node": "22.4.4",
"@nx/web": "22.4.4",
"@nx/web": "23.0.1",
"@nx/workspace": "22.4.4",
"@schematics/angular": "21.1.2",
"@swc-node/register": "1.9.2",
"@swc/core": "1.5.29",
"@swc/helpers": "0.5.18",
"@types/dompurify": "3.2.0",
"@types/dompurify": "^3.2.0",
"@types/jest": "30.0.0",
"@types/jsdom": "27.0.0",
"@types/jsonwebtoken": "9.0.10",
"@types/jsdom": "^27.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "20.19.9",
"@typescript-eslint/utils": "8.54.0",
"angular-eslint": "21.1.0",
+623 -143
View File
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -18,7 +18,4 @@ MOD_ROLE_ID="op://Environment Variables - Naomi/Library/mod role id"
STAFF_ROLE_ID="op://Environment Variables - Naomi/Library/staff role id"
# Application URL
BASE_URL="op://Environment Variables - Naomi/Library/base url"
# Logger
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
BASE_URL="op://Environment Variables - Naomi/Library/base url"
+8 -102
View File
@@ -3,113 +3,19 @@ import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const nhcarriganArray = Array.isArray(nhcarrigan) ? nhcarrigan : [nhcarrigan];
// Jest globals that should be available in test files
const jestGlobals = {
afterAll: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
beforeEach: 'readonly',
describe: 'readonly',
expect: 'readonly',
it: 'readonly',
jest: 'readonly',
test: 'readonly',
};
// Map the nhcarrigan configs to handle shared-types directory structure
const mappedConfigs = nhcarriganArray.flatMap(config => {
if (!config.files) {
return [config];
}
const newFiles = config.files
.map(pattern => {
if (pattern.startsWith('src/')) {
return pattern.replace('src/', 'shared-types/src/');
} else if (pattern.startsWith('test/')) {
return pattern.replace('test/', 'shared-types/test/');
}
return pattern;
});
// Determine if this is a test file config
const isTestFile = newFiles[0]?.includes('test/');
// Update configs to handle shared-types directory structure
let updatedConfig = { ...config, files: newFiles };
if (config.languageOptions) {
const updatedLanguageOptions = { ...config.languageOptions };
// Add Jest globals for test files
if (isTestFile) {
updatedLanguageOptions.globals = { ...updatedLanguageOptions.globals, ...jestGlobals };
}
// Update parserOptions to use our tsconfig with proper tsconfigRootDir
if (config.languageOptions.parserOptions) {
updatedLanguageOptions.parserOptions = {
...config.languageOptions.parserOptions,
tsconfigRootDir: __dirname,
};
}
updatedConfig = { ...updatedConfig, languageOptions: updatedLanguageOptions };
}
return [updatedConfig];
});
export default [
...nhcarrigan,
{
ignores: ['dist', 'out-tsc', 'node_modules'],
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
},
...mappedConfigs,
// Disable vitest rules for this Jest project
{
files: ['shared-types/test/**/*.spec.ts'],
rules: {
'vitest/consistent-test-filename': 'off',
'vitest/consistent-test-it': 'off',
'vitest/expect-expect': 'off',
'vitest/no-alias-methods': 'off',
'vitest/no-commented-out-tests': 'off',
'vitest/no-conditional-expect': 'off',
'vitest/no-conditional-in-test': 'off',
'vitest/no-conditional-tests': 'off',
'vitest/no-disabled-tests': 'off',
'vitest/no-duplicate-hooks': 'off',
'vitest/no-focused-tests': 'off',
'vitest/no-identical-title': 'off',
'vitest/no-standalone-expect': 'off',
'vitest/no-test-prefixes': 'off',
'vitest/no-test-return-statement': 'off',
'vitest/prefer-comparison-matcher': 'off',
'vitest/prefer-each': 'off',
'vitest/prefer-equality-matcher': 'off',
'vitest/prefer-expect-assertions': 'off',
'vitest/prefer-expect-resolves': 'off',
'vitest/prefer-hooks-in-order': 'off',
'vitest/prefer-hooks-on-top': 'off',
'vitest/prefer-lowercase-title': 'off',
'vitest/prefer-mock-promise-shorthand': 'off',
'vitest/prefer-spy-on': 'off',
'vitest/prefer-strict-equal': 'off',
'vitest/prefer-to-be': 'off',
'vitest/prefer-to-be-falsy': 'off',
'vitest/prefer-to-be-object': 'off',
'vitest/prefer-to-be-truthy': 'off',
'vitest/prefer-to-contain': 'off',
'vitest/prefer-to-have-length': 'off',
'vitest/prefer-todo': 'off',
'vitest/require-hook': 'off',
'vitest/require-to-throw-message': 'off',
'vitest/require-top-level-describe': 'off',
'vitest/valid-describe-callback': 'off',
'vitest/valid-expect': 'off',
'vitest/valid-title': 'off',
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.lib.json',
tsconfigRootDir: __dirname,
},
},
},
];
-22
View File
@@ -1,22 +0,0 @@
export default {
displayName: "shared-types",
preset: "../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../coverage/shared-types",
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/index.ts", // Just re-exports
],
};
+1 -16
View File
@@ -5,20 +5,5 @@
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project shared-types --web",
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["shared-types/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "shared-types/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
}
}
"targets": {}
}
+9 -9
View File
@@ -1,17 +1,17 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export type * from "./lib/art.types";
export * from "./lib/audit.types";
export type * from "./lib/auth.types";
export * from "./lib/book.types";
export type * from "./lib/comment.types";
export type * from "./lib/common.types";
export * from "./lib/game.types";
export type * from "./lib/like.types";
export * from "./lib/manga.types";
export * from "./lib/book.types";
export * from "./lib/music.types";
export * from "./lib/art.types";
export * from "./lib/show.types";
export * from "./lib/manga.types";
export type * from "./lib/auth.types";
export * from "./lib/comment.types";
export * from "./lib/audit.types";
export * from "./lib/suggestion.types";
export * from "./lib/common.types";
export * from "./lib/like.types";
+19 -22
View File
@@ -1,33 +1,30 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
import { Link } from "./common.types";
interface Art {
id: string;
title: string;
artist: string;
export interface Art {
id: string;
title: string;
artist: string;
description?: string;
imageUrl: string;
tags: Array<string>;
links: Array<Link>;
dateAdded: Date;
createdAt: Date;
updatedAt: Date;
imageUrl: string;
tags: string[];
links: Link[];
dateAdded: Date;
createdAt: Date;
updatedAt: Date;
}
interface CreateArtDto {
title: string;
artist: string;
export interface CreateArtDto {
title: string;
artist: string;
description?: string;
imageUrl: string;
tags?: Array<string>;
links?: Array<Link>;
imageUrl: string;
tags?: string[];
links?: Link[];
}
type UpdateArtDto = Partial<CreateArtDto>;
export type { Art, CreateArtDto, UpdateArtDto };
export interface UpdateArtDto extends Partial<CreateArtDto> {}
+45 -59
View File
@@ -1,72 +1,58 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
enum AuditAction {
login = "LOGIN",
logout = "LOGOUT",
loginFailed = "LOGIN_FAILED",
commentCreate = "COMMENT_CREATE",
commentUpdate = "COMMENT_UPDATE",
commentDelete = "COMMENT_DELETE",
entryCreate = "ENTRY_CREATE",
entryUpdate = "ENTRY_UPDATE",
entryDelete = "ENTRY_DELETE",
like = "LIKE",
unlike = "UNLIKE",
userBan = "USER_BAN",
userUnban = "USER_UNBAN",
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
export enum AuditAction {
LOGIN = "LOGIN",
LOGOUT = "LOGOUT",
LOGIN_FAILED = "LOGIN_FAILED",
COMMENT_CREATE = "COMMENT_CREATE",
COMMENT_UPDATE = "COMMENT_UPDATE",
COMMENT_DELETE = "COMMENT_DELETE",
ENTRY_CREATE = "ENTRY_CREATE",
ENTRY_UPDATE = "ENTRY_UPDATE",
ENTRY_DELETE = "ENTRY_DELETE",
LIKE = "LIKE",
UNLIKE = "UNLIKE",
USER_BAN = "USER_BAN",
USER_UNBAN = "USER_UNBAN",
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED",
UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS",
}
enum AuditCategory {
auth = "AUTH",
content = "CONTENT",
admin = "ADMIN",
security = "SECURITY",
export enum AuditCategory {
AUTH = "AUTH",
CONTENT = "CONTENT",
ADMIN = "ADMIN",
SECURITY = "SECURITY",
}
interface AuditLogUser {
id: string;
export interface AuditLogUser {
id: string;
username: string;
avatar?: string;
avatar?: string;
}
interface AuditLog {
id: string;
action: AuditAction;
category: AuditCategory;
userId?: string;
user?: AuditLogUser;
export interface AuditLog {
id: string;
action: AuditAction;
category: AuditCategory;
userId?: string;
user?: AuditLogUser;
targetUserId?: string;
targetUser?: AuditLogUser;
targetUser?: AuditLogUser;
resourceType?: string;
resourceId?: string;
details?: string;
userAgent?: string;
success: boolean;
createdAt: Date;
resourceId?: string;
details?: string;
userAgent?: string;
success: boolean;
createdAt: Date;
}
interface AuditLogFilters {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: boolean;
export interface AuditLogFilters {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: boolean;
startDate?: Date;
endDate?: Date;
page?: number;
limit?: number;
endDate?: Date;
page?: number;
limit?: number;
}
export {
AuditAction,
AuditCategory,
type AuditLog,
type AuditLogFilters,
type AuditLogUser,
};
+21 -35
View File
@@ -4,47 +4,33 @@
* @author Naomi Carrigan
*/
interface User {
id: string;
email: string;
username: string;
avatar?: string;
slug?: string;
displayName?: string;
bio?: string;
profilePublic: boolean;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
discordId: string;
isAdmin: boolean;
isBanned: boolean;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
export interface User {
id: string;
email: string;
username: string;
avatar?: string;
discordId: string;
isAdmin: boolean;
isBanned: boolean;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
}
interface JwtPayload {
export interface JwtPayload {
/**
* User id.
*/
sub: string;
email: string;
sub: string;
email: string;
username: string;
isAdmin: boolean;
iat?: number;
exp?: number;
isAdmin: boolean;
iat?: number;
exp?: number;
}
interface AuthResponse {
export interface AuthResponse {
accessToken: string;
user: User;
}
export type { AuthResponse, JwtPayload, User };
user: User;
}
+19 -26
View File
@@ -1,53 +1,46 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
enum BookStatus {
export enum BookStatus {
reading = "READING",
finished = "FINISHED",
toRead = "TO_READ",
retired = "RETIRED",
}
interface Book {
import { Link } from "./common.types";
export interface Book {
id: string;
title: string;
author: string;
isbn?: string;
status: BookStatus;
dateAdded: Date;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: Array<string>;
links: Array<Link>;
tags: string[];
links: Link[];
createdAt: Date;
updatedAt: Date;
}
interface CreateBookDto {
title: string;
author: string;
isbn?: string;
status: BookStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
export interface CreateBookDto {
title: string;
author: string;
isbn?: string;
status: BookStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
}
interface UpdateBookDto extends Partial<CreateBookDto> {
dateStarted?: Date;
export interface UpdateBookDto extends Partial<CreateBookDto> {
dateFinished?: Date;
}
export { type Book, BookStatus, type CreateBookDto, type UpdateBookDto };
+21 -23
View File
@@ -4,34 +4,32 @@
* @author Naomi Carrigan
*/
interface CommentUser {
id: string;
username: string;
avatar?: string;
export interface CommentUser {
id: string;
username: string;
avatar?: string;
inDiscord?: boolean;
isVip?: boolean;
isMod?: boolean;
isStaff?: boolean;
isVip?: boolean;
isMod?: boolean;
isStaff?: boolean;
}
interface Comment {
id: string;
content: string;
export interface Comment {
id: string;
content: string;
rawContent?: string;
userId: string;
user: CommentUser;
gameId?: string;
bookId?: string;
musicId?: string;
artId?: string;
showId?: string;
mangaId?: string;
createdAt: Date;
updatedAt: Date;
userId: string;
user: CommentUser;
gameId?: string;
bookId?: string;
musicId?: string;
artId?: string;
showId?: string;
mangaId?: string;
createdAt: Date;
updatedAt: Date;
}
interface CreateCommentDto {
export interface CreateCommentDto {
content: string;
}
export type { Comment, CommentUser, CreateCommentDto };
+1 -7
View File
@@ -1,10 +1,4 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface Link {
title: string;
url: string;
url: string;
}
+18 -27
View File
@@ -1,53 +1,44 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
enum GameStatus {
export enum GameStatus {
playing = "PLAYING",
completed = "COMPLETED",
backlog = "BACKLOG",
retired = "RETIRED",
}
interface Game {
import { Link } from "./common.types";
export interface Game {
id: string;
title: string;
platform?: string;
status: GameStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: Array<string>;
links: Array<Link>;
tags: string[];
links: Link[];
createdAt: Date;
updatedAt: Date;
}
interface CreateGameDto {
title: string;
platform?: string;
status: GameStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
export interface CreateGameDto {
title: string;
platform?: string;
status: GameStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
}
interface UpdateGameDto extends Partial<CreateGameDto> {
dateStarted?: Date;
export interface UpdateGameDto extends Partial<CreateGameDto> {
dateCompleted?: Date;
dateFinished?: Date;
}
export { type CreateGameDto, type Game, GameStatus, type UpdateGameDto };
+20 -26
View File
@@ -1,38 +1,32 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
interface Like {
id: string;
userId: string;
entityType: "book" | "game" | "show" | "manga" | "music" | "art";
entityId: string;
createdAt: Date;
export interface Like {
id: string;
userId: string;
entityType: 'book' | 'game' | 'show' | 'manga' | 'music' | 'art';
entityId: string;
createdAt: Date;
}
type CreateLikeDto = Pick<Like, "entityType" | "entityId">;
export type CreateLikeDto = Pick<Like, 'entityType' | 'entityId'>;
interface LikeCountDto {
entityId: string;
export interface LikeCountDto {
entityId: string;
entityType: string;
count: number;
}
interface LikedItemDto {
like: Like;
/**
* This will be the actual entity (Book, Game, etc.).
*/
item: unknown;
}
// Response types
interface LikeResponse {
liked: boolean;
count: number;
}
export type { CreateLikeDto, Like, LikeCountDto, LikedItemDto, LikeResponse };
export interface LikedItemDto {
like: Like;
item: any; // This will be the actual entity (Book, Game, etc.)
}
// Response types
export interface LikeResponse {
liked: boolean;
count: number;
}
+17 -27
View File
@@ -1,54 +1,44 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
enum MangaStatus {
export enum MangaStatus {
reading = "READING",
completed = "COMPLETED",
wantToRead = "WANT_TO_READ",
retired = "RETIRED",
}
interface Manga {
import { Link } from "./common.types";
export interface Manga {
id: string;
title: string;
author: string;
status: MangaStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: Array<string>;
links: Array<Link>;
tags: string[];
links: Link[];
createdAt: Date;
updatedAt: Date;
}
interface CreateMangaDto {
title: string;
author: string;
status: MangaStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
export interface CreateMangaDto {
title: string;
author: string;
status: MangaStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
}
interface UpdateMangaDto extends Partial<CreateMangaDto> {
dateStarted?: Date;
export interface UpdateMangaDto extends Partial<CreateMangaDto> {
dateCompleted?: Date;
dateFinished?: Date;
}
export { MangaStatus };
export type { CreateMangaDto, Manga, UpdateMangaDto };
+20 -30
View File
@@ -1,62 +1,52 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
enum MusicType {
export enum MusicType {
album = "ALBUM",
single = "SINGLE",
ep = "EP",
}
enum MusicStatus {
export enum MusicStatus {
listening = "LISTENING",
completed = "COMPLETED",
wantToListen = "WANT_TO_LISTEN",
retired = "RETIRED",
}
interface Music {
import { Link } from "./common.types";
export interface Music {
id: string;
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverArt?: string;
tags: Array<string>;
links: Array<Link>;
tags: string[];
links: Link[];
createdAt: Date;
updatedAt: Date;
}
interface CreateMusicDto {
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverArt?: string;
tags?: Array<string>;
links?: Array<Link>;
export interface CreateMusicDto {
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
rating?: number;
notes?: string;
coverArt?: string;
tags?: string[];
links?: Link[];
}
interface UpdateMusicDto extends Partial<CreateMusicDto> {
dateStarted?: Date;
export interface UpdateMusicDto extends Partial<CreateMusicDto> {
dateCompleted?: Date;
dateFinished?: Date;
}
export { MusicStatus, MusicType };
export type { CreateMusicDto, Music, UpdateMusicDto };
+18 -28
View File
@@ -1,61 +1,51 @@
/**
* @copyright NHCarrigan
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "./common.types";
enum ShowType {
export enum ShowType {
tvSeries = "TV_SERIES",
anime = "ANIME",
film = "FILM",
documentary = "DOCUMENTARY",
}
enum ShowStatus {
export enum ShowStatus {
watching = "WATCHING",
completed = "COMPLETED",
wantToWatch = "WANT_TO_WATCH",
retired = "RETIRED",
}
interface Show {
import { Link } from "./common.types";
export interface Show {
id: string;
title: string;
type: ShowType;
status: ShowStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: Array<string>;
links: Array<Link>;
tags: string[];
links: Link[];
createdAt: Date;
updatedAt: Date;
}
interface CreateShowDto {
title: string;
type: ShowType;
status: ShowStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
export interface CreateShowDto {
title: string;
type: ShowType;
status: ShowStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
}
interface UpdateShowDto extends Partial<CreateShowDto> {
dateStarted?: Date;
export interface UpdateShowDto extends Partial<CreateShowDto> {
dateCompleted?: Date;
dateFinished?: Date;
}
export { ShowStatus, ShowType };
export type { CreateShowDto, Show, UpdateShowDto };
+84 -105
View File
@@ -1,110 +1,104 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CreateArtDto } from "./art.types";
import type { CreateBookDto } from "./book.types";
import type { CreateGameDto } from "./game.types";
import type { CreateMangaDto } from "./manga.types";
import type { CreateBookDto } from "./book.types";
import type { CreateMusicDto } from "./music.types";
import type { CreateArtDto } from "./art.types";
import type { CreateShowDto } from "./show.types";
import type { CreateMangaDto } from "./manga.types";
enum SuggestionEntity {
game = "GAME",
book = "BOOK",
music = "MUSIC",
art = "ART",
show = "SHOW",
manga = "MANGA",
export enum SuggestionEntity {
GAME = "GAME",
BOOK = "BOOK",
MUSIC = "MUSIC",
ART = "ART",
SHOW = "SHOW",
MANGA = "MANGA",
}
enum SuggestionStatus {
unreviewed = "UNREVIEWED",
accepted = "ACCEPTED",
declined = "DECLINED",
export enum SuggestionStatus {
UNREVIEWED = "UNREVIEWED",
ACCEPTED = "ACCEPTED",
DECLINED = "DECLINED",
}
interface SuggestionUser {
id: string;
username: string;
avatar?: string;
export interface SuggestionUser {
id: string;
username: string;
avatar?: string;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
}
interface Suggestion {
id: string;
userId: string;
user: SuggestionUser;
entityType: SuggestionEntity;
status: SuggestionStatus;
export interface Suggestion {
id: string;
userId: string;
user: SuggestionUser;
entityType: SuggestionEntity;
status: SuggestionStatus;
declineReason?: string;
title: string;
gameData?: Omit<CreateGameDto, "rating">;
bookData?: Omit<CreateBookDto, "rating">;
musicData?: Omit<CreateMusicDto, "rating">;
artData?: CreateArtDto;
showData?: Omit<CreateShowDto, "rating">;
mangaData?: Omit<CreateMangaDto, "rating">;
createdAt: Date;
updatedAt: Date;
title: string;
gameData?: Omit<CreateGameDto, "rating">;
bookData?: Omit<CreateBookDto, "rating">;
musicData?: Omit<CreateMusicDto, "rating">;
artData?: CreateArtDto;
showData?: Omit<CreateShowDto, "rating">;
mangaData?: Omit<CreateMangaDto, "rating">;
createdAt: Date;
updatedAt: Date;
}
interface CreateGameSuggestionDto {
entityType: SuggestionEntity.game;
title: string;
platform?: string;
notes?: string;
export interface CreateGameSuggestionDto {
entityType: SuggestionEntity.GAME;
title: string;
platform?: string;
notes?: string;
coverImage?: string;
}
interface CreateBookSuggestionDto {
entityType: SuggestionEntity.book;
title: string;
author: string;
isbn?: string;
notes?: string;
export interface CreateBookSuggestionDto {
entityType: SuggestionEntity.BOOK;
title: string;
author: string;
isbn?: string;
notes?: string;
coverImage?: string;
}
interface CreateMusicSuggestionDto {
entityType: SuggestionEntity.music;
title: string;
artist: string;
type: string;
notes?: string;
coverArt?: string;
export interface CreateMusicSuggestionDto {
entityType: SuggestionEntity.MUSIC;
title: string;
artist: string;
type: string;
notes?: string;
coverArt?: string;
}
interface CreateArtSuggestionDto {
entityType: SuggestionEntity.art;
title: string;
artist: string;
export interface CreateArtSuggestionDto {
entityType: SuggestionEntity.ART;
title: string;
artist: string;
description?: string;
imageUrl: string;
imageUrl: string;
}
interface CreateShowSuggestionDto {
entityType: SuggestionEntity.show;
title: string;
type: string;
notes?: string;
export interface CreateShowSuggestionDto {
entityType: SuggestionEntity.SHOW;
title: string;
type: string;
notes?: string;
coverImage?: string;
}
interface CreateMangaSuggestionDto {
entityType: SuggestionEntity.manga;
title: string;
author: string;
notes?: string;
export interface CreateMangaSuggestionDto {
entityType: SuggestionEntity.MANGA;
title: string;
author: string;
notes?: string;
coverImage?: string;
}
type CreateSuggestionDto =
export type CreateSuggestionDto =
| CreateGameSuggestionDto
| CreateBookSuggestionDto
| CreateMusicSuggestionDto
@@ -112,37 +106,22 @@ type CreateSuggestionDto =
| CreateShowSuggestionDto
| CreateMangaSuggestionDto;
interface DeclineSuggestionDto {
export interface DeclineSuggestionDto {
reason?: string;
}
interface AcceptWithEditsDto {
title?: string;
platform?: string;
author?: string;
artist?: string;
isbn?: string;
type?: string;
notes?: string;
export interface AcceptWithEditsDto {
title?: string;
platform?: string;
author?: string;
artist?: string;
isbn?: string;
type?: string;
notes?: string;
description?: string;
coverImage?: string;
coverArt?: string;
imageUrl?: string;
tags?: Array<string>;
links?: Array<{ label: string; url: string }>;
coverImage?: string;
coverArt?: string;
imageUrl?: string;
tags?: string[];
links?: Array<{ label: string; url: string }>;
}
export { SuggestionEntity, SuggestionStatus };
export type {
AcceptWithEditsDto,
CreateArtSuggestionDto,
CreateBookSuggestionDto,
CreateGameSuggestionDto,
CreateMangaSuggestionDto,
CreateMusicSuggestionDto,
CreateShowSuggestionDto,
CreateSuggestionDto,
DeclineSuggestionDto,
Suggestion,
SuggestionUser,
};
-110
View File
@@ -1,110 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Art, CreateArtDto, UpdateArtDto } from "../src/lib/art.types";
describe("art Types", () => {
describe("art interface", () => {
it("should accept valid art object with minimal fields", () => {
const art: Art = {
artist: "Jane Doe",
createdAt: new Date(),
dateAdded: new Date(),
id: "art123",
imageUrl: "https://example.com/sunset.jpg",
links: [],
tags: [],
title: "Beautiful Sunset",
updatedAt: new Date(),
};
expect(art.description).toBeUndefined();
expect(art.tags).toEqual([]);
});
it("should accept valid art object with all fields", () => {
const fullArt: Art = {
artist: "John Smith",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
description: "A breathtaking view of the mountains",
id: "art456",
imageUrl: "https://example.com/mountains.jpg",
links: [
{ title: "Artist Portfolio", url: "https://artist.com" },
{ title: "Instagram", url: "https://instagram.com/artist" },
],
tags: [ "landscape", "nature", "mountains" ],
title: "Mountain Landscape",
updatedAt: new Date("2024-01-02"),
};
expect(fullArt.description).toBe("A breathtaking view of the mountains");
expect(fullArt.tags).toHaveLength(3);
expect(fullArt.links).toHaveLength(2);
});
});
describe("createArtDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateArtDto = {
artist: "New Artist",
imageUrl: "https://example.com/art.jpg",
title: "New Artwork",
};
expect(createDto.description).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateArtDto = {
artist: "Famous Artist",
description: "Detailed description",
imageUrl: "https://example.com/complete.jpg",
links: [ { title: "Website", url: "https://website.com" } ],
tags: [ "tag1", "tag2" ],
title: "Complete Artwork",
};
expect(fullCreateDto.tags).toEqual([ "tag1", "tag2" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateArtDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateArtDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates", () => {
const partialUpdate: UpdateArtDto = {
tags: [ "new-tag" ],
title: "Updated Title",
};
expect(partialUpdate.title).toBe("Updated Title");
expect(partialUpdate.artist).toBeUndefined();
expect(partialUpdate.tags).toEqual([ "new-tag" ]);
});
it("should accept full update", () => {
const fullUpdate: UpdateArtDto = {
artist: "Different Artist",
description: "New description",
imageUrl: "https://example.com/new-image.jpg",
links: [ { title: "New Link", url: "https://newlink.com" } ],
tags: [ "updated", "tags" ],
title: "Completely New Title",
};
expect(fullUpdate.title).toBe("Completely New Title");
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-129
View File
@@ -1,129 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
AuditAction,
AuditCategory,
type AuditLog,
type AuditLogFilters,
type AuditLogUser,
} from "../src/lib/audit.types";
describe("audit Types", () => {
describe("auditAction enum", () => {
it("should have all expected action values", () => {
expect(AuditAction.login).toBe("LOGIN");
expect(AuditAction.logout).toBe("LOGOUT");
expect(AuditAction.loginFailed).toBe("LOGIN_FAILED");
expect(AuditAction.commentCreate).toBe("COMMENT_CREATE");
expect(AuditAction.commentUpdate).toBe("COMMENT_UPDATE");
expect(AuditAction.commentDelete).toBe("COMMENT_DELETE");
expect(AuditAction.entryCreate).toBe("ENTRY_CREATE");
expect(AuditAction.entryUpdate).toBe("ENTRY_UPDATE");
expect(AuditAction.entryDelete).toBe("ENTRY_DELETE");
expect(AuditAction.like).toBe("LIKE");
expect(AuditAction.unlike).toBe("UNLIKE");
expect(AuditAction.userBan).toBe("USER_BAN");
expect(AuditAction.userUnban).toBe("USER_UNBAN");
expect(AuditAction.rateLimitExceeded).toBe("RATE_LIMIT_EXCEEDED");
expect(AuditAction.csrfValidationFailed).toBe("CSRF_VALIDATION_FAILED");
expect(AuditAction.unauthorizedAccess).toBe("UNAUTHORIZED_ACCESS");
});
});
describe("auditCategory enum", () => {
it("should have all expected category values", () => {
expect(AuditCategory.auth).toBe("AUTH");
expect(AuditCategory.content).toBe("CONTENT");
expect(AuditCategory.admin).toBe("ADMIN");
expect(AuditCategory.security).toBe("SECURITY");
});
});
describe("auditLogUser interface", () => {
it("should accept valid user objects", () => {
const userWithAvatar: AuditLogUser = {
avatar: "https://example.com/avatar.png",
id: "user123",
username: "testuser",
};
const userWithoutAvatar: AuditLogUser = {
id: "user456",
username: "anotheruser",
};
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
expect(userWithoutAvatar.avatar).toBeUndefined();
});
});
describe("auditLog interface", () => {
it("should accept valid audit log with minimal fields", () => {
const minimalLog: AuditLog = {
action: AuditAction.login,
category: AuditCategory.auth,
createdAt: new Date(),
id: "log123",
success: true,
};
expect(minimalLog.userId).toBeUndefined();
expect(minimalLog.details).toBeUndefined();
});
it("should accept valid audit log with all fields", () => {
const fullLog: AuditLog = {
action: AuditAction.commentDelete,
category: AuditCategory.admin,
createdAt: new Date(),
details: "Admin deleted inappropriate comment",
id: "log456",
resourceId: "comment123",
resourceType: "comment",
success: true,
targetUser: {
id: "user789",
username: "targetuser",
},
targetUserId: "user789",
user: {
avatar: "https://example.com/avatar.png",
id: "user123",
username: "admin",
},
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
userId: "user123",
};
expect(fullLog.user?.username).toBe("admin");
expect(fullLog.targetUser?.username).toBe("targetuser");
});
});
describe("auditLogFilters interface", () => {
it("should accept empty filters", () => {
const emptyFilters: AuditLogFilters = {};
expect(emptyFilters).toEqual({});
});
it("should accept filters with all fields", () => {
const fullFilters: AuditLogFilters = {
action: AuditAction.login,
category: AuditCategory.auth,
endDate: new Date("2024-12-31"),
limit: 50,
page: 1,
startDate: new Date("2024-01-01"),
success: true,
userId: "user123",
};
expect(fullFilters.page).toBe(1);
expect(fullFilters.limit).toBe(50);
});
});
});
-118
View File
@@ -1,118 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { AuthResponse, JwtPayload, User } from "../src/lib/auth.types";
describe("auth Types", () => {
describe("user interface", () => {
it("should accept valid user objects", () => {
const userWithAvatar: User = {
avatar: "https://example.com/avatar.png",
discordId: "discord123",
email: "test@example.com",
id: "user123",
inDiscord: true,
isAdmin: true,
isBanned: false,
isMod: true,
isStaff: true,
isVip: false,
username: "testuser",
};
const userWithoutAvatar: User = {
discordId: "discord456",
email: "another@example.com",
id: "user456",
inDiscord: false,
isAdmin: false,
isBanned: false,
isMod: false,
isStaff: false,
isVip: true,
username: "anotheruser",
};
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
expect(userWithoutAvatar.avatar).toBeUndefined();
expect(userWithAvatar.isAdmin).toBeTruthy();
expect(userWithoutAvatar.isVip).toBeTruthy();
});
it("should handle all boolean flags correctly", () => {
const bannedUser: User = {
discordId: "discord789",
email: "banned@example.com",
id: "banned123",
inDiscord: false,
isAdmin: false,
isBanned: true,
isMod: false,
isStaff: false,
isVip: false,
username: "banneduser",
};
expect(bannedUser.isBanned).toBeTruthy();
expect(bannedUser.isAdmin).toBeFalsy();
expect(bannedUser.inDiscord).toBeFalsy();
});
});
describe("jwtPayload interface", () => {
it("should accept JWT payload with required fields", () => {
const payload: JwtPayload = {
email: "test@example.com",
isAdmin: false,
sub: "user123",
username: "testuser",
};
expect(payload.sub).toBe("user123");
expect(payload.iat).toBeUndefined();
expect(payload.exp).toBeUndefined();
});
it("should accept JWT payload with timestamps", () => {
const now = Math.floor(Date.now() / 1000);
const payloadWithTimestamps: JwtPayload = {
email: "another@example.com",
exp: now + 3600,
iat: now,
isAdmin: true,
sub: "user456",
username: "anotheruser", // 1 hour
};
expect(payloadWithTimestamps.iat).toBe(now);
expect(payloadWithTimestamps.exp).toBe(now + 3600);
});
});
describe("authResponse interface", () => {
it("should accept valid auth response", () => {
const authResponse: AuthResponse = {
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
user: {
avatar: "https://example.com/avatar.png",
discordId: "discord123",
email: "test@example.com",
id: "user123",
inDiscord: true,
isAdmin: false,
isBanned: false,
isMod: false,
isStaff: false,
isVip: false,
username: "testuser",
},
};
expect(authResponse.accessToken).toContain("eyJ");
expect(authResponse.user.username).toBe("testuser");
});
});
});
-151
View File
@@ -1,151 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { BookStatus } from "../src/lib/book.types";
import type { Book, CreateBookDto, UpdateBookDto } from "../src/lib/book.types";
describe("book Types", () => {
describe("bookStatus enum", () => {
it("should have the correct values", () => {
expect(BookStatus.reading).toBe("READING");
expect(BookStatus.finished).toBe("FINISHED");
expect(BookStatus.toRead).toBe("TO_READ");
});
it("should have all expected enum values", () => {
const values = Object.values(BookStatus);
expect(values).toHaveLength(4);
expect(values).toContain("READING");
expect(values).toContain("FINISHED");
expect(values).toContain("TO_READ");
expect(values).toContain("RETIRED");
});
});
describe("book interface", () => {
it("should accept valid book object with minimal fields", () => {
const book: Book = {
author: "F. Scott Fitzgerald",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "book123",
links: [],
status: BookStatus.reading,
tags: [],
title: "The Great Gatsby",
updatedAt: new Date("2024-01-16"),
};
expect(book.isbn).toBeUndefined();
expect(book.dateFinished).toBeUndefined();
expect(book.rating).toBeUndefined();
expect(book.notes).toBeUndefined();
expect(book.coverImage).toBeUndefined();
});
it("should accept valid book object with all fields", () => {
const fullBook: Book = {
author: "George Orwell",
coverImage: "https://example.com/1984-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateFinished: new Date("2024-01-20"),
id: "book456",
isbn: "978-0-452-28423-4",
links: [
{ title: "Goodreads", url: "https://goodreads.com/book/1984" },
{ title: "Wikipedia", url: "https://wikipedia.org/wiki/1984" },
],
notes: "A dystopian masterpiece that remains relevant",
rating: 5,
status: BookStatus.finished,
tags: [ "dystopian", "classic", "political" ],
title: "1984",
updatedAt: new Date("2024-01-20"),
};
expect(fullBook.isbn).toBe("978-0-452-28423-4");
expect(fullBook.rating).toBe(5);
expect(fullBook.tags).toHaveLength(3);
expect(fullBook.links).toHaveLength(2);
});
});
describe("createBookDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateBookDto = {
author: "Harper Lee",
status: BookStatus.toRead,
title: "To Kill a Mockingbird",
};
expect(createDto.isbn).toBeUndefined();
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateBookDto = {
author: "J.R.R. Tolkien",
coverImage: "https://example.com/hobbit-cover.jpg",
isbn: "978-0-547-92822-7",
links: [ { title: "Author Website", url: "https://tolkien.com" } ],
notes: "Starting the journey to Middle-earth",
rating: 4,
status: BookStatus.reading,
tags: [ "fantasy", "adventure", "classic" ],
title: "The Hobbit",
};
expect(fullCreateDto.isbn).toBe("978-0-547-92822-7");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "fantasy", "adventure", "classic" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateBookDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateBookDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateFinished", () => {
const partialUpdate: UpdateBookDto = {
dateFinished: new Date("2024-02-01"),
rating: 5,
status: BookStatus.finished,
};
expect(partialUpdate.status).toBe(BookStatus.finished);
expect(partialUpdate.dateFinished).toEqual(new Date("2024-02-01"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateBookDto = {
author: "Different Author",
coverImage: "https://example.com/new-cover.jpg",
dateFinished: new Date("2024-02-15"),
isbn: "978-1-234-56789-0",
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated notes after finishing",
rating: 3,
status: BookStatus.finished,
tags: [ "updated", "tags" ],
title: "Updated Title",
};
expect(fullUpdate.title).toBe("Updated Title");
expect(fullUpdate.dateFinished).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-195
View File
@@ -1,195 +0,0 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CommentUser, Comment, CreateCommentDto } from "../src/lib/comment.types";
describe("comment Types", () => {
describe("commentUser interface", () => {
it("should accept valid user object with minimal fields", () => {
const user: CommentUser = {
id: "user123",
username: "testuser",
};
expect(user.avatar).toBeUndefined();
expect(user.inDiscord).toBeUndefined();
expect(user.isVip).toBeUndefined();
expect(user.isMod).toBeUndefined();
expect(user.isStaff).toBeUndefined();
});
it("should accept valid user object with all fields", () => {
const fullUser: CommentUser = {
avatar: "https://example.com/avatar.png",
id: "user456",
inDiscord: true,
isMod: true,
isStaff: false,
isVip: true,
username: "vipmoduser",
};
expect(fullUser.avatar).toBe("https://example.com/avatar.png");
expect(fullUser.inDiscord).toBeTruthy();
expect(fullUser.isVip).toBeTruthy();
expect(fullUser.isMod).toBeTruthy();
expect(fullUser.isStaff).toBeFalsy();
});
});
describe("comment interface", () => {
it("should accept valid comment object with minimal fields", () => {
const comment: Comment = {
content: "This is a great book!",
createdAt: new Date("2024-01-15"),
id: "comment123",
updatedAt: new Date("2024-01-15"),
user: {
id: "user123",
username: "bookworm",
},
userId: "user123",
};
expect(comment.rawContent).toBeUndefined();
expect(comment.gameId).toBeUndefined();
expect(comment.bookId).toBeUndefined();
expect(comment.musicId).toBeUndefined();
expect(comment.artId).toBeUndefined();
expect(comment.showId).toBeUndefined();
expect(comment.mangaId).toBeUndefined();
});
it("should accept comment for a book", () => {
const bookComment: Comment = {
bookId: "book123",
content: "<p>Amazing read!</p>",
createdAt: new Date("2024-01-20"),
id: "comment456",
rawContent: "Amazing read!",
updatedAt: new Date("2024-01-21"),
user: {
avatar: "https://example.com/reader.png",
id: "user789",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "reader",
},
userId: "user789",
};
expect(bookComment.bookId).toBe("book123");
expect(bookComment.rawContent).toBe("Amazing read!");
expect(bookComment.gameId).toBeUndefined();
});
it("should accept comment for a game", () => {
const gameComment: Comment = {
content: "Best game ever!",
createdAt: new Date("2024-02-01"),
gameId: "game789",
id: "comment789",
updatedAt: new Date("2024-02-01"),
user: {
id: "user456",
username: "gamer",
},
userId: "user456",
};
expect(gameComment.gameId).toBe("game789");
expect(gameComment.bookId).toBeUndefined();
});
it("should accept comment for music", () => {
const musicComment: Comment = {
content: "Beautiful album",
createdAt: new Date("2024-02-10"),
id: "comment999",
musicId: "music123",
updatedAt: new Date("2024-02-10"),
user: {
id: "user111",
username: "musiclover",
},
userId: "user111",
};
expect(musicComment.musicId).toBe("music123");
});
it("should accept comment for art", () => {
const artComment: Comment = {
artId: "art456",
content: "Stunning artwork!",
createdAt: new Date("2024-02-15"),
id: "comment111",
updatedAt: new Date("2024-02-15"),
user: {
id: "user222",
username: "artcritic",
},
userId: "user222",
};
expect(artComment.artId).toBe("art456");
});
it("should accept comment for a show", () => {
const showComment: Comment = {
content: "Great series!",
createdAt: new Date("2024-02-20"),
id: "comment222",
showId: "show789",
updatedAt: new Date("2024-02-20"),
user: {
id: "user333",
username: "tvfan",
},
userId: "user333",
};
expect(showComment.showId).toBe("show789");
});
it("should accept comment for manga", () => {
const mangaComment: Comment = {
content: "Awesome manga!",
createdAt: new Date("2024-02-25"),
id: "comment333",
mangaId: "manga123",
updatedAt: new Date("2024-02-25"),
user: {
id: "user444",
username: "mangareader",
},
userId: "user444",
};
expect(mangaComment.mangaId).toBe("manga123");
});
});
describe("createCommentDto interface", () => {
it("should accept DTO with content", () => {
const createDto: CreateCommentDto = {
content: "This is my comment",
};
expect(createDto.content).toBe("This is my comment");
});
it("should accept DTO with formatted content", () => {
const createDto: CreateCommentDto = {
content: "<p>This is <strong>formatted</strong> content!</p>",
};
expect(createDto.content).toBe("<p>This is <strong>formatted</strong> content!</p>");
});
});
});
-37
View File
@@ -1,37 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "../src/lib/common.types";
describe("common Types", () => {
describe("link interface", () => {
it("should accept valid Link objects", () => {
const validLink: Link = {
title: "Example Website",
url: "https://example.com",
};
expect(validLink.title).toBe("Example Website");
expect(validLink.url).toBe("https://example.com");
});
it("should work with different URL formats", () => {
const links: Array<Link> = [
{ title: "HTTP URL", url: "http://example.com" },
{ title: "HTTPS URL", url: "https://example.com" },
{ title: "Relative URL", url: "/path/to/page" },
{ title: "URL with query", url: "https://example.com?query=value" },
{ title: "URL with anchor", url: "https://example.com#section" },
];
expect(links).toHaveLength(5);
for (const link of links) {
expect(link).toHaveProperty("title");
expect(link).toHaveProperty("url");
}
});
});
});
-176
View File
@@ -1,176 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GameStatus } from "../src/lib/game.types";
import type { Game, CreateGameDto, UpdateGameDto } from "../src/lib/game.types";
describe("game Types", () => {
describe("gameStatus enum", () => {
it("should have the correct values", () => {
expect(GameStatus.playing).toBe("PLAYING");
expect(GameStatus.completed).toBe("COMPLETED");
expect(GameStatus.backlog).toBe("BACKLOG");
});
it("should have all expected enum values", () => {
const values = Object.values(GameStatus);
expect(values).toHaveLength(4);
expect(values).toContain("PLAYING");
expect(values).toContain("COMPLETED");
expect(values).toContain("BACKLOG");
expect(values).toContain("RETIRED");
});
});
describe("game interface", () => {
it("should accept valid game object with minimal fields", () => {
const game: Game = {
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "game123",
links: [],
status: GameStatus.playing,
tags: [],
title: "The Legend of Zelda: Breath of the Wild",
updatedAt: new Date("2024-01-16"),
};
expect(game.platform).toBeUndefined();
expect(game.dateCompleted).toBeUndefined();
expect(game.rating).toBeUndefined();
expect(game.notes).toBeUndefined();
expect(game.coverImage).toBeUndefined();
});
it("should accept valid game object with all fields", () => {
const fullGame: Game = {
coverImage: "https://example.com/hades-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "game456",
links: [
{ title: "Steam", url: "https://store.steampowered.com/app/hades" },
{ title: "Official Site", url: "https://supergiantgames.com/hades" },
],
notes: "One of the best roguelikes ever made",
platform: "Nintendo Switch",
rating: 5,
status: GameStatus.completed,
tags: [ "roguelike", "indie", "action", "mythology" ],
title: "Hades",
updatedAt: new Date("2024-01-20"),
};
expect(fullGame.platform).toBe("Nintendo Switch");
expect(fullGame.rating).toBe(5);
expect(fullGame.tags).toHaveLength(4);
expect(fullGame.links).toHaveLength(2);
});
it("should accept games on different platforms", () => {
const pcGame: Game = {
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "game789",
links: [],
platform: "PC",
status: GameStatus.completed,
tags: [ "puzzle", "first-person" ],
title: "Portal 2",
updatedAt: new Date("2024-02-01"),
};
const ps5Game: Game = {
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "game999",
links: [],
platform: "PlayStation 5",
status: GameStatus.backlog,
tags: [ "action", "superhero" ],
title: "Spider-Man: Miles Morales",
updatedAt: new Date("2024-02-05"),
};
expect(pcGame.platform).toBe("PC");
expect(ps5Game.platform).toBe("PlayStation 5");
});
});
describe("createGameDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateGameDto = {
status: GameStatus.backlog,
title: "Elden Ring",
};
expect(createDto.platform).toBeUndefined();
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateGameDto = {
coverImage: "https://example.com/hollow-knight.jpg",
links: [ { title: "Wiki", url: "https://hollowknight.wiki" } ],
notes: "Beautiful metroidvania with challenging gameplay",
platform: "Nintendo Switch",
rating: 4,
status: GameStatus.playing,
tags: [ "metroidvania", "indie", "platformer" ],
title: "Hollow Knight",
};
expect(fullCreateDto.platform).toBe("Nintendo Switch");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "metroidvania", "indie", "platformer" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateGameDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateGameDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateGameDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: GameStatus.completed,
};
expect(partialUpdate.status).toBe(GameStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateGameDto = {
coverImage: "https://example.com/new-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after completion",
platform: "Xbox Series X",
rating: 3,
status: GameStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Game Title",
};
expect(fullUpdate.title).toBe("Updated Game Title");
expect(fullUpdate.platform).toBe("Xbox Series X");
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-228
View File
@@ -1,228 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Like, CreateLikeDto, LikeResponse, LikedItemDto, LikeCountDto } from "../src/lib/like.types";
describe("like Types", () => {
describe("like interface", () => {
it("should accept valid like object for a book", () => {
const bookLike: Like = {
createdAt: new Date("2024-01-15"),
entityId: "book456",
entityType: "book",
id: "like123",
userId: "user123",
};
expect(bookLike.entityType).toBe("book");
expect(bookLike.entityId).toBe("book456");
});
it("should accept valid like object for a game", () => {
const gameLike: Like = {
createdAt: new Date("2024-01-20"),
entityId: "game123",
entityType: "game",
id: "like456",
userId: "user789",
};
expect(gameLike.entityType).toBe("game");
expect(gameLike.entityId).toBe("game123");
});
it("should accept likes for all entity types", () => {
const showLike: Like = {
createdAt: new Date("2024-02-01"),
entityId: "show222",
entityType: "show",
id: "like789",
userId: "user111",
};
const mangaLike: Like = {
createdAt: new Date("2024-02-05"),
entityId: "manga444",
entityType: "manga",
id: "like999",
userId: "user333",
};
const musicLike: Like = {
createdAt: new Date("2024-02-10"),
entityId: "music666",
entityType: "music",
id: "like111",
userId: "user555",
};
const artLike: Like = {
createdAt: new Date("2024-02-15"),
entityId: "art888",
entityType: "art",
id: "like222",
userId: "user777",
};
expect(showLike.entityType).toBe("show");
expect(mangaLike.entityType).toBe("manga");
expect(musicLike.entityType).toBe("music");
expect(artLike.entityType).toBe("art");
});
});
describe("createLikeDto type", () => {
it("should pick only entityType and entityId from Like", () => {
const createDto: CreateLikeDto = {
entityId: "book123",
entityType: "book",
};
expect(createDto.entityType).toBe("book");
expect(createDto.entityId).toBe("book123");
// Verify it only has these two properties
expect(Object.keys(createDto)).toHaveLength(2);
});
it("should accept different entity types", () => {
const gameDto: CreateLikeDto = {
entityId: "game456",
entityType: "game",
};
const artDto: CreateLikeDto = {
entityId: "art789",
entityType: "art",
};
expect(gameDto.entityType).toBe("game");
expect(artDto.entityType).toBe("art");
});
});
describe("likeCountDto interface", () => {
it("should accept valid like count object", () => {
const likeCount: LikeCountDto = {
count: 42,
entityId: "book123",
entityType: "book",
};
expect(likeCount.count).toBe(42);
expect(likeCount.entityType).toBe("book");
});
it("should accept like counts for different entities", () => {
const gameLikeCount: LikeCountDto = {
count: 15,
entityId: "game456",
entityType: "game",
};
const showLikeCount: LikeCountDto = {
count: 0,
entityId: "show789",
entityType: "show",
};
expect(gameLikeCount.count).toBe(15);
expect(showLikeCount.count).toBe(0);
});
});
describe("likedItemDto interface", () => {
it("should accept liked item with unknown item type", () => {
const likedBook: LikedItemDto = {
item: {
author: "F. Scott Fitzgerald",
id: "book456",
title: "The Great Gatsby",
// ... other book properties
},
like: {
createdAt: new Date("2024-01-15"),
entityId: "book456",
entityType: "book",
id: "like123",
userId: "user123",
},
};
expect(likedBook.like.entityType).toBe("book");
expect(likedBook.item).toBeDefined();
});
it("should accept liked items for different entity types", () => {
const likedGame: LikedItemDto = {
item: {
id: "game123",
platform: "Nintendo Switch",
title: "Hades",
// ... other game properties
},
like: {
createdAt: new Date("2024-02-01"),
entityId: "game123",
entityType: "game",
id: "like456",
userId: "user789",
},
};
const likedArt: LikedItemDto = {
item: {
artist: "Jane Doe",
id: "art456",
imageUrl: "https://example.com/sunset.jpg",
title: "Beautiful Sunset",
// ... other art properties
},
like: {
createdAt: new Date("2024-02-10"),
entityId: "art456",
entityType: "art",
id: "like789",
userId: "user999",
},
};
expect(likedGame.like.entityType).toBe("game");
expect(likedArt.like.entityType).toBe("art");
});
});
describe("likeResponse interface", () => {
it("should accept response with liked true", () => {
const likedResponse: LikeResponse = {
count: 10,
liked: true,
};
expect(likedResponse.liked).toBeTruthy();
expect(likedResponse.count).toBe(10);
});
it("should accept response with liked false", () => {
const notLikedResponse: LikeResponse = {
count: 5,
liked: false,
};
expect(notLikedResponse.liked).toBeFalsy();
expect(notLikedResponse.count).toBe(5);
});
it("should accept response with zero count", () => {
const zeroResponse: LikeResponse = {
count: 0,
liked: false,
};
expect(zeroResponse.liked).toBeFalsy();
expect(zeroResponse.count).toBe(0);
});
});
});
-173
View File
@@ -1,173 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MangaStatus } from "../src/lib/manga.types";
import type { Manga, CreateMangaDto, UpdateMangaDto } from "../src/lib/manga.types";
describe("manga Types", () => {
describe("mangaStatus enum", () => {
it("should have the correct values", () => {
expect(MangaStatus.reading).toBe("READING");
expect(MangaStatus.completed).toBe("COMPLETED");
expect(MangaStatus.wantToRead).toBe("WANT_TO_READ");
});
it("should have all expected enum values", () => {
const values = Object.values(MangaStatus);
expect(values).toHaveLength(4);
expect(values).toContain("READING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_READ");
expect(values).toContain("RETIRED");
});
});
describe("manga interface", () => {
it("should accept valid manga object with minimal fields", () => {
const manga: Manga = {
author: "Tsugumi Ohba",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "manga123",
links: [],
status: MangaStatus.reading,
tags: [],
title: "Death Note",
updatedAt: new Date("2024-01-16"),
};
expect(manga.dateCompleted).toBeUndefined();
expect(manga.rating).toBeUndefined();
expect(manga.notes).toBeUndefined();
expect(manga.coverImage).toBeUndefined();
});
it("should accept valid manga object with all fields", () => {
const fullManga: Manga = {
author: "Eiichiro Oda",
coverImage: "https://example.com/onepiece-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: undefined,
id: "manga456",
links: [
{ title: "MyAnimeList", url: "https://myanimelist.net/manga/13" },
{ title: "Official Site", url: "https://one-piece.com" },
],
notes: "Epic adventure that keeps getting better",
// Still ongoing
rating: 5,
status: MangaStatus.reading,
tags: [ "adventure", "shounen", "pirates", "long-running" ],
title: "One Piece",
updatedAt: new Date("2024-02-01"),
};
expect(fullManga.author).toBe("Eiichiro Oda");
expect(fullManga.rating).toBe(5);
expect(fullManga.tags).toHaveLength(4);
expect(fullManga.links).toHaveLength(2);
});
it("should accept completed manga", () => {
const completedManga: Manga = {
author: "Hiromu Arakawa",
coverImage: "https://example.com/fma-cover.jpg",
createdAt: new Date("2023-12-01"),
dateAdded: new Date("2023-12-01"),
dateCompleted: new Date("2024-01-30"),
id: "manga789",
links: [],
notes: "Perfect from start to finish",
rating: 5,
status: MangaStatus.completed,
tags: [ "shounen", "adventure", "alchemy" ],
title: "Fullmetal Alchemist",
updatedAt: new Date("2024-01-30"),
};
expect(completedManga.status).toBe(MangaStatus.completed);
expect(completedManga.dateCompleted).toEqual(new Date("2024-01-30"));
});
});
describe("createMangaDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateMangaDto = {
author: "Hajime Isayama",
status: MangaStatus.wantToRead,
title: "Attack on Titan",
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateMangaDto = {
author: "Kohei Horikoshi",
coverImage: "https://example.com/mha-cover.jpg",
links: [ { title: "Wiki", url: "https://mha.wiki" } ],
notes: "Great superhero manga with unique powers",
rating: 4,
status: MangaStatus.reading,
tags: [ "shounen", "superhero", "school" ],
title: "My Hero Academia",
};
expect(fullCreateDto.author).toBe("Kohei Horikoshi");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "shounen", "superhero", "school" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateMangaDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateMangaDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateMangaDto = {
dateCompleted: new Date("2024-02-10"),
rating: 4,
status: MangaStatus.completed,
};
expect(partialUpdate.status).toBe(MangaStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(4);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateMangaDto = {
author: "Different Author",
coverImage: "https://example.com/new-manga-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after finishing the series",
rating: 3,
status: MangaStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Manga Title",
};
expect(fullUpdate.title).toBe("Updated Manga Title");
expect(fullUpdate.author).toBe("Different Author");
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-201
View File
@@ -1,201 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MusicStatus, MusicType } from "../src/lib/music.types";
import type { Music, CreateMusicDto, UpdateMusicDto } from "../src/lib/music.types";
describe("music Types", () => {
describe("musicType enum", () => {
it("should have the correct values", () => {
expect(MusicType.album).toBe("ALBUM");
expect(MusicType.single).toBe("SINGLE");
expect(MusicType.ep).toBe("EP");
});
it("should have all expected enum values", () => {
const values = Object.values(MusicType);
expect(values).toHaveLength(3);
expect(values).toContain("ALBUM");
expect(values).toContain("SINGLE");
expect(values).toContain("EP");
});
});
describe("musicStatus enum", () => {
it("should have the correct values", () => {
expect(MusicStatus.listening).toBe("LISTENING");
expect(MusicStatus.completed).toBe("COMPLETED");
expect(MusicStatus.wantToListen).toBe("WANT_TO_LISTEN");
});
it("should have all expected enum values", () => {
const values = Object.values(MusicStatus);
expect(values).toHaveLength(4);
expect(values).toContain("LISTENING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_LISTEN");
expect(values).toContain("RETIRED");
});
});
describe("music interface", () => {
it("should accept valid music object with minimal fields", () => {
const music: Music = {
artist: "Pink Floyd",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "music123",
links: [],
status: MusicStatus.listening,
tags: [],
title: "Dark Side of the Moon",
type: MusicType.album,
updatedAt: new Date("2024-01-16"),
};
expect(music.dateCompleted).toBeUndefined();
expect(music.rating).toBeUndefined();
expect(music.notes).toBeUndefined();
expect(music.coverArt).toBeUndefined();
});
it("should accept valid music object with all fields", () => {
const fullMusic: Music = {
artist: "Pink Floyd",
coverArt: "https://example.com/the-wall-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "music456",
links: [
{ title: "Spotify", url: "https://spotify.com/album/the-wall" },
{ title: "Apple Music", url: "https://music.apple.com/album/the-wall" },
],
notes: "A rock opera masterpiece",
rating: 5,
status: MusicStatus.completed,
tags: [ "progressive rock", "concept album", "classic" ],
title: "The Wall",
type: MusicType.album,
updatedAt: new Date("2024-01-20"),
};
expect(fullMusic.artist).toBe("Pink Floyd");
expect(fullMusic.rating).toBe(5);
expect(fullMusic.tags).toHaveLength(3);
expect(fullMusic.links).toHaveLength(2);
});
it("should accept different music types", () => {
const single: Music = {
artist: "The Weeknd",
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "music789",
links: [],
status: MusicStatus.completed,
tags: [ "pop", "synthwave" ],
title: "Blinding Lights",
type: MusicType.single,
updatedAt: new Date("2024-02-01"),
};
const ep: Music = {
artist: "The Weeknd",
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "music999",
links: [],
status: MusicStatus.wantToListen,
tags: [ "r&b", "dark" ],
title: "My Dear Melancholy,",
type: MusicType.ep,
updatedAt: new Date("2024-02-05"),
};
expect(single.type).toBe(MusicType.single);
expect(ep.type).toBe(MusicType.ep);
});
});
describe("createMusicDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateMusicDto = {
artist: "Fleetwood Mac",
status: MusicStatus.wantToListen,
title: "Rumours",
type: MusicType.album,
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverArt).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateMusicDto = {
artist: "The Weeknd",
coverArt: "https://example.com/after-hours.jpg",
links: [ { title: "YouTube", url: "https://youtube.com/album/after-hours" } ],
notes: "Dark synthwave vibes",
rating: 4,
status: MusicStatus.listening,
tags: [ "synthwave", "pop", "r&b" ],
title: "After Hours",
type: MusicType.album,
};
expect(fullCreateDto.artist).toBe("The Weeknd");
expect(fullCreateDto.type).toBe(MusicType.album);
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "synthwave", "pop", "r&b" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateMusicDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateMusicDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateMusicDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: MusicStatus.completed,
};
expect(partialUpdate.status).toBe(MusicStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateMusicDto = {
artist: "Different Artist",
coverArt: "https://example.com/new-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after listening",
rating: 3,
status: MusicStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Album Title",
type: MusicType.ep,
};
expect(fullUpdate.title).toBe("Updated Album Title");
expect(fullUpdate.artist).toBe("Different Artist");
expect(fullUpdate.type).toBe(MusicType.ep);
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-195
View File
@@ -1,195 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ShowStatus, ShowType } from "../src/lib/show.types";
import type { Show, CreateShowDto, UpdateShowDto } from "../src/lib/show.types";
describe("show Types", () => {
describe("showType enum", () => {
it("should have the correct values", () => {
expect(ShowType.tvSeries).toBe("TV_SERIES");
expect(ShowType.anime).toBe("ANIME");
expect(ShowType.film).toBe("FILM");
expect(ShowType.documentary).toBe("DOCUMENTARY");
});
it("should have all expected enum values", () => {
const values = Object.values(ShowType);
expect(values).toHaveLength(4);
expect(values).toContain("TV_SERIES");
expect(values).toContain("ANIME");
expect(values).toContain("FILM");
expect(values).toContain("DOCUMENTARY");
});
});
describe("showStatus enum", () => {
it("should have the correct values", () => {
expect(ShowStatus.watching).toBe("WATCHING");
expect(ShowStatus.completed).toBe("COMPLETED");
expect(ShowStatus.wantToWatch).toBe("WANT_TO_WATCH");
});
it("should have all expected enum values", () => {
const values = Object.values(ShowStatus);
expect(values).toHaveLength(4);
expect(values).toContain("WATCHING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_WATCH");
expect(values).toContain("RETIRED");
});
});
describe("show interface", () => {
it("should accept valid show object with minimal fields", () => {
const show: Show = {
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "show123",
links: [],
status: ShowStatus.watching,
tags: [],
title: "Breaking Bad",
type: ShowType.tvSeries,
updatedAt: new Date("2024-01-16"),
};
expect(show.dateCompleted).toBeUndefined();
expect(show.rating).toBeUndefined();
expect(show.notes).toBeUndefined();
expect(show.coverImage).toBeUndefined();
});
it("should accept valid show object with all fields", () => {
const fullShow: Show = {
coverImage: "https://example.com/aot-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "show456",
links: [
{ title: "MyAnimeList", url: "https://myanimelist.net/anime/16498" },
{ title: "Crunchyroll", url: "https://crunchyroll.com/attack-on-titan" },
],
notes: "Incredible story with amazing animation",
rating: 5,
status: ShowStatus.completed,
tags: [ "action", "dark fantasy", "shounen" ],
title: "Attack on Titan",
type: ShowType.anime,
updatedAt: new Date("2024-01-20"),
};
expect(fullShow.type).toBe(ShowType.anime);
expect(fullShow.rating).toBe(5);
expect(fullShow.tags).toHaveLength(3);
expect(fullShow.links).toHaveLength(2);
});
it("should accept different show types", () => {
const film: Show = {
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "show789",
links: [],
rating: 5,
status: ShowStatus.completed,
tags: [ "sci-fi", "thriller" ],
title: "Inception",
type: ShowType.film,
updatedAt: new Date("2024-02-01"),
};
const documentary: Show = {
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "show999",
links: [],
status: ShowStatus.wantToWatch,
tags: [ "nature", "wildlife" ],
title: "Planet Earth II",
type: ShowType.documentary,
updatedAt: new Date("2024-02-05"),
};
expect(film.type).toBe(ShowType.film);
expect(documentary.type).toBe(ShowType.documentary);
});
});
describe("createShowDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateShowDto = {
status: ShowStatus.wantToWatch,
title: "Stranger Things",
type: ShowType.tvSeries,
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateShowDto = {
coverImage: "https://example.com/death-note.jpg",
links: [ { title: "Netflix", url: "https://netflix.com/death-note" } ],
notes: "Psychological thriller with great plot",
rating: 4,
status: ShowStatus.watching,
tags: [ "psychological", "thriller", "supernatural" ],
title: "Death Note",
type: ShowType.anime,
};
expect(fullCreateDto.type).toBe(ShowType.anime);
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "psychological", "thriller", "supernatural" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateShowDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateShowDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateShowDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: ShowStatus.completed,
};
expect(partialUpdate.status).toBe(ShowStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateShowDto = {
coverImage: "https://example.com/new-show-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after watching",
rating: 3,
status: ShowStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Show Title",
type: ShowType.documentary,
};
expect(fullUpdate.title).toBe("Updated Show Title");
expect(fullUpdate.type).toBe(ShowType.documentary);
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
-440
View File
@@ -1,440 +0,0 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GameStatus, BookStatus, MangaStatus, MusicStatus, MusicType, ShowStatus, ShowType } from "../src";
import { SuggestionEntity, SuggestionStatus } from "../src/lib/suggestion.types";
import type {
Suggestion,
SuggestionUser,
CreateSuggestionDto,
DeclineSuggestionDto,
CreateGameSuggestionDto,
CreateBookSuggestionDto,
CreateMusicSuggestionDto,
CreateArtSuggestionDto,
CreateShowSuggestionDto,
CreateMangaSuggestionDto,
AcceptWithEditsDto,
} from "../src/lib/suggestion.types";
describe("suggestion Types", () => {
describe("suggestionEntity enum", () => {
it("should have the correct values", () => {
expect(SuggestionEntity.game).toBe("GAME");
expect(SuggestionEntity.book).toBe("BOOK");
expect(SuggestionEntity.music).toBe("MUSIC");
expect(SuggestionEntity.art).toBe("ART");
expect(SuggestionEntity.show).toBe("SHOW");
expect(SuggestionEntity.manga).toBe("MANGA");
});
it("should have all expected enum values", () => {
const values = Object.values(SuggestionEntity);
expect(values).toHaveLength(6);
expect(values).toContain("GAME");
expect(values).toContain("BOOK");
expect(values).toContain("MUSIC");
expect(values).toContain("ART");
expect(values).toContain("SHOW");
expect(values).toContain("MANGA");
});
});
describe("suggestionStatus enum", () => {
it("should have the correct values", () => {
expect(SuggestionStatus.unreviewed).toBe("UNREVIEWED");
expect(SuggestionStatus.accepted).toBe("ACCEPTED");
expect(SuggestionStatus.declined).toBe("DECLINED");
});
it("should have all expected enum values", () => {
const values = Object.values(SuggestionStatus);
expect(values).toHaveLength(3);
expect(values).toContain("UNREVIEWED");
expect(values).toContain("ACCEPTED");
expect(values).toContain("DECLINED");
});
});
describe("suggestionUser interface", () => {
it("should accept valid user object", () => {
const user: SuggestionUser = {
avatar: "https://example.com/avatar.png",
id: "user123",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "suggester",
};
expect(user.inDiscord).toBeTruthy();
expect(user.isVip).toBeFalsy();
});
it("should accept user without avatar", () => {
const user: SuggestionUser = {
id: "user456",
inDiscord: true,
isMod: false,
isStaff: true,
isVip: true,
username: "vipuser",
};
expect(user.avatar).toBeUndefined();
expect(user.isVip).toBeTruthy();
expect(user.isStaff).toBeTruthy();
});
});
describe("suggestion interface", () => {
it("should accept game suggestion", () => {
const gameSuggestion: Suggestion = {
createdAt: new Date("2024-01-15"),
entityType: SuggestionEntity.game,
gameData: {
coverImage: "https://example.com/hades.jpg",
notes: "Amazing roguelike",
platform: "Nintendo Switch",
status: GameStatus.backlog,
title: "Hades",
},
id: "sug123",
status: SuggestionStatus.unreviewed,
title: "Hades",
updatedAt: new Date("2024-01-15"),
user: {
id: "user123",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "gamer",
},
userId: "user123",
};
expect(gameSuggestion.entityType).toBe(SuggestionEntity.game);
expect(gameSuggestion.gameData).toBeDefined();
expect(gameSuggestion.bookData).toBeUndefined();
});
it("should accept book suggestion", () => {
const bookSuggestion: Suggestion = {
bookData: {
author: "George Orwell",
isbn: "978-0-452-28423-4",
notes: "Dystopian classic",
status: BookStatus.toRead,
title: "1984",
},
createdAt: new Date("2024-01-20"),
entityType: SuggestionEntity.book,
id: "sug456",
status: SuggestionStatus.accepted,
title: "1984",
updatedAt: new Date("2024-01-21"),
user: {
id: "user456",
inDiscord: false,
isMod: false,
isStaff: false,
isVip: true,
username: "reader",
},
userId: "user456",
};
expect(bookSuggestion.entityType).toBe(SuggestionEntity.book);
expect(bookSuggestion.bookData).toBeDefined();
expect(bookSuggestion.gameData).toBeUndefined();
});
it("should accept declined suggestion with reason", () => {
const declinedSuggestion: Suggestion = {
createdAt: new Date("2024-02-01"),
declineReason: "Already in the library",
entityType: SuggestionEntity.music,
id: "sug789",
musicData: {
artist: "Pink Floyd",
status: MusicStatus.wantToListen,
title: "Dark Side of the Moon",
type: MusicType.album,
},
status: SuggestionStatus.declined,
title: "Dark Side of the Moon",
updatedAt: new Date("2024-02-02"),
user: {
id: "user789",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "suggester",
},
userId: "user789",
};
expect(declinedSuggestion.status).toBe(SuggestionStatus.declined);
expect(declinedSuggestion.declineReason).toBe("Already in the library");
});
it("should accept art suggestion", () => {
const artSuggestion: Suggestion = {
artData: {
artist: "Jane Doe",
description: "A stunning sunset painting",
imageUrl: "https://example.com/sunset.jpg",
title: "Beautiful Sunset",
},
createdAt: new Date("2024-02-10"),
entityType: SuggestionEntity.art,
id: "sug999",
status: SuggestionStatus.unreviewed,
title: "Beautiful Sunset",
updatedAt: new Date("2024-02-10"),
user: {
id: "user999",
inDiscord: true,
isMod: true,
isStaff: false,
isVip: false,
username: "artlover",
},
userId: "user999",
};
expect(artSuggestion.entityType).toBe(SuggestionEntity.art);
expect(artSuggestion.artData).toBeDefined();
});
it("should accept show and manga suggestions", () => {
const showSuggestion: Suggestion = {
createdAt: new Date("2024-02-15"),
entityType: SuggestionEntity.show,
id: "sug111",
showData: {
status: ShowStatus.wantToWatch,
title: "Breaking Bad",
type: ShowType.tvSeries,
},
status: SuggestionStatus.unreviewed,
title: "Breaking Bad",
updatedAt: new Date("2024-02-15"),
user: {
id: "user111",
inDiscord: false,
isMod: false,
isStaff: true,
isVip: false,
username: "tvfan",
},
userId: "user111",
};
const mangaSuggestion: Suggestion = {
createdAt: new Date("2024-02-20"),
entityType: SuggestionEntity.manga,
id: "sug222",
mangaData: {
author: "Eiichiro Oda",
status: MangaStatus.reading,
title: "One Piece",
},
status: SuggestionStatus.accepted,
title: "One Piece",
updatedAt: new Date("2024-02-21"),
user: {
id: "user222",
inDiscord: true,
isMod: true,
isStaff: true,
isVip: true,
username: "mangareader",
},
userId: "user222",
};
expect(showSuggestion.entityType).toBe(SuggestionEntity.show);
expect(mangaSuggestion.entityType).toBe(SuggestionEntity.manga);
});
});
describe("createSuggestionDto types", () => {
it("should accept game suggestion DTO", () => {
const gameDto: CreateGameSuggestionDto = {
coverImage: "https://example.com/hollow.jpg",
entityType: SuggestionEntity.game,
notes: "Great metroidvania",
platform: "PC",
title: "Hollow Knight",
};
expect(gameDto.entityType).toBe(SuggestionEntity.game);
expect(gameDto.platform).toBe("PC");
});
it("should accept book suggestion DTO", () => {
const bookDto: CreateBookSuggestionDto = {
author: "J.R.R. Tolkien",
coverImage: "https://example.com/hobbit.jpg",
entityType: SuggestionEntity.book,
isbn: "978-0-547-92822-7",
notes: "Fantasy classic",
title: "The Hobbit",
};
expect(bookDto.entityType).toBe(SuggestionEntity.book);
expect(bookDto.author).toBe("J.R.R. Tolkien");
});
it("should accept music suggestion DTO", () => {
const musicDto: CreateMusicSuggestionDto = {
artist: "Pink Floyd",
coverArt: "https://example.com/wall.jpg",
entityType: SuggestionEntity.music,
notes: "Rock opera",
title: "The Wall",
type: "ALBUM",
};
expect(musicDto.entityType).toBe(SuggestionEntity.music);
expect(musicDto.type).toBe("ALBUM");
});
it("should accept art suggestion DTO", () => {
const artDto: CreateArtSuggestionDto = {
artist: "Vincent van Gogh",
description: "Famous painting",
entityType: SuggestionEntity.art,
imageUrl: "https://example.com/starry.jpg",
title: "Starry Night",
};
expect(artDto.entityType).toBe(SuggestionEntity.art);
expect(artDto.imageUrl).toBe("https://example.com/starry.jpg");
});
it("should accept show suggestion DTO", () => {
const showDto: CreateShowSuggestionDto = {
coverImage: "https://example.com/office.jpg",
entityType: SuggestionEntity.show,
notes: "Comedy series",
title: "The Office",
type: "TV_SERIES",
};
expect(showDto.entityType).toBe(SuggestionEntity.show);
expect(showDto.type).toBe("TV_SERIES");
});
it("should accept manga suggestion DTO", () => {
const mangaDto: CreateMangaSuggestionDto = {
author: "Tsugumi Ohba",
coverImage: "https://example.com/deathnote.jpg",
entityType: SuggestionEntity.manga,
notes: "Psychological thriller",
title: "Death Note",
};
expect(mangaDto.entityType).toBe(SuggestionEntity.manga);
expect(mangaDto.author).toBe("Tsugumi Ohba");
});
it("should work with union type", () => {
const suggestions: Array<CreateSuggestionDto> = [
{
entityType: SuggestionEntity.game,
title: "Game Title",
},
{
author: "Author Name",
entityType: SuggestionEntity.book,
title: "Book Title",
},
];
expect(suggestions).toHaveLength(2);
expect(suggestions[0].entityType).toBe(SuggestionEntity.game);
expect(suggestions[1].entityType).toBe(SuggestionEntity.book);
});
});
describe("declineSuggestionDto interface", () => {
it("should accept empty decline DTO", () => {
const declineDto: DeclineSuggestionDto = {};
expect(declineDto.reason).toBeUndefined();
});
it("should accept decline DTO with reason", () => {
const declineDto: DeclineSuggestionDto = {
reason: "Already exists in the library",
};
expect(declineDto.reason).toBe("Already exists in the library");
});
});
describe("acceptWithEditsDto interface", () => {
it("should accept empty edits DTO", () => {
const editsDto: AcceptWithEditsDto = {};
expect(editsDto).toEqual({});
});
it("should accept edits for book fields", () => {
const editsDto: AcceptWithEditsDto = {
author: "Corrected Author",
coverImage: "https://example.com/new-cover.jpg",
isbn: "978-0-123-45678-9",
links: [ { label: "Goodreads", url: "https://goodreads.com" } ],
notes: "Updated notes",
tags: [ "fiction", "classic" ],
title: "Corrected Title",
};
expect(editsDto.title).toBe("Corrected Title");
expect(editsDto.author).toBe("Corrected Author");
expect(editsDto.tags).toHaveLength(2);
});
it("should accept edits for music fields", () => {
const editsDto: AcceptWithEditsDto = {
artist: "Artist Name",
coverArt: "https://example.com/album.jpg",
title: "Album Title",
type: "ALBUM",
};
expect(editsDto.artist).toBe("Artist Name");
expect(editsDto.type).toBe("ALBUM");
expect(editsDto.coverArt).toBe("https://example.com/album.jpg");
});
it("should accept edits for game fields", () => {
const editsDto: AcceptWithEditsDto = {
coverImage: "https://example.com/game.jpg",
notes: "Action RPG",
platform: "PlayStation 5",
title: "Game Title",
};
expect(editsDto.platform).toBe("PlayStation 5");
});
it("should accept edits for art fields", () => {
const editsDto: AcceptWithEditsDto = {
artist: "Artist Name",
description: "Beautiful artwork",
imageUrl: "https://example.com/art.jpg",
title: "Art Title",
};
expect(editsDto.description).toBe("Beautiful artwork");
expect(editsDto.imageUrl).toBe("https://example.com/art.jpg");
});
});
});
+7 -3
View File
@@ -1,7 +1,6 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
@@ -11,6 +10,11 @@
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["test/**/*.spec.ts"]
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["test/**/*.spec.ts"]
}
+1 -1
View File
@@ -18,5 +18,5 @@
"@library/shared-types": ["shared-types/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp", "**/test/**/*.spec.ts"]
"exclude": ["node_modules", "tmp"]
}