/** * @copyright 2026 NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { Game, GameStatus, CreateGameDto, UpdateGameDto } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import { validateUrl, validateRating, validateStringLength, validateDataUrl, MAX_LENGTHS, } from "../utils/validation"; export class GameService { private prisma = prisma; constructor() {} /** * Validate game data for security. */ private validateGameData(data: CreateGameDto | UpdateGameDto): void { // Validate string lengths if (!validateStringLength(data.title, MAX_LENGTHS.TITLE)) { throw new Error(`Title must be ${MAX_LENGTHS.TITLE} characters or less.`); } if (!validateStringLength(data.platform, MAX_LENGTHS.AUTHOR)) { throw new Error(`Platform must be ${MAX_LENGTHS.AUTHOR} characters or less.`); } if (!validateStringLength(data.notes, MAX_LENGTHS.NOTES)) { throw new Error(`Notes must be ${MAX_LENGTHS.NOTES} characters or less.`); } // Validate rating if (!validateRating(data.rating)) { throw new Error("Rating must be an integer between 0 and 10."); } // Validate cover image URL if (data.coverImage) { if (data.coverImage.startsWith("data:")) { // Extract just the base64 data (after the comma) const base64Data = data.coverImage.split(",")[1]; if (!base64Data) { throw new Error("Invalid image data URL format."); } // Calculate decoded size: base64 is ~4/3 larger than original, so multiply by 0.75 const decodedSizeInBytes = base64Data.length * 0.75; if (decodedSizeInBytes > MAX_LENGTHS.DATA_URL) { throw new Error("Cover image must be under 5MB."); } if (!validateDataUrl(data.coverImage)) { throw new Error("Invalid image data URL."); } } else { if (!validateStringLength(data.coverImage, MAX_LENGTHS.URL)) { throw new Error(`Cover image URL must be ${MAX_LENGTHS.URL} characters or less.`); } if (!validateUrl(data.coverImage)) { throw new Error("Invalid cover image URL. Only http and https URLs are allowed."); } } } // Validate tags if (data.tags) { for (const tag of data.tags) { if (!validateStringLength(tag, MAX_LENGTHS.TAGS)) { throw new Error(`Each tag must be ${MAX_LENGTHS.TAGS} characters or less.`); } } } // Validate link URLs if (data.links) { for (const link of data.links) { if (!validateUrl(link.url)) { throw new Error(`Invalid link URL: ${link.title}. Only http and https URLs are allowed.`); } if (!validateStringLength(link.title, MAX_LENGTHS.TITLE)) { throw new Error(`Link title must be ${MAX_LENGTHS.TITLE} characters or less.`); } if (!validateStringLength(link.url, MAX_LENGTHS.URL)) { throw new Error(`Link URL must be ${MAX_LENGTHS.URL} characters or less.`); } } } } /** * Get all games. */ async getAllGames(): Promise { const games = await this.prisma.game.findMany({ orderBy: { updatedAt: "desc" }, }); return games.map((game) => ({ ...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, updatedAt: game.updatedAt, })); } /** * Get game by ID. */ async getGameById(id: string): Promise { const game = await this.prisma.game.findUnique({ where: { id }, }); if (!game) return null; return { ...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, updatedAt: game.updatedAt, }; } /** * Get all games in a series, ordered by seriesOrder. */ async getGamesBySeries(seriesName: string): Promise { const games = await this.prisma.game.findMany({ where: { series: seriesName }, orderBy: { seriesOrder: "asc" }, }); return games.map((game) => ({ ...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, updatedAt: game.updatedAt, })); } /** * Create new game. */ async createGame(data: CreateGameDto): Promise { // Validate input this.validateGameData(data); const game = await this.prisma.game.create({ data: { ...data, status: data.status.toUpperCase() as any, }, }); return { ...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, updatedAt: game.updatedAt, }; } /** * Update game by ID. */ async updateGame(id: string, data: UpdateGameDto): Promise { // Validate input this.validateGameData(data); const updateData = { ...data }; if (updateData.status) { updateData.status = updateData.status.toUpperCase() as any; } const game = await this.prisma.game.update({ where: { id }, data: updateData, }); return { ...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, updatedAt: game.updatedAt, }; } /** * Delete game by ID. */ async deleteGame(id: string): Promise { await this.prisma.game.delete({ where: { id }, }); } }