From 42bfe60fa81b2667af2c408c6f3d4423d012981c Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Sat, 27 Jun 2026 11:23:34 +0300 Subject: [PATCH 1/8] Implement trip notifications and read state --- Migrations/Version20260626100000.php | 30 +++ assets/js/landing/landing.js | 6 +- .../components/_components.dashboard.css | 27 +- .../tailwindcss/objects/_objects.rounded.css | 2 + fixtures/preferences.yml | 9 + src/Controller/TripController.php | 19 ++ src/Entity/MemberTripRead.php | 56 +++++ src/Entity/Preference.php | 3 + src/Form/SignupFormFinalizeType.php | 11 + src/Model/SignupModel.php | 12 + src/Model/TripModel.php | 64 +++++ src/Repository/SubtripRepository.php | 92 ++++++- src/Service/Mailer.php | 22 ++ .../emails/trip.notification.new.html.twig | 13 + templates/signup/finalize.html.twig | 2 +- templates/trip/landing.html.twig | 18 +- templates/trip/leg.html.twig | 8 +- tests/Model/MatchingHostsRepository.php | 19 ++ tests/Model/NotifyingTripModel.php | 22 ++ tests/Model/TripModelNotificationTest.php | 234 ++++++++++++++++++ tests/Model/TripModelTestCase.php | 4 +- translations/missing/general.yaml | 14 +- translations/missing/preference.yaml | 18 ++ translations/missing/trips.yaml | 15 ++ 24 files changed, 701 insertions(+), 19 deletions(-) create mode 100644 Migrations/Version20260626100000.php create mode 100644 src/Entity/MemberTripRead.php create mode 100644 templates/emails/trip.notification.new.html.twig create mode 100644 tests/Model/MatchingHostsRepository.php create mode 100644 tests/Model/NotifyingTripModel.php create mode 100644 tests/Model/TripModelNotificationTest.php diff --git a/Migrations/Version20260626100000.php b/Migrations/Version20260626100000.php new file mode 100644 index 0000000000..117e31159f --- /dev/null +++ b/Migrations/Version20260626100000.php @@ -0,0 +1,30 @@ +addSql('CREATE TABLE IF NOT EXISTS member_trips_read (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, trip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_7F6D7D317597D3FE (member_id), INDEX IDX_7F6D7D31A5BC2E0E (trip_id), UNIQUE INDEX member_trip_read_unique (member_id, trip_id), CONSTRAINT FK_7F6D7D317597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_7F6D7D31A5BC2E0E FOREIGN KEY (trip_id) REFERENCES trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql("INSERT INTO preferences (position, codeName, codeDescription, Description, created, DefaultValue, PossibleValues, Status) SELECT 65, 'TripsNotifications', 'trips.notifications', 'How often the member wants notifications for trips in their area', NOW(), 'No', 'No;Yes', 'Normal' WHERE NOT EXISTS (SELECT 1 FROM preferences WHERE codeName = 'TripsNotifications')"); + } + + #[Override] + public function down(Schema $schema): void + { + $this->addSql("DELETE mp FROM memberspreferences mp INNER JOIN preferences p ON p.id = mp.IdPreference WHERE p.codeName = 'TripsNotifications'"); + $this->addSql("DELETE FROM preferences WHERE codeName = 'TripsNotifications'"); + $this->addSql('DROP TABLE IF EXISTS member_trips_read'); + } +} diff --git a/assets/js/landing/landing.js b/assets/js/landing/landing.js index da20926011..80a876990e 100644 --- a/assets/js/landing/landing.js +++ b/assets/js/landing/landing.js @@ -28,6 +28,10 @@ document.addEventListener('DOMContentLoaded', function() { tabElements.forEach(tab => { tab.addEventListener('show.bs.tab', Home.onTabChange); }); + const tabFromHash = Array.from(tabElements).find(tab => tab.getAttribute('href') === window.location.hash); + if (tabFromHash) { + window.bootstrap.Tab.getOrCreateInstance(tabFromHash).show(); + } const allRadio = document.getElementById('all'); if (allRadio) { @@ -239,4 +243,4 @@ var Home = { }); }); } -}; \ No newline at end of file +}; diff --git a/assets/tailwindcss/components/_components.dashboard.css b/assets/tailwindcss/components/_components.dashboard.css index 794601feeb..616d41e533 100644 --- a/assets/tailwindcss/components/_components.dashboard.css +++ b/assets/tailwindcss/components/_components.dashboard.css @@ -22,6 +22,31 @@ overflow: hidden; } +.c-dashboard__trip-actions { + display: flex; + flex-wrap: wrap; + align-content: center; + justify-content: center; + gap: 4px; + width: 68px; + margin-left: auto; + flex-shrink: 0; +} + +.c-dashboard__trip-actions form { + display: flex; + margin: 0; +} + +@media (max-width: 480px) { + .c-dashboard__item--trip { + height: auto; + min-height: 80px; + padding-top: 8px; + padding-bottom: 8px; + } +} + .c-dashboard__message-item { display: grid; align-items: center; @@ -33,5 +58,3 @@ overflow: hidden; grid-template-columns: 80px auto 16px; } - - diff --git a/assets/tailwindcss/objects/_objects.rounded.css b/assets/tailwindcss/objects/_objects.rounded.css index ed8ccc69fc..98395b0a03 100644 --- a/assets/tailwindcss/objects/_objects.rounded.css +++ b/assets/tailwindcss/objects/_objects.rounded.css @@ -14,6 +14,8 @@ justify-content: center; width: 32px; height: 32px; + padding: 0; + border: 0; border-radius: 100%; background-color: var(--u-color-bewelcome); flex-shrink: 0; diff --git a/fixtures/preferences.yml b/fixtures/preferences.yml index 4735448a6d..44ce9a5800 100644 --- a/fixtures/preferences.yml +++ b/fixtures/preferences.yml @@ -215,3 +215,12 @@ App\Entity\Preference: DefaultValue: No PossibleValues: No;Yes Status: 'Normal' + TRIP_NOTIFICATIONS: + position: 65 + codename: 'TripsNotifications' + codeDescription: 'trips.notifications' + description: 'How often the member wants notifications for trips in their area' + created: + DefaultValue: No + PossibleValues: No;Yes + Status: 'Normal' diff --git a/src/Controller/TripController.php b/src/Controller/TripController.php index 19820dc1bc..03833b9efe 100644 --- a/src/Controller/TripController.php +++ b/src/Controller/TripController.php @@ -100,6 +100,7 @@ public function create(Request $request, EntityManagerInterface $entityManager): $entityManager->persist($trip); $entityManager->flush(); + $this->tripModel->notifyHostsAboutTrip($trip); $this->addTranslatedFlash('success', 'trip.created'); @@ -179,6 +180,24 @@ public function copy(Trip $trip): Response return $this->redirectToRoute('trip_edit', ['id' => $newTrip->getId()]); } + #[Route(path: '/trip/{id}/read', name: 'trip_mark_read', requirements: ['id' => '\d+'], methods: ['POST'])] + public function markRead(Request $request, Trip $trip): RedirectResponse + { + if (!$this->isCsrfTokenValid('trip_read' . $trip->getId(), $request->request->get('_token'))) { + throw $this->createAccessDeniedException(); + } + + /** @var Member $member */ + $member = $this->getUser(); + $this->tripModel->markTripAsRead($member, $trip); + + if ('homepage' === $request->request->get('redirectTo')) { + return $this->redirectToRoute('homepage', ['_fragment' => 'visitors']); + } + + return $this->redirectToRoute('visitors'); + } + /** * Show all trip legs that are in the vicinity of a member. */ diff --git a/src/Entity/MemberTripRead.php b/src/Entity/MemberTripRead.php new file mode 100644 index 0000000000..b1033eb9e4 --- /dev/null +++ b/src/Entity/MemberTripRead.php @@ -0,0 +1,56 @@ +member = $member; + $this->trip = $trip; + } + + public function getMember(): Member + { + return $this->member; + } + + public function getTrip(): Trip + { + return $this->trip; + } + + public function getCreated(): DateTime + { + return $this->created; + } + + #[ORM\PrePersist] + public function onPrePersist(): void + { + $this->created = new DateTime('now'); + } +} diff --git a/src/Entity/Preference.php b/src/Entity/Preference.php index 4572492fdf..098c909023 100644 --- a/src/Entity/Preference.php +++ b/src/Entity/Preference.php @@ -34,6 +34,9 @@ class Preference public const READ_COMMENT_GUIDELINES = 'ReadCommentGuidelines'; public const FORUM_ORDER_LIST_ASC = 'PreferenceForumOrderListAsc'; public const TRIPS_VICINITY_RADIUS = 'TripLegsVicinityRadius'; + public const TRIP_NOTIFICATIONS = 'TripsNotifications'; + public const TRIP_NOTIFICATIONS_NEVER = 'No'; + public const TRIP_NOTIFICATIONS_IMMEDIATELY = 'Yes'; public const SHOW_PROFILE_VISITORS = 'PreferenceShowProfileVisits'; public const SHOW_FORUMS_POSTS = 'MyForumPostsPagePublic'; public const SEARCH_OPTIONS = 'SearchOptions'; diff --git a/src/Form/SignupFormFinalizeType.php b/src/Form/SignupFormFinalizeType.php index 435bd32b8e..a41517d834 100644 --- a/src/Form/SignupFormFinalizeType.php +++ b/src/Form/SignupFormFinalizeType.php @@ -3,6 +3,7 @@ namespace App\Form; use App\Doctrine\AccommodationType; +use App\Entity\Preference; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; @@ -143,6 +144,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'signup.label.local_events', 'required' => false, ]) + ->add('trips_notifications', ChoiceType::class, [ + 'label' => 'label.trips_notifications', + 'help' => 'help.trips_notifications', + 'choices' => [ + 'trips.no' => Preference::TRIP_NOTIFICATIONS_NEVER, + 'trips.yes' => Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + ], + 'data' => Preference::TRIP_NOTIFICATIONS_NEVER, + 'required' => true, + ]) ; } diff --git a/src/Model/SignupModel.php b/src/Model/SignupModel.php index 30d82139fb..8fbcf1a32f 100644 --- a/src/Model/SignupModel.php +++ b/src/Model/SignupModel.php @@ -176,6 +176,18 @@ public function updateMember(Member $member, array $data): void ; $this->entityManager->persist($localEventsPreference); + $preference = $this->entityManager->getRepository(Preference::class)->findOneBy([ + 'codename' => Preference::TRIP_NOTIFICATIONS, + ]); + + $tripNotificationsPreference = new MemberPreference(); + $tripNotificationsPreference + ->setMember($member) + ->setPreference($preference) + ->setValue($data['trips_notifications']) + ; + $this->entityManager->persist($tripNotificationsPreference); + $this->entityManager->flush(); } diff --git a/src/Model/TripModel.php b/src/Model/TripModel.php index a2b5149af6..ceeec3b991 100644 --- a/src/Model/TripModel.php +++ b/src/Model/TripModel.php @@ -3,9 +3,14 @@ namespace App\Model; use App\Entity\Member; +use App\Entity\MemberTripRead; +use App\Entity\Notification; use App\Entity\Preference; +use App\Entity\Subtrip; use App\Entity\Trip; +use App\Repository\SubtripRepository; use App\Repository\TripRepository; +use App\Service\Mailer; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Doctrine\ORM\QueryAdapter; @@ -17,6 +22,7 @@ class TripModel public function __construct( private readonly EntityManagerInterface $entityManager, + private readonly ?Mailer $mailer = null, ) { } @@ -134,6 +140,64 @@ public function hideTrip(Trip $trip): void $this->entityManager->flush(); } + public function markTripAsRead(Member $member, Trip $trip): void + { + $repository = $this->entityManager->getRepository(MemberTripRead::class); + $read = $repository->findOneBy(['member' => $member, 'trip' => $trip]); + $changed = false; + + if (null === $read) { + $this->entityManager->persist(new MemberTripRead($member, $trip)); + $changed = true; + } + + $notificationRepository = $this->entityManager->getRepository(Notification::class); + $notifications = $notificationRepository->findBy([ + 'member' => $member, + 'type' => 'trip', + 'link' => '/trip/' . $trip->getId(), + 'checked' => false, + ]); + + foreach ($notifications as $notification) { + $notification->setChecked(true); + $changed = true; + } + + if ($changed) { + $this->entityManager->flush(); + } + } + + public function notifyHostsAboutTrip(Trip $trip): void + { + /** @var SubtripRepository $repository */ + $repository = $this->entityManager->getRepository(Subtrip::class); + $hosts = $repository->getMembersToNotifyAboutTrip($trip); + + if ([] === $hosts) { + return; + } + + foreach ($hosts as $host) { + $notification = new Notification(); + $notification + ->setMember($host) + ->setRelMember($trip->getCreator()) + ->setType('trip') + ->setLink('/trip/' . $trip->getId()) + ->setWordcode('trip.notification.new') + ->setChecked(false) + ->setSendmail(null !== $this->mailer) + ; + + $this->entityManager->persist($notification); + $this->mailer?->sendTripNotificationEmail($host, $trip); + } + + $this->entityManager->flush(); + } + public function hasTripExpired(Trip $trip) { return $trip->isExpired(); diff --git a/src/Repository/SubtripRepository.php b/src/Repository/SubtripRepository.php index 24a65972fb..bb34251f1c 100644 --- a/src/Repository/SubtripRepository.php +++ b/src/Repository/SubtripRepository.php @@ -2,11 +2,17 @@ namespace App\Repository; +use App\Doctrine\MemberStatusType; use App\Doctrine\SubtripOptionsType; use App\Entity\Member; +use App\Entity\MemberTripRead; +use App\Entity\Preference; +use App\Entity\Subtrip; +use App\Entity\Trip; use Carbon\CarbonImmutable; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; +use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; /** @@ -59,6 +65,88 @@ public function getLegsInAreaQuery(Member $member, int $radius = 20, int $durati ->getQuery(); } + public function getMembersToNotifyAboutTrip(Trip $trip, int $duration = 3): array + { + $preferenceRepository = $this->getEntityManager()->getRepository(Preference::class); + $tripNotificationPreference = $preferenceRepository->findOneBy([ + 'codename' => Preference::TRIP_NOTIFICATIONS, + ]); + $radiusPreference = $preferenceRepository->findOneBy([ + 'codename' => Preference::TRIPS_VICINITY_RADIUS, + ]); + + if (null === $tripNotificationPreference || null === $radiusPreference) { + return []; + } + + $now = CarbonImmutable::today(); + $durationMonthsAhead = $now->addMonths($duration); + + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb + ->select('DISTINCT host') + ->from(Member::class, 'host') + ->from(Subtrip::class, 's') + ->join('host.addresses', 'a', Join::WITH, 'a.active = true') + ->leftJoin( + 'host.preferences', + 'tripNotifications', + Join::WITH, + 'tripNotifications.preference = :tripNotificationPreference' + ) + ->leftJoin( + 'host.preferences', + 'radiusPreference', + Join::WITH, + 'radiusPreference.preference = :radiusPreference' + ) + ->join('s.location', 'l') + ->join('s.trip', 't') + ->join('t.creator', 'creator') + ->where('s.trip = :trip') + ->andWhere('s.arrival >= :now') + ->andWhere('s.arrival <= :durationMonthsAhead') + ->andWhere($qb->expr()->notLike('s.options', ':privateOption')) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->isNull('s.invitedBy'), + $qb->expr()->eq('s.invitedBy', 'host'), + $qb->expr()->in('s.options', ':meetLocalsOptions') + ) + ) + ->andWhere($qb->expr()->in('host.status', ':activeStatuses')) + ->andWhere($qb->expr()->in('creator.status', ':activeStatuses')) + ->andWhere('host <> creator') + ->andWhere('t.countOfTravellers <= host.maxGuests') + ->andWhere($qb->expr()->isNull('t.deleted')) + ->andWhere( + 'COALESCE(tripNotifications.value, :defaultNotification) = :immediately' + ) + ->andWhere( + 'GeoDistance(a.latitude, a.longitude, l.latitude, l.longitude) <= COALESCE(radiusPreference.value, :defaultRadius)' + ) + ->andWhere( + $qb->expr()->orX( + $qb->expr()->lte('GeoDistance(a.latitude, a.longitude, l.latitude, l.longitude)', 't.invitationRadius'), + $qb->expr()->eq('s.location', 'a.location') + ) + ) + ->setParameter('trip', $trip) + ->setParameter('now', $now) + ->setParameter('durationMonthsAhead', $durationMonthsAhead) + ->setParameter('privateOption', '%' . SubtripOptionsType::PRIVATE . '%') + ->setParameter('meetLocalsOptions', [SubtripOptionsType::MEET_LOCALS]) + ->setParameter('activeStatuses', [MemberStatusType::ACTIVE, MemberStatusType::OUT_OF_REMIND]) + ->setParameter('tripNotificationPreference', $tripNotificationPreference) + ->setParameter('radiusPreference', $radiusPreference) + ->setParameter('defaultNotification', Preference::TRIP_NOTIFICATIONS_NEVER) + ->setParameter('immediately', Preference::TRIP_NOTIFICATIONS_IMMEDIATELY) + ->setParameter('defaultRadius', $radiusPreference->getDefaultValue()) + ; + + return $qb->getQuery()->getResult(); + } + private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $duration): QueryBuilder { $address = $member->getActiveAddress(); @@ -66,7 +154,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d $latitude = false === $address ? null : $address->getLatitude(); $longitude = false === $address ? null : $address->getLongitude(); - $now = new CarbonImmutable(); + $now = CarbonImmutable::today(); $durationMonthsAhead = $now->addMonths($duration); $qb = $this->createQueryBuilder('s'); @@ -74,6 +162,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d ->join('s.location', 'l') ->join('s.trip', 't') ->join('t.creator', 'm') + ->leftJoin(MemberTripRead::class, 'readTrip', Join::WITH, 'readTrip.trip = t AND readTrip.member = :member') ->where($qb->expr()->notLike('s.options', $qb->expr()->literal('%' . SubtripOptionsType::PRIVATE . '%'))) ->andWhere( $qb->expr()->orX( @@ -87,6 +176,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d ->andWhere($qb->expr()->in('m.status', ['Active', 'OutOfRemind'])) ->andWhere('t.creator <> :member') ->andWhere($qb->expr()->isNull('t.deleted')) + ->andWhere($qb->expr()->isNull('readTrip.id')) ->andWhere('GeoDistance(:latitude, :longitude, l.latitude, l.longitude) <= :distance') ->andWhere( $qb->expr()->orX( diff --git a/src/Service/Mailer.php b/src/Service/Mailer.php index f55fd56652..b956f3995c 100644 --- a/src/Service/Mailer.php +++ b/src/Service/Mailer.php @@ -7,6 +7,7 @@ use App\Entity\Friend; use App\Entity\Member; use App\Entity\Newsletter; +use App\Entity\Trip; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Symfony\Bridge\Twig\Mime\TemplatedEmail; @@ -189,6 +190,27 @@ public function sendFriendshipNotification(Friend $friend, Member $requester): b ); } + public function sendTripNotificationEmail(Member $receiver, Trip $trip): bool + { + $sender = $trip->getCreator(); + + return $this->sendTemplateEmail( + $this->getBeWelcomeAddress($sender, self::NO_REPLY_EMAIL_ADDRESS), + $receiver, + 'trip.notification.new', + [ + 'sender' => $sender, + 'subject' => [ + 'translationId' => 'trip.notification.new.subject', + 'parameters' => [ + 'username' => $sender->getUsername(), + ], + ], + 'trip' => $trip, + ] + ); + } + /** * Send notification for new comment. */ diff --git a/templates/emails/trip.notification.new.html.twig b/templates/emails/trip.notification.new.html.twig new file mode 100644 index 0000000000..bdf05d3eea --- /dev/null +++ b/templates/emails/trip.notification.new.html.twig @@ -0,0 +1,13 @@ +{% extends 'emails/email.html.twig' %} + +{% block content %} +

{{ 'trip.notification.new.email'|trans({'username': sender.username}) }}

+ + +
+ +
+
+
+

{{ 'trip.notification.preference.link'|trans }}

+{% endblock %} diff --git a/templates/signup/finalize.html.twig b/templates/signup/finalize.html.twig index 67a38926ad..a6a996786e 100644 --- a/templates/signup/finalize.html.twig +++ b/templates/signup/finalize.html.twig @@ -73,7 +73,7 @@

{{ 'signup.optional'|trans }}

{{ form_row(finalize.newsletters) }} {{ form_row(finalize.local_events) }} - {# {{ form_row(finalize.trips_notifications) }} #} + {{ form_row(finalize.trips_notifications) }}
diff --git a/templates/trip/landing.html.twig b/templates/trip/landing.html.twig index 53e8e7d900..71d78f4b1c 100644 --- a/templates/trip/landing.html.twig +++ b/templates/trip/landing.html.twig @@ -1,6 +1,6 @@ {% import 'macros.twig' as macros %} -
+
{{ macros.roundedavatarstack(leg.trip.creator.username, 72, false) }}

{{ leg.location.name }}, @@ -19,9 +19,16 @@

{{ 'trip.posted.by'|trans({username: macros.profilelink(leg.trip.creator.username)})|raw }}

-
+
+
+ + + +
{% if constant('App\\Doctrine\\SubtripOptionsType::MEET_LOCALS') in leg.options %} - + {% endif %} @@ -29,15 +36,14 @@ {% if leg.invitationBy(app.user) %} {% set invitation = leg.invitationBy(app.user) %} {% set username = invitation.messages[0].initiator.Username %} - + {% else %} - + {% endif %} {% endif %}
- diff --git a/templates/trip/leg.html.twig b/templates/trip/leg.html.twig index da2e8b0d6b..15777e6aa5 100644 --- a/templates/trip/leg.html.twig +++ b/templates/trip/leg.html.twig @@ -2,6 +2,13 @@
+
+ + + +
{% if constant('App\\Doctrine\\SubtripOptionsType::MEET_LOCALS') in leg.options %} @@ -56,4 +63,3 @@ {{ macros.roundedavatarstack(leg.trip.creator.username, 96) }}
- diff --git a/tests/Model/MatchingHostsRepository.php b/tests/Model/MatchingHostsRepository.php new file mode 100644 index 0000000000..dc924b96cc --- /dev/null +++ b/tests/Model/MatchingHostsRepository.php @@ -0,0 +1,19 @@ +hosts; + } +} diff --git a/tests/Model/NotifyingTripModel.php b/tests/Model/NotifyingTripModel.php new file mode 100644 index 0000000000..e429bd672d --- /dev/null +++ b/tests/Model/NotifyingTripModel.php @@ -0,0 +1,22 @@ +notifiedTrips[] = $trip; + } +} diff --git a/tests/Model/TripModelNotificationTest.php b/tests/Model/TripModelNotificationTest.php new file mode 100644 index 0000000000..aeaef7440e --- /dev/null +++ b/tests/Model/TripModelNotificationTest.php @@ -0,0 +1,234 @@ +setEntityId($trip, 42); + + $readRepository = $this->createMock(EntityRepository::class); + $readRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['member' => $member, 'trip' => $trip]) + ->willReturn(null) + ; + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects($this->once()) + ->method('findBy') + ->willReturn([]) + ; + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [MemberTripRead::class, $readRepository], + [Notification::class, $notificationRepository], + ]) + ; + $entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->callback(static function (MemberTripRead $read) use ($member, $trip): bool { + return $read->getMember() === $member && $read->getTrip() === $trip; + })) + ; + $entityManager->expects($this->once())->method('flush'); + + $tripModel = new TripModel($entityManager); + $tripModel->markTripAsRead($member, $trip); + } + + public function testMarkTripAsReadDoesNotDuplicateReadState(): void + { + $member = new Member(); + $trip = new Trip(); + $this->setEntityId($trip, 42); + $existingRead = new MemberTripRead($member, $trip); + + $readRepository = $this->createMock(EntityRepository::class); + $readRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['member' => $member, 'trip' => $trip]) + ->willReturn($existingRead) + ; + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects($this->once()) + ->method('findBy') + ->willReturn([]) + ; + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [MemberTripRead::class, $readRepository], + [Notification::class, $notificationRepository], + ]) + ; + $entityManager->expects($this->never())->method('persist'); + $entityManager->expects($this->never())->method('flush'); + + $tripModel = new TripModel($entityManager); + $tripModel->markTripAsRead($member, $trip); + } + + public function testMarkTripAsReadChecksTripNotifications(): void + { + $member = new Member(); + $trip = new Trip(); + $this->setEntityId($trip, 42); + $existingRead = new MemberTripRead($member, $trip); + $notification = (new Notification())->setChecked(false); + + $readRepository = $this->createMock(EntityRepository::class); + $readRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['member' => $member, 'trip' => $trip]) + ->willReturn($existingRead) + ; + $notificationRepository = $this->createMock(EntityRepository::class); + $notificationRepository + ->expects($this->once()) + ->method('findBy') + ->with([ + 'member' => $member, + 'type' => 'trip', + 'link' => '/trip/42', + 'checked' => false, + ]) + ->willReturn([$notification]) + ; + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [MemberTripRead::class, $readRepository], + [Notification::class, $notificationRepository], + ]) + ; + $entityManager->expects($this->never())->method('persist'); + $entityManager->expects($this->once())->method('flush'); + + $tripModel = new TripModel($entityManager); + $tripModel->markTripAsRead($member, $trip); + + $this->assertTrue($notification->getChecked()); + } + + public function testNotifyHostsAboutTripCreatesUnreadNotificationsForMatchingHosts(): void + { + $creator = new Member()->setUsername('traveller'); + $host = new Member(); + $trip = new Trip()->setCreator($creator); + $this->setEntityId($trip, 42); + + $subtripRepository = $this->createSubtripRepository([$host]); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(Subtrip::class) + ->willReturn($subtripRepository) + ; + $entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->callback(static function (Notification $notification) use ($creator, $host): bool { + return $notification->getMember() === $host + && $notification->getRelMember() === $creator + && 'trip.notification.new' === $notification->getWordcode() + && '/trip/42' === $notification->getLink() + && false === $notification->getChecked(); + })) + ; + $entityManager->expects($this->once())->method('flush'); + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects($this->once()) + ->method('sendTripNotificationEmail') + ->with($host, $trip) + ->willReturn(true) + ; + + $tripModel = new TripModel($entityManager, $mailer); + $tripModel->notifyHostsAboutTrip($trip); + } + + public function testNotifyHostsAboutTripDoesNotFlushWhenNoHostMatches(): void + { + $trip = new Trip(); + $subtripRepository = $this->createSubtripRepository([]); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(Subtrip::class) + ->willReturn($subtripRepository) + ; + $entityManager->expects($this->never())->method('persist'); + $entityManager->expects($this->never())->method('flush'); + + $tripModel = new TripModel($entityManager); + $tripModel->notifyHostsAboutTrip($trip); + } + + public function testCopyTripDoesNotNotifyHostsAboutCopiedTrip(): void + { + $trip = new Trip(); + $trip->setSummary('Berlin visit'); + $leg = new Subtrip(); + $leg->setArrival(new DateTime('+1 week')); + $leg->setDeparture(new DateTime('+2 weeks')); + $trip->addSubtrip($leg); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects($this->atLeast(2))->method('persist'); + $entityManager->expects($this->atLeast(2))->method('flush'); + + $tripModel = new NotifyingTripModel($entityManager); + $copiedTrip = $tripModel->copyTrip($trip); + + $this->assertSame('Berlin visit - copy', $copiedTrip->getSummary()); + $this->assertSame([], $tripModel->notifiedTrips); + } + + private function setEntityId(object $entity, int $id): void + { + $property = new ReflectionProperty($entity, 'id'); + $property->setValue($entity, $id); + } + + private function createSubtripRepository(array $hosts): EntityRepository + { + return new MatchingHostsRepository($hosts); + } +} diff --git a/tests/Model/TripModelTestCase.php b/tests/Model/TripModelTestCase.php index 27b3f449a6..43a3d4f665 100644 --- a/tests/Model/TripModelTestCase.php +++ b/tests/Model/TripModelTestCase.php @@ -5,15 +5,13 @@ use App\Model\TripModel; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; -use Symfony\Contracts\Translation\TranslatorInterface; class TripModelTestCase extends TestCase { protected function getTripModel(): TripModel { $entityManager = $this->createStub(EntityManagerInterface::class); - $translator = $this->createStub(TranslatorInterface::class); - return new TripModel($entityManager, $translator); + return new TripModel($entityManager); } } diff --git a/translations/missing/general.yaml b/translations/missing/general.yaml index 6a6ff972fa..2aa882f388 100644 --- a/translations/missing/general.yaml +++ b/translations/missing/general.yaml @@ -63,12 +63,18 @@ help.password: label.trips_notifications: - How often do you want to be informed about members visiting your area? - Label for the drop down for trips notifications. -trips.never: - - Never +trips.no: + - '@no' - One of the options for trips notifications -trips.immediately: - - Immediately +trips.yes: + - '@yes' - One of the options for trips notifications +trips.never: + - '@no' + - One of the options for trips notifications (legacy value). +trips.immediately: + - '@yes' + - One of the options for trips notifications (legacy value). trips.daily: - Daily - One of the options for trips notifications diff --git a/translations/missing/preference.yaml b/translations/missing/preference.yaml index b58f803806..654b85a09d 100644 --- a/translations/missing/preference.yaml +++ b/translations/missing/preference.yaml @@ -53,3 +53,21 @@ preference.allow.no.picture: preference.allow.no.about_me: - If set to 'No' you will only get requests or invitations from members who've written something about themselves (in about me). - Description on the preference page +tripsnotifications: + - Trip notifications + - Label on the preferences page for trip notifications. +trips.notifications: + - Choose whether to be notified when members add trips in your area. + - Help text on the preferences page for trip notifications. +tripsnotificationsno: + - '@no' + - Option on the preferences page for trip notifications. +tripsnotificationsyes: + - '@yes' + - Option on the preferences page for trip notifications. +tripsnotificationsnever: + - '@no' + - Option on the preferences page for trip notifications (legacy value). +tripsnotificationsimmediately: + - '@yes' + - Option on the preferences page for trip notifications (legacy value). diff --git a/translations/missing/trips.yaml b/translations/missing/trips.yaml index 7a6b43dc65..19c5401e2b 100644 --- a/translations/missing/trips.yaml +++ b/translations/missing/trips.yaml @@ -125,6 +125,21 @@ trip.location.none: trip.show: - 'Show' - Tool tip/aria label for the icon to show a trip. +trip.mark.read: + - 'Hide this trip' + - Tool tip/aria label for the button that hides a trip from the visitors list. +trip.notification.new: + - '%s added a trip in your area.' + - Dashboard notification text for a new trip in the logged-in member's area. +trip.notification.new.subject: + - '{username} added a trip in your area.' + - Email subject for trip notifications. +trip.notification.new.email: + - '{username} added a trip in your area.' + - Email body for trip notifications. +trip.notification.preference.link: + - Change your trip notification preference + - Link text in trip notification emails pointing to the preferences page. trip.created: - 'Trip has been created.' - Flash message shown after trip creation (on top of the full list of own trips). From 5e78ffd43f020664191ebb183b9eec7fcdf36df9 Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Sat, 27 Jun 2026 11:52:30 +0300 Subject: [PATCH 2/8] Fix notification test style --- tests/Model/TripModelNotificationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Model/TripModelNotificationTest.php b/tests/Model/TripModelNotificationTest.php index aeaef7440e..9eb4eac6bf 100644 --- a/tests/Model/TripModelNotificationTest.php +++ b/tests/Model/TripModelNotificationTest.php @@ -102,7 +102,7 @@ public function testMarkTripAsReadChecksTripNotifications(): void $trip = new Trip(); $this->setEntityId($trip, 42); $existingRead = new MemberTripRead($member, $trip); - $notification = (new Notification())->setChecked(false); + $notification = new Notification()->setChecked(false); $readRepository = $this->createMock(EntityRepository::class); $readRepository From 11f79d1570bd6069bc547d943004765bf844a934 Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Sun, 28 Jun 2026 12:41:04 +0300 Subject: [PATCH 3/8] Implement trip host notifications --- Migrations/Version20260626100000.php | 10 +- fixtures/preferences.yml | 2 +- src/Controller/TripController.php | 8 +- ...mberTripRead.php => MemberSubtripRead.php} | 20 +- src/Entity/Preference.php | 6 +- src/Form/SignupFormFinalizeType.php | 5 +- src/Model/TripModel.php | 50 ++--- src/Repository/SubtripRepository.php | 175 +++++++++++------- templates/trip/landing.html.twig | 4 +- templates/trip/leg.html.twig | 6 +- tests/Model/TripModelNotificationTest.php | 122 +++--------- translations/missing/general.yaml | 2 +- translations/missing/preference.yaml | 13 +- translations/missing/trips.yaml | 3 - 14 files changed, 194 insertions(+), 232 deletions(-) rename src/Entity/{MemberTripRead.php => MemberSubtripRead.php} (62%) diff --git a/Migrations/Version20260626100000.php b/Migrations/Version20260626100000.php index 117e31159f..3ba426540e 100644 --- a/Migrations/Version20260626100000.php +++ b/Migrations/Version20260626100000.php @@ -11,13 +11,15 @@ final class Version20260626100000 extends AbstractMigration #[Override] public function getDescription(): string { - return 'Add trip read state and trip notification preference'; + return 'Add subtrip read state and trip notification preference'; } public function up(Schema $schema): void { - $this->addSql('CREATE TABLE IF NOT EXISTS member_trips_read (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, trip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_7F6D7D317597D3FE (member_id), INDEX IDX_7F6D7D31A5BC2E0E (trip_id), UNIQUE INDEX member_trip_read_unique (member_id, trip_id), CONSTRAINT FK_7F6D7D317597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_7F6D7D31A5BC2E0E FOREIGN KEY (trip_id) REFERENCES trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql("INSERT INTO preferences (position, codeName, codeDescription, Description, created, DefaultValue, PossibleValues, Status) SELECT 65, 'TripsNotifications', 'trips.notifications', 'How often the member wants notifications for trips in their area', NOW(), 'No', 'No;Yes', 'Normal' WHERE NOT EXISTS (SELECT 1 FROM preferences WHERE codeName = 'TripsNotifications')"); + $this->addSql('CREATE TABLE IF NOT EXISTS member_subtrips_read (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, subtrip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_B1EC0277597D3FE (member_id), INDEX IDX_B1EC027F2BD7DD7 (subtrip_id), UNIQUE INDEX member_subtrip_read_unique (member_id, subtrip_id), CONSTRAINT FK_B1EC0277597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_B1EC027F2BD7DD7 FOREIGN KEY (subtrip_id) REFERENCES sub_trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql("INSERT INTO preferences (position, codeName, codeDescription, Description, created, DefaultValue, PossibleValues, Status) SELECT 65, 'TripsNotifications', 'trips.notifications', 'How often the member wants notifications for trips in their area', NOW(), 'No', 'No;Immediately;Daily;Weekly;Monthly', 'Normal' WHERE NOT EXISTS (SELECT 1 FROM preferences WHERE codeName = 'TripsNotifications')"); + $this->addSql("UPDATE preferences SET PossibleValues = 'No;Immediately;Daily;Weekly;Monthly' WHERE codeName = 'TripsNotifications'"); + $this->addSql("UPDATE memberspreferences mp INNER JOIN preferences p ON p.id = mp.IdPreference SET mp.Value = 'Immediately' WHERE p.codeName = 'TripsNotifications' AND mp.Value = 'Yes'"); } #[Override] @@ -25,6 +27,6 @@ public function down(Schema $schema): void { $this->addSql("DELETE mp FROM memberspreferences mp INNER JOIN preferences p ON p.id = mp.IdPreference WHERE p.codeName = 'TripsNotifications'"); $this->addSql("DELETE FROM preferences WHERE codeName = 'TripsNotifications'"); - $this->addSql('DROP TABLE IF EXISTS member_trips_read'); + $this->addSql('DROP TABLE IF EXISTS member_subtrips_read'); } } diff --git a/fixtures/preferences.yml b/fixtures/preferences.yml index 44ce9a5800..62b7ea709c 100644 --- a/fixtures/preferences.yml +++ b/fixtures/preferences.yml @@ -222,5 +222,5 @@ App\Entity\Preference: description: 'How often the member wants notifications for trips in their area' created: DefaultValue: No - PossibleValues: No;Yes + PossibleValues: No;Immediately;Daily;Weekly;Monthly Status: 'Normal' diff --git a/src/Controller/TripController.php b/src/Controller/TripController.php index 03833b9efe..e04dfa9e22 100644 --- a/src/Controller/TripController.php +++ b/src/Controller/TripController.php @@ -180,16 +180,16 @@ public function copy(Trip $trip): Response return $this->redirectToRoute('trip_edit', ['id' => $newTrip->getId()]); } - #[Route(path: '/trip/{id}/read', name: 'trip_mark_read', requirements: ['id' => '\d+'], methods: ['POST'])] - public function markRead(Request $request, Trip $trip): RedirectResponse + #[Route(path: '/trip/leg/{id}/read', name: 'trip_mark_read', requirements: ['id' => '\d+'], methods: ['POST'])] + public function markRead(Request $request, Subtrip $subtrip): RedirectResponse { - if (!$this->isCsrfTokenValid('trip_read' . $trip->getId(), $request->request->get('_token'))) { + if (!$this->isCsrfTokenValid('subtrip_read' . $subtrip->getId(), $request->request->get('_token'))) { throw $this->createAccessDeniedException(); } /** @var Member $member */ $member = $this->getUser(); - $this->tripModel->markTripAsRead($member, $trip); + $this->tripModel->markSubtripAsRead($member, $subtrip); if ('homepage' === $request->request->get('redirectTo')) { return $this->redirectToRoute('homepage', ['_fragment' => 'visitors']); diff --git a/src/Entity/MemberTripRead.php b/src/Entity/MemberSubtripRead.php similarity index 62% rename from src/Entity/MemberTripRead.php rename to src/Entity/MemberSubtripRead.php index b1033eb9e4..0be0d54f24 100644 --- a/src/Entity/MemberTripRead.php +++ b/src/Entity/MemberSubtripRead.php @@ -5,19 +5,19 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; -#[ORM\Table(name: 'member_trips_read')] -#[ORM\UniqueConstraint(name: 'member_trip_read_unique', columns: ['member_id', 'trip_id'])] +#[ORM\Table(name: 'member_subtrips_read')] +#[ORM\UniqueConstraint(name: 'member_subtrip_read_unique', columns: ['member_id', 'subtrip_id'])] #[ORM\Entity] #[ORM\HasLifecycleCallbacks] -class MemberTripRead +class MemberSubtripRead { #[ORM\JoinColumn(name: 'member_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[ORM\ManyToOne(targetEntity: Member::class)] private Member $member; - #[ORM\JoinColumn(name: 'trip_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - #[ORM\ManyToOne(targetEntity: Trip::class)] - private Trip $trip; + #[ORM\JoinColumn(name: 'subtrip_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\ManyToOne(targetEntity: Subtrip::class)] + private Subtrip $subtrip; #[ORM\Column(name: 'created', type: 'datetime', nullable: false)] private DateTime $created; @@ -27,10 +27,10 @@ class MemberTripRead #[ORM\GeneratedValue(strategy: 'IDENTITY')] private int $id; - public function __construct(Member $member, Trip $trip) + public function __construct(Member $member, Subtrip $subtrip) { $this->member = $member; - $this->trip = $trip; + $this->subtrip = $subtrip; } public function getMember(): Member @@ -38,9 +38,9 @@ public function getMember(): Member return $this->member; } - public function getTrip(): Trip + public function getSubtrip(): Subtrip { - return $this->trip; + return $this->subtrip; } public function getCreated(): DateTime diff --git a/src/Entity/Preference.php b/src/Entity/Preference.php index 098c909023..772a78e13f 100644 --- a/src/Entity/Preference.php +++ b/src/Entity/Preference.php @@ -36,7 +36,11 @@ class Preference public const TRIPS_VICINITY_RADIUS = 'TripLegsVicinityRadius'; public const TRIP_NOTIFICATIONS = 'TripsNotifications'; public const TRIP_NOTIFICATIONS_NEVER = 'No'; - public const TRIP_NOTIFICATIONS_IMMEDIATELY = 'Yes'; + public const TRIP_NOTIFICATIONS_IMMEDIATELY = 'Immediately'; + public const TRIP_NOTIFICATIONS_LEGACY_IMMEDIATELY = 'Yes'; + public const TRIP_NOTIFICATIONS_DAILY = 'Daily'; + public const TRIP_NOTIFICATIONS_WEEKLY = 'Weekly'; + public const TRIP_NOTIFICATIONS_MONTHLY = 'Monthly'; public const SHOW_PROFILE_VISITORS = 'PreferenceShowProfileVisits'; public const SHOW_FORUMS_POSTS = 'MyForumPostsPagePublic'; public const SEARCH_OPTIONS = 'SearchOptions'; diff --git a/src/Form/SignupFormFinalizeType.php b/src/Form/SignupFormFinalizeType.php index a41517d834..a02e4c7b34 100644 --- a/src/Form/SignupFormFinalizeType.php +++ b/src/Form/SignupFormFinalizeType.php @@ -149,7 +149,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'help.trips_notifications', 'choices' => [ 'trips.no' => Preference::TRIP_NOTIFICATIONS_NEVER, - 'trips.yes' => Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + 'trips.immediately' => Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + 'trips.daily' => Preference::TRIP_NOTIFICATIONS_DAILY, + 'trips.weekly' => Preference::TRIP_NOTIFICATIONS_WEEKLY, + 'trips.monthly' => Preference::TRIP_NOTIFICATIONS_MONTHLY, ], 'data' => Preference::TRIP_NOTIFICATIONS_NEVER, 'required' => true, diff --git a/src/Model/TripModel.php b/src/Model/TripModel.php index ceeec3b991..e8279c7e53 100644 --- a/src/Model/TripModel.php +++ b/src/Model/TripModel.php @@ -3,8 +3,7 @@ namespace App\Model; use App\Entity\Member; -use App\Entity\MemberTripRead; -use App\Entity\Notification; +use App\Entity\MemberSubtripRead; use App\Entity\Preference; use App\Entity\Subtrip; use App\Entity\Trip; @@ -140,31 +139,13 @@ public function hideTrip(Trip $trip): void $this->entityManager->flush(); } - public function markTripAsRead(Member $member, Trip $trip): void + public function markSubtripAsRead(Member $member, Subtrip $subtrip): void { - $repository = $this->entityManager->getRepository(MemberTripRead::class); - $read = $repository->findOneBy(['member' => $member, 'trip' => $trip]); - $changed = false; + $repository = $this->entityManager->getRepository(MemberSubtripRead::class); + $read = $repository->findOneBy(['member' => $member, 'subtrip' => $subtrip]); if (null === $read) { - $this->entityManager->persist(new MemberTripRead($member, $trip)); - $changed = true; - } - - $notificationRepository = $this->entityManager->getRepository(Notification::class); - $notifications = $notificationRepository->findBy([ - 'member' => $member, - 'type' => 'trip', - 'link' => '/trip/' . $trip->getId(), - 'checked' => false, - ]); - - foreach ($notifications as $notification) { - $notification->setChecked(true); - $changed = true; - } - - if ($changed) { + $this->entityManager->persist(new MemberSubtripRead($member, $subtrip)); $this->entityManager->flush(); } } @@ -179,23 +160,16 @@ public function notifyHostsAboutTrip(Trip $trip): void return; } + $sent = []; foreach ($hosts as $host) { - $notification = new Notification(); - $notification - ->setMember($host) - ->setRelMember($trip->getCreator()) - ->setType('trip') - ->setLink('/trip/' . $trip->getId()) - ->setWordcode('trip.notification.new') - ->setChecked(false) - ->setSendmail(null !== $this->mailer) - ; - - $this->entityManager->persist($notification); + $hostId = $host->getId(); + if (isset($sent[$hostId])) { + continue; + } + + $sent[$hostId] = true; $this->mailer?->sendTripNotificationEmail($host, $trip); } - - $this->entityManager->flush(); } public function hasTripExpired(Trip $trip) diff --git a/src/Repository/SubtripRepository.php b/src/Repository/SubtripRepository.php index bb34251f1c..adba553a56 100644 --- a/src/Repository/SubtripRepository.php +++ b/src/Repository/SubtripRepository.php @@ -2,12 +2,12 @@ namespace App\Repository; +use AnthonyMartin\GeoLocation\GeoPoint; use App\Doctrine\MemberStatusType; use App\Doctrine\SubtripOptionsType; use App\Entity\Member; -use App\Entity\MemberTripRead; +use App\Entity\MemberSubtripRead; use App\Entity\Preference; -use App\Entity\Subtrip; use App\Entity\Trip; use Carbon\CarbonImmutable; use Doctrine\ORM\EntityRepository; @@ -79,72 +79,117 @@ public function getMembersToNotifyAboutTrip(Trip $trip, int $duration = 3): arra return []; } + if (null !== $trip->getDeleted()) { + return []; + } + + $activeStatuses = [MemberStatusType::ACTIVE, MemberStatusType::OUT_OF_REMIND]; + if (!\in_array($trip->getCreator()->getStatus(), $activeStatuses, true)) { + return []; + } + $now = CarbonImmutable::today(); $durationMonthsAhead = $now->addMonths($duration); - - $qb = $this->getEntityManager()->createQueryBuilder(); - $qb - ->select('DISTINCT host') - ->from(Member::class, 'host') - ->from(Subtrip::class, 's') - ->join('host.addresses', 'a', Join::WITH, 'a.active = true') - ->leftJoin( - 'host.preferences', - 'tripNotifications', - Join::WITH, - 'tripNotifications.preference = :tripNotificationPreference' - ) - ->leftJoin( - 'host.preferences', - 'radiusPreference', - Join::WITH, - 'radiusPreference.preference = :radiusPreference' - ) - ->join('s.location', 'l') - ->join('s.trip', 't') - ->join('t.creator', 'creator') - ->where('s.trip = :trip') - ->andWhere('s.arrival >= :now') - ->andWhere('s.arrival <= :durationMonthsAhead') - ->andWhere($qb->expr()->notLike('s.options', ':privateOption')) - ->andWhere( - $qb->expr()->orX( - $qb->expr()->isNull('s.invitedBy'), - $qb->expr()->eq('s.invitedBy', 'host'), - $qb->expr()->in('s.options', ':meetLocalsOptions') + $hosts = []; + + foreach ($trip->getSubtrips() as $subtrip) { + $arrival = $subtrip->getArrival(); + $location = $subtrip->getLocation(); + $options = $subtrip->getOptions(); + + if ( + null === $arrival + || null === $location + || $arrival < $now + || $arrival > $durationMonthsAhead + || \in_array(SubtripOptionsType::PRIVATE, $options, true) + ) { + continue; + } + + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb + ->select('DISTINCT host') + ->from(Member::class, 'host') + ->join('host.addresses', 'a', Join::WITH, 'a.active = true') + ->join('a.location', 'hostLocation') + ->leftJoin( + 'host.preferences', + 'tripNotifications', + Join::WITH, + 'tripNotifications.preference = :tripNotificationPreference' ) - ) - ->andWhere($qb->expr()->in('host.status', ':activeStatuses')) - ->andWhere($qb->expr()->in('creator.status', ':activeStatuses')) - ->andWhere('host <> creator') - ->andWhere('t.countOfTravellers <= host.maxGuests') - ->andWhere($qb->expr()->isNull('t.deleted')) - ->andWhere( - 'COALESCE(tripNotifications.value, :defaultNotification) = :immediately' - ) - ->andWhere( - 'GeoDistance(a.latitude, a.longitude, l.latitude, l.longitude) <= COALESCE(radiusPreference.value, :defaultRadius)' - ) - ->andWhere( - $qb->expr()->orX( - $qb->expr()->lte('GeoDistance(a.latitude, a.longitude, l.latitude, l.longitude)', 't.invitationRadius'), - $qb->expr()->eq('s.location', 'a.location') + ->leftJoin( + 'host.preferences', + 'radiusPreference', + Join::WITH, + 'radiusPreference.preference = :radiusPreference' ) - ) - ->setParameter('trip', $trip) - ->setParameter('now', $now) - ->setParameter('durationMonthsAhead', $durationMonthsAhead) - ->setParameter('privateOption', '%' . SubtripOptionsType::PRIVATE . '%') - ->setParameter('meetLocalsOptions', [SubtripOptionsType::MEET_LOCALS]) - ->setParameter('activeStatuses', [MemberStatusType::ACTIVE, MemberStatusType::OUT_OF_REMIND]) - ->setParameter('tripNotificationPreference', $tripNotificationPreference) - ->setParameter('radiusPreference', $radiusPreference) - ->setParameter('defaultNotification', Preference::TRIP_NOTIFICATIONS_NEVER) - ->setParameter('immediately', Preference::TRIP_NOTIFICATIONS_IMMEDIATELY) - ->setParameter('defaultRadius', $radiusPreference->getDefaultValue()) - ; + ->where($qb->expr()->in('host.status', ':activeStatuses')) + ->andWhere('host <> :creator') + ->andWhere('host.maxGuests >= :travellers') + ->andWhere('COALESCE(tripNotifications.value, :defaultNotification) IN (:immediateNotifications)') + ->andWhere( + 'ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= 1000 * COALESCE(radiusPreference.value, :defaultRadius)' + ) + ->setParameter('activeStatuses', $activeStatuses) + ->setParameter('creator', $trip->getCreator()) + ->setParameter('travellers', $trip->getCountOfTravellers()) + ->setParameter('tripNotificationPreference', $tripNotificationPreference) + ->setParameter('radiusPreference', $radiusPreference) + ->setParameter('defaultNotification', Preference::TRIP_NOTIFICATIONS_NEVER) + ->setParameter( + 'immediateNotifications', + [ + Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + Preference::TRIP_NOTIFICATIONS_LEGACY_IMMEDIATELY, + ] + ) + ->setParameter('defaultRadius', $radiusPreference->getDefaultValue()) + ->setParameter('centerPoint', \sprintf('POINT(%F %F)', $location->getLongitude(), $location->getLatitude())) + ; + + if (null !== $subtrip->getInvitedBy() && !\in_array(SubtripOptionsType::MEET_LOCALS, $options, true)) { + $qb + ->andWhere('host = :invitedBy') + ->setParameter('invitedBy', $subtrip->getInvitedBy()) + ; + } + + if (0 === $trip->getInvitationRadius()) { + $qb + ->andWhere('a.location = :location') + ->setParameter('location', $location) + ; + } else { + $center = new GeoPoint((float) $location->getLatitude(), (float) $location->getLongitude()); + $boundingBox = $center->boundingBox($trip->getInvitationRadius(), 'km'); + $lineString = \sprintf( + 'LINESTRING(%F %F, %F %F)', + $boundingBox->getMinLongitude(), + $boundingBox->getMinLatitude(), + $boundingBox->getMaxLongitude(), + $boundingBox->getMaxLatitude() + ); + + $qb + ->andWhere('hostLocation.latitude BETWEEN :minLat AND :maxLat') + ->andWhere('hostLocation.longitude BETWEEN :minLng AND :maxLng') + ->andWhere('MBRContains(ST_Envelope(ST_GeomFromText(:lineString, 0)), hostLocation.coordinates) = 1') + ->andWhere('ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= :invitationRadiusMeters') + ->setParameter('minLat', $boundingBox->getMinLatitude()) + ->setParameter('maxLat', $boundingBox->getMaxLatitude()) + ->setParameter('minLng', $boundingBox->getMinLongitude()) + ->setParameter('maxLng', $boundingBox->getMaxLongitude()) + ->setParameter('lineString', $lineString) + ->setParameter('invitationRadiusMeters', $trip->getInvitationRadius() * 1000) + ; + } + + array_push($hosts, ...$qb->getQuery()->getResult()); + } - return $qb->getQuery()->getResult(); + return $hosts; } private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $duration): QueryBuilder @@ -162,7 +207,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d ->join('s.location', 'l') ->join('s.trip', 't') ->join('t.creator', 'm') - ->leftJoin(MemberTripRead::class, 'readTrip', Join::WITH, 'readTrip.trip = t AND readTrip.member = :member') + ->leftJoin(MemberSubtripRead::class, 'readSubtrip', Join::WITH, 'readSubtrip.subtrip = s AND readSubtrip.member = :member') ->where($qb->expr()->notLike('s.options', $qb->expr()->literal('%' . SubtripOptionsType::PRIVATE . '%'))) ->andWhere( $qb->expr()->orX( @@ -176,7 +221,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d ->andWhere($qb->expr()->in('m.status', ['Active', 'OutOfRemind'])) ->andWhere('t.creator <> :member') ->andWhere($qb->expr()->isNull('t.deleted')) - ->andWhere($qb->expr()->isNull('readTrip.id')) + ->andWhere($qb->expr()->isNull('readSubtrip.id')) ->andWhere('GeoDistance(:latitude, :longitude, l.latitude, l.longitude) <= :distance') ->andWhere( $qb->expr()->orX( diff --git a/templates/trip/landing.html.twig b/templates/trip/landing.html.twig index 71d78f4b1c..649e4b4356 100644 --- a/templates/trip/landing.html.twig +++ b/templates/trip/landing.html.twig @@ -20,8 +20,8 @@

