Kuratoren-Accounts und Anpassungen im Admin- und Kuratoren-Dashboard

This commit is contained in:
Hördle Bot
2025-12-03 12:52:38 +01:00
parent 49e98ade3c
commit 38148ace8d
12 changed files with 2171 additions and 620 deletions

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const curator = await prisma.curator.findUnique({
where: { username },
});
if (!curator) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const isValid = await bcrypt.compare(password, curator.passwordHash);
if (!isValid) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
return NextResponse.json({
success: true,
curator: {
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
},
});
} catch (error) {
console.error('Curator login error:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
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) {
const { error, context } = await requireStaffAuth(request);
if (error || !context) return error!;
if (context.role !== 'curator') {
return NextResponse.json(
{ error: 'Only curators can access this endpoint' },
{ status: 403 }
);
}
const [genres, specials] = await Promise.all([
prisma.curatorGenre.findMany({
where: { curatorId: context.curator.id },
select: { genreId: true },
}),
prisma.curatorSpecial.findMany({
where: { curatorId: context.curator.id },
select: { specialId: true },
}),
]);
return NextResponse.json({
id: context.curator.id,
username: context.curator.username,
isGlobalCurator: context.curator.isGlobalCurator,
genreIds: genres.map(g => g.genreId),
specialIds: specials.map(s => s.specialId),
});
}

182
app/api/curators/route.ts Normal file
View File

@@ -0,0 +1,182 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { requireAdminAuth } from '@/lib/auth';
const prisma = new PrismaClient();
export async function GET(request: NextRequest) {
// Only admin may list and manage curators
const authError = await requireAdminAuth(request);
if (authError) return authError;
const curators = await prisma.curator.findMany({
include: {
genres: true,
specials: true,
},
orderBy: { username: 'asc' },
});
return NextResponse.json(
curators.map(c => ({
id: c.id,
username: c.username,
isGlobalCurator: c.isGlobalCurator,
genreIds: c.genres.map(g => g.genreId),
specialIds: c.specials.map(s => s.specialId),
}))
);
}
export async function POST(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { username, password, isGlobalCurator = false, genreIds = [], specialIds = [] } = await request.json();
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const curator = await prisma.curator.create({
data: {
username,
passwordHash,
isGlobalCurator: Boolean(isGlobalCurator),
genres: {
create: (genreIds as number[]).map(id => ({ genreId: id })),
},
specials: {
create: (specialIds as number[]).map(id => ({ specialId: id })),
},
},
include: {
genres: true,
specials: true,
},
});
return NextResponse.json({
id: curator.id,
username: curator.username,
isGlobalCurator: curator.isGlobalCurator,
genreIds: curator.genres.map(g => g.genreId),
specialIds: curator.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error creating curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id, username, password, isGlobalCurator, genreIds, specialIds } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
const data: any = {};
if (username !== undefined) data.username = username;
if (isGlobalCurator !== undefined) data.isGlobalCurator = Boolean(isGlobalCurator);
if (password) {
data.passwordHash = await bcrypt.hash(password, 10);
}
try {
const updated = await prisma.$transaction(async (tx) => {
const curator = await tx.curator.update({
where: { id: Number(id) },
data,
include: {
genres: true,
specials: true,
},
});
if (Array.isArray(genreIds)) {
await tx.curatorGenre.deleteMany({
where: { curatorId: curator.id },
});
if (genreIds.length > 0) {
await tx.curatorGenre.createMany({
data: (genreIds as number[]).map(gid => ({
curatorId: curator.id,
genreId: gid,
})),
});
}
}
if (Array.isArray(specialIds)) {
await tx.curatorSpecial.deleteMany({
where: { curatorId: curator.id },
});
if (specialIds.length > 0) {
await tx.curatorSpecial.createMany({
data: (specialIds as number[]).map(sid => ({
curatorId: curator.id,
specialId: sid,
})),
});
}
}
const finalCurator = await tx.curator.findUnique({
where: { id: curator.id },
include: {
genres: true,
specials: true,
},
});
if (!finalCurator) {
throw new Error('Curator not found after update');
}
return finalCurator;
});
return NextResponse.json({
id: updated.id,
username: updated.username,
isGlobalCurator: updated.isGlobalCurator,
genreIds: updated.genres.map(g => g.genreId),
specialIds: updated.specials.map(s => s.specialId),
});
} catch (error) {
console.error('Error updating curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
const authError = await requireAdminAuth(request);
if (authError) return authError;
const { id } = await request.json();
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
try {
await prisma.curator.delete({
where: { id: Number(id) },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting curator:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View File

@@ -1,13 +1,60 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
import { writeFile, unlink } from 'fs/promises';
import path from 'path';
import { parseBuffer } from 'music-metadata';
import { isDuplicateSong } from '@/lib/fuzzyMatch';
import { requireAdminAuth } from '@/lib/auth';
import { getStaffContext, 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) => s.specialId ?? s.id);
// Songs ohne Genres/Specials sind für Kuratoren generell editierbar
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;
}
function curatorCanDeleteSong(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) => s.specialId ?? s.id);
const allGenresAllowed = songGenreIds.every((id: number) => assignments.genreIds.has(id));
const allSpecialsAllowed = songSpecialIds.every((id: number) => assignments.specialIds.has(id));
return allGenresAllowed && allSpecialsAllowed;
}
// Configure route to handle large file uploads
export const runtime = 'nodejs';
export const maxDuration = 60; // 60 seconds timeout for uploads
@@ -50,11 +97,11 @@ export async function GET() {
export async function POST(request: Request) {
console.log('[UPLOAD] Starting song upload request');
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) {
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) {
console.log('[UPLOAD] Authentication failed');
return authError;
return error!;
}
try {
@@ -63,10 +110,17 @@ export async function POST(request: Request) {
const file = formData.get('file') as File;
let title = '';
let artist = '';
const excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
let excludeFromGlobal = formData.get('excludeFromGlobal') === 'true';
console.log('[UPLOAD] Received file:', file?.name, 'Size:', file?.size, 'Type:', file?.type);
console.log('[UPLOAD] excludeFromGlobal:', excludeFromGlobal);
console.log('[UPLOAD] excludeFromGlobal (raw):', excludeFromGlobal);
// Apply global playlist rules:
// - Admin: may control the flag via form data
// - Curator: uploads are always excluded from global by default
if (context.role === 'curator') {
excludeFromGlobal = true;
}
if (!file) {
console.error('[UPLOAD] No file provided');
@@ -261,9 +315,9 @@ export async function POST(request: Request) {
}
export async function PUT(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id, title, artist, releaseYear, genreIds, specialIds, excludeFromGlobal } = await request.json();
@@ -272,6 +326,69 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
}
// Load current song with relations for permission checks
const existingSong = await prisma.song.findUnique({
where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
});
if (!existingSong) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
let effectiveGenreIds = genreIds as number[] | undefined;
let effectiveSpecialIds = specialIds as number[] | undefined;
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanEditSong(context, existingSong, assignments)) {
return NextResponse.json({ error: 'Forbidden: You are not allowed to edit this song' }, { status: 403 });
}
// Curators may assign genres, but only within their own assignments.
// Genres außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveGenreIds !== undefined) {
const invalidGenre = effectiveGenreIds.some(id => !assignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only assign their own genres' },
{ status: 403 }
);
}
const fixedGenreIds = existingSong.genres
.filter(g => !assignments.genreIds.has(g.id))
.map(g => g.id);
const managedGenreIds = effectiveGenreIds.filter(id => assignments.genreIds.has(id));
effectiveGenreIds = Array.from(new Set([...fixedGenreIds, ...managedGenreIds]));
}
// Curators may assign specials, but only within their own assignments.
// Specials außerhalb ihres Zuständigkeitsbereichs bleiben unverändert bestehen.
if (effectiveSpecialIds !== undefined) {
const invalidSpecial = effectiveSpecialIds.some(id => !assignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only assign their own specials' },
{ status: 403 }
);
}
const currentSpecials = await prisma.specialSong.findMany({
where: { songId: Number(id) }
});
const fixedSpecialIds = currentSpecials
.map(ss => ss.specialId)
.filter(sid => !assignments.specialIds.has(sid));
const managedSpecialIds = effectiveSpecialIds.filter(id => assignments.specialIds.has(id));
effectiveSpecialIds = Array.from(new Set([...fixedSpecialIds, ...managedSpecialIds]));
}
}
const data: any = { title, artist };
// Update releaseYear if provided (can be null to clear it)
@@ -280,24 +397,35 @@ export async function PUT(request: Request) {
}
if (excludeFromGlobal !== undefined) {
data.excludeFromGlobal = excludeFromGlobal;
if (context.role === 'admin') {
data.excludeFromGlobal = excludeFromGlobal;
} else {
// Curators may only change the flag if they are global curators
if (!context.curator.isGlobalCurator) {
return NextResponse.json(
{ error: 'Forbidden: Only global curators or admins can change global playlist flag' },
{ status: 403 }
);
}
data.excludeFromGlobal = excludeFromGlobal;
}
}
if (genreIds) {
if (effectiveGenreIds && effectiveGenreIds.length > 0) {
data.genres = {
set: genreIds.map((gId: number) => ({ id: gId }))
set: effectiveGenreIds.map((gId: number) => ({ id: gId }))
};
}
// Handle SpecialSong relations separately
if (specialIds !== undefined) {
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 = specialIds as number[];
const newSpecialIds = effectiveSpecialIds as number[];
// Delete removed specials
const toDelete = currentSpecialIds.filter(sid => !newSpecialIds.includes(sid));
@@ -344,9 +472,9 @@ export async function PUT(request: Request) {
}
export async function DELETE(request: Request) {
// Check authentication
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
// Check authentication (admin or curator)
const { error, context } = await requireStaffAuth(request as unknown as NextRequest);
if (error || !context) return error!;
try {
const { id } = await request.json();
@@ -355,15 +483,30 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: 'Missing id' }, { status: 400 });
}
// Get song to find filename
// Get song to find filename and relations for permission checks
const song = await prisma.song.findUnique({
where: { id: Number(id) },
include: {
genres: true,
specials: true,
},
});
if (!song) {
return NextResponse.json({ error: 'Song not found' }, { status: 404 });
}
if (context.role === 'curator') {
const assignments = await getCuratorAssignments(context.curator.id);
if (!curatorCanDeleteSong(context, song, assignments)) {
return NextResponse.json(
{ error: 'Forbidden: You are not allowed to delete this song' },
{ status: 403 }
);
}
}
// Delete file
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
try {