createMailingForm($request); if ($form->isSubmitted()) { $this->draftHandler->saveDraft($form->getData()); } if ($form->isSubmitted() && $form->isValid()) { $this->mailingService->sendPreview($form->getData(), $this->getAuthenticatedUser()); $this->addFlash('success', sprintf( 'Die Vorschau wurde an %s gesendet', $this->getUser()->getUserIdentifier() )); } $filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings(); // No redirect after the preview mail, the composed mail has to survive it. The // preview pane is rendered here so the page arrives complete; from then on the // draft route above keeps it up to date. return $this->render('admin/teamer/mailing.html.twig', [ 'form' => $form->createView(), 'preview' => $this->renderPreview($form->getData()), 'filterDto' => $filterDto, 'recipients' => $this->mailingService->resolveRecipients($filterDto), 'placeholders' => TeamerMailingService::PLACEHOLDERS, 'hasDraft' => $this->draftHandler->hasDraft(), 'returnUrl' => $this->getReturnUrl($request, $this->getListRoute($request)), 'scope' => $request->query->get('scope'), ]); } /** * Park what has been typed so far, so that a trip to the filter and back does not throw * the composed mail away, and answer with the preview of that very draft. * * Both hang off the same form content, so they are deliberately one request rather than * two routes on the same keystroke: the preview can never show wording other than the * one that was parked. */ #[Route('/admin/teamer/mailing/draft', name: 'app_admin_teamer_mailing_draft', methods: ['POST'])] #[IsGranted('ROLE_TEAM_ADMIN')] public function draft(Request $request): Response { $mailingDto = $this->createMailingForm($request)->getData(); $this->draftHandler->saveDraft($mailingDto); return $this->render('admin/teamer/_mailing_preview.html.twig', $this->renderPreview($mailingDto)); } #[Route('/admin/teamer/mailing/discard', name: 'app_admin_teamer_mailing_discard')] #[IsGranted('ROLE_TEAM_ADMIN')] public function discard(Request $request): Response { $this->draftHandler->resetDraft(); return $this->redirectToRoute('app_admin_teamer_mailing', [ 'scope' => $request->query->get('scope'), ]); } #[Route('/admin/teamer/mailing/confirm', name: 'app_admin_teamer_mailing_confirm', methods: ['POST'])] #[IsGranted('ROLE_TEAM_ADMIN')] public function confirm(Request $request): Response { $form = $this->createMailingForm($request); $filterDto = $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings(); // the composed mail is carried on in hidden fields so no half written mailing has // to be parked in the session between the confirmation and the send return $this->render('admin/teamer/modal_mailing_confirm.html.twig', [ 'form' => $form->createView(), 'mailingDto' => $form->getData(), 'valid' => $form->isSubmitted() && $form->isValid(), 'recipients' => $this->mailingService->resolveRecipients($filterDto), 'scope' => $request->query->get('scope'), ]); } #[Route('/admin/teamer/mailing/send', name: 'app_admin_teamer_mailing_send', methods: ['POST'])] #[IsGranted('ROLE_TEAM_ADMIN')] public function send(Request $request): Response { $form = $this->createMailingForm($request); if (false === $form->isSubmitted() || false === $form->isValid()) { $this->addFlash('error', 'Die Mail konnte nicht gesendet werden, Betreff oder Nachricht fehlen'); return new HxRedirectResponse($this->generateUrl('app_admin_teamer_mailing', [ 'scope' => $request->query->get('scope'), ])); } $recipients = $this->mailingService->resolveRecipients( $this->filterHandler->setScope($request->query->get('scope'))->getFilterSettings() ); $mailingDto = $form->getData(); // the recipients travel with the message, so the mailing reaches exactly the // people the confirmation modal counted even though it is sent from a worker $this->messageBus->dispatch(SendTeamerMailing::fromRecipients( (string) $mailingDto->getSubject(), (string) $mailingDto->getMessage(), $recipients )); // it is on its way, the next mailing starts on a blank page $this->draftHandler->resetDraft(); // deliberately not "wurde gesendet": at this point nothing has been handed to a // mail server yet, and saying otherwise would make a failed mailing look fine $this->addFlash('success', sprintf( 'Die Mail wird an %d Teamer:innen gesendet', $recipients->getEligibleCount() )); return new HxRedirectResponse($this->generateUrl($this->getListRoute($request))); } /** * The list this mailing was started from, so a send returns to it and an unscoped * mailing never falls back to the wrong one. */ private function getListRoute(Request $request): string { return TeamerFilterHandler::SCOPE_ALL_SEASON === $request->query->get('scope') ? 'app_administrative_season_teamer_index' : 'app_administrative_teamer_index'; } /** * What the composing admin would receive. The placeholders are filled from their own * record, the same way sendPreview() fills the test mail, so the pane and the mail that * lands in their inbox show the same name. * * Deliberately rendered whether or not the form validates: a mail that is still missing * its subject is exactly the one an admin is looking at while writing it. * * @return array{subject: string, bodyHtml: string} */ private function renderPreview(TeamerMailingDto $mailingDto): array { $values = $this->mailingService->placeholderValuesForUser($this->getAuthenticatedUser()); return [ 'subject' => $this->mailingService->render($mailingDto->getSubject(), $values), 'bodyHtml' => $this->mailBodyRenderer->render($mailingDto->getMessage(), $values), ]; } /** * A GET starts from the parked draft, a POST always carries the current one itself. */ private function createMailingForm(Request $request): FormInterface { $form = $this->createForm(TeamerMailingType::class, $this->draftHandler->getDraft()); $form->handleRequest($request); return $form; } }