- 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
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
}
|
|
|