diff --git a/api/jest.config.cts b/api/jest.config.cts index 91a829a..b0402c5 100644 --- a/api/jest.config.cts +++ b/api/jest.config.cts @@ -3,8 +3,12 @@ module.exports = { preset: '../jest.preset.js', testEnvironment: 'node', transform: { - '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + '^.+\\.[tj]s$': ['ts-jest', { + tsconfig: '/tsconfig.spec.json', + isolatedModules: true, + }], }, moduleFileExtensions: ['ts', 'js', 'html'], coverageDirectory: '../coverage/api', + setupFilesAfterEnv: ['/src/test-setup.ts'], }; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a722689..9f8043e 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -24,7 +24,9 @@ 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? @@ -39,6 +41,7 @@ enum GameStatus { PLAYING COMPLETED BACKLOG + RETIRED } model Book { @@ -48,6 +51,7 @@ model Book { isbn String? status BookStatus dateAdded DateTime @default(now()) + dateStarted DateTime? dateFinished DateTime? rating Int? @db.Int @default(0) notes String? @@ -63,6 +67,7 @@ enum BookStatus { READING FINISHED TO_READ + RETIRED } model Music { @@ -72,7 +77,9 @@ 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? @@ -93,6 +100,7 @@ enum MusicStatus { LISTENING COMPLETED WANT_TO_LISTEN + RETIRED } model Art { @@ -115,7 +123,9 @@ 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? @@ -137,6 +147,7 @@ enum ShowStatus { WATCHING COMPLETED WANT_TO_WATCH + RETIRED } model Manga { @@ -145,7 +156,9 @@ 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? @@ -160,6 +173,7 @@ enum MangaStatus { READING COMPLETED WANT_TO_READ + RETIRED } model User { diff --git a/api/src/app/app.spec.ts b/api/src/app/app.spec.ts index b6b1947..31c73ce 100644 --- a/api/src/app/app.spec.ts +++ b/api/src/app/app.spec.ts @@ -15,6 +15,6 @@ describe('GET /', () => { url: '/', }); - expect(response.json()).toEqual({ message: 'Hello API' }); + expect(response.json()).toEqual({ version: expect.any(String) }); }); }); diff --git a/api/src/app/app.ts b/api/src/app/app.ts index 37d7d4e..bdaee04 100644 --- a/api/src/app/app.ts +++ b/api/src/app/app.ts @@ -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.CSRF_VALIDATION_FAILED, - category: AuditCategory.SECURITY, + action: AuditAction.csrfValidationFailed, + 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.UNAUTHORIZED_ACCESS, - category: AuditCategory.SECURITY, + action: AuditAction.unauthorizedAccess, + category: AuditCategory.security, details: `Unauthorized access attempt: ${error.message}, URL: ${request.url}`, success: false, }, request).catch(() => { @@ -57,5 +57,13 @@ 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$/, }); } diff --git a/api/src/app/plugins/auth.ts b/api/src/app/plugins/auth.ts index c3929bb..4843b37 100644 --- a/api/src/app/plugins/auth.ts +++ b/api/src/app/plugins/auth.ts @@ -82,7 +82,9 @@ const authPlugin: FastifyPluginAsync = async (app) => { try { await request.jwtVerify(); } catch (err) { - throw app.httpErrors.unauthorized("Invalid token"); + const error = new Error("Invalid token"); + (error as any).statusCode = 401; + throw error; } }); }; diff --git a/api/src/app/plugins/rate-limit.ts b/api/src/app/plugins/rate-limit.ts index 3f2ba5f..8343a14 100644 --- a/api/src/app/plugins/rate-limit.ts +++ b/api/src/app/plugins/rate-limit.ts @@ -17,8 +17,8 @@ const rateLimitPlugin: FastifyPluginAsync = async (app) => { errorResponseBuilder: (request) => { // Log rate limit exceeded event AuditService.log({ - action: AuditAction.RATE_LIMIT_EXCEEDED, - category: AuditCategory.SECURITY, + action: AuditAction.rateLimitExceeded, + category: AuditCategory.security, details: `Rate limit exceeded for URL: ${request.url}`, success: false, }, request).catch(() => { diff --git a/api/src/app/routes/art/index.ts b/api/src/app/routes/art/index.ts index 4c32b73..3c24820 100644 --- a/api/src/app/routes/art/index.ts +++ b/api/src/app/routes/art/index.ts @@ -46,8 +46,8 @@ const artRoutes: FastifyPluginAsync = async (app) => { async (request) => { const art = await artService.createArt(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "art", resourceId: id, details: `Deleted comment ${commentId} from art`, diff --git a/api/src/app/routes/auth/index.ts b/api/src/app/routes/auth/index.ts index d666cf8..3bdadb9 100644 --- a/api/src/app/routes/auth/index.ts +++ b/api/src/app/routes/auth/index.ts @@ -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.LOGIN_FAILED, - category: AuditCategory.SECURITY, + action: AuditAction.loginFailed, + 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, diff --git a/api/src/app/routes/books/index.ts b/api/src/app/routes/books/index.ts index 5e8a818..f22cfa2 100644 --- a/api/src/app/routes/books/index.ts +++ b/api/src/app/routes/books/index.ts @@ -46,8 +46,8 @@ const booksRoutes: FastifyPluginAsync = async (app) => { async (request) => { const book = await bookService.createBook(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "book", resourceId: id, details: `Deleted comment ${commentId} from book`, diff --git a/api/src/app/routes/games/index.ts b/api/src/app/routes/games/index.ts index 5b5b66a..53e2e15 100644 --- a/api/src/app/routes/games/index.ts +++ b/api/src/app/routes/games/index.ts @@ -40,8 +40,8 @@ const gamesRoutes: FastifyPluginAsync = async (app) => { async (request) => { const game = await gameService.createGame(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "game", resourceId: id, details: `Deleted comment ${commentId} from game`, diff --git a/api/src/app/routes/log/index.ts b/api/src/app/routes/log/index.ts new file mode 100644 index 0000000..9cdfc6f --- /dev/null +++ b/api/src/app/routes/log/index.ts @@ -0,0 +1,40 @@ +/** + * @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 }; + }); +} diff --git a/api/src/app/routes/manga/index.ts b/api/src/app/routes/manga/index.ts index 1f46d63..3909744 100644 --- a/api/src/app/routes/manga/index.ts +++ b/api/src/app/routes/manga/index.ts @@ -37,8 +37,8 @@ const mangaRoutes: FastifyPluginAsync = async (app) => { async (request) => { const manga = await mangaService.createManga(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "manga", resourceId: id, details: `Deleted comment ${commentId} from manga`, diff --git a/api/src/app/routes/music/index.ts b/api/src/app/routes/music/index.ts index eae0662..5f63553 100644 --- a/api/src/app/routes/music/index.ts +++ b/api/src/app/routes/music/index.ts @@ -46,8 +46,8 @@ const musicRoutes: FastifyPluginAsync = async (app) => { async (request) => { const music = await musicService.createMusic(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "music", resourceId: id, details: `Deleted comment ${commentId} from music`, diff --git a/api/src/app/routes/root.ts b/api/src/app/routes/root.ts index 923ea65..1a2aeb7 100644 --- a/api/src/app/routes/root.ts +++ b/api/src/app/routes/root.ts @@ -1,7 +1,30 @@ 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 { message: 'Hello API' }; + return { version: getVersion() }; }); } diff --git a/api/src/app/routes/shows/index.ts b/api/src/app/routes/shows/index.ts index 2512ea3..a41c422 100644 --- a/api/src/app/routes/shows/index.ts +++ b/api/src/app/routes/shows/index.ts @@ -37,8 +37,8 @@ const showsRoutes: FastifyPluginAsync = async (app) => { async (request) => { const show = await showService.createShow(request.body); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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.ENTRY_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryUpdate, + 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.ENTRY_DELETE, - category: AuditCategory.CONTENT, + action: AuditAction.entryDelete, + 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.COMMENT_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentCreate, + 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.COMMENT_UPDATE, - category: AuditCategory.CONTENT, + action: AuditAction.commentUpdate, + 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.COMMENT_DELETE, - category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.commentDelete, + category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.admin : AuditCategory.content, resourceType: "show", resourceId: id, details: `Deleted comment ${commentId} from show`, diff --git a/api/src/app/routes/suggestions/index.ts b/api/src/app/routes/suggestions/index.ts index 35d04cc..569deb4 100644 --- a/api/src/app/routes/suggestions/index.ts +++ b/api/src/app/routes/suggestions/index.ts @@ -85,8 +85,8 @@ export default async function (app: FastifyInstance): Promise { ); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_CREATE, - category: AuditCategory.CONTENT, + action: AuditAction.entryCreate, + 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 { const suggestion = await SuggestionService.acceptSuggestion(id); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_UPDATE, - category: AuditCategory.ADMIN, + action: AuditAction.entryUpdate, + 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 { const suggestion = await SuggestionService.acceptSuggestionWithEdits(id, editedData); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_UPDATE, - category: AuditCategory.ADMIN, + action: AuditAction.entryUpdate, + 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 { const suggestion = await SuggestionService.declineSuggestion(id, reason); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_UPDATE, - category: AuditCategory.ADMIN, + action: AuditAction.entryUpdate, + 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 { const suggestion = await SuggestionService.deleteSuggestion(id, userId, isAdmin); await AuditService.logFromRequest(request, { - action: AuditAction.ENTRY_DELETE, - category: isAdmin ? AuditCategory.ADMIN : AuditCategory.CONTENT, + action: AuditAction.entryDelete, + category: isAdmin ? AuditCategory.admin : AuditCategory.content, resourceType: "Suggestion", resourceId: suggestion.id, details: `Deleted ${suggestion.entityType} suggestion: ${suggestion.title}`, diff --git a/api/src/app/routes/users/index.ts b/api/src/app/routes/users/index.ts index 485fd56..710f765 100644 --- a/api/src/app/routes/users/index.ts +++ b/api/src/app/routes/users/index.ts @@ -54,8 +54,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => { } await AuditService.logFromRequest(request, { - action: AuditAction.USER_BAN, - category: AuditCategory.ADMIN, + action: AuditAction.userBan, + category: AuditCategory.admin, targetUserId: id, details: `Banned user: ${user.username}`, }); @@ -78,8 +78,8 @@ const usersRoutes: FastifyPluginAsync = async (app) => { } await AuditService.logFromRequest(request, { - action: AuditAction.USER_UNBAN, - category: AuditCategory.ADMIN, + action: AuditAction.userUnban, + category: AuditCategory.admin, targetUserId: id, details: `Unbanned user: ${user.username}`, }); diff --git a/api/src/app/services/audit.service.ts b/api/src/app/services/audit.service.ts index c45b117..1461692 100644 --- a/api/src/app/services/audit.service.ts +++ b/api/src/app/services/audit.service.ts @@ -36,7 +36,7 @@ export const AuditService = { request: FastifyRequest, data: Omit ) { - const userId = (request.user as { id?: string } | undefined)?.id; + const userId = ((request as any).user as { id?: string } | undefined)?.id; return this.log( { diff --git a/api/src/app/services/book.service.ts b/api/src/app/services/book.service.ts index de13a29..afb97bc 100644 --- a/api/src/app/services/book.service.ts +++ b/api/src/app/services/book.service.ts @@ -24,6 +24,7 @@ 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 ?? [], @@ -46,6 +47,7 @@ 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 ?? [], @@ -69,6 +71,7 @@ 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 ?? [], @@ -95,6 +98,7 @@ 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 ?? [], diff --git a/api/src/app/services/game.service.ts b/api/src/app/services/game.service.ts index d763807..d748c22 100644 --- a/api/src/app/services/game.service.ts +++ b/api/src/app/services/game.service.ts @@ -24,7 +24,9 @@ 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, @@ -46,7 +48,9 @@ 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, @@ -69,7 +73,9 @@ 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, @@ -95,7 +101,9 @@ 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, diff --git a/api/src/app/services/like.service.ts b/api/src/app/services/like.service.ts index 8c48d2d..7875836 100644 --- a/api/src/app/services/like.service.ts +++ b/api/src/app/services/like.service.ts @@ -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}` diff --git a/api/src/app/services/manga.service.ts b/api/src/app/services/manga.service.ts index bc7dbfd..f63dcd0 100644 --- a/api/src/app/services/manga.service.ts +++ b/api/src/app/services/manga.service.ts @@ -21,7 +21,9 @@ 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, @@ -40,7 +42,9 @@ 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, @@ -60,7 +64,9 @@ 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, @@ -83,7 +89,9 @@ 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, diff --git a/api/src/app/services/music.service.ts b/api/src/app/services/music.service.ts index b7c8e03..405d8f0 100644 --- a/api/src/app/services/music.service.ts +++ b/api/src/app/services/music.service.ts @@ -25,7 +25,9 @@ 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, @@ -48,7 +50,9 @@ 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, @@ -73,7 +77,9 @@ 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, @@ -103,7 +109,9 @@ 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, diff --git a/api/src/app/services/show.service.ts b/api/src/app/services/show.service.ts index 57d7206..7d87652 100644 --- a/api/src/app/services/show.service.ts +++ b/api/src/app/services/show.service.ts @@ -22,7 +22,9 @@ 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, @@ -42,7 +44,9 @@ 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, @@ -64,7 +68,9 @@ 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, @@ -91,7 +97,9 @@ 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, diff --git a/api/src/app/utils/logger.ts b/api/src/app/utils/logger.ts new file mode 100644 index 0000000..10d3df6 --- /dev/null +++ b/api/src/app/utils/logger.ts @@ -0,0 +1,9 @@ +/** + * @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 ?? ""); \ No newline at end of file diff --git a/api/src/main.ts b/api/src/main.ts index b54fdb7..3270acd 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -1,9 +1,42 @@ 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, @@ -19,6 +52,6 @@ server.listen({ port, host }, (err) => { server.log.error(err); process.exit(1); } else { - console.log(`[ ready ] http://${host}:${port}`); + void logger.log('info', `Server ready at http://${host}:${port}`); } }); diff --git a/api/src/test-setup.ts b/api/src/test-setup.ts new file mode 100644 index 0000000..a394aba --- /dev/null +++ b/api/src/test-setup.ts @@ -0,0 +1,47 @@ +/** + * @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) => `

${input}

`), +})); + +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); + }, +})); \ No newline at end of file diff --git a/api/tsconfig.app.json b/api/tsconfig.app.json index 43114b8..2d3bdf4 100644 --- a/api/tsconfig.app.json +++ b/api/tsconfig.app.json @@ -10,6 +10,7 @@ "jest.config.ts", "jest.config.cts", "src/**/*.spec.ts", - "src/**/*.test.ts" + "src/**/*.test.ts", + "src/test-setup.ts" ] } diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 3fe03c2..5e709b8 100644 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -2,6 +2,7 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, APP_INITIALIZER, + ErrorHandler, } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient, withInterceptors, HTTP_INTERCEPTORS } from '@angular/common/http'; @@ -9,12 +10,19 @@ 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, @@ -25,6 +33,12 @@ export const appConfig: ApplicationConfig = { useFactory: initializeAuth, deps: [AuthService], multi: true + }, + { + provide: APP_INITIALIZER, + useFactory: initializeConsoleLogger, + deps: [ConsoleLoggerService], + multi: true } ], }; diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index 4bdc0fb..f447414 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -3,3 +3,4 @@ + diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts index 88e48d6..7826492 100644 --- a/apps/frontend/src/app/app.ts +++ b/apps/frontend/src/app/app.ts @@ -2,10 +2,11 @@ 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], + imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent], selector: 'app-root', templateUrl: './app.html', styleUrl: './app.scss', diff --git a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts index 2a7b175..a0198ed 100644 --- a/apps/frontend/src/app/components/admin/admin-suggestions.component.ts +++ b/apps/frontend/src/app/components/admin/admin-suggestions.component.ts @@ -39,22 +39,22 @@ import { Suggestion, SuggestionStatus, SuggestionEntity } from '@library/shared- All ({{ suggestions().length }}) +
+ + +
+ +
+ + +
+
- -
+
@for (tag of newBook.tags; track tag; let i = $index) { {{ tag }} @@ -144,8 +164,7 @@ import { Book, BookStatus, CreateBookDto, UpdateBookDto, Comment, SuggestionEnti
-
- +