feat: stores user's media preferences

This commit is contained in:
2025-04-29 16:17:40 -05:00
parent 0225bead60
commit c3eaf109e3
15 changed files with 429 additions and 31 deletions

View File

@@ -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",

55
composer.lock generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250429032311 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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);
}
}

View File

@@ -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<SaveUserMediaPreferencesCommand> */
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());
}
}

View File

@@ -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,

View File

@@ -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,
) {}
}

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ class Preference
/**
* @var Collection<int, PreferenceOption>
*/
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference')]
#[ORM\OneToMany(targetEntity: PreferenceOption::class, mappedBy: 'preference', fetch: 'EAGER')]
private Collection $preferenceOptions;
public function __construct()

View File

@@ -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<int, UserPreference>
*/
#[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<int, UserPreference>
*/
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;
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\User\Framework\Entity;
use App\User\Framework\Repository\UserPreferenceRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: UserPreferenceRepository::class)]
class UserPreference
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'userPreferences')]
#[ORM\JoinColumn(nullable: false)]
private ?User $user = null;
#[ORM\ManyToOne(fetch: 'EAGER')]
#[ORM\JoinColumn(nullable: false)]
private ?Preference $preference = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $preference_value = null;
public function getId(): ?int
{
return $this->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;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\User\Framework\Repository;
use App\User\Framework\Entity\UserPreference;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserPreference>
*/
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']);
}
}

View File

@@ -59,7 +59,7 @@
<li>
<a
href="#"
href="{{ path('app_media_preferences') }}"
class="block rounded-lg px-4 py-2 text-sm font-medium text-gray-50 hover:bg-gray-100 hover:text-stone-700"
>
Preferences

View File

@@ -7,29 +7,48 @@
<twig:Card title="Choose your preferences">
<p class="text-gray-50 mb-2">Define a set of filters to apply to your media download option results.</p>
<form id="media_preferences" class="flex flex-col max-w-64" name="media_preferences" method="post" action="{{ path('app_media_preferences') }}">
{% for preference in preferences %}
<label class="text-gray-50" for="{{ preference.name }}">{{ preference.name }}</label>
{% if preference.name|lower == "language" %}
<select class="p-1.5 rounded-md mb-2" name="{{ preference.name|lower }}" id="{{ preference.name }}">
{% for key, value in languages %}
<option class="text-gray-800" value="{{ key }}">{{ value }}</option>
{% endfor %}
</select>
{% elseif preference.name|lower == "provider" %}
<select class="p-1.5 rounded-md mb-2" name="{{ preference.name|lower }}" id="{{ preference.name }}">
{% for key, value in providers %}
<option class="text-gray-800" value="{{ key }}">{{ value }}</option>
{% endfor %}
</select>
{% else %}
<select class="p-1.5 rounded-md mb-2" name="{{ preference.name|lower }}" id="{{ preference.name }}">
{% for option in preference.preferenceOptions %}
<option class="text-gray-800" value="{{ option.id }}">{{ option.name }}</option>
{% endfor %}
</select>
{% endif %}
{% endfor %}
<label class="text-gray-50" for="resolution">Resolution</label>
<select class="p-1.5 rounded-md mb-2" name="resolution" id="resolution" value="{{ userPreferences['resolution'].getPreferenceValue() }}">
{% for pref in userPreferences['resolution'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['resolution'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="codec">Codec</label>
<select class="p-1.5 rounded-md mb-2" name="codec" id="codec" value="{{ userPreferences['codec'].getPreferenceValue() }}">
{% for pref in userPreferences['codec'].getPreference().getPreferenceOptions() %}
<option class="text-gray-800"
value="{{ pref.id }}"
{{ pref.id == userPreferences['codec'].getPreferenceValue() ? "selected" }}
>{{ pref.name }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="provider">Provider</label>
<select class="p-1.5 rounded-md mb-2" name="provider" id="provider" value="{{ userPreferences['provider'].getPreferenceValue() }}">
{% for key, value in providers %}
<option class="text-gray-800"
value="{{ key }}"
{{ key == userPreferences['provider'].getPreferenceValue() ? "selected" }}
>{{ value }}</option>
{% endfor %}
</select>
<label class="text-gray-50" for="language">Language</label>
<select class="p-1.5 rounded-md mb-2" name="language" id="language" value="{{ userPreferences['language'].getPreferenceValue() }}">
{% for key, value in languages %}
<option class="text-gray-800"
value="{{ key }}"
{{ key == userPreferences['language'].getPreferenceValue() ? "selected" }}
>{{ value }}</option>
{% endfor %}
</select>
<button class="px-1.5 py-1 max-w-20 rounded-md bg-green-600 text-white" type="submit">Submit</button>
</form>
</twig:Card>