diff --git a/Migrations/Version20260626100000.php b/Migrations/Version20260626100000.php new file mode 100644 index 000000000..b8449b8ab --- /dev/null +++ b/Migrations/Version20260626100000.php @@ -0,0 +1,33 @@ +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'"); + } + + #[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_trip_notification_sent'); + $this->addSql('DROP TABLE IF EXISTS member_subtrip_hidden'); + } +} diff --git a/assets/js/landing/landing.js b/assets/js/landing/landing.js index da2092601..80a876990 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 794601fee..616d41e53 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 ed8ccc69f..98395b0a0 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 4735448a6..62b7ea709 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;Immediately;Daily;Weekly;Monthly + Status: 'Normal' diff --git a/src/Command/SendTripNotificationsCommand.php b/src/Command/SendTripNotificationsCommand.php new file mode 100644 index 000000000..80c6de946 --- /dev/null +++ b/src/Command/SendTripNotificationsCommand.php @@ -0,0 +1,59 @@ + [Preference::TRIP_NOTIFICATIONS_DAILY, 'P1D'], + 'weekly' => [Preference::TRIP_NOTIFICATIONS_WEEKLY, 'P7D'], + 'monthly' => [Preference::TRIP_NOTIFICATIONS_MONTHLY, 'P31D'], + ]; + + public function __construct( + private readonly TripModel $tripModel, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addArgument('frequency', InputArgument::REQUIRED, 'daily, weekly or monthly'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $frequency = strtolower((string) $input->getArgument('frequency')); + + if (!isset(self::FREQUENCIES[$frequency])) { + $io->error('Frequency must be daily, weekly or monthly.'); + + return Command::INVALID; + } + + [$preferenceValue, $period] = self::FREQUENCIES[$frequency]; + $until = new DateTimeImmutable(); + $since = $until->sub(new DateInterval($period)); + $sent = $this->tripModel->sendScheduledTripNotifications($preferenceValue, $since, $until); + + $io->success(\sprintf('Sent %d trip notification emails.', $sent)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/TripController.php b/src/Controller/TripController.php index 19820dc1b..7a48f8932 100644 --- a/src/Controller/TripController.php +++ b/src/Controller/TripController.php @@ -22,6 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Component\Security\Http\Attribute\IsGranted; class TripController extends AbstractController @@ -100,6 +101,7 @@ public function create(Request $request, EntityManagerInterface $entityManager): $entityManager->persist($trip); $entityManager->flush(); + $this->tripModel->notifyHostsAboutTrip($trip); $this->addTranslatedFlash('success', 'trip.created'); @@ -179,6 +181,21 @@ public function copy(Trip $trip): Response return $this->redirectToRoute('trip_edit', ['id' => $newTrip->getId()]); } + #[Route(path: '/trip/leg/{id}/hide', name: 'trip_hide', requirements: ['id' => '\d+'], methods: ['POST'])] + #[IsCsrfTokenValid('hide_subtrip')] + public function hideSubtrip(Request $request, Subtrip $subtrip): RedirectResponse + { + /** @var Member $member */ + $member = $this->getUser(); + $this->tripModel->markSubtripAsHidden($member, $subtrip); + + 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/MemberSubtripHidden.php b/src/Entity/MemberSubtripHidden.php new file mode 100644 index 000000000..49c0e07a7 --- /dev/null +++ b/src/Entity/MemberSubtripHidden.php @@ -0,0 +1,56 @@ +member = $member; + $this->subtrip = $subtrip; + } + + public function getMember(): Member + { + return $this->member; + } + + public function getSubtrip(): Subtrip + { + return $this->subtrip; + } + + public function getCreated(): DateTime + { + return $this->created; + } + + #[ORM\PrePersist] + public function onPrePersist(): void + { + $this->created = new DateTime('now'); + } +} diff --git a/src/Entity/MemberTripNotificationSent.php b/src/Entity/MemberTripNotificationSent.php new file mode 100644 index 000000000..6f23ed553 --- /dev/null +++ b/src/Entity/MemberTripNotificationSent.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 4572492fd..df06cc43d 100644 --- a/src/Entity/Preference.php +++ b/src/Entity/Preference.php @@ -34,6 +34,12 @@ 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 = 'Immediately'; + 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 435bd32b8..a02e4c7b3 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,19 @@ 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.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/SignupModel.php b/src/Model/SignupModel.php index 30d82139f..8fbcf1a32 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 a2b5149af..f5157e8e0 100644 --- a/src/Model/TripModel.php +++ b/src/Model/TripModel.php @@ -3,20 +3,33 @@ namespace App\Model; use App\Entity\Member; +use App\Entity\MemberSubtripHidden; +use App\Entity\MemberTripNotificationSent; 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 DateTimeInterface; use Doctrine\ORM\EntityManagerInterface; +use InvalidArgumentException; use Pagerfanta\Doctrine\ORM\QueryAdapter; use Pagerfanta\Pagerfanta; class TripModel { private const array ALLOWED_TRIPS_RADIUS = [0, 5, 10, 20, 50, 100]; + private const array SCHEDULED_TRIP_NOTIFICATIONS = [ + Preference::TRIP_NOTIFICATIONS_DAILY, + Preference::TRIP_NOTIFICATIONS_WEEKLY, + Preference::TRIP_NOTIFICATIONS_MONTHLY, + ]; public function __construct( private readonly EntityManagerInterface $entityManager, + private readonly ?Mailer $mailer = null, ) { } @@ -134,6 +147,43 @@ public function hideTrip(Trip $trip): void $this->entityManager->flush(); } + public function markSubtripAsHidden(Member $member, Subtrip $subtrip): void + { + $repository = $this->entityManager->getRepository(MemberSubtripHidden::class); + $hidden = $repository->findOneBy(['member' => $member, 'subtrip' => $subtrip]); + + if (null === $hidden) { + $this->entityManager->persist(new MemberSubtripHidden($member, $subtrip)); + $this->entityManager->flush(); + } + } + + public function notifyHostsAboutTrip(Trip $trip): void + { + $this->sendTripNotifications($trip, [Preference::TRIP_NOTIFICATIONS_IMMEDIATELY]); + } + + public function sendScheduledTripNotifications( + string $frequency, + DateTimeInterface $since, + DateTimeInterface $until, + ): int { + if (!\in_array($frequency, self::SCHEDULED_TRIP_NOTIFICATIONS, true)) { + throw new InvalidArgumentException('Unsupported trip notification frequency.'); + } + + /** @var TripRepository $tripRepository */ + $tripRepository = $this->entityManager->getRepository(Trip::class); + $trips = $tripRepository->findTripsCreatedBetween($since, $until); + + $sent = 0; + foreach ($trips as $trip) { + $sent += $this->sendTripNotifications($trip, [$frequency]); + } + + return $sent; + } + public function hasTripExpired(Trip $trip) { return $trip->isExpired(); @@ -167,4 +217,62 @@ public function copyTrip(Trip $trip) return $newTrip; } + + private function sendTripNotifications(Trip $trip, array $notificationValues): int + { + /** @var SubtripRepository $repository */ + $repository = $this->entityManager->getRepository(Subtrip::class); + $hosts = $repository->getMembersToNotifyAboutTrip($trip, $notificationValues); + + if ([] === $hosts) { + return 0; + } + + $sentRepository = $this->entityManager->getRepository(MemberTripNotificationSent::class); + $sent = []; + $sentCount = 0; + foreach ($hosts as $host) { + $hostId = $host->getId(); + if (isset($sent[$hostId])) { + continue; + } + + $sent[$hostId] = true; + $lockName = $this->acquireTripNotificationLock($host, $trip); + if (null === $lockName) { + continue; + } + + try { + if (null !== $sentRepository->findOneBy(['member' => $host, 'trip' => $trip])) { + continue; + } + + if (true !== $this->mailer?->sendTripNotificationEmail($host, $trip)) { + continue; + } + + $this->entityManager->persist(new MemberTripNotificationSent($host, $trip)); + $this->entityManager->flush(); + ++$sentCount; + } finally { + $this->releaseTripNotificationLock($lockName); + } + } + + return $sentCount; + } + + private function acquireTripNotificationLock(Member $member, Trip $trip): ?string + { + $lockName = \sprintf('trip-notification:%d:%d', $member->getId(), $trip->getId()); + $locked = $this->entityManager->getConnection()->fetchOne('SELECT GET_LOCK(?, 0)', [$lockName]); + + return 1 === (int) $locked ? $lockName : null; + } + + private function releaseTripNotificationLock(string $lockName): void + { + $this->entityManager->getConnection()->fetchOne('SELECT RELEASE_LOCK(?)', [$lockName]); + } } diff --git a/src/Repository/SubtripRepository.php b/src/Repository/SubtripRepository.php index 24a65972f..e1da9af4b 100644 --- a/src/Repository/SubtripRepository.php +++ b/src/Repository/SubtripRepository.php @@ -2,11 +2,17 @@ namespace App\Repository; +use AnthonyMartin\GeoLocation\GeoPoint; +use App\Doctrine\MemberStatusType; use App\Doctrine\SubtripOptionsType; use App\Entity\Member; +use App\Entity\MemberSubtripHidden; +use App\Entity\Preference; +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,135 @@ public function getLegsInAreaQuery(Member $member, int $radius = 20, int $durati ->getQuery(); } + public function getMembersToNotifyAboutTrip( + Trip $trip, + array $notificationValues = [ + Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + ], + 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 []; + } + + 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); + $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' + ) + ->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 + ->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 $hosts; + } + private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $duration): QueryBuilder { $address = $member->getActiveAddress(); @@ -66,7 +201,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 +209,7 @@ private function getLegsInAreaQueryBuilder(Member $member, int $distance, int $d ->join('s.location', 'l') ->join('s.trip', 't') ->join('t.creator', 'm') + ->leftJoin(MemberSubtripHidden::class, 'hiddenSubtrip', Join::WITH, 'hiddenSubtrip.subtrip = s AND hiddenSubtrip.member = :member') ->where($qb->expr()->notLike('s.options', $qb->expr()->literal('%' . SubtripOptionsType::PRIVATE . '%'))) ->andWhere( $qb->expr()->orX( @@ -87,6 +223,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('hiddenSubtrip.id')) ->andWhere('GeoDistance(:latitude, :longitude, l.latitude, l.longitude) <= :distance') ->andWhere( $qb->expr()->orX( diff --git a/src/Repository/TripRepository.php b/src/Repository/TripRepository.php index e1a2034c2..a0a2cb277 100644 --- a/src/Repository/TripRepository.php +++ b/src/Repository/TripRepository.php @@ -4,6 +4,7 @@ use App\Entity\Member; use DateTime; +use DateTimeInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; @@ -31,4 +32,21 @@ public function queryTripsOfMember(Member $member) ->orderBy('t.created', 'DESC') ->getQuery(); } + + /** + * @return array + */ + public function findTripsCreatedBetween(DateTimeInterface $since, DateTimeInterface $until): array + { + return $this->createQueryBuilder('t') + ->where('t.created >= :since') + ->andWhere('t.created < :until') + ->andWhere('t.deleted IS NULL') + ->setParameter('since', $since) + ->setParameter('until', $until) + ->orderBy('t.created', 'ASC') + ->getQuery() + ->getResult() + ; + } } diff --git a/src/Service/Mailer.php b/src/Service/Mailer.php index f55fd5665..b956f3995 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 000000000..bdf05d3ee --- /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 67a38926a..a6a996786 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 53e8e7d90..9afa325b7 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 da2e8b0d6..648eb4557 100644 --- a/templates/trip/leg.html.twig +++ b/templates/trip/leg.html.twig @@ -2,6 +2,13 @@ - diff --git a/tests/Command/SendTripNotificationsCommandTest.php b/tests/Command/SendTripNotificationsCommandTest.php new file mode 100644 index 000000000..296caa5f3 --- /dev/null +++ b/tests/Command/SendTripNotificationsCommandTest.php @@ -0,0 +1,62 @@ + ['daily', Preference::TRIP_NOTIFICATIONS_DAILY, 1]; + yield 'weekly' => ['weekly', Preference::TRIP_NOTIFICATIONS_WEEKLY, 7]; + yield 'monthly' => ['monthly', Preference::TRIP_NOTIFICATIONS_MONTHLY, 31]; + } + + #[DataProvider('frequencyProvider')] + public function testExecuteSendsExpectedFrequency(string $argument, string $preference, int $days): void + { + $tripModel = $this->createMock(TripModel::class); + $tripModel + ->expects($this->once()) + ->method('sendScheduledTripNotifications') + ->with( + $preference, + $this->callback(static function (DateTimeInterface $since) use ($days): bool { + $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'); + }), + $this->isInstanceOf(DateTimeInterface::class), + ) + ->willReturn(3) + ; + + $tester = new CommandTester(new SendTripNotificationsCommand($tripModel)); + + $this->assertSame(Command::SUCCESS, $tester->execute(['frequency' => $argument])); + $this->assertStringContainsString('Sent 3 trip notification emails.', $tester->getDisplay()); + } + + public function testExecuteRejectsInvalidFrequency(): void + { + $tripModel = $this->createMock(TripModel::class); + $tripModel->expects($this->never())->method('sendScheduledTripNotifications'); + + $tester = new CommandTester(new SendTripNotificationsCommand($tripModel)); + + $this->assertSame(Command::INVALID, $tester->execute(['frequency' => 'yearly'])); + $this->assertStringContainsString('Frequency must be daily, weekly or monthly.', $tester->getDisplay()); + } +} diff --git a/tests/Model/AboutModelTest.php b/tests/Model/AboutModelTest.php index 72dacb9ca..ecaec5881 100644 --- a/tests/Model/AboutModelTest.php +++ b/tests/Model/AboutModelTest.php @@ -24,12 +24,12 @@ public function testGetFeedbackCategoriesReturnsResult(): void $query = $this->createStub(Query::class); $query->method('getResult')->willReturn($expectedCategories); - $qb = $this->createStub(QueryBuilder::class); - $qb->method('select')->willReturnSelf(); - $qb->method('from')->willReturnSelf(); - $qb->method('where')->willReturnSelf(); - $qb->method('orderBy')->willReturnSelf(); - $qb->method('indexBy')->willReturnSelf(); + $qb = $this->createMock(QueryBuilder::class); + $qb->expects($this->once())->method('select')->with('c')->willReturnSelf(); + $qb->expects($this->once())->method('from')->with(FeedbackCategory::class, 'c')->willReturnSelf(); + $qb->expects($this->once())->method('where')->with('c.visible = 1')->willReturnSelf(); + $qb->expects($this->once())->method('orderBy')->with('c.sortorder', 'ASC')->willReturnSelf(); + $qb->expects($this->once())->method('indexBy')->with('c', 'c.id')->willReturnSelf(); $qb->method('getQuery')->willReturn($query); $mailer = $this->createStub(Mailer::class); @@ -45,10 +45,6 @@ public function testGetFeedbackCategoriesReturnsResult(): void public function testSendFeedbackEmailTriggersMailer(): void { // Side-effect test: verify mailer is called. - $mailer = $this->createMock(Mailer::class); - $mailer->expects($this->once())->method('sendFeedbackEmail'); - $entityManager = $this->createStub(EntityManagerInterface::class); - $category = new FeedbackCategory(); $category->setEmailtonotify('admin@example.com'); @@ -58,6 +54,18 @@ public function testSendFeedbackEmailTriggersMailer(): void 'message' => 'hello', ]; + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects($this->once()) + ->method('sendFeedbackEmail') + ->with( + 'test@test.com', + $this->callback(static fn ($address) => 'admin@example.com' === $address->getAddress()), + $data + ) + ; + $entityManager = $this->createStub(EntityManagerInterface::class); + $aboutModel = new AboutModel($entityManager, $mailer); $aboutModel->sendFeedbackEmail($data); } @@ -65,10 +73,6 @@ public function testSendFeedbackEmailTriggersMailer(): void public function testSendFeedbackEmailWithNoEnailTriggersMailerWithDefaultEmail(): void { // Side-effect test: verify mailer is called. - $mailer = $this->createMock(Mailer::class); - $mailer->expects($this->once())->method('sendFeedbackEmail'); - $entityManager = $this->createStub(EntityManagerInterface::class); - $category = new FeedbackCategory(); $category->setEmailtonotify('admin@example.com'); @@ -78,6 +82,18 @@ public function testSendFeedbackEmailWithNoEnailTriggersMailerWithDefaultEmail() 'message' => 'hello', ]; + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects($this->once()) + ->method('sendFeedbackEmail') + ->with( + 'feedback@bewelcome.org', + $this->callback(static fn ($address) => 'admin@example.com' === $address->getAddress()), + $data + ) + ; + $entityManager = $this->createStub(EntityManagerInterface::class); + $aboutModel = new AboutModel($entityManager, $mailer); $aboutModel->sendFeedbackEmail($data); } @@ -87,20 +103,33 @@ public function testAddFeedbackPersistsData(): void // Side-effect test: verify persistence. $mailer = $this->createStub(Mailer::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(Feedback::class)); - $entityManager->expects($this->once())->method('flush'); // Stub repository to return a dummy language - $repository = $this->createStub(EntityRepository::class); - $repository->method('find')->willReturn(new Language()); + $language = new Language(); + $repository = $this->createMock(EntityRepository::class); + $repository->expects($this->once())->method('find')->with(0)->willReturn($language); $entityManager->method('getRepository')->willReturn($repository); + $member = new Member(); + $category = new FeedbackCategory(); $data = [ - 'member' => new Member(), + 'member' => $member, 'FeedbackQuestion' => 'Question', - 'IdCategory' => new FeedbackCategory(), + 'IdCategory' => $category, ]; + $entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->callback(static function (Feedback $feedback) use ($member, $category, $language): bool { + return $member === $feedback->getAuthor() + && 'Question' === $feedback->getDiscussion() + && $category === $feedback->getCategory() + && $language === $feedback->getLanguage(); + })) + ; + $entityManager->expects($this->once())->method('flush'); + $aboutModel = new AboutModel($entityManager, $mailer); $aboutModel->addFeedback($data); } diff --git a/tests/Model/ConversationModelTest.php b/tests/Model/ConversationModelTest.php index 423ab4321..9396b43ab 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; @@ -27,14 +28,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 +39,8 @@ public function testMarkConversationPurgedUpdatesMessages(): void $message->setReceiver($receiver); $message->setSender($sender); $message->setDeleteRequest(''); + $message->setFolder(InFolderType::SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -64,6 +60,8 @@ public function testMarkConversationDeletedUpdatesMessages(): void $message->setReceiver($receiver); $message->setSender($sender); $message->setDeleteRequest(''); + $message->setFolder(InFolderType::SPAM); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -82,6 +80,7 @@ public function testMarkConversationAsSpamUpdatesStatusAndInfo(): void $message->setReceiver($receiver); $message->setFolder(InFolderType::NORMAL); $message->setStatus(MessageStatusType::SENT); + $this->expectPersistAndFlush($message); $conversation = [$message]; @@ -96,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); @@ -129,6 +128,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 +148,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 +165,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 +193,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 +268,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/CreatedTripsRepository.php b/tests/Model/CreatedTripsRepository.php new file mode 100644 index 000000000..c9c0e445a --- /dev/null +++ b/tests/Model/CreatedTripsRepository.php @@ -0,0 +1,29 @@ +since = $since; + $this->until = $until; + + return $this->trips; + } +} diff --git a/tests/Model/MatchingHostsRepository.php b/tests/Model/MatchingHostsRepository.php new file mode 100644 index 000000000..a0068daad --- /dev/null +++ b/tests/Model/MatchingHostsRepository.php @@ -0,0 +1,29 @@ +calls[] = [$trip, $notificationValues, $duration]; + + return $this->hosts; + } +} diff --git a/tests/Model/NotifyingTripModel.php b/tests/Model/NotifyingTripModel.php new file mode 100644 index 000000000..e429bd672 --- /dev/null +++ b/tests/Model/NotifyingTripModel.php @@ -0,0 +1,22 @@ +notifiedTrips[] = $trip; + } +} diff --git a/tests/Model/SentTripNotificationsRepository.php b/tests/Model/SentTripNotificationsRepository.php new file mode 100644 index 000000000..99cde03f5 --- /dev/null +++ b/tests/Model/SentTripNotificationsRepository.php @@ -0,0 +1,32 @@ +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 new file mode 100644 index 000000000..56d461367 --- /dev/null +++ b/tests/Model/TripModelNotificationTest.php @@ -0,0 +1,352 @@ +createMock(EntityRepository::class); + $hiddenRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['member' => $member, 'subtrip' => $subtrip]) + ->willReturn(null) + ; + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(MemberSubtripHidden::class) + ->willReturn($hiddenRepository) + ; + $entityManager + ->expects($this->once()) + ->method('persist') + ->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->markSubtripAsHidden($member, $subtrip); + } + + public function testMarkSubtripAsHiddenDoesNotDuplicateHiddenState(): void + { + $member = new Member(); + $subtrip = new Subtrip(); + $existingHidden = new MemberSubtripHidden($member, $subtrip); + + $hiddenRepository = $this->createMock(EntityRepository::class); + $hiddenRepository + ->expects($this->once()) + ->method('findOneBy') + ->with(['member' => $member, 'subtrip' => $subtrip]) + ->willReturn($existingHidden) + ; + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->once()) + ->method('getRepository') + ->with(MemberSubtripHidden::class) + ->willReturn($hiddenRepository) + ; + $entityManager->expects($this->never())->method('persist'); + $entityManager->expects($this->never())->method('flush'); + + $tripModel = new TripModel($entityManager); + $tripModel->markSubtripAsHidden($member, $subtrip); + } + + public function testNotifyHostsAboutTripSendsEmailsToMatchingHostsOnce(): void + { + $creator = new Member()->setUsername('traveller'); + $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(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [Subtrip::class, $subtripRepository], + [MemberTripNotificationSent::class, $sentRepository], + ]) + ; + $entityManager + ->expects($this->once()) + ->method('persist') + ->with($this->callback(static function (MemberTripNotificationSent $sent) use ($host, $trip): bool { + return $sent->getMember() === $host && $sent->getTrip() === $trip; + })) + ; + $entityManager->expects($this->once())->method('flush'); + $this->allowTripNotificationLocks($entityManager); + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects($this->once()) + ->method('sendTripNotificationEmail') + ->with($host, $trip) + ->willReturn(true) + ; + + $tripModel = new TripModel($entityManager, $mailer); + $tripModel->notifyHostsAboutTrip($trip); + + $this->assertSame([ + [$trip, [ + Preference::TRIP_NOTIFICATIONS_IMMEDIATELY, + ], 3], + ], $subtripRepository->calls); + $this->assertCount(1, $sentRepository->finds); + } + + 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 testNotifyHostsAboutTripSkipsAlreadySentNotification(): void + { + $creator = new Member()->setUsername('traveller'); + $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), + ]); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [Subtrip::class, $subtripRepository], + [MemberTripNotificationSent::class, $sentRepository], + ]) + ; + $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); + + $this->assertCount(1, $sentRepository->finds); + } + + public function testNotifyHostsAboutTripSkipsHostWhenNotificationLockIsBusy(): void + { + $creator = new Member()->setUsername('traveller'); + $host = new Member(); + $this->setEntityId($host, 12); + $trip = new Trip()->setCreator($creator); + $this->setEntityId($trip, 42); + + $subtripRepository = $this->createSubtripRepository([$host]); + $sentRepository = new SentTripNotificationsRepository(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(2)) + ->method('getRepository') + ->willReturnMap([ + [Subtrip::class, $subtripRepository], + [MemberTripNotificationSent::class, $sentRepository], + ]) + ; + $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); + + $this->assertSame([], $sentRepository->finds); + } + + public function testSendScheduledTripNotificationsUsesFrequencyAndCreatedWindow(): void + { + $creator = new Member()->setUsername('traveller'); + $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'); + + $tripRepository = new CreatedTripsRepository([$trip]); + $subtripRepository = $this->createSubtripRepository([$host]); + $sentRepository = new SentTripNotificationsRepository(); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(3)) + ->method('getRepository') + ->willReturnMap([ + [Trip::class, $tripRepository], + [Subtrip::class, $subtripRepository], + [MemberTripNotificationSent::class, $sentRepository], + ]) + ; + $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) + ->willReturn(true) + ; + + $tripModel = new TripModel($entityManager, $mailer); + $sent = $tripModel->sendScheduledTripNotifications( + Preference::TRIP_NOTIFICATIONS_DAILY, + $since, + $until + ); + + $this->assertSame(1, $sent); + $this->assertSame($since, $tripRepository->since); + $this->assertSame($until, $tripRepository->until); + $this->assertSame([ + [$trip, [Preference::TRIP_NOTIFICATIONS_DAILY], 3], + ], $subtripRepository->calls); + } + + public function testSendScheduledTripNotificationsDoesNotStoreFailedSend(): void + { + $creator = new Member()->setUsername('traveller'); + $host = new Member(); + $this->setEntityId($host, 12); + $trip = new Trip()->setCreator($creator); + $this->setEntityId($trip, 42); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects($this->exactly(3)) + ->method('getRepository') + ->willReturnMap([ + [Trip::class, new CreatedTripsRepository([$trip])], + [Subtrip::class, $this->createSubtripRepository([$host])], + [MemberTripNotificationSent::class, new SentTripNotificationsRepository()], + ]) + ; + $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) + ->willReturn(false) + ; + + $tripModel = new TripModel($entityManager, $mailer); + $sent = $tripModel->sendScheduledTripNotifications( + Preference::TRIP_NOTIFICATIONS_WEEKLY, + new DateTimeImmutable('2026-06-21 00:00:00'), + new DateTimeImmutable('2026-06-28 00:00:00') + ); + + $this->assertSame(0, $sent); + } + + 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): MatchingHostsRepository + { + return new MatchingHostsRepository($hosts); + } + + private function allowTripNotificationLocks(EntityManagerInterface $entityManager): void + { + $connection = $this->createStub(Connection::class); + $connection->method('fetchOne')->willReturn(1); + $entityManager->method('getConnection')->willReturn($connection); + } + + private function denyTripNotificationLocks(EntityManagerInterface $entityManager): void + { + $connection = $this->createStub(Connection::class); + $connection->method('fetchOne')->willReturn(0); + $entityManager->method('getConnection')->willReturn($connection); + } +} diff --git a/tests/Model/TripModelTestCase.php b/tests/Model/TripModelTestCase.php index 27b3f449a..43a3d4f66 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 6a6ff972f..23a1b940d 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.never: - - Never +trips.no: + - '@no' - One of the options for trips notifications trips.immediately: - Immediately diff --git a/translations/missing/preference.yaml b/translations/missing/preference.yaml index b58f80380..f85730cb2 100644 --- a/translations/missing/preference.yaml +++ b/translations/missing/preference.yaml @@ -53,3 +53,24 @@ 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. +tripsnotificationsimmediately: + - Immediately + - Option on the preferences page for trip notifications. +tripsnotificationsdaily: + - Daily + - Option on the preferences page for trip notifications. +tripsnotificationsweekly: + - Weekly + - Option on the preferences page for trip notifications. +tripsnotificationsmonthly: + - Monthly + - Option on the preferences page for trip notifications. diff --git a/translations/missing/trips.yaml b/translations/missing/trips.yaml index 7a6b43dc6..1cccc7dad 100644 --- a/translations/missing/trips.yaml +++ b/translations/missing/trips.yaml @@ -125,6 +125,18 @@ trip.location.none: trip.show: - 'Show' - Tool tip/aria label for the icon to show a trip. +trip.hide: + - 'Hide this trip' + - Tool tip/aria label for the button that hides a trip from the visitors list. +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).