Compare commits

..

21 Commits

Author SHA1 Message Date
Hördle Bot
a61caa2d13 feat: README.md um Batch-Edit-Funktionalität für Kuratoren erweitert
- Beschreibung der neuen Batch-Edit-Optionen hinzugefügt, einschließlich der Möglichkeit, mehrere Titel gleichzeitig zu bearbeiten.
- Details zu Genre/Special Toggle, Artist-Änderung und Exclude Global Flag für globale Kuratoren ergänzt.
2025-12-04 00:45:08 +01:00
Hördle Bot
52a15b7504 chore: Version auf v0.1.6.1 erhöht 2025-12-04 00:43:27 +01:00
Hördle Bot
00160d9602 feat: Batch-Edit Übersetzungen hinzugefügt
- Englische und deutsche Übersetzungen für alle Batch-Edit-Funktionen
- Keys für Toolbar, Buttons, Meldungen und Fehlerbehandlung
- Unterstützt Genre/Special Toggle, Artist-Änderung und Exclude Global
2025-12-04 00:42:19 +01:00
Hördle Bot
296a227d22 feat: Batch-Edit-Funktionalität für Curator Track-Liste
- Neue API-Route /api/songs/batch für Batch-Updates
- Checkbox-Spalte in Tabelle mit Select-All-Funktionalität
- Batch-Edit-Toolbar mit Genre/Special Toggle, Artist-Änderung und Exclude Global Flag
- Visuelle Hervorhebung ausgewählter Zeilen
- Unterstützt Toggle-Modus für Genres/Specials (hinzufügen/entfernen)
- Validiert Kurator-Berechtigungen für jeden Song
- Transaktionsbasierte Updates für Konsistenz
2025-12-04 00:38:08 +01:00
Hördle Bot
50ca51b143 Enhance special ID handling in song API routes
- Updated logic to prioritize specialId and special.id for SpecialSong objects.
- Added comments for clarity on ID usage and conditions for retrieving special IDs.
- Modified API response to include related special details for better data integrity.
2025-12-04 00:24:14 +01:00
Hördle Bot
afe6e12afc Implement special selection feature in CuratorPageClient
- Added a new section for curators to select specials associated with their account.
- Introduced checkboxes for editing special selections, allowing for dynamic updates.
- Updated the display logic for specials to differentiate between selected and unselected items.
2025-12-04 00:14:27 +01:00
Hördle Bot
91b12ad859 Erweitere README.md um Kuratoren-System und Analytics-Funktionen
- Einführung eines Kuratoren-Managements mit separaten Accounts, Genre- und Special-Zuweisungen.
- Kuratoren können Songs verwalten und Spieler-Kommentare einsehen.
- Integration von Plausible Analytics für anonyme Nutzungsstatistiken und automatisches Domain-Tracking.
- Aktualisierung der Anweisungen für Kurator-Zugang und -Funktionen.
2025-12-03 23:30:31 +01:00
Hördle Bot
d2548c2870 Bump version to 0.1.6.0 2025-12-03 23:17:56 +01:00
Hördle Bot
40d6ea75f0 Behebe zwei Bugs im Kurator-Kommentar-System
Bug 1: Special-Kuratoren für Special-Puzzles berücksichtigen
- Kommentare zu Special-Puzzles werden jetzt korrekt an Special-Kuratoren geroutet
- Logik erweitert: Prüft zuerst puzzle.specialId, dann genreId, dann global
- Special-Kuratoren werden über CuratorSpecial abgerufen

