diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 20364a8..58054e0 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -45,6 +45,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: ^/monitors/ical.ics, roles: PUBLIC_ACCESS } - { path: ^/reset-password, roles: PUBLIC_ACCESS } - { path: ^/getting-started, roles: PUBLIC_ACCESS } - { path: ^/register, roles: PUBLIC_ACCESS } diff --git a/src/Base/Framework/Command/SeedDatabaseCommand.php b/src/Base/Framework/Command/SeedDatabaseCommand.php index 7b30f49..0371342 100644 --- a/src/Base/Framework/Command/SeedDatabaseCommand.php +++ b/src/Base/Framework/Command/SeedDatabaseCommand.php @@ -135,6 +135,13 @@ class SeedDatabaseCommand extends Command 'enabled' => true, 'type' => 'download' ], + [ + 'id' => 'enable_ical_up_ep', + 'name' => 'Enable a publicly available iCal calendar?', + 'description' => 'Enable a publicly accessible iCal URL for your upcoming episodes.', + 'enabled' => false, + 'type' => 'calendar' + ], ]; } } diff --git a/src/Monitor/Dto/UpcomingEpisode.php b/src/Monitor/Dto/UpcomingEpisode.php deleted file mode 100644 index 9798c21..0000000 --- a/src/Monitor/Dto/UpcomingEpisode.php +++ /dev/null @@ -1,17 +0,0 @@ - Carbon::parse($this->airDate)->format('m/d/Y'); - }, - public string $episodeTitle, - public int $episodeNumber, - ) {} -} \ No newline at end of file diff --git a/src/Monitor/Framework/Controller/CalendarController.php b/src/Monitor/Framework/Controller/CalendarController.php index 20e9626..feb9896 100644 --- a/src/Monitor/Framework/Controller/CalendarController.php +++ b/src/Monitor/Framework/Controller/CalendarController.php @@ -4,27 +4,35 @@ namespace App\Monitor\Framework\Controller; use Aimeos\Map; use App\Monitor\Framework\Repository\MonitorRepository; +use App\User\Framework\Entity\User; use Spatie\IcalendarGenerator\Components\Calendar; use Spatie\IcalendarGenerator\Components\Event; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsGranted; class CalendarController extends AbstractController { - #[Route('/monitors/ical.ics', name: 'app.monitors.ical')] - public function icalAction(MonitorRepository $monitorRepository) + #[IsGranted('PUBLIC_ACCESS')] + #[Route('/monitors/ical/{email:user}/upcoming-episodes.ics', name: 'app.monitors.ical')] + public function icalAction(MonitorRepository $monitorRepository, User $user) { - $calendar = new Calendar(); + if (false === $user->hasICalEnabled()) { + return new Response('Calendar not found.', 404); + } + + $calendar = Calendar::create() + ->name('Upcoming Episodes') + ->refreshInterval(10); + $monitors = $monitorRepository->whereAirDateNotNull(); - $events = Map::from($monitors)->map(function ($monitor) { + $calendar->event(Map::from($monitors)->map(function ($monitor) { return new Event($monitor->getTitle()) ->startsAt($monitor->getAirDate()) - ->withoutTimezone() - ->fullDay() - ; - }); - $calendar->event($events->toArray()); + ->fullDay(); + })->toArray()); + return new Response($calendar->get(), 200, [ 'Content-Type' => 'text/calendar', 'Content-Disposition' => 'attachment; filename="upcoming-episodes.ics"', diff --git a/src/Twig/Components/UpcomingEpisodes.php b/src/Twig/Components/UpcomingEpisodes.php index bb894a8..4a8fbf1 100644 --- a/src/Twig/Components/UpcomingEpisodes.php +++ b/src/Twig/Components/UpcomingEpisodes.php @@ -3,7 +3,7 @@ namespace App\Twig\Components; use Aimeos\Map; -use App\Monitor\Dto\UpcomingEpisode; +use App\Monitor\Factory\UpcomingEpisodeDto; use App\Monitor\Framework\Entity\Monitor; use App\Tmdb\Tmdb; use Carbon\CarbonImmutable; @@ -70,7 +70,7 @@ final class UpcomingEpisodes extends AbstractController } return $episodes->map(function (array $episode) use ($monitor) { - return new UpcomingEpisode( + return new UpcomingEpisodeDto( $monitor->getTitle(), $episode['air_date'], $episode['name'], diff --git a/src/User/Action/Command/SaveUserCalendarPreferencesCommand.php b/src/User/Action/Command/SaveUserCalendarPreferencesCommand.php new file mode 100644 index 0000000..31a780e --- /dev/null +++ b/src/User/Action/Command/SaveUserCalendarPreferencesCommand.php @@ -0,0 +1,13 @@ + */ +class SaveUserCalendarPreferencesCommand implements CommandInterface +{ + public function __construct( + public string $enable_ical_up_ep, + ) {} +} \ No newline at end of file diff --git a/src/User/Action/Handler/SaveUserCalendarPreferencesHandler.php b/src/User/Action/Handler/SaveUserCalendarPreferencesHandler.php new file mode 100644 index 0000000..be2b2ba --- /dev/null +++ b/src/User/Action/Handler/SaveUserCalendarPreferencesHandler.php @@ -0,0 +1,52 @@ + */ +class SaveUserCalendarPreferencesHandler implements HandlerInterface +{ + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly PreferencesRepository $preferenceRepository, + private readonly Security $token, + ) {} + + public function handle(C $command): R + { + /** @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 SaveUserDownloadPreferencesResult($user->getDownloadPreferences()); + } +} diff --git a/src/User/Action/Input/SaveUserCalendarPreferencesInput.php b/src/User/Action/Input/SaveUserCalendarPreferencesInput.php new file mode 100644 index 0000000..626a055 --- /dev/null +++ b/src/User/Action/Input/SaveUserCalendarPreferencesInput.php @@ -0,0 +1,29 @@ + */ +class SaveUserCalendarPreferencesInput implements InputInterface +{ + public function __construct( + #[SourceSecurity] + public mixed $userId, + + #[SourceRequest('enable_ical_up_ep', nullify: true)] + public bool $enableIcalUpcomingEpisodes, + ) {} + + public function toCommand(): C + { + return new SaveUserCalendarPreferencesCommand( + $this->enableIcalUpcomingEpisodes, + ); + } +} diff --git a/src/User/Framework/Controller/Web/PreferencesController.php b/src/User/Framework/Controller/Web/PreferencesController.php index 84b52d8..2e80ee4 100644 --- a/src/User/Framework/Controller/Web/PreferencesController.php +++ b/src/User/Framework/Controller/Web/PreferencesController.php @@ -6,8 +6,10 @@ namespace App\User\Framework\Controller\Web; use App\Base\Service\Broadcaster; use App\User\Action\Command\SaveUserMediaPreferencesCommand; +use App\User\Action\Handler\SaveUserCalendarPreferencesHandler; use App\User\Action\Handler\SaveUserDownloadPreferencesHandler; use App\User\Action\Handler\SaveUserMediaPreferencesHandler; +use App\User\Action\Input\SaveUserCalendarPreferencesInput; use App\User\Action\Input\SaveUserDownloadPreferencesInput; use App\User\Action\Input\SaveUserMediaPreferencesInput; use App\User\Database\CountryLanguages; @@ -33,15 +35,15 @@ class PreferencesController extends AbstractController public function mediaPreferences(): Response { $downloadPreferences = $this->getUser()->getDownloadPreferences(); + $calendarPreferences = $this->getUser()->getCalendarPreferences(); $formData = (array) UserPreferencesFactory::createFromUser($this->getUser()); $form = $this->createForm(UserMediaPreferencesForm::class, $formData); -// dd($form); - return $this->render( 'user/preferences.html.twig', [ 'downloadPreferences' => $downloadPreferences, + 'calendarPreferences' => $calendarPreferences, 'preferences_form' => $form, ] ); @@ -54,8 +56,8 @@ class PreferencesController extends AbstractController ): Response { $downloadPreferences = $this->getUser()->getDownloadPreferences(); + $calendarPreferences = $this->getUser()->getCalendarPreferences(); $form = $this->createForm(UserMediaPreferencesForm::class); - $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { @@ -69,6 +71,7 @@ class PreferencesController extends AbstractController 'user/preferences.html.twig', [ 'downloadPreferences' => $downloadPreferences, + 'calendarPreferences' => $calendarPreferences, 'preferences_form' => $form, ] ); @@ -81,6 +84,7 @@ class PreferencesController extends AbstractController ): Response { $downloadPreferences = $this->getUser()->getDownloadPreferences(); + $calendarPreferences = $this->getUser()->getCalendarPreferences(); $formData = (array) UserPreferencesFactory::createFromUser($this->getUser()); $form = $this->createForm(UserMediaPreferencesForm::class, $formData); @@ -95,6 +99,34 @@ class PreferencesController extends AbstractController 'user/preferences.html.twig', [ 'downloadPreferences' => $downloadPreferences, + 'calendarPreferences' => $calendarPreferences, + 'preferences_form' => $form, + ] + ); + } + + #[Route('/user/preferences/calendar', 'app.save.calendar-preferences', methods: ['POST'])] + public function saveCalendarPreferences( + SaveUserCalendarPreferencesInput $input, + SaveUserCalendarPreferencesHandler $handler, + ): Response + { + $calendarPreferences = $this->getUser()->getCalendarPreferences(); + $formData = (array) UserPreferencesFactory::createFromUser($this->getUser()); + $form = $this->createForm(UserMediaPreferencesForm::class, $formData); + + $handler->handle($input->toCommand()); + + $this->broadcaster->alert( + title: 'Success', + message: 'Your calendar preferences have been saved.' + ); + + return $this->render( + 'user/preferences.html.twig', + [ + 'downloadPreferences' => $this->getUser()->getDownloadPreferences(), + 'calendarPreferences' => $calendarPreferences, 'preferences_form' => $form, ] ); diff --git a/src/User/Framework/Entity/User.php b/src/User/Framework/Entity/User.php index eb35837..680ac2a 100644 --- a/src/User/Framework/Entity/User.php +++ b/src/User/Framework/Entity/User.php @@ -327,4 +327,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface } return []; } + + public function getCalendarPreferences(): array + { + return Map::from($this->userPreferences) + ->rekey(fn(UserPreference $userPreference) => $userPreference->getPreference()->getId()) + ->filter(fn(UserPreference $userPreference) => $userPreference->getPreference()->getType() === 'calendar') + ->toArray() + ; + } + + public function hasICalEnabled(): bool + { + return $this->hasUserPreference('enable_ical_up_ep') && + (bool) $this->getUserPreference('enable_ical_up_ep')->getPreferenceValue() === true; + } } diff --git a/templates/monitor/upcoming-episodes.html.twig b/templates/monitor/upcoming-episodes.html.twig index a59e5f4..8a446be 100644 --- a/templates/monitor/upcoming-episodes.html.twig +++ b/templates/monitor/upcoming-episodes.html.twig @@ -39,7 +39,10 @@ const calendarEl = document.getElementById('calendar'); const calendar = new FullCalendar.Calendar(calendarEl, { initialView: getView(), - events: data['episodes'], + events: { + url: '{{ path('app.monitors.ical') }}', + format: 'ics' + }, windowResize: function(arg) { this.changeView(getView()); } diff --git a/templates/user/preferences.html.twig b/templates/user/preferences.html.twig index 8c69958..3efcb3d 100644 --- a/templates/user/preferences.html.twig +++ b/templates/user/preferences.html.twig @@ -36,4 +36,25 @@ + +
Manage your Upcoming Episodes calendar.
+ +