From 42bfe60fa81b2667af2c408c6f3d4423d012981c Mon Sep 17 00:00:00 2001 From: buyolitsez Date: Sat, 27 Jun 2026 11:23:34 +0300 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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);