Added admin dashboard, refactored user dashboard. Removed old reviews route.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m53s

This commit is contained in:
2025-11-20 20:40:49 +00:00
parent cd04fa5212
commit f15d9a9cfd
16 changed files with 534 additions and 75 deletions

View File

@@ -4,6 +4,9 @@ namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use App\Form\ChangePasswordFormType;
use App\Repository\ReviewRepository;
use App\Repository\AlbumRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
@@ -16,33 +19,42 @@ use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_USER')]
class AccountController extends AbstractController
{
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])]
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ProfileFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$newPassword = (string) $form->get('newPassword')->getData();
if ($newPassword !== '') {
$current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/dashboard.html.twig', [
'form' => $form->createView(),
]);
}
$user->setPassword($hasher->hashPassword($user, $newPassword));
}
$em->flush();
$this->addFlash('success', 'Profile updated.');
return $this->redirectToRoute('account_dashboard');
}
$reviewCount = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->where('r.author = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$albumCount = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->getQuery()->getSingleScalarResult();
$userType = $this->isGranted('ROLE_ADMIN') ? 'Admin' : 'User';
$userReviews = $reviews->createQueryBuilder('r')
->where('r.author = :u')->setParameter('u', $user)
->orderBy('r.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
$userAlbums = $albums->createQueryBuilder('a')
->where('a.source = :src')->setParameter('src', 'user')
->andWhere('a.createdBy = :u')->setParameter('u', $user)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(10)
->getQuery()->getResult();
return $this->render('account/dashboard.html.twig', [
'form' => $form->createView(),
'email' => $user->getEmail(),
'displayName' => $user->getDisplayName(),
'reviewCount' => $reviewCount,
'albumCount' => $albumCount,
'userType' => $userType,
'userReviews' => $userReviews,
'userAlbums' => $userAlbums,
]);
}
@@ -51,6 +63,32 @@ class AccountController extends AbstractController
{
return $this->render('account/settings.html.twig');
}
#[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])]
public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
{
/** @var User $user */
$user = $this->getUser();
$form = $this->createForm(ChangePasswordFormType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$current = (string) $form->get('currentPassword')->getData();
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
return $this->render('account/password.html.twig', [
'form' => $form->createView(),
]);
}
$newPassword = (string) $form->get('newPassword')->getData();
$user->setPassword($hasher->hashPassword($user, $newPassword));
$em->flush();
$this->addFlash('success', 'Password updated.');
return $this->redirectToRoute('account_dashboard');
}
return $this->render('account/password.html.twig', [
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Controller\Admin;
use App\Repository\AlbumRepository;
use App\Repository\ReviewRepository;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_ADMIN')]
class SiteDashboardController extends AbstractController
{
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
{
$totalReviews = (int) $reviews->createQueryBuilder('r')
->select('COUNT(r.id)')
->getQuery()->getSingleScalarResult();
$totalAlbums = (int) $albums->createQueryBuilder('a')
->select('COUNT(a.id)')
->getQuery()->getSingleScalarResult();
$totalUsers = (int) $users->createQueryBuilder('u')
->select('COUNT(u.id)')
->getQuery()->getSingleScalarResult();
$recentReviews = $reviews->findLatest(50);
$recentAlbums = $albums->createQueryBuilder('a')
->orderBy('a.createdAt', 'DESC')
->setMaxResults(50)
->getQuery()->getResult();
return $this->render('admin/site_dashboard.html.twig', [
'totalReviews' => $totalReviews,
'totalAlbums' => $totalAlbums,
'totalUsers' => $totalUsers,
'recentReviews' => $recentReviews,
'recentAlbums' => $recentAlbums,
]);
}
}

View File

@@ -24,6 +24,7 @@ class AlbumController extends AbstractController
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
$artist = trim($request->query->getString('artist', ''));
$source = $request->query->getString('source', 'all'); // 'all' | 'spotify' | 'user'
// Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
$yearToRaw = trim((string) $request->query->get('year_to', ''));
@@ -53,11 +54,11 @@ class AlbumController extends AbstractController
$q = implode(' ', $parts);
}
if ($q !== '') {
if ($q !== '' || $source === 'user') {
$result = $spotifyClient->searchAlbums($q, 20);
$searchItems = $result['albums']['items'] ?? [];
$logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
if ($searchItems) {
if ($searchItems && ($source === 'all' || $source === 'spotify')) {
// Build ordered list of IDs from search results
$ids = [];
foreach ($searchItems as $it) {
@@ -84,7 +85,9 @@ class AlbumController extends AbstractController
$logger->info('Albums upserted to DB', ['upserted' => $upserted]);
if ($ids) {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
if ($source === 'spotify' || $source === 'all') {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
}
$existing = $albumsRepo->findBySpotifyIdsKeyed($ids);
$savedIds = array_keys($existing);
// Preserve Spotify order and render from DB
@@ -96,6 +99,25 @@ class AlbumController extends AbstractController
}
}
}
// User-created search results
if ($source === 'user' || $source === 'all') {
$userAlbums = $albumsRepo->searchUserAlbums($albumName, $artist, $yearFrom, $yearTo, 20);
if ($userAlbums) {
$entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums));
$userStatsByEntityId = $reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
// Merge into stats keyed by localId
foreach ($userAlbums as $ua) {
$localId = (string) $ua->getLocalId();
$entityId = (int) $ua->getId();
if (isset($userStatsByEntityId[$entityId])) {
$stats[$localId] = $userStatsByEntityId[$entityId];
}
}
$userAlbumPayloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums);
// Prepend user albums to list
$albums = array_merge($userAlbumPayloads, $albums);
}
}
}
return $this->render('album/search.html.twig', [
@@ -107,6 +129,7 @@ class AlbumController extends AbstractController
'albums' => $albums,
'stats' => $stats,
'savedIds' => $savedIds,
'source' => $source,
]);
}
@@ -123,6 +146,8 @@ class AlbumController extends AbstractController
$artistsCsv = (string) $form->get('artistsCsv')->getData();
$artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== ''));
$album->setArtists($artists);
// Normalize release date to YYYY-MM-DD
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
// Assign createdBy and generate unique localId
$u = $this->getUser();
if ($u instanceof \App\Entity\User) {
@@ -154,6 +179,14 @@ class AlbumController extends AbstractController
}
$isSaved = $albumEntity !== null;
$album = $albumEntity->toTemplateArray();
$isAdmin = $this->isGranted('ROLE_ADMIN');
$current = $this->getUser();
$isOwner = false;
if ($current instanceof \App\Entity\User) {
$isOwner = ($albumEntity->getCreatedBy()?->getId() === $current->getId());
}
$allowedEdit = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner);
$allowedDelete = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner);
$existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing);
@@ -183,6 +216,8 @@ class AlbumController extends AbstractController
'album' => $album,
'albumId' => $id,
'isSaved' => $isSaved,
'allowedEdit' => $allowedEdit,
'allowedDelete' => $allowedDelete,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
@@ -255,6 +290,75 @@ class AlbumController extends AbstractController
} while ($albumsRepo->findOneByLocalId($id) !== null);
return $id;
}
private function normalizeReleaseDate(?string $input): ?string
{
if ($input === null || trim($input) === '') {
return null;
}
$s = trim($input);
// YYYY
if (preg_match('/^\d{4}$/', $s)) {
return $s . '-01-01';
}
// YYYY-MM
if (preg_match('/^\d{4}-\d{2}$/', $s)) {
return $s . '-01';
}
// YYYY-MM-DD
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
return $s;
}
// Fallback: attempt to parse
try {
$dt = new \DateTimeImmutable($s);
return $dt->format('Y-m-d');
} catch (\Throwable) {
return null;
}
}
#[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])]
public function edit(string $id, Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response
{
$album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id);
if (!$album) {
throw $this->createNotFoundException('Album not found');
}
$isAdmin = $this->isGranted('ROLE_ADMIN');
$current = $this->getUser();
$isOwner = false;
if ($current instanceof \App\Entity\User) {
$isOwner = ($album->getCreatedBy()?->getId() === $current->getId());
}
if ($album->getSource() === 'user') {
if (!$isAdmin && !$isOwner) {
throw $this->createAccessDeniedException();
}
} else {
if (!$isAdmin) {
throw $this->createAccessDeniedException();
}
}
$form = $this->createForm(AlbumType::class, $album);
// Prepopulate artistsCsv
$form->get('artistsCsv')->setData(implode(', ', $album->getArtists()));
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$artistsCsv = (string) $form->get('artistsCsv')->getData();
$artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== ''));
$album->setArtists($artists);
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
$em->flush();
$this->addFlash('success', 'Album updated.');
return $this->redirectToRoute('album_show', ['id' => $id]);
}
return $this->render('album/edit.html.twig', [
'form' => $form->createView(),
'albumId' => $id,
]);
}
}