{{ 'trip.posted.by'|trans({username: macros.profilelink(leg.trip.creator.username)})|raw }}

-
- + +
- - + + - diff --git a/templates/trip/leg.html.twig b/templates/trip/leg.html.twig index 1374246bcb..648eb4557f 100644 --- a/templates/trip/leg.html.twig +++ b/templates/trip/leg.html.twig @@ -2,10 +2,10 @@
-
- + + -
diff --git a/tests/Model/ConversationModelTest.php b/tests/Model/ConversationModelTest.php index 423ab43213..98270cb45c 100644 --- a/tests/Model/ConversationModelTest.php +++ b/tests/Model/ConversationModelTest.php @@ -27,14 +27,7 @@ class ConversationModelTest extends TestCase protected function setUp(): void { $this->entityManager = $this->createStub(EntityManagerInterface::class); - $mailer = $this->createStub(Mailer::class); - $translator = $this->createStub(TranslatorInterface::class); - - $this->model = new ConversationModel( - $mailer, - $this->entityManager, - $translator - ); + $this->model = $this->createModel($this->entityManager); } public function testMarkConversationPurgedUpdatesMessages(): void @@ -45,6 +38,8 @@ public function testMarkConversationPurgedUpdatesMessages(): void $message->setReceiver($receiver); $message->setSender($sender); $message->setDeleteRequest(''); + $message->setFolder(InFolderType::SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -64,6 +59,8 @@ public function testMarkConversationDeletedUpdatesMessages(): void $message->setReceiver($receiver); $message->setSender($sender); $message->setDeleteRequest(''); + $message->setFolder(InFolderType::SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -82,6 +79,7 @@ public function testMarkConversationAsSpamUpdatesStatusAndInfo(): void $message->setReceiver($receiver); $message->setFolder(InFolderType::NORMAL); $message->setStatus(MessageStatusType::SENT); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -129,6 +127,8 @@ public function testUnmarkConversationDeletedRestoresMessages(): void $message->setSender($sender); // Start as deleted $message->setDeleteRequest(DeleteRequestType::RECEIVER_DELETED); + $message->setFolder(InFolderType::SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -147,6 +147,7 @@ public function testUnmarkConversationAsSpamRestoresNormalState(): void $message->setFolder(InFolderType::SPAM); $message->setStatus(MessageStatusType::CHECK); $message->setSpamInfo(SpamInfoType::MEMBER_SAYS_SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -163,12 +164,23 @@ public function testMarkConversationAsReadSetsTime(): void $message = new Message(); $message->setReceiver($receiver); $message->setFirstRead(null); // Initialize to avoid type error - - $conversation = [$message]; + $senderMessage = new Message(); + $senderMessage->setReceiver(new Member()); + $senderMessage->setSender($receiver); + $senderMessage->setFirstRead(null); + $alreadyReadAt = new \DateTime('2026-06-28 12:00:00'); + $alreadyReadMessage = new Message(); + $alreadyReadMessage->setReceiver($receiver); + $alreadyReadMessage->setFirstRead($alreadyReadAt); + $this->expectPersistAndFlush($message); + + $conversation = [$message, $senderMessage, $alreadyReadMessage]; $this->model->markConversationAsRead($receiver, $conversation); $this->assertNotNull($message->getFirstRead()); + $this->assertNull($senderMessage->getFirstRead()); + $this->assertSame($alreadyReadAt->getTimestamp(), $alreadyReadMessage->getFirstRead()->getTimestamp()); } public function testGetLastMessageInConversationReturnsLatest(): void @@ -180,10 +192,12 @@ public function testGetLastMessageInConversationReturnsLatest(): void $msg1 = new Message(); $msg2 = new Message(); - $repo = $this->createStub(EntityRepository::class); - $repo->method('findBy')->willReturn([$msg1, $msg2]); + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with(['subject' => $subject])->willReturn([$msg1, $msg2]); - $this->entityManager->method('getRepository')->willReturn($repo); + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->entityManager->expects($this->once())->method('getRepository')->with(Message::class)->willReturn($repo); + $this->model = $this->createModel($this->entityManager); $result = $this->model->getLastMessageInConversation($parent); @@ -253,4 +267,21 @@ private function setupLimitMock(array $returnData): void $this->entityManager->method('getConnection')->willReturn($conn); } + + private function expectPersistAndFlush(Message $message): void + { + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->entityManager->expects($this->once())->method('persist')->with($message); + $this->entityManager->expects($this->once())->method('flush'); + $this->model = $this->createModel($this->entityManager); + } + + private function createModel(EntityManagerInterface $entityManager): ConversationModel + { + return new ConversationModel( + $this->createStub(Mailer::class), + $entityManager, + $this->createStub(TranslatorInterface::class) + ); + } } diff --git a/tests/Model/TripModelNotificationTest.php b/tests/Model/TripModelNotificationTest.php index 55e330d7c9..56d4613670 100644 --- a/tests/Model/TripModelNotificationTest.php +++ b/tests/Model/TripModelNotificationTest.php @@ -3,8 +3,8 @@ namespace App\Tests\Model; use App\Entity\Member; +use App\Entity\MemberSubtripHidden; use App\Entity\MemberTripNotificationSent; -use App\Entity\MemberSubtripRead; use App\Entity\Preference; use App\Entity\Subtrip; use App\Entity\Trip; @@ -20,13 +20,13 @@ class TripModelNotificationTest extends TestCase { - public function testMarkSubtripAsReadPersistsReadState(): void + public function testMarkSubtripAsHiddenPersistsHiddenState(): void { $member = new Member(); $subtrip = new Subtrip(); - $readRepository = $this->createMock(EntityRepository::class); - $readRepository + $hiddenRepository = $this->createMock(EntityRepository::class); + $hiddenRepository ->expects($this->once()) ->method('findOneBy') ->with(['member' => $member, 'subtrip' => $subtrip]) @@ -37,48 +37,48 @@ public function testMarkSubtripAsReadPersistsReadState(): void $entityManager ->expects($this->once()) ->method('getRepository') - ->with(MemberSubtripRead::class) - ->willReturn($readRepository) + ->with(MemberSubtripHidden::class) + ->willReturn($hiddenRepository) ; $entityManager ->expects($this->once()) ->method('persist') - ->with($this->callback(static function (MemberSubtripRead $read) use ($member, $subtrip): bool { - return $read->getMember() === $member && $read->getSubtrip() === $subtrip; + ->with($this->callback(static function (MemberSubtripHidden $hidden) use ($member, $subtrip): bool { + return $hidden->getMember() === $member && $hidden->getSubtrip() === $subtrip; })) ; $entityManager->expects($this->once())->method('flush'); $tripModel = new TripModel($entityManager); - $tripModel->markSubtripAsRead($member, $subtrip); + $tripModel->markSubtripAsHidden($member, $subtrip); } - public function testMarkSubtripAsReadDoesNotDuplicateReadState(): void + public function testMarkSubtripAsHiddenDoesNotDuplicateHiddenState(): void { $member = new Member(); $subtrip = new Subtrip(); - $existingRead = new MemberSubtripRead($member, $subtrip); + $existingHidden = new MemberSubtripHidden($member, $subtrip); - $readRepository = $this->createMock(EntityRepository::class); - $readRepository + $hiddenRepository = $this->createMock(EntityRepository::class); + $hiddenRepository ->expects($this->once()) ->method('findOneBy') ->with(['member' => $member, 'subtrip' => $subtrip]) - ->willReturn($existingRead) + ->willReturn($existingHidden) ; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects($this->once()) ->method('getRepository') - ->with(MemberSubtripRead::class) - ->willReturn($readRepository) + ->with(MemberSubtripHidden::class) + ->willReturn($hiddenRepository) ; $entityManager->expects($this->never())->method('persist'); $entityManager->expects($this->never())->method('flush'); $tripModel = new TripModel($entityManager); - $tripModel->markSubtripAsRead($member, $subtrip); + $tripModel->markSubtripAsHidden($member, $subtrip); } public function testNotifyHostsAboutTripSendsEmailsToMatchingHostsOnce(): void diff --git a/translations/missing/trips.yaml b/translations/missing/trips.yaml index 3791ed15fc..1cccc7dad9 100644 --- a/translations/missing/trips.yaml +++ b/translations/missing/trips.yaml @@ -125,7 +125,7 @@ trip.location.none: trip.show: - 'Show' - Tool tip/aria label for the icon to show a trip. -trip.mark.read: +trip.hide: - 'Hide this trip' - Tool tip/aria label for the button that hides a trip from the visitors list. trip.notification.new.subject: From 357e4f603e04d67e98cb7aabbf0d91ae558c104d Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Mon, 29 Jun 2026 17:56:26 +0300 Subject: [PATCH 7/8] Update notification and conversation tests --- tests/Command/SendTripNotificationsCommandTest.php | 8 +++++--- tests/Model/ConversationModelTest.php | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/Command/SendTripNotificationsCommandTest.php b/tests/Command/SendTripNotificationsCommandTest.php index 2d0b7d8ef7..296caa5f34 100644 --- a/tests/Command/SendTripNotificationsCommandTest.php +++ b/tests/Command/SendTripNotificationsCommandTest.php @@ -5,6 +5,8 @@ use App\Command\SendTripNotificationsCommand; use App\Entity\Preference; use App\Model\TripModel; +use DateInterval; +use DateTimeImmutable; use DateTimeInterface; use Generator; use PHPUnit\Framework\Attributes\DataProvider; @@ -31,10 +33,10 @@ public function testExecuteSendsExpectedFrequency(string $argument, string $pref ->with( $preference, $this->callback(static function (DateTimeInterface $since) use ($days): bool { - $now = new \DateTimeImmutable(); + $now = new DateTimeImmutable(); - return $since <= $now->sub(new \DateInterval('P' . $days . 'D'))->modify('+5 seconds') - && $since >= $now->sub(new \DateInterval('P' . $days . 'D'))->modify('-5 seconds'); + return $since <= $now->sub(new DateInterval('P' . $days . 'D'))->modify('+5 seconds') + && $since >= $now->sub(new DateInterval('P' . $days . 'D'))->modify('-5 seconds'); }), $this->isInstanceOf(DateTimeInterface::class), ) diff --git a/tests/Model/ConversationModelTest.php b/tests/Model/ConversationModelTest.php index 98270cb45c..9396b43ab7 100644 --- a/tests/Model/ConversationModelTest.php +++ b/tests/Model/ConversationModelTest.php @@ -11,6 +11,7 @@ use App\Entity\Subject; use App\Model\ConversationModel; use App\Service\Mailer; +use DateTime; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Result; use Doctrine\DBAL\Statement; @@ -94,7 +95,7 @@ public function testMarkConversationAsSpamUpdatesStatusAndInfo(): void public function testFormatConversationDetectsSpamPatterns(): void { $message = new Message(); - $message->setMessage('Contact me at test (at) example.com'); + $message->setMessage('Contact me at test (AT) example.com'); $message->setStatus(MessageStatusType::SENT); $message->setFolder(InFolderType::NORMAL); @@ -168,7 +169,7 @@ public function testMarkConversationAsReadSetsTime(): void $senderMessage->setReceiver(new Member()); $senderMessage->setSender($receiver); $senderMessage->setFirstRead(null); - $alreadyReadAt = new \DateTime('2026-06-28 12:00:00'); + $alreadyReadAt = new DateTime('2026-06-28 12:00:00'); $alreadyReadMessage = new Message(); $alreadyReadMessage->setReceiver($receiver); $alreadyReadMessage->setFirstRead($alreadyReadAt); From b318c92232bdf94d7bc1e770799d71cfaa42a92a Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Mon, 6 Jul 2026 11:38:22 +0300 Subject: [PATCH 8/8] Address trip notification review feedback --- Migrations/Version20260626100000.php | 7 +- fixtures/preferences.yml | 4 +- src/Command/SendTripNotificationsCommand.php | 6 +- ....php => MemberSubtripNotificationSent.php} | 20 +- src/Entity/Preference.php | 2 +- src/Form/SignupFormFinalizeType.php | 2 +- src/Model/TripModel.php | 39 ++-- src/Repository/SubtripRepository.php | 187 ++++++++--------- src/Service/Mailer.php | 6 +- tests/Model/ConversationModelTest.php | 14 ++ tests/Model/CreatedTripsRepository.php | 29 --- tests/Model/MatchingHostsRepository.php | 29 --- .../Model/SentTripNotificationsRepository.php | 32 --- tests/Model/TripModelNotificationTest.php | 198 +++++++++++------- translations/missing/general.yaml | 4 +- translations/missing/preference.yaml | 4 +- 16 files changed, 278 insertions(+), 305 deletions(-) rename src/Entity/{MemberTripNotificationSent.php => MemberSubtripNotificationSent.php} (61%) delete mode 100644 tests/Model/CreatedTripsRepository.php delete mode 100644 tests/Model/MatchingHostsRepository.php delete mode 100644 tests/Model/SentTripNotificationsRepository.php diff --git a/Migrations/Version20260626100000.php b/Migrations/Version20260626100000.php index b8449b8ab2..bead63affa 100644 --- a/Migrations/Version20260626100000.php +++ b/Migrations/Version20260626100000.php @@ -17,9 +17,8 @@ public function getDescription(): string public function up(Schema $schema): void { $this->addSql('CREATE TABLE IF NOT EXISTS member_subtrip_hidden (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, subtrip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_B1EC0277597D3FE (member_id), INDEX IDX_B1EC027F2BD7DD7 (subtrip_id), UNIQUE INDEX member_subtrip_hidden_unique (member_id, subtrip_id), CONSTRAINT FK_B1EC0277597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_B1EC027F2BD7DD7 FOREIGN KEY (subtrip_id) REFERENCES sub_trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql('CREATE TABLE IF NOT EXISTS member_trip_notification_sent (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, trip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_9D9311917597D3FE (member_id), INDEX IDX_9D931191A5BC2E0E (trip_id), UNIQUE INDEX member_trip_notification_sent_unique (member_id, trip_id), CONSTRAINT FK_9D9311917597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_9D931191A5BC2E0E FOREIGN KEY (trip_id) REFERENCES trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); - $this->addSql("INSERT INTO preferences (position, codeName, codeDescription, Description, created, DefaultValue, PossibleValues, Status) SELECT 65, 'TripsNotifications', 'trips.notifications', 'How often the member wants notifications for trips in their area', NOW(), 'No', 'No;Immediately;Daily;Weekly;Monthly', 'Normal' WHERE NOT EXISTS (SELECT 1 FROM preferences WHERE codeName = 'TripsNotifications')"); - $this->addSql("UPDATE preferences SET PossibleValues = 'No;Immediately;Daily;Weekly;Monthly' WHERE codeName = 'TripsNotifications'"); + $this->addSql('CREATE TABLE IF NOT EXISTS member_subtrip_notification_sent (id INT AUTO_INCREMENT NOT NULL, member_id INT NOT NULL, subtrip_id INT NOT NULL, created DATETIME NOT NULL, INDEX IDX_9D9311917597D3FE (member_id), INDEX IDX_9D931191F2BD7DD7 (subtrip_id), UNIQUE INDEX member_subtrip_notification_sent_unique (member_id, subtrip_id), CONSTRAINT FK_9D9311917597D3FE FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE, CONSTRAINT FK_9D931191F2BD7DD7 FOREIGN KEY (subtrip_id) REFERENCES sub_trips (id) ON DELETE CASCADE, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql("INSERT INTO preferences (position, codeName, codeDescription, Description, created, DefaultValue, PossibleValues, Status) SELECT 65, 'TripsNotifications', 'trips.notifications', 'How often the member wants notifications for trips in their area', NOW(), 'Never', 'Never;Immediately;Daily;Weekly;Monthly', 'Normal' WHERE NOT EXISTS (SELECT 1 FROM preferences WHERE codeName = 'TripsNotifications')"); } #[Override] @@ -27,7 +26,7 @@ public function down(Schema $schema): void { $this->addSql("DELETE mp FROM memberspreferences mp INNER JOIN preferences p ON p.id = mp.IdPreference WHERE p.codeName = 'TripsNotifications'"); $this->addSql("DELETE FROM preferences WHERE codeName = 'TripsNotifications'"); - $this->addSql('DROP TABLE IF EXISTS member_trip_notification_sent'); + $this->addSql('DROP TABLE IF EXISTS member_subtrip_notification_sent'); $this->addSql('DROP TABLE IF EXISTS member_subtrip_hidden'); } } diff --git a/fixtures/preferences.yml b/fixtures/preferences.yml index 62b7ea709c..007b7c9c7b 100644 --- a/fixtures/preferences.yml +++ b/fixtures/preferences.yml @@ -221,6 +221,6 @@ App\Entity\Preference: codeDescription: 'trips.notifications' description: 'How often the member wants notifications for trips in their area' created: - DefaultValue: No - PossibleValues: No;Immediately;Daily;Weekly;Monthly + DefaultValue: Never + PossibleValues: Never;Immediately;Daily;Weekly;Monthly Status: 'Normal' diff --git a/src/Command/SendTripNotificationsCommand.php b/src/Command/SendTripNotificationsCommand.php index 80c6de9469..31e8101dca 100644 --- a/src/Command/SendTripNotificationsCommand.php +++ b/src/Command/SendTripNotificationsCommand.php @@ -48,9 +48,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int } [$preferenceValue, $period] = self::FREQUENCIES[$frequency]; - $until = new DateTimeImmutable(); - $since = $until->sub(new DateInterval($period)); - $sent = $this->tripModel->sendScheduledTripNotifications($preferenceValue, $since, $until); + $createdUntil = new DateTimeImmutable(); + $createdSince = $createdUntil->sub(new DateInterval($period)); + $sent = $this->tripModel->sendScheduledTripNotifications($preferenceValue, $createdSince, $createdUntil); $io->success(\sprintf('Sent %d trip notification emails.', $sent)); diff --git a/src/Entity/MemberTripNotificationSent.php b/src/Entity/MemberSubtripNotificationSent.php similarity index 61% rename from src/Entity/MemberTripNotificationSent.php rename to src/Entity/MemberSubtripNotificationSent.php index 6f23ed5532..12e3a10f5d 100644 --- a/src/Entity/MemberTripNotificationSent.php +++ b/src/Entity/MemberSubtripNotificationSent.php @@ -5,19 +5,19 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; -#[ORM\Table(name: 'member_trip_notification_sent')] -#[ORM\UniqueConstraint(name: 'member_trip_notification_sent_unique', columns: ['member_id', 'trip_id'])] +#[ORM\Table(name: 'member_subtrip_notification_sent')] +#[ORM\UniqueConstraint(name: 'member_subtrip_notification_sent_unique', columns: ['member_id', 'subtrip_id'])] #[ORM\Entity] #[ORM\HasLifecycleCallbacks] -class MemberTripNotificationSent +class MemberSubtripNotificationSent { #[ORM\JoinColumn(name: 'member_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] #[ORM\ManyToOne(targetEntity: Member::class)] private Member $member; - #[ORM\JoinColumn(name: 'trip_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] - #[ORM\ManyToOne(targetEntity: Trip::class)] - private Trip $trip; + #[ORM\JoinColumn(name: 'subtrip_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\ManyToOne(targetEntity: Subtrip::class)] + private Subtrip $subtrip; #[ORM\Column(name: 'created', type: 'datetime', nullable: false)] private DateTime $created; @@ -27,10 +27,10 @@ class MemberTripNotificationSent #[ORM\GeneratedValue(strategy: 'IDENTITY')] private int $id; - public function __construct(Member $member, Trip $trip) + public function __construct(Member $member, Subtrip $subtrip) { $this->member = $member; - $this->trip = $trip; + $this->subtrip = $subtrip; } public function getMember(): Member @@ -38,9 +38,9 @@ public function getMember(): Member return $this->member; } - public function getTrip(): Trip + public function getSubtrip(): Subtrip { - return $this->trip; + return $this->subtrip; } public function getCreated(): DateTime diff --git a/src/Entity/Preference.php b/src/Entity/Preference.php index df06cc43db..46443c7488 100644 --- a/src/Entity/Preference.php +++ b/src/Entity/Preference.php @@ -35,7 +35,7 @@ class Preference public const FORUM_ORDER_LIST_ASC = 'PreferenceForumOrderListAsc'; public const TRIPS_VICINITY_RADIUS = 'TripLegsVicinityRadius'; public const TRIP_NOTIFICATIONS = 'TripsNotifications'; - public const TRIP_NOTIFICATIONS_NEVER = 'No'; + public const TRIP_NOTIFICATIONS_NEVER = 'Never'; public const TRIP_NOTIFICATIONS_IMMEDIATELY = 'Immediately'; public const TRIP_NOTIFICATIONS_DAILY = 'Daily'; public const TRIP_NOTIFICATIONS_WEEKLY = 'Weekly'; diff --git a/src/Form/SignupFormFinalizeType.php b/src/Form/SignupFormFinalizeType.php index a02e4c7b34..c0b91e715a 100644 --- a/src/Form/SignupFormFinalizeType.php +++ b/src/Form/SignupFormFinalizeType.php @@ -148,7 +148,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => 'label.trips_notifications', 'help' => 'help.trips_notifications', 'choices' => [ - 'trips.no' => Preference::TRIP_NOTIFICATIONS_NEVER, + 'trips.never' => Preference::TRIP_NOTIFICATIONS_NEVER, 'trips.immediately' => Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, 'trips.daily' => Preference::TRIP_NOTIFICATIONS_DAILY, 'trips.weekly' => Preference::TRIP_NOTIFICATIONS_WEEKLY, diff --git a/src/Model/TripModel.php b/src/Model/TripModel.php index f5157e8e07..f7e1966646 100644 --- a/src/Model/TripModel.php +++ b/src/Model/TripModel.php @@ -4,7 +4,7 @@ use App\Entity\Member; use App\Entity\MemberSubtripHidden; -use App\Entity\MemberTripNotificationSent; +use App\Entity\MemberSubtripNotificationSent; use App\Entity\Preference; use App\Entity\Subtrip; use App\Entity\Trip; @@ -160,13 +160,20 @@ public function markSubtripAsHidden(Member $member, Subtrip $subtrip): void public function notifyHostsAboutTrip(Trip $trip): void { - $this->sendTripNotifications($trip, [Preference::TRIP_NOTIFICATIONS_IMMEDIATELY]); + foreach ($trip->getSubtrips() as $subtrip) { + $this->notifyHostsAboutVisitors($subtrip); + } + } + + public function notifyHostsAboutVisitors(Subtrip $subtrip): int + { + return $this->sendSubtripNotifications($subtrip, [Preference::TRIP_NOTIFICATIONS_IMMEDIATELY]); } public function sendScheduledTripNotifications( string $frequency, - DateTimeInterface $since, - DateTimeInterface $until, + DateTimeInterface $createdSince, + DateTimeInterface $createdUntil, ): int { if (!\in_array($frequency, self::SCHEDULED_TRIP_NOTIFICATIONS, true)) { throw new InvalidArgumentException('Unsupported trip notification frequency.'); @@ -174,11 +181,13 @@ public function sendScheduledTripNotifications( /** @var TripRepository $tripRepository */ $tripRepository = $this->entityManager->getRepository(Trip::class); - $trips = $tripRepository->findTripsCreatedBetween($since, $until); + $trips = $tripRepository->findTripsCreatedBetween($createdSince, $createdUntil); $sent = 0; foreach ($trips as $trip) { - $sent += $this->sendTripNotifications($trip, [$frequency]); + foreach ($trip->getSubtrips() as $subtrip) { + $sent += $this->sendSubtripNotifications($subtrip, [$frequency]); + } } return $sent; @@ -218,17 +227,17 @@ public function copyTrip(Trip $trip) return $newTrip; } - private function sendTripNotifications(Trip $trip, array $notificationValues): int + private function sendSubtripNotifications(Subtrip $subtrip, array $notificationValues): int { /** @var SubtripRepository $repository */ $repository = $this->entityManager->getRepository(Subtrip::class); - $hosts = $repository->getMembersToNotifyAboutTrip($trip, $notificationValues); + $hosts = $repository->getMembersToNotifyAboutSubtrip($subtrip, $notificationValues); if ([] === $hosts) { return 0; } - $sentRepository = $this->entityManager->getRepository(MemberTripNotificationSent::class); + $sentRepository = $this->entityManager->getRepository(MemberSubtripNotificationSent::class); $sent = []; $sentCount = 0; foreach ($hosts as $host) { @@ -238,21 +247,21 @@ private function sendTripNotifications(Trip $trip, array $notificationValues): i } $sent[$hostId] = true; - $lockName = $this->acquireTripNotificationLock($host, $trip); + $lockName = $this->acquireSubtripNotificationLock($host, $subtrip); if (null === $lockName) { continue; } try { - if (null !== $sentRepository->findOneBy(['member' => $host, 'trip' => $trip])) { + if (null !== $sentRepository->findOneBy(['member' => $host, 'subtrip' => $subtrip])) { continue; } - if (true !== $this->mailer?->sendTripNotificationEmail($host, $trip)) { + if (true !== $this->mailer?->sendTripNotificationEmail($host, $subtrip)) { continue; } - $this->entityManager->persist(new MemberTripNotificationSent($host, $trip)); + $this->entityManager->persist(new MemberSubtripNotificationSent($host, $subtrip)); $this->entityManager->flush(); ++$sentCount; } finally { @@ -263,9 +272,9 @@ private function sendTripNotifications(Trip $trip, array $notificationValues): i return $sentCount; } - private function acquireTripNotificationLock(Member $member, Trip $trip): ?string + private function acquireSubtripNotificationLock(Member $member, Subtrip $subtrip): ?string { - $lockName = \sprintf('trip-notification:%d:%d', $member->getId(), $trip->getId()); + $lockName = \sprintf('member-subtrip-notification-sent:%d:%d', $member->getId(), $subtrip->getId()); $locked = $this->entityManager->getConnection()->fetchOne('SELECT GET_LOCK(?, 0)', [$lockName]); return 1 === (int) $locked ? $lockName : null; diff --git a/src/Repository/SubtripRepository.php b/src/Repository/SubtripRepository.php index e1da9af4b9..c0a1428f43 100644 --- a/src/Repository/SubtripRepository.php +++ b/src/Repository/SubtripRepository.php @@ -8,7 +8,7 @@ use App\Entity\Member; use App\Entity\MemberSubtripHidden; use App\Entity\Preference; -use App\Entity\Trip; +use App\Entity\Subtrip; use Carbon\CarbonImmutable; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; @@ -65,13 +65,14 @@ public function getLegsInAreaQuery(Member $member, int $radius = 20, int $durati ->getQuery(); } - public function getMembersToNotifyAboutTrip( - Trip $trip, + public function getMembersToNotifyAboutSubtrip( + Subtrip $subtrip, array $notificationValues = [ Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, ], int $duration = 3, ): array { + $trip = $subtrip->getTrip(); $preferenceRepository = $this->getEntityManager()->getRepository(Preference::class); $tripNotificationPreference = $preferenceRepository->findOneBy([ 'codename' => Preference::TRIP_NOTIFICATIONS, @@ -80,7 +81,7 @@ public function getMembersToNotifyAboutTrip( 'codename' => Preference::TRIPS_VICINITY_RADIUS, ]); - if (null === $tripNotificationPreference || null === $radiusPreference) { + if (null === $trip || null === $tripNotificationPreference || null === $radiusPreference) { return []; } @@ -95,103 +96,97 @@ public function getMembersToNotifyAboutTrip( $now = CarbonImmutable::today(); $durationMonthsAhead = $now->addMonths($duration); - $hosts = []; - - foreach ($trip->getSubtrips() as $subtrip) { - $arrival = $subtrip->getArrival(); - $location = $subtrip->getLocation(); - $options = $subtrip->getOptions(); - - if ( - null === $arrival - || null === $location - || $arrival < $now - || $arrival > $durationMonthsAhead - || \in_array(SubtripOptionsType::PRIVATE, $options, true) - ) { - continue; - } - - $qb = $this->getEntityManager()->createQueryBuilder(); + $arrival = $subtrip->getArrival(); + $location = $subtrip->getLocation(); + $options = $subtrip->getOptions(); + + if ( + null === $arrival + || null === $location + || $arrival < $now + || $arrival > $durationMonthsAhead + || \in_array(SubtripOptionsType::PRIVATE, $options, true) + ) { + return []; + } + + $qb = $this->getEntityManager()->createQueryBuilder(); + $qb + ->select('DISTINCT host') + ->from(Member::class, 'host') + ->join('host.addresses', 'a', Join::WITH, 'a.active = true') + ->join('a.location', 'hostLocation') + ->leftJoin( + 'host.preferences', + 'tripNotifications', + Join::WITH, + 'tripNotifications.preference = :tripNotificationPreference' + ) + ->leftJoin( + 'host.preferences', + 'radiusPreference', + Join::WITH, + 'radiusPreference.preference = :radiusPreference' + ) + ->where($qb->expr()->in('host.status', ':activeStatuses')) + ->andWhere('host <> :creator') + ->andWhere('host.maxGuests >= :travellers') + ->andWhere('COALESCE(tripNotifications.value, :defaultNotification) IN (:tripNotificationValues)') + ->andWhere( + 'ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= 1000 * COALESCE(radiusPreference.value, :defaultRadius)' + ) + ->setParameter('activeStatuses', $activeStatuses) + ->setParameter('creator', $trip->getCreator()) + ->setParameter('travellers', $trip->getCountOfTravellers()) + ->setParameter('tripNotificationPreference', $tripNotificationPreference) + ->setParameter('radiusPreference', $radiusPreference) + ->setParameter('defaultNotification', Preference::TRIP_NOTIFICATIONS_NEVER) + ->setParameter( + 'tripNotificationValues', + $notificationValues + ) + ->setParameter('defaultRadius', $radiusPreference->getDefaultValue()) + ->setParameter('centerPoint', \sprintf('POINT(%F %F)', $location->getLongitude(), $location->getLatitude())) + ; + + if (null !== $subtrip->getInvitedBy() && !\in_array(SubtripOptionsType::MEET_LOCALS, $options, true)) { $qb - ->select('DISTINCT host') - ->from(Member::class, 'host') - ->join('host.addresses', 'a', Join::WITH, 'a.active = true') - ->join('a.location', 'hostLocation') - ->leftJoin( - 'host.preferences', - 'tripNotifications', - Join::WITH, - 'tripNotifications.preference = :tripNotificationPreference' - ) - ->leftJoin( - 'host.preferences', - 'radiusPreference', - Join::WITH, - 'radiusPreference.preference = :radiusPreference' - ) - ->where($qb->expr()->in('host.status', ':activeStatuses')) - ->andWhere('host <> :creator') - ->andWhere('host.maxGuests >= :travellers') - ->andWhere('COALESCE(tripNotifications.value, :defaultNotification) IN (:tripNotificationValues)') - ->andWhere( - 'ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= 1000 * COALESCE(radiusPreference.value, :defaultRadius)' - ) - ->setParameter('activeStatuses', $activeStatuses) - ->setParameter('creator', $trip->getCreator()) - ->setParameter('travellers', $trip->getCountOfTravellers()) - ->setParameter('tripNotificationPreference', $tripNotificationPreference) - ->setParameter('radiusPreference', $radiusPreference) - ->setParameter('defaultNotification', Preference::TRIP_NOTIFICATIONS_NEVER) - ->setParameter( - 'tripNotificationValues', - $notificationValues - ) - ->setParameter('defaultRadius', $radiusPreference->getDefaultValue()) - ->setParameter('centerPoint', \sprintf('POINT(%F %F)', $location->getLongitude(), $location->getLatitude())) + ->andWhere('host = :invitedBy') + ->setParameter('invitedBy', $subtrip->getInvitedBy()) + ; + } + + if (0 === $trip->getInvitationRadius()) { + $qb + ->andWhere('a.location = :location') + ->setParameter('location', $location) ; + } else { + $center = new GeoPoint((float) $location->getLatitude(), (float) $location->getLongitude()); + $boundingBox = $center->boundingBox($trip->getInvitationRadius(), 'km'); + $lineString = \sprintf( + 'LINESTRING(%F %F, %F %F)', + $boundingBox->getMinLongitude(), + $boundingBox->getMinLatitude(), + $boundingBox->getMaxLongitude(), + $boundingBox->getMaxLatitude() + ); - if (null !== $subtrip->getInvitedBy() && !\in_array(SubtripOptionsType::MEET_LOCALS, $options, true)) { - $qb - ->andWhere('host = :invitedBy') - ->setParameter('invitedBy', $subtrip->getInvitedBy()) - ; - } - - if (0 === $trip->getInvitationRadius()) { - $qb - ->andWhere('a.location = :location') - ->setParameter('location', $location) - ; - } else { - $center = new GeoPoint((float) $location->getLatitude(), (float) $location->getLongitude()); - $boundingBox = $center->boundingBox($trip->getInvitationRadius(), 'km'); - $lineString = \sprintf( - 'LINESTRING(%F %F, %F %F)', - $boundingBox->getMinLongitude(), - $boundingBox->getMinLatitude(), - $boundingBox->getMaxLongitude(), - $boundingBox->getMaxLatitude() - ); - - $qb - ->andWhere('hostLocation.latitude BETWEEN :minLat AND :maxLat') - ->andWhere('hostLocation.longitude BETWEEN :minLng AND :maxLng') - ->andWhere('MBRContains(ST_Envelope(ST_GeomFromText(:lineString, 0)), hostLocation.coordinates) = 1') - ->andWhere('ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= :invitationRadiusMeters') - ->setParameter('minLat', $boundingBox->getMinLatitude()) - ->setParameter('maxLat', $boundingBox->getMaxLatitude()) - ->setParameter('minLng', $boundingBox->getMinLongitude()) - ->setParameter('maxLng', $boundingBox->getMaxLongitude()) - ->setParameter('lineString', $lineString) - ->setParameter('invitationRadiusMeters', $trip->getInvitationRadius() * 1000) - ; - } - - array_push($hosts, ...$qb->getQuery()->getResult()); + $qb + ->andWhere('hostLocation.latitude BETWEEN :minLat AND :maxLat') + ->andWhere('hostLocation.longitude BETWEEN :minLng AND :maxLng') + ->andWhere('MBRContains(ST_Envelope(ST_GeomFromText(:lineString, 0)), hostLocation.coordinates) = 1') + ->andWhere('ST_Distance_Sphere(hostLocation.coordinates, ST_GeomFromText(:centerPoint, 0)) <= :invitationRadiusMeters') + ->setParameter('minLat', $boundingBox->getMinLatitude()) + ->setParameter('maxLat', $boundingBox->getMaxLatitude()) + ->setParameter('minLng', $boundingBox->getMinLongitude()) + ->setParameter('maxLng', $boundingBox->getMaxLongitude()) + ->setParameter('lineString', $lineString) + ->setParameter('invitationRadiusMeters', $trip->getInvitationRadius() * 1000) + ; } - return $hosts; + return $qb->getQuery()->getResult(); } private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $duration): QueryBuilder diff --git a/src/Service/Mailer.php b/src/Service/Mailer.php index b956f3995c..41e9149172 100644 --- a/src/Service/Mailer.php +++ b/src/Service/Mailer.php @@ -7,7 +7,7 @@ use App\Entity\Friend; use App\Entity\Member; use App\Entity\Newsletter; -use App\Entity\Trip; +use App\Entity\Subtrip; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use Symfony\Bridge\Twig\Mime\TemplatedEmail; @@ -190,8 +190,9 @@ public function sendFriendshipNotification(Friend $friend, Member $requester): b ); } - public function sendTripNotificationEmail(Member $receiver, Trip $trip): bool + public function sendTripNotificationEmail(Member $receiver, Subtrip $subtrip): bool { + $trip = $subtrip->getTrip(); $sender = $trip->getCreator(); return $this->sendTemplateEmail( @@ -206,6 +207,7 @@ public function sendTripNotificationEmail(Member $receiver, Trip $trip): bool 'username' => $sender->getUsername(), ], ], + 'subtrip' => $subtrip, 'trip' => $trip, ] ); diff --git a/tests/Model/ConversationModelTest.php b/tests/Model/ConversationModelTest.php index 9396b43ab7..8df6e4ac03 100644 --- a/tests/Model/ConversationModelTest.php +++ b/tests/Model/ConversationModelTest.php @@ -106,6 +106,20 @@ public function testFormatConversationDetectsSpamPatterns(): void $this->assertEquals(SpamInfoType::SPAM_BLOCKED_WORD, $message->getSpamInfo()); } + public function testFormatConversationDetectsUppercaseObfuscatedAt(): void + { + $message = new Message(); + $message->setMessage('TEST (AT) EXAMPLE.COM'); + $message->setStatus(MessageStatusType::SENT); + $message->setFolder(InFolderType::NORMAL); + + $this->model->formatConversation($message); + + $this->assertEquals(InFolderType::SPAM, $message->getFolder()); + $this->assertEquals(MessageStatusType::CHECK, $message->getStatus()); + $this->assertEquals(SpamInfoType::SPAM_BLOCKED_WORD, $message->getSpamInfo()); + } + public function testFormatConversationIgnoresCleanMessages(): void { $message = new Message(); diff --git a/tests/Model/CreatedTripsRepository.php b/tests/Model/CreatedTripsRepository.php deleted file mode 100644 index c9c0e445ab..0000000000 --- a/tests/Model/CreatedTripsRepository.php +++ /dev/null @@ -1,29 +0,0 @@ -since = $since; - $this->until = $until; - - return $this->trips; - } -} diff --git a/tests/Model/MatchingHostsRepository.php b/tests/Model/MatchingHostsRepository.php deleted file mode 100644 index a0068daad1..0000000000 --- a/tests/Model/MatchingHostsRepository.php +++ /dev/null @@ -1,29 +0,0 @@ -calls[] = [$trip, $notificationValues, $duration]; - - return $this->hosts; - } -} diff --git a/tests/Model/SentTripNotificationsRepository.php b/tests/Model/SentTripNotificationsRepository.php deleted file mode 100644 index 99cde03f5d..0000000000 --- a/tests/Model/SentTripNotificationsRepository.php +++ /dev/null @@ -1,32 +0,0 @@ -finds[] = $criteria; - - foreach ($this->existing as $sent) { - if ($sent->getMember() === $criteria['member'] && $sent->getTrip() === $criteria['trip']) { - return $sent; - } - } - - return null; - } -} diff --git a/tests/Model/TripModelNotificationTest.php b/tests/Model/TripModelNotificationTest.php index 56d4613670..d3e1eafbaf 100644 --- a/tests/Model/TripModelNotificationTest.php +++ b/tests/Model/TripModelNotificationTest.php @@ -4,11 +4,13 @@ use App\Entity\Member; use App\Entity\MemberSubtripHidden; -use App\Entity\MemberTripNotificationSent; +use App\Entity\MemberSubtripNotificationSent; use App\Entity\Preference; use App\Entity\Subtrip; use App\Entity\Trip; use App\Model\TripModel; +use App\Repository\SubtripRepository; +use App\Repository\TripRepository; use App\Service\Mailer; use DateTime; use DateTimeImmutable; @@ -81,173 +83,160 @@ public function testMarkSubtripAsHiddenDoesNotDuplicateHiddenState(): void $tripModel->markSubtripAsHidden($member, $subtrip); } - public function testNotifyHostsAboutTripSendsEmailsToMatchingHostsOnce(): void + public function testNotifyHostsAboutVisitorsSendsEmailsToMatchingHostsOnce(): void { - $creator = new Member()->setUsername('traveller'); + [, $leg] = $this->createTripWithLeg(); $host = new Member(); $sameHost = new Member(); $this->setEntityId($host, 12); $this->setEntityId($sameHost, 12); - $trip = new Trip()->setCreator($creator); - $this->setEntityId($trip, 42); - - $subtripRepository = $this->createSubtripRepository([$host, $sameHost]); - $sentRepository = new SentTripNotificationsRepository(); + $subtripCalls = []; + $finds = []; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager - ->expects($this->exactly(2)) ->method('getRepository') ->willReturnMap([ - [Subtrip::class, $subtripRepository], - [MemberTripNotificationSent::class, $sentRepository], + [Subtrip::class, $this->createSubtripRepository([$host, $sameHost], $subtripCalls)], + [MemberSubtripNotificationSent::class, $this->createSentRepository(null, $finds)], ]) ; $entityManager ->expects($this->once()) ->method('persist') - ->with($this->callback(static function (MemberTripNotificationSent $sent) use ($host, $trip): bool { - return $sent->getMember() === $host && $sent->getTrip() === $trip; + ->with($this->callback(static function (MemberSubtripNotificationSent $sent) use ($host, $leg): bool { + return $sent->getMember() === $host && $sent->getSubtrip() === $leg; })) ; $entityManager->expects($this->once())->method('flush'); $this->allowTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); $mailer ->expects($this->once()) ->method('sendTripNotificationEmail') - ->with($host, $trip) + ->with($host, $leg) ->willReturn(true) ; $tripModel = new TripModel($entityManager, $mailer); - $tripModel->notifyHostsAboutTrip($trip); + $tripModel->notifyHostsAboutVisitors($leg); $this->assertSame([ - [$trip, [ - Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, - ], 3], - ], $subtripRepository->calls); - $this->assertCount(1, $sentRepository->finds); + [$leg, [Preference::TRIP_NOTIFICATIONS_IMMEDIATELY], 3], + ], $subtripCalls); + $this->assertCount(1, $finds); } - public function testNotifyHostsAboutTripDoesNotFlushWhenNoHostMatches(): void + public function testNotifyHostsAboutVisitorsDoesNotFlushWhenNoHostMatches(): void { - $trip = new Trip(); - $subtripRepository = $this->createSubtripRepository([]); + [, $leg] = $this->createTripWithLeg(); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager ->expects($this->once()) ->method('getRepository') ->with(Subtrip::class) - ->willReturn($subtripRepository) + ->willReturn($this->createSubtripRepository([])) ; $entityManager->expects($this->never())->method('persist'); $entityManager->expects($this->never())->method('flush'); $tripModel = new TripModel($entityManager); - $tripModel->notifyHostsAboutTrip($trip); + $tripModel->notifyHostsAboutVisitors($leg); } - public function testNotifyHostsAboutTripSkipsAlreadySentNotification(): void + public function testNotifyHostsAboutVisitorsSkipsAlreadySentNotification(): void { - $creator = new Member()->setUsername('traveller'); + [, $leg] = $this->createTripWithLeg(); $host = new Member(); $this->setEntityId($host, 12); - $trip = new Trip()->setCreator($creator); - $this->setEntityId($trip, 42); - - $subtripRepository = $this->createSubtripRepository([$host]); - $sentRepository = new SentTripNotificationsRepository([ - new MemberTripNotificationSent($host, $trip), - ]); + $finds = []; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager - ->expects($this->exactly(2)) ->method('getRepository') ->willReturnMap([ - [Subtrip::class, $subtripRepository], - [MemberTripNotificationSent::class, $sentRepository], + [Subtrip::class, $this->createSubtripRepository([$host])], + [MemberSubtripNotificationSent::class, $this->createSentRepository(new MemberSubtripNotificationSent($host, $leg), $finds)], ]) ; $entityManager->expects($this->never())->method('persist'); $entityManager->expects($this->never())->method('flush'); $this->allowTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); $mailer->expects($this->never())->method('sendTripNotificationEmail'); $tripModel = new TripModel($entityManager, $mailer); - $tripModel->notifyHostsAboutTrip($trip); + $tripModel->notifyHostsAboutVisitors($leg); - $this->assertCount(1, $sentRepository->finds); + $this->assertCount(1, $finds); } - public function testNotifyHostsAboutTripSkipsHostWhenNotificationLockIsBusy(): void + public function testNotifyHostsAboutVisitorsSkipsHostWhenNotificationLockIsBusy(): void { - $creator = new Member()->setUsername('traveller'); + [, $leg] = $this->createTripWithLeg(); $host = new Member(); $this->setEntityId($host, 12); - $trip = new Trip()->setCreator($creator); - $this->setEntityId($trip, 42); - - $subtripRepository = $this->createSubtripRepository([$host]); - $sentRepository = new SentTripNotificationsRepository(); + $finds = []; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager - ->expects($this->exactly(2)) ->method('getRepository') ->willReturnMap([ - [Subtrip::class, $subtripRepository], - [MemberTripNotificationSent::class, $sentRepository], + [Subtrip::class, $this->createSubtripRepository([$host])], + [MemberSubtripNotificationSent::class, $this->createSentRepository(null, $finds)], ]) ; $entityManager->expects($this->never())->method('persist'); $entityManager->expects($this->never())->method('flush'); $this->denyTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); $mailer->expects($this->never())->method('sendTripNotificationEmail'); $tripModel = new TripModel($entityManager, $mailer); - $tripModel->notifyHostsAboutTrip($trip); + $tripModel->notifyHostsAboutVisitors($leg); - $this->assertSame([], $sentRepository->finds); + $this->assertSame([], $finds); } public function testSendScheduledTripNotificationsUsesFrequencyAndCreatedWindow(): void { - $creator = new Member()->setUsername('traveller'); + [$trip, $leg] = $this->createTripWithLeg(); $host = new Member(); $this->setEntityId($host, 12); - $trip = new Trip()->setCreator($creator); - $this->setEntityId($trip, 42); $since = new DateTimeImmutable('2026-06-27 00:00:00'); $until = new DateTimeImmutable('2026-06-28 00:00:00'); + $subtripCalls = []; - $tripRepository = new CreatedTripsRepository([$trip]); - $subtripRepository = $this->createSubtripRepository([$host]); - $sentRepository = new SentTripNotificationsRepository(); + $tripRepository = $this->createMock(TripRepository::class); + $tripRepository + ->expects($this->once()) + ->method('findTripsCreatedBetween') + ->with($since, $until) + ->willReturn([$trip]) + ; $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager - ->expects($this->exactly(3)) ->method('getRepository') ->willReturnMap([ [Trip::class, $tripRepository], - [Subtrip::class, $subtripRepository], - [MemberTripNotificationSent::class, $sentRepository], + [Subtrip::class, $this->createSubtripRepository([$host], $subtripCalls)], + [MemberSubtripNotificationSent::class, $this->createSentRepository()], ]) ; $entityManager->expects($this->once())->method('persist'); $entityManager->expects($this->once())->method('flush'); $this->allowTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); $mailer ->expects($this->once()) ->method('sendTripNotificationEmail') - ->with($host, $trip) + ->with($host, $leg) ->willReturn(true) ; @@ -259,39 +248,38 @@ public function testSendScheduledTripNotificationsUsesFrequencyAndCreatedWindow( ); $this->assertSame(1, $sent); - $this->assertSame($since, $tripRepository->since); - $this->assertSame($until, $tripRepository->until); $this->assertSame([ - [$trip, [Preference::TRIP_NOTIFICATIONS_DAILY], 3], - ], $subtripRepository->calls); + [$leg, [Preference::TRIP_NOTIFICATIONS_DAILY], 3], + ], $subtripCalls); } public function testSendScheduledTripNotificationsDoesNotStoreFailedSend(): void { - $creator = new Member()->setUsername('traveller'); + [$trip, $leg] = $this->createTripWithLeg(); $host = new Member(); $this->setEntityId($host, 12); - $trip = new Trip()->setCreator($creator); - $this->setEntityId($trip, 42); + + $tripRepository = $this->createStub(TripRepository::class); + $tripRepository->method('findTripsCreatedBetween')->willReturn([$trip]); $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager - ->expects($this->exactly(3)) ->method('getRepository') ->willReturnMap([ - [Trip::class, new CreatedTripsRepository([$trip])], + [Trip::class, $tripRepository], [Subtrip::class, $this->createSubtripRepository([$host])], - [MemberTripNotificationSent::class, new SentTripNotificationsRepository()], + [MemberSubtripNotificationSent::class, $this->createSentRepository()], ]) ; $entityManager->expects($this->never())->method('persist'); $entityManager->expects($this->never())->method('flush'); $this->allowTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); $mailer ->expects($this->once()) ->method('sendTripNotificationEmail') - ->with($host, $trip) + ->with($host, $leg) ->willReturn(false) ; @@ -325,15 +313,71 @@ public function testCopyTripDoesNotNotifyHostsAboutCopiedTrip(): void $this->assertSame([], $tripModel->notifiedTrips); } - private function setEntityId(object $entity, int $id): void + private function createSentRepository( + ?MemberSubtripNotificationSent $existing = null, + array &$finds = [], + ): EntityRepository { + $repository = $this->createStub(EntityRepository::class); + $repository + ->method('findOneBy') + ->willReturnCallback(static function (array $criteria) use ($existing, &$finds): ?MemberSubtripNotificationSent { + $finds[] = $criteria; + + if ( + null !== $existing + && $existing->getMember() === $criteria['member'] + && $existing->getSubtrip() === $criteria['subtrip'] + ) { + return $existing; + } + + return null; + }) + ; + + return $repository; + } + + /** + * @param Member[] $hosts + */ + private function createSubtripRepository(array $hosts, array &$calls = []): SubtripRepository { - $property = new ReflectionProperty($entity, 'id'); - $property->setValue($entity, $id); + $repository = $this->createStub(SubtripRepository::class); + $repository + ->method('getMembersToNotifyAboutSubtrip') + ->willReturnCallback(static function ( + Subtrip $subtrip, + array $notificationValues = [Preference::TRIP_NOTIFICATIONS_IMMEDIATELY], + int $duration = 3, + ) use ($hosts, &$calls): array { + $calls[] = [$subtrip, $notificationValues, $duration]; + + return $hosts; + }) + ; + + return $repository; } - private function createSubtripRepository(array $hosts): MatchingHostsRepository + /** + * @return array{Trip, Subtrip} + */ + private function createTripWithLeg(): array { - return new MatchingHostsRepository($hosts); + $trip = new Trip()->setCreator(new Member()->setUsername('traveller')); + $leg = new Subtrip(); + $trip->addSubtrip($leg); + $this->setEntityId($trip, 42); + $this->setEntityId($leg, 7); + + return [$trip, $leg]; + } + + private function setEntityId(object $entity, int $id): void + { + $property = new ReflectionProperty($entity, 'id'); + $property->setValue($entity, $id); } private function allowTripNotificationLocks(EntityManagerInterface $entityManager): void diff --git a/translations/missing/general.yaml b/translations/missing/general.yaml index 23a1b940de..6a6ff972fa 100644 --- a/translations/missing/general.yaml +++ b/translations/missing/general.yaml @@ -63,8 +63,8 @@ help.password: label.trips_notifications: - How often do you want to be informed about members visiting your area? - Label for the drop down for trips notifications. -trips.no: - - '@no' +trips.never: + - Never - One of the options for trips notifications trips.immediately: - Immediately diff --git a/translations/missing/preference.yaml b/translations/missing/preference.yaml index f85730cb25..db6101a187 100644 --- a/translations/missing/preference.yaml +++ b/translations/missing/preference.yaml @@ -59,8 +59,8 @@ tripsnotifications: trips.notifications: - Choose whether to be notified when members add trips in your area. - Help text on the preferences page for trip notifications. -tripsnotificationsno: - - '@no' +tripsnotificationsnever: + - Never - Option on the preferences page for trip notifications. tripsnotificationsimmediately: - Immediately