diff --git a/composer.json b/composer.json index e5d311f..f327298 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "ext-ctype": "*", "ext-iconv": "*", "1tomany/rich-bundle": "^1.8", + "aimeos/map": "^3.12", "doctrine/dbal": "^3", "doctrine/doctrine-bundle": "^2.14", "doctrine/doctrine-migrations-bundle": "^3.4", diff --git a/composer.lock b/composer.lock index a3994ae..b0f50c7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1b5e4263ced36558032c50f1d8f7f4fb", + "content-hash": "e0322cfec0023bee458190f38b4cab8c", "packages": [ { "name": "1tomany/data-uri", @@ -122,6 +122,59 @@ }, "time": "2025-04-14T20:49:47+00:00" }, + { + "name": "aimeos/map", + "version": "3.12.0", + "source": { + "type": "git", + "url": "https://github.com/aimeos/map.git", + "reference": "3cb4aff05a92cc47a45a7488094b945370b83381" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/aimeos/map/zipball/3cb4aff05a92cc47a45a7488094b945370b83381", + "reference": "3cb4aff05a92cc47a45a7488094b945370b83381", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-pcre": "*", + "php": "^7.1||^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "~2.0", + "phpunit/phpunit": "~7.0||~8.0||~9.0", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/function.php" + ], + "psr-4": { + "Aimeos\\": "src/" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Easy and elegant handling of PHP arrays as array-like collection objects similar to jQuery and Laravel Collections", + "keywords": [ + "array", + "collection", + "map", + "php" + ], + "support": { + "issues": "https://github.com/aimeos/map/issues", + "source": "https://github.com/aimeos/map/tree/3.12.0" + }, + "time": "2025-03-05T09:16:18+00:00" + }, { "name": "composer/semver", "version": "3.4.3", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 44d8a8a..b29c265 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -35,6 +35,7 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: + - { path: ^/register, roles: PUBLIC_ACCESS } - { path: ^/login, roles: PUBLIC_ACCESS } - { path: ^/, roles: ROLE_USER } # Or ROLE_ADMIN, ROLE_SUPER_ADMIN, diff --git a/migrations/Version20250429032311.php b/migrations/Version20250429032311.php new file mode 100644 index 0000000..11594c5 --- /dev/null +++ b/migrations/Version20250429032311.php @@ -0,0 +1,47 @@ +addSql(<<<'SQL' + CREATE TABLE user_preference (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, preference_id VARCHAR(255) NOT NULL, preference_value VARCHAR(255) DEFAULT NULL, INDEX IDX_FA0E76BFA76ED395 (user_id), INDEX IDX_FA0E76BFD81022C0 (preference_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFA76ED395 FOREIGN KEY (user_id) REFERENCES user (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_preference ADD CONSTRAINT FK_FA0E76BFD81022C0 FOREIGN KEY (preference_id) REFERENCES preference (id) + SQL); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql(<<<'SQL' + ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFA76ED395 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE user_preference DROP FOREIGN KEY FK_FA0E76BFD81022C0 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE user_preference + SQL); + } +} diff --git a/src/User/Action/Handler/SaveUserMediaPreferencesHandler.php b/src/User/Action/Handler/SaveUserMediaPreferencesHandler.php index b39b6b8..f8f12db 100644 --- a/src/User/Action/Handler/SaveUserMediaPreferencesHandler.php +++ b/src/User/Action/Handler/SaveUserMediaPreferencesHandler.php @@ -4,15 +4,47 @@ namespace App\User\Action\Handler; use App\User\Action\Command\SaveUserMediaPreferencesCommand; use App\User\Action\Result\SaveUserMediaPreferencesResult; +use App\User\Framework\Entity\User; +use App\User\Framework\Entity\UserPreference; +use App\User\Framework\Repository\PreferencesRepository; +use Doctrine\ORM\EntityManagerInterface; use OneToMany\RichBundle\Contract\CommandInterface as C; use OneToMany\RichBundle\Contract\HandlerInterface; use OneToMany\RichBundle\Contract\ResultInterface as R; +use Symfony\Bundle\SecurityBundle\Security; /** @implements HandlerInterface */ class SaveUserMediaPreferencesHandler implements HandlerInterface { + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly PreferencesRepository $preferenceRepository, + private readonly Security $token, + ) {} + public function handle(C $command): R { - return new SaveUserMediaPreferencesResult('Success'); + /** @var User $user */ + $user = $this->token->getUser(); + + foreach ($command as $preference => $value) { + if ($user->hasUserPreference($preference)) { + $user->updateUserPreference($preference, $value); + $this->entityManager->flush(); + continue; + } + + $preference = $this->preferenceRepository->find($preference); + $user->addUserPreference( + (new UserPreference()) + ->setUser($user) + ->setPreference($preference) + ->setPreferenceValue($value) + ); + } + + $this->entityManager->flush(); + + return new SaveUserMediaPreferencesResult($user->getUserPreferences()); } } diff --git a/src/User/Action/Input/SaveUserMediaPreferencesInput.php b/src/User/Action/Input/SaveUserMediaPreferencesInput.php index f6e80c9..5c9b979 100644 --- a/src/User/Action/Input/SaveUserMediaPreferencesInput.php +++ b/src/User/Action/Input/SaveUserMediaPreferencesInput.php @@ -4,6 +4,7 @@ namespace App\User\Action\Input; use App\User\Action\Command\SaveUserMediaPreferencesCommand; use OneToMany\RichBundle\Attribute\SourceRequest; +use OneToMany\RichBundle\Attribute\SourceSecurity; use OneToMany\RichBundle\Contract\CommandInterface as C; use OneToMany\RichBundle\Contract\InputInterface; @@ -11,6 +12,9 @@ use OneToMany\RichBundle\Contract\InputInterface; class SaveUserMediaPreferencesInput implements InputInterface { public function __construct( + #[SourceSecurity] + public mixed $userId, + #[SourceRequest('resolution')] public string $resolution, diff --git a/src/User/Action/Result/SaveUserMediaPreferencesResult.php b/src/User/Action/Result/SaveUserMediaPreferencesResult.php index a8b36ab..ae8d752 100644 --- a/src/User/Action/Result/SaveUserMediaPreferencesResult.php +++ b/src/User/Action/Result/SaveUserMediaPreferencesResult.php @@ -2,12 +2,13 @@ namespace App\User\Action\Result; +use Doctrine\Common\Collections\Collection; use OneToMany\RichBundle\Contract\ResultInterface; /** @implements ResultInterface */ class SaveUserMediaPreferencesResult implements ResultInterface { public function __construct( - public string $status, + public Collection $userPreferences, ) {} } \ No newline at end of file diff --git a/src/User/Framework/Controller/Web/PreferencesController.php b/src/User/Framework/Controller/Web/PreferencesController.php index 0163a57..95d50aa 100644 --- a/src/User/Framework/Controller/Web/PreferencesController.php +++ b/src/User/Framework/Controller/Web/PreferencesController.php @@ -4,10 +4,15 @@ declare(strict_types=1); namespace App\User\Framework\Controller\Web; +use Aimeos\Map; +use App\User\Action\Handler\SaveUserMediaPreferencesHandler; use App\User\Action\Input\SaveUserMediaPreferencesInput; +use App\User\Framework\Entity\User; +use App\User\Framework\Entity\UserPreference; use App\User\Framework\Repository\PreferencesRepository; use App\Util\CountryCodes; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -15,16 +20,29 @@ class PreferencesController extends AbstractController { public function __construct( private readonly PreferencesRepository $preferencesRepository, + private readonly SaveUserMediaPreferencesHandler $saveUserMediaPreferencesHandler, + private readonly Security $security, ) {} #[Route('/media/preferences', 'app_media_preferences', methods: ['GET'])] public function mediaPreferences(): Response { + $enabledPreferences = $this->preferencesRepository->findEnabled(); + + if ($this->security->getUser()->getUserPreferences()->count() !== count($enabledPreferences)) { + $this->setUserPreferences($this->security->getUser(), $enabledPreferences); + } + + $userPreferences = $this->security->getUser()->getUserPreferences()->toArray(); + $userPreferences = Map::from($userPreferences) + ->rekey(fn($preference) => $preference->getPreference()->getId()); + return $this->render( 'user/preferences.html.twig', [ 'preferences' => $this->preferencesRepository->findEnabled(), 'languages' => CountryCodes::$countries, 'providers' => ['test' => 'Test'], + 'userPreferences' => $userPreferences->toArray(), ] ); } @@ -34,12 +52,31 @@ class PreferencesController extends AbstractController SaveUserMediaPreferencesInput $input, ): Response { - dd($input); + $userPreferences = $this->saveUserMediaPreferencesHandler->handle($input->toCommand())->userPreferences; + $userPreferences = Map::from($userPreferences)->rekey(fn($preference) => $preference->getPreference()->getId()); + return $this->render( 'user/preferences.html.twig', [ 'preferences' => $this->preferencesRepository->findEnabled(), + 'languages' => CountryCodes::$countries, + 'providers' => ['test' => 'Test'], + 'userPreferences' => $userPreferences->toArray(), ] ); } + + private function setUserPreferences(User $user, array $preferences): void + { + foreach ($preferences as $preference) { + if (false === $user->hasUserPreference($preference->getId())) { + $user->addUserPreference((new UserPreference()) + ->setUser($user) + ->setPreference($preference) + ->setPreferenceValue(null) + ); + } + } + $this->preferencesRepository->getEntityManager()->flush(); + } } diff --git a/src/User/Framework/Controller/Web/RegistrationController.php b/src/User/Framework/Controller/Web/RegistrationController.php index 67276ec..5cadc2b 100644 --- a/src/User/Framework/Controller/Web/RegistrationController.php +++ b/src/User/Framework/Controller/Web/RegistrationController.php @@ -3,7 +3,9 @@ namespace App\User\Framework\Controller\Web; use App\User\Framework\Entity\User; +use App\User\Framework\Entity\UserPreference; use App\User\Framework\Form\RegistrationFormType; +use App\User\Framework\Repository\PreferencesRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -14,8 +16,12 @@ use Symfony\Component\Routing\Attribute\Route; class RegistrationController extends AbstractController { #[Route('/register', name: 'app_register')] - public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response - { + public function register( + Request $request, + UserPasswordHasherInterface $userPasswordHasher, + EntityManagerInterface $entityManager, + PreferencesRepository $preferencesRepository, + ): Response { $user = new User(); $form = $this->createForm(RegistrationFormType::class, $user); $form->handleRequest($request); @@ -30,7 +36,9 @@ class RegistrationController extends AbstractController $entityManager->persist($user); $entityManager->flush(); - // do anything else you need here, like send an email + $this->setUserPreferences($user, $preferencesRepository->findEnabled()); + + $preferencesRepository->getEntityManager()->flush(); return $this->redirectToRoute('app_index'); } @@ -39,4 +47,15 @@ class RegistrationController extends AbstractController 'registrationForm' => $form, ]); } + + private function setUserPreferences(User $user, array $preferences): void + { + foreach ($preferences as $preference) { + $user->addUserPreference((new UserPreference()) + ->setUser($user) + ->setPreference($preference) + ->setPreferenceValue(null) + ); + } + } } diff --git a/src/User/Framework/Entity/Preference.php b/src/User/Framework/Entity/Preference.php index ed5bf0b..f07421e 100644 --- a/src/User/Framework/Entity/Preference.php +++ b/src/User/Framework/Entity/Preference.php @@ -26,7 +26,7 @@ class Preference /** * @var Collection */ - #[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference')] + #[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')] private Collection $preferenceOptions; public function __construct() diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index b617492..ae5ea0a 100644 --- a/src/User/Framework/Entity/User.php +++ b/src/User/Framework/Entity/User.php @@ -2,13 +2,17 @@ namespace App\User\Framework\Entity; +use App\User\Framework\Repository\PreferencesRepository; use App\User\Framework\Repository\UserRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\HasLifecycleCallbacks] #[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')] class User implements UserInterface, PasswordAuthenticatedUserInterface { @@ -29,6 +33,17 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: 'string')] private string $password; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: UserPreference::class, mappedBy: 'user', cascade: ['persist', 'remove'])] + private Collection $userPreferences; + + public function __construct() + { + $this->userPreferences = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -110,4 +125,63 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } + + /** + * @return Collection + */ + public function getUserPreferences(): Collection + { + return $this->userPreferences; + } + + public function getUserPreference(string $preferenceName) + { + foreach ($this->userPreferences as $userPreference) { + if ($userPreference->getPreference()->getName() === $preferenceName) { + return $userPreference->getPreference(); + } + } + } + + public function hasUserPreference(string $preferenceName): bool + { + foreach ($this->userPreferences as $userPreference) { + if ($userPreference->getPreference()->getId() === $preferenceName) { + return true; + } + } + return false; + } + + public function updateUserPreference(string $preferenceName, mixed $preferenceValue): static + { + foreach ($this->userPreferences as $userPreference) { + if ($userPreference->getPreference()->getId() === $preferenceName) { + $userPreference->setPreferenceValue($preferenceValue); + } + } + return $this; + } + + public function addUserPreference(UserPreference $userPreference): static + { + if (!$this->userPreferences->contains($userPreference)) { + $this->userPreferences->add($userPreference); + $userPreference->setUser($this); + } + + return $this; + } + + public function removeUserPreference(UserPreference $userPreference): static + { + if ($this->userPreferences->removeElement($userPreference)) { + // set the owning side to null (unless already changed) + if ($userPreference->getUser() === $this) { + $userPreference->setUser(null); + } + } + + return $this; + } } diff --git a/src/User/Framework/Entity/UserPreference.php b/src/User/Framework/Entity/UserPreference.php new file mode 100644 index 0000000..472aa74 --- /dev/null +++ b/src/User/Framework/Entity/UserPreference.php @@ -0,0 +1,67 @@ +id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getPreference(): ?Preference + { + return $this->preference; + } + + public function setPreference(?Preference $preference): static + { + $this->preference = $preference; + + return $this; + } + + public function getPreferenceValue(): ?string + { + return $this->preference_value; + } + + public function setPreferenceValue(?string $preference_value): static + { + $this->preference_value = $preference_value; + + return $this; + } +} diff --git a/src/User/Framework/Repository/UserPreferenceRepository.php b/src/User/Framework/Repository/UserPreferenceRepository.php new file mode 100644 index 0000000..482a1a7 --- /dev/null +++ b/src/User/Framework/Repository/UserPreferenceRepository.php @@ -0,0 +1,43 @@ + + */ +class UserPreferenceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, UserPreference::class); + } + + public function findUserPreferences(int $userId): array + { + return $this->findBy(['userId' => $userId]); + } + + public function findUserResolution(int $userId): ?UserPreference + { + return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']); + } + + public function findUserCodec(int $userId): ?UserPreference + { + return $this->findOneBy(['userId' => $userId, 'preference' => 'codec']); + } + + public function findUserLanguage(int $userId): ?UserPreference + { + return $this->findOneBy(['userId' => $userId, 'preference' => 'language']); + } + + public function findUserProvider(int $userId): ?UserPreference + { + return $this->findOneBy(['userId' => $userId, 'preference' => 'resolution']); + } +} diff --git a/templates/components/NavBar.html.twig b/templates/components/NavBar.html.twig index 4c5c8a3..ae12943 100644 --- a/templates/components/NavBar.html.twig +++ b/templates/components/NavBar.html.twig @@ -59,7 +59,7 @@
  • Preferences diff --git a/templates/user/preferences.html.twig b/templates/user/preferences.html.twig index e95b0eb..d8051d0 100644 --- a/templates/user/preferences.html.twig +++ b/templates/user/preferences.html.twig @@ -7,29 +7,48 @@

    Define a set of filters to apply to your media download option results.

    - {% for preference in preferences %} - - {% if preference.name|lower == "language" %} - - {% elseif preference.name|lower == "provider" %} - - {% else %} - - {% endif %} - {% endfor %} + + + + + + + + + + + + +