View File

@@ -17,13 +17,11 @@ use Symfony\Component\Routing\Attribute\Route;
#[Route('/reviews')]
class ReviewController extends AbstractController
{
// Exclusively for compat, used to route to standalone reviews page.
#[Route('', name: 'review_index', methods: ['GET'])]
public function index(ReviewRepository $reviewRepository): Response
public function index(): Response
{
$reviews = $reviewRepository->findLatest(50);
return $this->render('review/index.html.twig', [
'reviews' => $reviews,
]);
return $this->redirectToRoute('account_dashboard');
}
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
@@ -77,7 +75,7 @@ class ReviewController extends AbstractController
$em->flush();
$this->addFlash('success', 'Review deleted.');
}
return $this->redirectToRoute('review_index');
return $this->redirectToRoute('account_dashboard');
}
// fetchAlbumById no longer needed; album view handles retrieval and creation

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ChangePasswordFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('currentPassword', PasswordType::class, [
'label' => 'Current password',
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'first_options' => ['label' => 'New password'],
'second_options' => ['label' => 'Repeat new password'],
'invalid_message' => 'The password fields must match.',
'constraints' => [new Assert\Length(min: 8)],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View File

@@ -63,6 +63,38 @@ class AlbumRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult();
}
/**
* Search user-created albums by optional fields.
*
* @return list<Album>
*/
public function searchUserAlbums(?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array
{
$qb = $this->createQueryBuilder('a')
->where('a.source = :src')
->setParameter('src', 'user')
->setMaxResults($limit);
if ($albumName !== null && $albumName !== '') {
$qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%');
}
if ($artist !== null && $artist !== '') {
// artists is JSON; use text match
$qb->andWhere("CAST(a.artists as text) ILIKE :ar")->setParameter('ar', '%' . $artist . '%');
}
if ($yearFrom > 0 || $yearTo > 0) {
// releaseDate is YYYY-MM-DD; compare by year via substring
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt")
->setParameter('yf', (string) $yearFrom)
->setParameter('yt', (string) $yearTo);
} else {
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
$qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y);
}
}
return $qb->getQuery()->getResult();
}
/**
* Upsert based on a Spotify album payload.
*

View File

@@ -55,6 +55,33 @@ class ReviewRepository extends ServiceEntityRepository
}
return $out;
}
/**
* Aggregates keyed by album entity id.
*
* @param list<int> $albumEntityIds
* @return array<int,array{count:int,avg:float}>
*/
public function getAggregatesForAlbumEntityIds(array $albumEntityIds): array
{
if ($albumEntityIds === []) {
return [];
}
$rows = $this->createQueryBuilder('r')
->innerJoin('r.album', 'a')
->select('a.id AS albumEntityId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('a.id IN (:ids)')
->setParameter('ids', $albumEntityIds)
->groupBy('a.id')
->getQuery()
->getArrayResult();
$out = [];
foreach ($rows as $row) {
$avg = isset($row['avgRating']) ? round((float) $row['avgRating'], 1) : 0.0;
$out[(int) $row['albumEntityId']] = ['count' => (int) $row['cnt'], 'avg' => $avg];
}
return $out;
}
}