import { NextRequest, NextResponse } from 'next/server'; import { PrismaClient } from '@prisma/client'; import { rateLimit } from '@/lib/rateLimit'; const prisma = new PrismaClient(); export async function POST(request: NextRequest) { // Rate limiting: 3 requests per minute const rateLimitError = rateLimit(request, { windowMs: 60000, maxRequests: 3 }); if (rateLimitError) return rateLimitError; try { const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json(); // Validate required fields if (!puzzleId || !message || !playerIdentifier) { return NextResponse.json( { error: 'puzzleId, message, and playerIdentifier are required' }, { status: 400 } ); } // Message validation: max 2000 characters, no empty message const trimmedMessage = message.trim(); if (trimmedMessage.length === 0) { return NextResponse.json( { error: 'Message cannot be empty' }, { status: 400 } ); } if (trimmedMessage.length > 300) { return NextResponse.json( { error: 'Message too long. Maximum 300 characters allowed.' }, { status: 400 } ); } // PlayerIdentifier validation: Check if it exists in PlayerState const playerState = await prisma.playerState.findFirst({ where: { identifier: playerIdentifier } }); if (!playerState) { return NextResponse.json( { error: 'Invalid player identifier' }, { status: 400 } ); } // Puzzle validation: Check if puzzle exists and matches genreId const puzzle = await prisma.dailyPuzzle.findUnique({ where: { id: Number(puzzleId) }, include: { song: true } }); if (!puzzle) { return NextResponse.json( { error: 'Puzzle not found' }, { status: 404 } ); } // Validate genreId matches puzzle (if genreId is provided) if (genreId !== null && genreId !== undefined) { if (puzzle.genreId !== Number(genreId)) { return NextResponse.json( { error: 'Puzzle does not match the provided genre' }, { status: 400 } ); } } else { // If no genreId provided, use puzzle's genreId // For global puzzles, genreId is null } // Rate limit check: Check if comment already exists for this playerIdentifier + puzzleId const existingComment = await prisma.curatorComment.findUnique({ where: { playerIdentifier_puzzleId: { playerIdentifier: playerIdentifier, puzzleId: Number(puzzleId) } } }); if (existingComment) { return NextResponse.json( { error: 'You have already sent a comment for this puzzle' }, { status: 429 } ); } // Determine responsible curators const finalGenreId = genreId !== null && genreId !== undefined ? Number(genreId) : puzzle.genreId; const specialId = puzzle.specialId; let curatorIds: number[] = []; const allCuratorIds = new Set(); // Get all global curators (always included) const globalCurators = await prisma.curator.findMany({ where: { isGlobalCurator: true }, select: { id: true } }); globalCurators.forEach(gc => allCuratorIds.add(gc.id)); // Check for special puzzle first (takes precedence) if (specialId !== null) { // Special puzzle: Get curators for this special + all global curators const specialCurators = await prisma.curatorSpecial.findMany({ where: { specialId: specialId }, select: { curatorId: true } }); specialCurators.forEach(cs => allCuratorIds.add(cs.curatorId)); } else if (finalGenreId !== null) { // Genre puzzle: Get curators for this genre + all global curators const genreCurators = await prisma.curatorGenre.findMany({ where: { genreId: finalGenreId }, select: { curatorId: true } }); genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId)); } // else: Global puzzle - only global curators (already added above) curatorIds = Array.from(allCuratorIds); if (curatorIds.length === 0) { return NextResponse.json( { error: 'No curators found for this puzzle' }, { status: 500 } ); } // Create comment and recipients in a transaction const result = await prisma.$transaction(async (tx) => { // Create the comment const comment = await tx.curatorComment.create({ data: { playerIdentifier: playerIdentifier, puzzleId: Number(puzzleId), genreId: finalGenreId, message: trimmedMessage } }); // Create recipients for all curators await tx.curatorCommentRecipient.createMany({ data: curatorIds.map(curatorId => ({ commentId: comment.id, curatorId: curatorId })) }); return comment; }); // Send Gotify notification (fire and forget) const { sendCommentNotification } = await import('@/app/actions'); // originalMessage is already available from the initial request.json() call // Determine genre name for notification let genreName: string | null = null; if (finalGenreId) { const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } }); if (genreObj) genreName = genreObj.name as string; } sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null); return NextResponse.json({ success: true, commentId: result.id }); } catch (error) { console.error('Error creating curator comment:', error); // Handle unique constraint violation (shouldn't happen due to our check, but just in case) if (error instanceof Error && error.message.includes('Unique constraint')) { return NextResponse.json( { error: 'You have already sent a comment for this puzzle' }, { status: 429 } ); } return NextResponse.json( { error: 'Internal Server Error' }, { status: 500 } ); } }