From cd564b5d8c5c8ca95ea2235f16f701fb976ba5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Wed, 3 Dec 2025 22:46:02 +0100 Subject: [PATCH] Implementiere Kurator-Kommentar-System MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Benutzer können nach Rätsel-Abschluss optional Nachricht an Kuratoren senden - Kommentare werden in Datenbank gespeichert und in /curator angezeigt - Neue Datenbank-Modelle: CuratorComment und CuratorCommentRecipient - API-Routen für Kommentar-Versand, Abfrage und Markierung als gelesen - Rate-Limiting: 1 Kommentar pro Spieler pro Rätsel (persistent in DB) - Sicherheitsschutz: PlayerIdentifier-Validierung, Puzzle-Validierung - Automatische Zuordnung zu Kuratoren (Genre-basiert + globale Kuratoren) - Frontend: Kommentar-Formular in Game-Komponente - Frontend: Kommentare-Anzeige in Kuratoren-Seite mit Markierung als gelesen - Übersetzungen für DE und EN hinzugefügt --- app/api/curator-comment/route.ts | 191 ++++++++++++++++++ app/api/curator-comments/[id]/read/route.ts | 66 ++++++ app/api/curator-comments/route.ts | 88 ++++++++ app/curator/CuratorPageClient.tsx | 169 ++++++++++++++++ components/Game.tsx | 127 ++++++++++++ messages/de.json | 18 +- messages/en.json | 18 +- .../migration.sql | 34 ++++ prisma/schema.prisma | 31 +++ 9 files changed, 740 insertions(+), 2 deletions(-) create mode 100644 app/api/curator-comment/route.ts create mode 100644 app/api/curator-comments/[id]/read/route.ts create mode 100644 app/api/curator-comments/route.ts create mode 100644 prisma/migrations/20251203223311_add_curator_comments/migration.sql diff --git a/app/api/curator-comment/route.ts b/app/api/curator-comment/route.ts new file mode 100644 index 0000000..74fd077 --- /dev/null +++ b/app/api/curator-comment/route.ts @@ -0,0 +1,191 @@ +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 } = 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 > 2000) { + return NextResponse.json( + { error: 'Message too long. Maximum 2000 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; + + let curatorIds: number[] = []; + + if (finalGenreId === null) { + // Global puzzle: Get all global curators + const globalCurators = await prisma.curator.findMany({ + where: { + isGlobalCurator: true + }, + select: { + id: true + } + }); + curatorIds = globalCurators.map(c => c.id); + } else { + // Genre puzzle: Get curators for this genre + all global curators + const genreCurators = await prisma.curatorGenre.findMany({ + where: { + genreId: finalGenreId + }, + select: { + curatorId: true + } + }); + + const globalCurators = await prisma.curator.findMany({ + where: { + isGlobalCurator: true + }, + select: { + id: true + } + }); + + // Combine and deduplicate curator IDs + const allCuratorIds = new Set(); + genreCurators.forEach(cg => allCuratorIds.add(cg.curatorId)); + globalCurators.forEach(gc => allCuratorIds.add(gc.id)); + 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; + }); + + 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 } + ); + } +} + diff --git a/app/api/curator-comments/[id]/read/route.ts b/app/api/curator-comments/[id]/read/route.ts new file mode 100644 index 0000000..0325ec5 --- /dev/null +++ b/app/api/curator-comments/[id]/read/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; +import { requireStaffAuth } from '@/lib/auth'; + +const prisma = new PrismaClient(); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + // Require curator authentication + const { error, context } = await requireStaffAuth(request); + if (error || !context) { + return error!; + } + + // Only curators can mark comments as read + if (context.role !== 'curator') { + return NextResponse.json( + { error: 'Only curators can mark comments as read' }, + { status: 403 } + ); + } + + try { + const { id } = await params; + const commentId = Number(id); + const curatorId = context.curator.id; + + // Verify that this comment belongs to this curator + const recipient = await prisma.curatorCommentRecipient.findUnique({ + where: { + commentId_curatorId: { + commentId: commentId, + curatorId: curatorId + } + } + }); + + if (!recipient) { + return NextResponse.json( + { error: 'Comment not found or access denied' }, + { status: 404 } + ); + } + + // Update readAt timestamp + await prisma.curatorCommentRecipient.update({ + where: { + id: recipient.id + }, + data: { + readAt: new Date() + } + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error marking comment as read:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + diff --git a/app/api/curator-comments/route.ts b/app/api/curator-comments/route.ts new file mode 100644 index 0000000..b6cfe34 --- /dev/null +++ b/app/api/curator-comments/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; +import { requireStaffAuth } from '@/lib/auth'; + +const prisma = new PrismaClient(); + +export async function GET(request: NextRequest) { + // Require curator authentication + const { error, context } = await requireStaffAuth(request); + if (error || !context) { + return error!; + } + + // Only curators can view comments (not admins directly) + if (context.role !== 'curator') { + return NextResponse.json( + { error: 'Only curators can view comments' }, + { status: 403 } + ); + } + + try { + const curatorId = context.curator.id; + + // Get all comments for this curator, ordered by creation date (newest first) + const comments = await prisma.curatorCommentRecipient.findMany({ + where: { + curatorId: curatorId + }, + include: { + comment: { + include: { + puzzle: { + include: { + song: { + select: { + title: true, + artist: true + } + }, + genre: { + select: { + id: true, + name: true + } + } + } + } + } + } + }, + orderBy: { + comment: { + createdAt: 'desc' + } + } + }); + + // Format the response + const formattedComments = comments.map(recipient => ({ + id: recipient.comment.id, + message: recipient.comment.message, + createdAt: recipient.comment.createdAt, + readAt: recipient.readAt, + puzzle: { + id: recipient.comment.puzzle.id, + date: recipient.comment.puzzle.date, + song: { + title: recipient.comment.puzzle.song.title, + artist: recipient.comment.puzzle.song.artist + }, + genre: recipient.comment.puzzle.genre ? { + id: recipient.comment.puzzle.genre.id, + name: recipient.comment.puzzle.genre.name + } : null + } + })); + + return NextResponse.json(formattedComments); + } catch (error) { + console.error('Error fetching curator comments:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + diff --git a/app/curator/CuratorPageClient.tsx b/app/curator/CuratorPageClient.tsx index ebeae75..216f66f 100644 --- a/app/curator/CuratorPageClient.tsx +++ b/app/curator/CuratorPageClient.tsx @@ -37,6 +37,25 @@ interface CuratorInfo { specialIds: number[]; } +interface CuratorComment { + id: number; + message: string; + createdAt: string; + readAt: string | null; + puzzle: { + id: number; + date: string; + song: { + title: string; + artist: string; + }; + genre: { + id: number; + name: any; + } | null; + }; +} + function getCuratorAuthHeaders() { const authToken = localStorage.getItem('hoerdle_curator_auth'); const username = localStorage.getItem('hoerdle_curator_username') || ''; @@ -98,6 +117,11 @@ export default function CuratorPageClient() { const [itemsPerPage, setItemsPerPage] = useState(10); const [playingSongId, setPlayingSongId] = useState(null); const [audioElement, setAudioElement] = useState(null); + + // Comments state + const [comments, setComments] = useState([]); + const [loadingComments, setLoadingComments] = useState(false); + const [showComments, setShowComments] = useState(false); useEffect(() => { const authToken = localStorage.getItem('hoerdle_curator_auth'); @@ -109,6 +133,12 @@ export default function CuratorPageClient() { } }, []); + useEffect(() => { + if (showComments && isAuthenticated) { + fetchComments(); + } + }, [showComments, isAuthenticated]); + const bootstrapCuratorData = async () => { try { setLoading(true); @@ -118,6 +148,42 @@ export default function CuratorPageClient() { } }; + const fetchComments = async () => { + try { + setLoadingComments(true); + const res = await fetch('/api/curator-comments', { + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + const data: CuratorComment[] = await res.json(); + setComments(data); + } else { + setMessage(t('loadCommentsError')); + } + } catch (error) { + setMessage(t('loadCommentsError')); + } finally { + setLoadingComments(false); + } + }; + + const markCommentAsRead = async (commentId: number) => { + try { + const res = await fetch(`/api/curator-comments/${commentId}/read`, { + method: 'POST', + headers: getCuratorAuthHeaders(), + }); + if (res.ok) { + // Update local state + setComments(comments.map(c => + c.id === commentId ? { ...c, readAt: new Date().toISOString() } : c + )); + } + } catch (error) { + console.error('Error marking comment as read:', error); + } + }; + const fetchCuratorInfo = async () => { const res = await fetch('/api/curator/me', { headers: getCuratorAuthHeaders(), @@ -1325,6 +1391,109 @@ export default function CuratorPageClient() { )} + + {/* Comments Section */} +
+
+

+ {t('commentsTitle')} ({comments.length}) +

+ +
+ + {showComments && ( + <> + {loadingComments ? ( +

{t('loadingComments')}

+ ) : comments.length === 0 ? ( +

{t('noComments')}

+ ) : ( +
+ {comments.map(comment => { + const genreName = comment.puzzle.genre + ? typeof comment.puzzle.genre.name === 'string' + ? comment.puzzle.genre.name + : comment.puzzle.genre.name?.de ?? comment.puzzle.genre.name?.en + : null; + const isRead = comment.readAt !== null; + + return ( +
{ + if (!isRead) { + markCommentAsRead(comment.id); + } + }} + > + {!isRead && ( +
+ )} +
+
+ + {t('commentFromPuzzle')} #{comment.puzzle.id} + + {genreName && ( + + ({t('commentGenre')}: {genreName}) + + )} +
+ + {new Date(comment.createdAt).toLocaleDateString()} {new Date(comment.createdAt).toLocaleTimeString()} + +
+
+ {comment.puzzle.song.title} - {comment.puzzle.song.artist} +
+
+ {comment.message} +
+
+ ); + })} +
+ )} + + )} +
); } diff --git a/components/Game.tsx b/components/Game.tsx index 76098bf..67298e3 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -13,6 +13,7 @@ import type { ExternalPuzzle } from '@/lib/externalPuzzles'; import { getRandomExternalPuzzle } from '@/lib/externalPuzzles'; import { hasPlayedAllDailyPuzzlesForToday, hasSeenExtraPuzzlesPopoverToday, markDailyPuzzlePlayedToday, markExtraPuzzlesPopoverShownToday } from '@/lib/extraPuzzlesTracker'; import { sendGotifyNotification, submitRating } from '../app/actions'; +import { getOrCreatePlayerId } from '@/lib/playerId'; // Plausible Analytics declare global { @@ -60,6 +61,10 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max const [showExtraPuzzlesPopover, setShowExtraPuzzlesPopover] = useState(false); const [extraPuzzle, setExtraPuzzle] = useState(null); const audioPlayerRef = useRef(null); + const [commentText, setCommentText] = useState(''); + const [commentSending, setCommentSending] = useState(false); + const [commentSent, setCommentSent] = useState(false); + const [commentError, setCommentError] = useState(null); useEffect(() => { const updateCountdown = () => { @@ -134,6 +139,15 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max } else { setHasRated(false); } + + // Check if comment already sent for this puzzle + const playerIdentifier = getOrCreatePlayerId(); + if (playerIdentifier) { + const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]'); + if (commentedPuzzles.includes(dailyPuzzle.id)) { + setCommentSent(true); + } + } } }, [dailyPuzzle]); @@ -300,6 +314,59 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score); }; + const handleCommentSubmit = async () => { + if (!commentText.trim() || commentSending || commentSent || !dailyPuzzle) { + return; + } + + setCommentSending(true); + setCommentError(null); + + try { + const playerIdentifier = getOrCreatePlayerId(); + if (!playerIdentifier) { + throw new Error('Could not get player identifier'); + } + + // For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle + const genreId = isSpecial ? null : null; // API will determine from puzzle + + const response = await fetch('/api/curator-comment', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + puzzleId: dailyPuzzle.id, + genreId: genreId, + message: commentText.trim(), + playerIdentifier: playerIdentifier + }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to send comment'); + } + + setCommentSent(true); + setCommentText(''); + + // Store in localStorage that comment was sent + const commentedPuzzles = JSON.parse(localStorage.getItem(`${config.appName.toLowerCase()}_commented_puzzles`) || '[]'); + if (!commentedPuzzles.includes(dailyPuzzle.id)) { + commentedPuzzles.push(dailyPuzzle.id); + localStorage.setItem(`${config.appName.toLowerCase()}_commented_puzzles`, JSON.stringify(commentedPuzzles)); + } + } catch (error) { + console.error('Error sending comment:', error); + setCommentError(error instanceof Error ? error.message : 'Failed to send comment'); + } finally { + setCommentSending(false); + } + }; + const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)]; const handleShare = async () => { @@ -532,6 +599,66 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max + {/* Comment Form */} + {!commentSent && ( +
+

+ {t('sendComment')} +

+

+ {t('commentHelp')} +

+