Bug 2: Prisma Schema konsistent mit Migration
- onDelete: SetNull zur genre-Relation in CuratorComment hinzugefügt
- Entspricht jetzt dem Foreign Key Constraint in der Migration
2025-12-03 23:15:18 +01:00
Hördle Bot
0054facbe7 Füge Puzzle-Kontext zu Kommentaren hinzu und verbessere Sichtbarkeit neuer Kommentare
- Puzzle-Kontext: Hördle #, Genre/Special, Titel werden jetzt angezeigt
- API erweitert: puzzleNumber wird berechnet, special-Informationen inkludiert
- Badge für neue Kommentare: zeigt Anzahl ungelesener Kommentare
- Verbesserte Kommentar-Anzeige mit vollständigem Rätsel-Kontext
- UI-Anpassungen: nur Badge für neue Kommentare, keine übermäßige Hervorhebung
2025-12-03 23:09:45 +01:00
Hördle Bot
95bcf9ed1e Füge Archivierungs-Funktion für Kommentare hinzu und fixe initiales Laden
- Archivierungs-Funktionalität: Kuratoren können Kommentare archivieren
- archived-Flag in CuratorCommentRecipient hinzugefügt
- API-Route für Archivieren: /api/curator-comments/[id]/archive
- Kommentare werden beim initialen Laden automatisch abgerufen
- Archivierte Kommentare werden nicht mehr in der Liste angezeigt
- Archivieren-Button in der UI hinzugefügt
- Migration für archived-Feld
- Übersetzungen für Archivierung (DE/EN)
2025-12-03 22:57:28 +01:00
Hördle Bot
08fedf9881 Verschiebe Kommentare-Sektion ganz nach oben in Kuratoren-Seite 2025-12-03 22:47:40 +01:00
Hördle Bot
cd564b5d8c Implementiere Kurator-Kommentar-System
- 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
2025-12-03 22:46:02 +01:00
Hördle Bot
863539a5e9 Add Restic backup to remote repository in deploy script 2025-12-03 19:19:11 +01:00
Hördle Bot
2fa8aa0042 Bump version to v0.1.5.2 2025-12-03 18:36:47 +01:00
Hördle Bot
8ecf430bf5 Wrap song updates and deletes in database transactions for consistency 2025-12-03 18:36:32 +01:00
Hördle Bot
71abb7c322 Bump version to v0.1.5.1 2025-12-03 17:34:40 +01:00
Hördle Bot
b730c6637a Fix random song selection bias in daily puzzle generation 2025-12-03 17:34:23 +01:00
Hördle Bot
6e93529bc3 Add backup metadata and restore script for full DB rollback 2025-12-03 16:25:50 +01:00
Hördle Bot
990e1927e9 Curator: Client-Komponente ausgelagert, Server-Wrapper für stabilen Build 2025-12-03 15:28:17 +01:00
Hördle Bot
d7fee047c2 Deploy: shallow fetch + dynamische /curator-Seite für Docker-Build 2025-12-03 15:16:38 +01:00
20 changed files with 3260 additions and 1398 deletions

View File

@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Bearbeitung von Metadaten.
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
- **Cover Art:**
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- Live-Vorschau beim Hovern über die Waveform.
- Playback-Cursor zeigt aktuelle Abspielposition.
- Einzelne Segmente zum Testen abspielen.
- Einzelne Segmente zum Testen abspielen.
- Manuelle Speicherung mit visueller Bestätigung.
- **News & Announcements:**
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
@@ -51,6 +51,25 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
- Verwaltung über das Admin-Dashboard.
- **Kurator-System:**
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
- **Batch-Edit:** Mehrere Titel gleichzeitig bearbeiten (Genre/Special Toggle, Artist ändern, Exclude Global Flag setzen).
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
- **Spieler-Kommentare:**
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
- **Analytics:**
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
## Internationalisierung (i18n)
@@ -139,6 +158,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
2. **Starten:**
```bash
@@ -156,7 +176,18 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
- URL: `/de/admin` oder `/en/admin`
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
5. **Special Curation & Scheduling verwenden:**
5. **Kurator-Zugang:**
- URL: `/de/curator` oder `/en/curator`
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
- **Batch-Edit-Funktionalität:**
- Mehrere Titel über Checkboxen auswählen
- Genre/Special Toggle (hinzufügen/entfernen)
- Artist-Änderung für alle ausgewählten Titel
- Exclude Global Flag setzen/entfernen (nur für Global-Kuratoren)
- Toolbar erscheint automatisch bei Auswahl von Titeln
6. **Special Curation & Scheduling verwenden:**
- Erstelle ein Special im Admin-Dashboard:
- Gib Name, Max Attempts und Unlock Steps ein.
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.

View File

@@ -0,0 +1,194 @@
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;
const specialId = puzzle.specialId;
let curatorIds: number[] = [];
const allCuratorIds = new Set<number>();
// 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;
});
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 }
);
}
}

View File

@@ -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 archive comments
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can archive comments' },
{ 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 archived flag
await prisma.curatorCommentRecipient.update({
where: {
id: recipient.id
},
data: {
archived: true
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error archiving comment:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -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 }
);
}
}

View File

@@ -0,0 +1,139 @@
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 non-archived comments for this curator, ordered by creation date (newest first)
const comments = await prisma.curatorCommentRecipient.findMany({
where: {
curatorId: curatorId,
archived: false
},
include: {
comment: {
include: {
puzzle: {
include: {
song: {
select: {
title: true,
artist: true
}
},
genre: {
select: {
id: true,
name: true
}
},
special: {
select: {
id: true,
name: true
}
}
}
}
}
}
},
orderBy: {
comment: {
createdAt: 'desc'
}
}
});
// Format the response with puzzle context
const formattedComments = await Promise.all(comments.map(async (recipient) => {
const puzzle = recipient.comment.puzzle;
// Calculate puzzle number
let puzzleNumber = 0;
if (puzzle.specialId) {
// Special puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
specialId: puzzle.specialId,
date: {
lte: puzzle.date
}
}
});
} else if (puzzle.genreId) {
// Genre puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: puzzle.genreId,
date: {
lte: puzzle.date
}
}
});
} else {
// Global puzzle
puzzleNumber = await prisma.dailyPuzzle.count({
where: {
genreId: null,
specialId: null,
date: {
lte: puzzle.date
}
}
});
}
return {
id: recipient.comment.id,
message: recipient.comment.message,
createdAt: recipient.comment.createdAt,
readAt: recipient.readAt,
puzzle: {
id: puzzle.id,
date: puzzle.date,
puzzleNumber: puzzleNumber,
song: {
title: puzzle.song.title,
artist: puzzle.song.artist
},
genre: puzzle.genre ? {
id: puzzle.genre.id,
name: puzzle.genre.name
} : null,
special: puzzle.special ? {
id: puzzle.special.id,
name: puzzle.special.name
} : null
}
};
}));
return NextResponse.json(formattedComments);
} catch (error) {
console.error('Error fetching curator comments:', error);
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,265 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { requireStaffAuth, StaffContext } from '@/lib/auth';
const prisma = new PrismaClient();
async function getCuratorAssignments(curatorId: number) {
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId },
select: { specialId: true },
}),
]);
return {
genreIds: new Set(genres.map(g => g.genreId)),
specialIds: new Set(specials.map(s => s.specialId)),
};
}
function curatorCanEditSong(context: StaffContext, song: any, assignments: { genreIds: Set<number>; specialIds: Set<number> }) {
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
if (songGenreIds.length === 0 && songSpecialIds.length === 0) {
return true;
}
const hasGenre = songGenreIds.some((id: number) => assignments.genreIds.has(id));
const hasSpecial = songSpecialIds.some((id: number) => assignments.specialIds.has(id));
return hasGenre || hasSpecial;
}
export async function POST(request: Request) {
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { songIds, genreToggleIds, specialToggleIds, artist, excludeFromGlobal } = await request.json();
if (!songIds || !Array.isArray(songIds) || songIds.length === 0) {
return NextResponse.json({ error: 'Missing or invalid songIds array' }, { status: 400 });
}
// Validate that at least one operation is requested
const hasGenreToggle = genreToggleIds && Array.isArray(genreToggleIds) && genreToggleIds.length > 0;
const hasSpecialToggle = specialToggleIds && Array.isArray(specialToggleIds) && specialToggleIds.length > 0;
const hasArtistChange = artist !== undefined && artist !== null && artist.trim() !== '';
const hasExcludeGlobalChange = excludeFromGlobal !== undefined;
if (!hasGenreToggle && !hasSpecialToggle && !hasArtistChange && !hasExcludeGlobalChange) {
return NextResponse.json({ error: 'No update operations specified' }, { status: 400 });
}
// Validate artist if provided
if (hasArtistChange && artist.trim() === '') {
return NextResponse.json({ error: 'Artist cannot be empty' }, { status: 400 });
}
// Validate excludeFromGlobal permission
if (hasExcludeGlobalChange && context.role === 'curator' && !context.curator.isGlobalCurator) {
return NextResponse.json(
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
{ status: 403 }
);
}
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
if (context.role === 'curator') {
assignments = await getCuratorAssignments(context.curator.id);
// Validate genre/special toggles are within curator's assignments
if (hasGenreToggle) {
const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only toggle their own genres' },
{ status: 403 }
);
}
}
if (hasSpecialToggle) {
const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only toggle their own specials' },
{ status: 403 }
);
}
}
}
// Load all songs with relations for permission checks
const songs = await prisma.song.findMany({
where: { id: { in: songIds.map((id: any) => Number(id)) } },
include: {
genres: true,
specials: {
include: {
special: true
}
},
},
});
if (songs.length === 0) {
return NextResponse.json({ error: 'No songs found' }, { status: 404 });
}
// Filter songs that can be edited
const editableSongs = context.role === 'admin'
? songs
: songs.filter(song => curatorCanEditSong(context, song, assignments!));
if (editableSongs.length === 0) {
return NextResponse.json(
{ error: 'No songs can be edited with current permissions' },
{ status: 403 }
);
}
const results = {
total: songIds.length,
processed: editableSongs.length,
skipped: songs.length - editableSongs.length,
success: 0,
errors: [] as Array<{ songId: number; error: string }>,
};
// Process each song in a transaction
for (const song of editableSongs) {
try {
await prisma.$transaction(async (tx) => {
const updateData: any = {};
// Handle artist change
if (hasArtistChange) {
updateData.artist = artist.trim();
}
// Handle excludeFromGlobal change
if (hasExcludeGlobalChange) {
updateData.excludeFromGlobal = excludeFromGlobal;
}
// Handle genre toggles
if (hasGenreToggle) {
const currentGenreIds = song.genres.map(g => g.id);
const genreIdsToToggle = genreToggleIds as number[];
// Determine which genres to add/remove
const genresToAdd = genreIdsToToggle.filter(id => !currentGenreIds.includes(id));
const genresToRemove = genreIdsToToggle.filter(id => currentGenreIds.includes(id));
// For curators, preserve genres they can't manage
let finalGenreIds: number[];
if (context.role === 'curator') {
const fixedGenreIds = currentGenreIds.filter(gid => !assignments!.genreIds.has(gid));
const managedGenreIds = currentGenreIds
.filter(gid => assignments!.genreIds.has(gid) && !genresToRemove.includes(gid))
.concat(genresToAdd);
finalGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
} else {
const newGenreIds = currentGenreIds
.filter(id => !genresToRemove.includes(id))
.concat(genresToAdd);
finalGenreIds = Array.from(new Set(newGenreIds));
}
updateData.genres = {
set: finalGenreIds.map(gId => ({ id: gId }))
};
}
// Update song basic data
if (Object.keys(updateData).length > 0) {
await tx.song.update({
where: { id: song.id },
data: updateData,
});
}
// Handle special toggles
if (hasSpecialToggle) {
const currentSpecials = await tx.specialSong.findMany({
where: { songId: song.id }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const specialIdsToToggle = specialToggleIds as number[];
// Determine which specials to add/remove
const specialsToAdd = specialIdsToToggle.filter(id => !currentSpecialIds.includes(id));
const specialsToRemove = specialIdsToToggle.filter(id => currentSpecialIds.includes(id));
// For curators, preserve specials they can't manage
let finalSpecialIds: number[];
if (context.role === 'curator') {
const fixedSpecialIds = currentSpecialIds.filter(sid => !assignments!.specialIds.has(sid));
const managedSpecialIds = currentSpecialIds
.filter(sid => assignments!.specialIds.has(sid) && !specialsToRemove.includes(sid))
.concat(specialsToAdd);
finalSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
} else {
const newSpecialIds = currentSpecialIds
.filter(id => !specialsToRemove.includes(id))
.concat(specialsToAdd);
finalSpecialIds = Array.from(new Set(newSpecialIds));
}
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !finalSpecialIds.includes(sid));
if (toDelete.length > 0) {
await tx.specialSong.deleteMany({
where: {
songId: song.id,
specialId: { in: toDelete }
}
});
}
// Add new specials
const toAdd = finalSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await tx.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: song.id,
specialId,
startTime: 0
}))
});
}
}
});
results.success++;
} catch (error: any) {
results.errors.push({
songId: song.id,
error: error.message || 'Unknown error'
});
}
}
return NextResponse.json(results);
} catch (error) {
console.error('Error in batch update:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -35,11 +35,15 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
// - `SpecialSong` (mit `specialId`)
// - `SpecialSong` (mit Relation `special.id`)
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
@@ -59,11 +63,15 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
if (context.role === 'admin') return true;
const songGenreIds = (song.genres || []).map((g: any) => g.id);
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
// Daher zuerst specialId oder special.id prüfen.
const songSpecialIds = (song.specials || [])
.map((s: any) => {
if (s?.id != null) return s.id;
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
if (s?.specialId != null) return s.specialId;
if (s?.special?.id != null) return s.special.id;
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
return undefined;
})
.filter((id: any): id is number => typeof id === 'number');
@@ -382,7 +390,11 @@ export async function PUT(request: Request) {
where: { id: Number(id) },
include: {
genres: true,
specials: true,
specials: {
include: {
special: true
}
},
},
});
@@ -469,51 +481,55 @@ export async function PUT(request: Request) {
};
}
// Handle SpecialSong relations separately
if (effectiveSpecialIds !== undefined) {
// First, get current special assignments
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = effectiveSpecialIds as number[];
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
if (toDelete.length > 0) {
await prisma.specialSong.deleteMany({
where: {
songId: Number(id),
specialId: { in: toDelete }
}
// Execute all database write operations in a transaction to ensure consistency
const updatedSong = await prisma.$transaction(async (tx) => {
// Handle SpecialSong relations separately
if (effectiveSpecialIds !== undefined) {
// First, get current special assignments (within transaction)
const currentSpecials = await tx.specialSong.findMany({
where: { songId: Number(id) }
});
}
// Add new specials
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await prisma.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: Number(id),
specialId,
startTime: 0
}))
});
}
}
const currentSpecialIds = currentSpecials.map(ss => ss.specialId);
const newSpecialIds = effectiveSpecialIds as number[];
const updatedSong = await prisma.song.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: {
include: {
special: true
}
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
if (toDelete.length > 0) {
await tx.specialSong.deleteMany({
where: {
songId: Number(id),
specialId: { in: toDelete }
}
});
}
// Add new specials
const toAdd = newSpecialIds.filter(sid => !currentSpecialIds.includes(sid));
if (toAdd.length > 0) {
await tx.specialSong.createMany({
data: toAdd.map(specialId => ({
songId: Number(id),
specialId,
startTime: 0
}))
});
}
}
// Update song (this also handles genre relations via Prisma's set operation)
return await tx.song.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: {
include: {
special: true
}
}
}
});
});
return NextResponse.json(updatedSong);
@@ -559,7 +575,7 @@ export async function DELETE(request: Request) {
}
}
// Delete file
// Delete files first (outside transaction, as file system operations can't be rolled back)
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
try {
await unlink(filePath);
@@ -578,9 +594,11 @@ export async function DELETE(request: Request) {
}
}
// Delete from database (will cascade delete related puzzles)
await prisma.song.delete({
where: { id: Number(id) },
// Delete from database in transaction (will cascade delete related puzzles, SpecialSong, etc.)
await prisma.$transaction(async (tx) => {
await tx.song.delete({
where: { id: Number(id) },
});
});
return NextResponse.json({ success: true });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<ExternalPuzzle | null>(null);
const audioPlayerRef = useRef<AudioPlayerRef>(null);
const [commentText, setCommentText] = useState('');
const [commentSending, setCommentSending] = useState(false);
const [commentSent, setCommentSent] = useState(false);
const [commentError, setCommentError] = useState<string | null>(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
</button>
</div>
{/* Comment Form */}
{!commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
{t('sendComment')}
</h3>
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
{t('commentHelp')}
</p>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('commentPlaceholder')}
maxLength={2000}
style={{
width: '100%',
minHeight: '100px',
padding: '0.75rem',
borderRadius: '0.5rem',
border: '1px solid var(--border)',
fontSize: '0.9rem',
fontFamily: 'inherit',
resize: 'vertical',
marginBottom: '0.5rem'
}}
disabled={commentSending}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
{commentText.length}/2000
</span>
{commentError && (
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
{commentError}
</span>
)}
</div>
<button
onClick={handleCommentSubmit}
disabled={!commentText.trim() || commentSending || commentSent}
className="btn-primary"
style={{
width: '100%',
opacity: (!commentText.trim() || commentSending || commentSent) ? 0.5 : 1,
cursor: (!commentText.trim() || commentSending || commentSent) ? 'not-allowed' : 'pointer'
}}
>
{commentSending ? t('sending') : t('sendComment')}
</button>
</div>
)}
{commentSent && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
{t('commentSent')}
</p>
</div>
)}
{statistics && <Statistics statistics={statistics} />}
</div>
)}

View File

@@ -49,13 +49,15 @@ export async function getOrCreateDailyPuzzle(genre: Genre | null = null) {
// Calculate total weight
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
// Pick a random song based on weights
// Pick a random song based on weights using cumulative weights
// This ensures proper distribution and handles edge cases
let random = Math.random() * totalWeight;
let selectedSong = weightedSongs[0].song;
let selectedSong = weightedSongs[weightedSongs.length - 1].song; // Fallback to last song
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSong = item.song;
break;
}
@@ -156,11 +158,13 @@ export async function getOrCreateSpecialPuzzle(special: Special) {
const totalWeight = weightedSongs.reduce((sum, item) => sum + item.weight, 0);
let random = Math.random() * totalWeight;
let selectedSpecialSong = weightedSongs[0].specialSong;
let selectedSpecialSong = weightedSongs[weightedSongs.length - 1].specialSong; // Fallback to last song
// Pick a random song based on weights using cumulative weights
let cumulativeWeight = 0;
for (const item of weightedSongs) {
random -= item.weight;
if (random <= 0) {
cumulativeWeight += item.weight;
if (random <= cumulativeWeight) {
selectedSpecialSong = item.specialSong;
break;
}

View File

@@ -57,6 +57,13 @@
"points": "Punkte",
"skipBonus": "Bonus überspringen",
"notQuite": "Nicht ganz!",
"sendComment": "Nachricht an Kurator senden",
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
"commentError": "Fehler beim Senden der Nachricht",
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
"sending": "Wird gesendet...",
"youGuessed": "Du hast geraten",
"actuallyReleasedIn": "Tatsächlich veröffentlicht in",
"skipped": "Übersprungen",
@@ -232,7 +239,40 @@
"loadingData": "Lade Daten...",
"loggedInAs": "Eingeloggt als {username}",
"globalCuratorSuffix": " (Globaler Kurator)",
"pageSizeLabel": "Pro Seite:"
"pageSizeLabel": "Pro Seite:",
"commentsTitle": "Kommentare",
"showComments": "Kommentare anzeigen",
"hideComments": "Kommentare ausblenden",
"loadingComments": "Kommentare werden geladen...",
"noComments": "Keine Kommentare vorhanden.",
"loadCommentsError": "Fehler beim Laden der Kommentare.",
"commentFromPuzzle": "Kommentar zu Puzzle",
"commentGenre": "Genre",
"unreadComment": "Ungelesen",
"archiveComment": "Archivieren",
"archiveCommentConfirm": "Möchtest du diesen Kommentar wirklich archivieren?",
"archiveCommentError": "Fehler beim Archivieren des Kommentars.",
"newComments": "neu",
"batchEditTitle": "Batch-Bearbeitung",
"clearSelection": "Auswahl aufheben",
"batchToggleGenres": "Genres umschalten",
"batchToggleSpecials": "Specials umschalten",
"batchChangeArtist": "Artist ändern",
"batchArtistPlaceholder": "Neuen Artist-Namen eingeben",
"batchExcludeGlobal": "Von Global ausschließen",
"batchNoChange": "Keine Änderung",
"batchExclude": "Ausschließen",
"batchInclude": "Einschließen",
"batchUpdating": "Aktualisiere...",
"batchApply": "Änderungen anwenden",
"selectAll": "Alle auswählen",
"selectSong": "Titel auswählen",
"cannotEditSong": "Dieser Titel kann nicht bearbeitet werden",
"noSongsSelected": "Keine Titel ausgewählt",
"noBatchOperations": "Keine Batch-Operationen angegeben",
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
"batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
},
"About": {
"title": "Über Hördle & Impressum",

View File

@@ -57,6 +57,13 @@
"points": "points",
"skipBonus": "Skip Bonus",
"notQuite": "Not quite!",
"sendComment": "Send message to curator",
"commentPlaceholder": "Write a message to the curators of this genre...",
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
"commentSent": "✓ Message sent! Thank you for your feedback.",
"commentError": "Error sending message",
"commentRateLimited": "You have already sent a message for this puzzle.",
"sending": "Sending...",
"youGuessed": "You guessed",
"actuallyReleasedIn": "Actually released in",
"skipped": "Skipped",
@@ -232,7 +239,40 @@
"loadingData": "Loading data...",
"loggedInAs": "Logged in as {username}",
"globalCuratorSuffix": " (Global curator)",
"pageSizeLabel": "Per page:"
"pageSizeLabel": "Per page:",
"commentsTitle": "Comments",
"showComments": "Show comments",
"hideComments": "Hide comments",
"loadingComments": "Loading comments...",
"noComments": "No comments available.",
"loadCommentsError": "Error loading comments.",
"commentFromPuzzle": "Comment from puzzle",
"commentGenre": "Genre",
"unreadComment": "Unread",
"archiveComment": "Archive",
"archiveCommentConfirm": "Do you really want to archive this comment?",
"archiveCommentError": "Error archiving comment.",
"newComments": "new",
"batchEditTitle": "Batch Edit",
"clearSelection": "Clear Selection",
"batchToggleGenres": "Toggle Genres",
"batchToggleSpecials": "Toggle Specials",
"batchChangeArtist": "Change Artist",
"batchArtistPlaceholder": "Enter new artist name",
"batchExcludeGlobal": "Exclude from Global",
"batchNoChange": "No change",
"batchExclude": "Exclude",
"batchInclude": "Include",
"batchUpdating": "Updating...",
"batchApply": "Apply Changes",
"selectAll": "Select all",
"selectSong": "Select song",
"cannotEditSong": "Cannot edit this song",
"noSongsSelected": "No songs selected",
"noBatchOperations": "No batch operations specified",
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
"batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update"
},
"About": {
"title": "About Hördle & Imprint",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.5.0",
"version": "0.1.6.1",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "CuratorComment" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"playerIdentifier" TEXT NOT NULL,
"puzzleId" INTEGER NOT NULL,
"genreId" INTEGER,
"message" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CuratorComment_puzzleId_fkey" FOREIGN KEY ("puzzleId") REFERENCES "DailyPuzzle" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorComment_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CuratorCommentRecipient" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"commentId" INTEGER NOT NULL,
"curatorId" INTEGER NOT NULL,
"readAt" DATETIME,
CONSTRAINT "CuratorCommentRecipient_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "CuratorComment" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "CuratorCommentRecipient_curatorId_fkey" FOREIGN KEY ("curatorId") REFERENCES "Curator" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "CuratorComment_playerIdentifier_puzzleId_key" ON "CuratorComment"("playerIdentifier", "puzzleId");
-- CreateIndex
CREATE INDEX "CuratorComment_genreId_idx" ON "CuratorComment"("genreId");
-- CreateIndex
CREATE UNIQUE INDEX "CuratorCommentRecipient_commentId_curatorId_key" ON "CuratorCommentRecipient"("commentId", "curatorId");
-- CreateIndex
CREATE INDEX "CuratorCommentRecipient_curatorId_idx" ON "CuratorCommentRecipient"("curatorId");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CuratorCommentRecipient" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -34,6 +34,7 @@ model Genre {
songs Song[]
dailyPuzzles DailyPuzzle[]
curatorGenres CuratorGenre[]
comments CuratorComment[]
}
model Special {
@@ -73,6 +74,7 @@ model DailyPuzzle {
genre Genre? @relation(fields: [genreId], references: [id])
specialId Int?
special Special? @relation(fields: [specialId], references: [id])
comments CuratorComment[]
@@unique([date, genreId, specialId])
}
@@ -114,6 +116,7 @@ model Curator {
genres CuratorGenre[]
specials CuratorSpecial[]
commentRecipients CuratorCommentRecipient[]
}
model CuratorGenre {
@@ -149,3 +152,32 @@ model PoliticalStatement {
@@index([locale, active])
}
model CuratorComment {
id Int @id @default(autoincrement())
playerIdentifier String
puzzleId Int
puzzle DailyPuzzle @relation(fields: [puzzleId], references: [id], onDelete: Cascade)
genreId Int?
genre Genre? @relation(fields: [genreId], references: [id], onDelete: SetNull)
message String
createdAt DateTime @default(now())
recipients CuratorCommentRecipient[]
@@unique([playerIdentifier, puzzleId])
@@index([genreId])
}
model CuratorCommentRecipient {
id Int @id @default(autoincrement())
commentId Int
comment CuratorComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
curatorId Int
curator Curator @relation(fields: [curatorId], references: [id], onDelete: Cascade)
readAt DateTime?
archived Boolean @default(false)
@@unique([commentId, curatorId])
@@index([curatorId])
}

77
scripts/backup-restic.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/bin/bash
# Restic backup script for Hördle deployment
# Creates a backup snapshot with tags and handles errors gracefully
set -e
echo "💾 Creating Restic backup..."
if ! command -v restic >/dev/null 2>&1; then
echo "⚠️ restic not found in PATH, skipping Restic backup"
exit 0
fi
# Check required environment variables
if [ -z "$RESTIC_PASSWORD" ]; then
echo "⚠️ RESTIC_PASSWORD not set, skipping Restic backup"
exit 0
fi
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
echo "⚠️ RESTIC_AUTH_USER or RESTIC_AUTH_PASSWORD not set, skipping Restic backup"
exit 0
fi
# Build repository URL
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
# Get current commit hash for tagging
CURRENT_COMMIT_SHORT="$(git rev-parse --short HEAD 2>/dev/null || echo 'unknown')"
CURRENT_DATE="$(date +%Y-%m-%d)"
# Export password for restic
export RESTIC_PASSWORD
# Check if repository exists, initialize if not
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
echo " Initializing Restic repository..."
if ! restic -r "$RESTIC_REPO" init >/dev/null 2>&1; then
echo "⚠️ Failed to initialize Restic repository, skipping backup"
exit 0
fi
fi
# Create backup with tags
# Backup important directories: backups, config files, but exclude node_modules, .git, etc.
echo " Creating Restic snapshot..."
RESTIC_EXIT_CODE=0
restic -r "$RESTIC_REPO" backup \
--tag deployment \
--tag hoerdle \
--tag "date:${CURRENT_DATE}" \
--tag "commit:${CURRENT_COMMIT_SHORT}" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='*.log' \
./backups \
./data \
./public/uploads \
docker-compose.yml \
.env \
package.json \
prisma/schema.prisma \
prisma/migrations \
scripts/ || RESTIC_EXIT_CODE=$?
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
echo "✅ Restic backup completed successfully"
exit 0
elif [ $RESTIC_EXIT_CODE -eq 3 ]; then
echo "⚠️ Restic backup completed with warnings (some files could not be read), continuing..."
exit 0
else
echo "⚠️ Restic backup failed (exit code: $RESTIC_EXIT_CODE), continuing deployment..."
exit 0
fi

View File

@@ -1,10 +1,10 @@
#!/bin/bash
set -e
echo "🚀 Starting optimized deployment..."
echo "🚀 Starting optimized deployment with full rollback support..."
# Backup database
echo "💾 Creating database backup..."
# Backup database (per Deployment, inkl. Metadaten für Rollback)
echo "💾 Creating database backup for this deployment..."
# Try to find database path from docker-compose.yml or .env
DB_PATH=""
@@ -26,16 +26,29 @@ if [ -n "$DB_PATH" ]; then
# Convert container path to host path if needed
# /app/data/prod.db -> ./data/prod.db
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
if [ -f "$DB_PATH" ]; then
# Create backups directory
mkdir -p ./backups
# Create timestamped backup
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_$(date +%Y%m%d_%H%M%S).db"
DEPLOY_TS="$(date +%Y%m%d_%H%M%S)"
BACKUP_FILE="./backups/$(basename "$DB_PATH" .db)_${DEPLOY_TS}.db"
cp "$DB_PATH" "$BACKUP_FILE"
echo "✅ Database backed up to: $BACKUP_FILE"
# Store metadata for restore (Timestamp, DB-Path, Git-Commit)
CURRENT_COMMIT="$(git rev-parse HEAD || echo 'unknown')"
{
echo "timestamp=${DEPLOY_TS}"
echo "db_path=${DB_PATH}"
echo "backup_file=${BACKUP_FILE}"
echo "git_commit=${CURRENT_COMMIT}"
} > "./backups/last_deploy.meta"
# Append to history manifest (eine Zeile pro Deployment)
echo "${DEPLOY_TS}|${DB_PATH}|${BACKUP_FILE}|${CURRENT_COMMIT}" >> "./backups/deploy_history.log"
# Keep only last 10 backups
ls -t ./backups/*.db | tail -n +11 | xargs -r rm
echo "🧹 Cleaned old backups (keeping last 10)"
@@ -46,13 +59,13 @@ else
echo "⚠️ Could not determine database path from config files"
fi
# Pull latest changes
echo "📥 Pulling latest changes from git..."
git pull
# Restic backup to remote repository
./scripts/backup-restic.sh
# Fetch all tags
echo "🏷️ Fetching git tags..."
git fetch --tags
# Nur neueste Version holen (shallow fetch), vollständiges Repo ist im Deployment nicht nötig
echo "📥 Fetching latest commit (shallow clone) from git..."
git fetch --prune --tags --depth=1 origin master
git reset --hard origin/master
# Prüfe und erstelle/repariere Netzwerk falls nötig
echo "🌐 Prüfe Docker-Netzwerk..."

93
scripts/restore.sh Normal file
View File

@@ -0,0 +1,93 @@
#!/bin/bash
set -e
echo "🧯 Hördle restore script Rollback auf früheres Datenbank-Backup"
# Hilfsfunktion für Fehlerausgabe
die() {
echo "$1" >&2
exit 1
}
# Backup-Verzeichnis
BACKUP_DIR="./backups"
if [ ! -d "$BACKUP_DIR" ]; then
die "Kein Backup-Verzeichnis gefunden (${BACKUP_DIR}). Es scheint noch kein Deployment-Backup erstellt worden zu sein."
fi
# Argument: gewünschter Backup-Timestamp oder 'latest'
TARGET="$1"
if [ -z "$TARGET" ]; then
echo "⚙️ Nutzung:"
echo " ./scripts/restore.sh latest # neuestes Backup zurückspielen"
echo " ./scripts/restore.sh 20250101_120000 # bestimmtes Backup (Timestamp aus Dateiname)"
echo ""
echo "Verfügbare Backups:"
ls -1 "${BACKUP_DIR}"/*.db 2>/dev/null || echo " (keine .db-Backups gefunden)"
exit 1
fi
# DB-Pfad wie in deploy.sh bestimmen
DB_PATH=""
if [ -f "docker-compose.yml" ]; then
DB_PATH=$(grep -oP 'DATABASE_URL=file:\K[^\s]+' docker-compose.yml | head -1)
fi
if [ -z "$DB_PATH" ] && [ -f ".env" ]; then
DB_PATH=$(grep -oP '^DATABASE_URL=file:\K.+' .env | head -1)
fi
DB_PATH=$(echo "$DB_PATH" | tr -d '"' | tr -d "'")
if [ -z "$DB_PATH" ]; then
die "Konnte den Datenbank-Pfad aus docker-compose.yml oder .env nicht ermitteln."
fi
# Containerpfad zu Hostpfad umbauen (/app/... -> ./...)
DB_PATH=$(echo "$DB_PATH" | sed 's|/app/|./|')
echo "📁 Ziel-Datenbank-Datei: $DB_PATH"
# Backup-Datei bestimmen
if [ "$TARGET" = "latest" ]; then
BACKUP_FILE=$(ls -t "${BACKUP_DIR}"/*.db 2>/dev/null | head -1 || true)
[ -z "$BACKUP_FILE" ] && die "Kein Backup gefunden."
else
# Versuchen, exakten Dateinamen zu finden
if [ -f "${BACKUP_DIR}/${TARGET}" ]; then
BACKUP_FILE="${BACKUP_DIR}/${TARGET}"
else
# Versuchen, anhand des Timestamps ein Backup zu finden
BACKUP_FILE=$(ls "${BACKUP_DIR}"/*"${TARGET}"*.db 2>/dev/null | head -1 || true)
fi
[ -z "$BACKUP_FILE" ] && die "Kein Backup für '${TARGET}' gefunden."
fi
echo "⏪ Verwende Backup-Datei: $BACKUP_FILE"
if [ ! -f "$BACKUP_FILE" ]; then
die "Backup-Datei existiert nicht: $BACKUP_FILE"
fi
read -p "❗ Dies überschreibt die aktuelle Datenbank-Datei. Fortfahren? [y/N] " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Abgebrochen."
exit 0
fi
echo "📦 Kopiere Backup nach: $DB_PATH"
cp "$BACKUP_FILE" "$DB_PATH"
echo "🔄 Starte Docker-Container neu..."
docker compose restart hoerdle
echo "✅ Restore abgeschlossen."
echo " Hinweis: Der Code-Stand (Git-Commit) ist nicht automatisch zurückgedreht."
echo " Falls du auch die App-Version zurückrollen möchtest, checke lokal den passenden Commit/Tag aus"
echo " und führe anschließend wieder ./scripts/deploy.sh aus."