feat: remind teamers of open questionnaires on the dashboard
This commit is contained in:
1 parent
5e4e574076
commit
890e04f8a6
7 files changed
+313
-4
No files matched your search
@@ -51,6 +51,20 @@ class QuestionnaireCatalog
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is
|
||||||
|
* offered for assignments ending on or after it, and to nobody while it is null.
|
||||||
|
*/
|
||||||
|
public static function parseGoLive(?string $goLive): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
if (null === $goLive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive)
|
||||||
|
?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int|null $version null for the current version
|
* @param int|null $version null for the current version
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Controller\Teamer;
|
namespace App\Controller\Teamer;
|
||||||
|
|
||||||
|
use App\Config\QuestionnaireCatalog;
|
||||||
use App\Entity\Application;
|
use App\Entity\Application;
|
||||||
use App\Entity\Assignment;
|
use App\Entity\Assignment;
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
@@ -71,6 +72,14 @@ class IndexController extends AbstractController
|
|||||||
|
|
||||||
$dispositionDocuments = $this->processDocuments($upcomingDispositions);
|
$dispositionDocuments = $this->processDocuments($upcomingDispositions);
|
||||||
|
|
||||||
|
// offered to nobody until the go-live date is configured, as in DispositionVoter
|
||||||
|
$questionnaireGoLive = QuestionnaireCatalog::parseGoLive($this->getParameter('questionnaire_go_live'));
|
||||||
|
$pendingQuestionnaires = null === $questionnaireGoLive ? [] : $this
|
||||||
|
->entityManager
|
||||||
|
->getRepository(Disposition::class)
|
||||||
|
->findPendingQuestionnaireByTeamer($teamer, $questionnaireGoLive)
|
||||||
|
;
|
||||||
|
|
||||||
// the list merges in season assignments; the document notices above stay on dispositions
|
// the list merges in season assignments; the document notices above stay on dispositions
|
||||||
$upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5);
|
$upcomingPlacements = array_slice($this->upcomingPlacementCollector->collect($teamer), 0, 5);
|
||||||
|
|
||||||
@@ -85,6 +94,7 @@ class IndexController extends AbstractController
|
|||||||
// the season page shows only this one, so only this one is linked
|
// the season page shows only this one, so only this one is linked
|
||||||
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
|
'linkedSeasonAssignment' => $this->seasonAssignmentResolver->resolveFor($teamer),
|
||||||
'dispositionDocuments' => $dispositionDocuments,
|
'dispositionDocuments' => $dispositionDocuments,
|
||||||
|
'pendingQuestionnaires' => $pendingQuestionnaires,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Config\QuestionnaireCatalog;
|
||||||
use App\Entity\Assignment;
|
use App\Entity\Assignment;
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\QuestionnaireResponse;
|
||||||
use App\Entity\Teamer;
|
use App\Entity\Teamer;
|
||||||
use App\Entity\Upload;
|
use App\Entity\Upload;
|
||||||
|
use App\Enum\QuestionnaireResponseStatus;
|
||||||
use App\Repository\Filter\SeasonPeriodFilter;
|
use App\Repository\Filter\SeasonPeriodFilter;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
use Doctrine\ORM\Query\Expr\Comparison;
|
use Doctrine\ORM\Query\Expr\Comparison;
|
||||||
use Doctrine\ORM\Query\Expr\Join;
|
use Doctrine\ORM\Query\Expr\Join;
|
||||||
@@ -72,6 +76,73 @@ class DispositionRepository extends ServiceEntityRepository
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The teamer's placements whose questionnaire (config/questionnaires/teamer_escort.yaml) is
|
||||||
|
* still to be submitted: not started, or saved as a draft. The rules are those of
|
||||||
|
* DispositionVoter::QUESTIONNAIRE - bus escort, over (today included), on or after go-live,
|
||||||
|
* neither called off nor skip-formalities - so every row shown on the dashboard can be opened.
|
||||||
|
*
|
||||||
|
* The response is joined as an arbitrary entity because the association is one-way; the
|
||||||
|
* questionnaire key has to be part of the JOIN condition, or a response to another
|
||||||
|
* questionnaire would hide this one.
|
||||||
|
*
|
||||||
|
* Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus].
|
||||||
|
*/
|
||||||
|
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('disposition');
|
||||||
|
|
||||||
|
return $qb
|
||||||
|
->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus')
|
||||||
|
->innerJoin('disposition.assignment', 'assignment')
|
||||||
|
->innerJoin('assignment.destination', 'destination')
|
||||||
|
->leftJoin(
|
||||||
|
QuestionnaireResponse::class,
|
||||||
|
'response',
|
||||||
|
Join::ON,
|
||||||
|
'response.disposition = disposition AND response.questionnaire = :questionnaire'
|
||||||
|
)
|
||||||
|
->where($qb->expr()->andX(
|
||||||
|
$qb->expr()->eq('disposition.teamer', ':teamer'),
|
||||||
|
$qb->expr()->neq('disposition.status', ':dispositionCalledOff'),
|
||||||
|
$qb->expr()->notIn('assignment.status', ':assignmentStatuses'),
|
||||||
|
$qb->expr()->eq('assignment.skipFormalities', ':skipFormalities'),
|
||||||
|
$qb->expr()->isNotNull('assignment.pickup'),
|
||||||
|
$qb->expr()->neq('assignment.pickup', 0),
|
||||||
|
$qb->expr()->gte('destination.dateTo', ':goLive'),
|
||||||
|
$qb->expr()->lte('destination.dateTo', ':today'),
|
||||||
|
$qb->expr()->orX(
|
||||||
|
$qb->expr()->isNull('response.id'),
|
||||||
|
$qb->expr()->eq('response.status', ':draft')
|
||||||
|
)
|
||||||
|
))
|
||||||
|
->orderBy('destination.dateTo', \SortDirection::Descending)
|
||||||
|
->setParameter('teamer', $teamer)
|
||||||
|
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ESCORT)
|
||||||
|
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
|
||||||
|
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||||
|
->setParameter('skipFormalities', false)
|
||||||
|
->setParameter('goLive', $goLive, Types::DATE_IMMUTABLE)
|
||||||
|
->setParameter('today', new \DateTimeImmutable('today'), Types::DATE_IMMUTABLE)
|
||||||
|
->setParameter('draft', QuestionnaireResponseStatus::DRAFT)
|
||||||
|
->getQuery()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{disposition: Disposition, draft: bool}>
|
||||||
|
*/
|
||||||
|
public function findPendingQuestionnaireByTeamer(Teamer $teamer, \DateTimeImmutable $goLive): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn (array $row): array => [
|
||||||
|
'disposition' => $row[0],
|
||||||
|
'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'],
|
||||||
|
],
|
||||||
|
$this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function getRecentDispositionsByTeamerQuery(Teamer $teamer): Query
|
public function getRecentDispositionsByTeamerQuery(Teamer $teamer): Query
|
||||||
{
|
{
|
||||||
$qb = $this->createQueryBuilder('disposition');
|
$qb = $this->createQueryBuilder('disposition');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Security\Voter;
|
namespace App\Security\Voter;
|
||||||
|
|
||||||
|
use App\Config\QuestionnaireCatalog;
|
||||||
use App\Entity\Disposition;
|
use App\Entity\Disposition;
|
||||||
use App\Entity\Upload;
|
use App\Entity\Upload;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
@@ -33,10 +34,7 @@ class DispositionVoter extends Voter
|
|||||||
private readonly Security $security,
|
private readonly Security $security,
|
||||||
?string $questionnaireGoLive,
|
?string $questionnaireGoLive,
|
||||||
) {
|
) {
|
||||||
$this->questionnaireGoLive = null === $questionnaireGoLive
|
$this->questionnaireGoLive = QuestionnaireCatalog::parseGoLive($questionnaireGoLive);
|
||||||
? null
|
|
||||||
: (\DateTimeImmutable::createFromFormat('!Y-m-d', $questionnaireGoLive)
|
|
||||||
?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $questionnaireGoLive)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function supports(string $attribute, mixed $subject): bool
|
protected function supports(string $attribute, mixed $subject): bool
|
||||||
@@ -128,6 +126,9 @@ class DispositionVoter extends Voter
|
|||||||
* assignment ending today counts as over, as in DispositionRepository's feedback queries.
|
* assignment ending today counts as over, as in DispositionRepository's feedback queries.
|
||||||
*
|
*
|
||||||
* Skip-formalities placements are left out, as they are from contract, invoice and feedback.
|
* Skip-formalities placements are left out, as they are from contract, invoice and feedback.
|
||||||
|
*
|
||||||
|
* DispositionRepository::getPendingQuestionnaireQuery() applies the same rules in DQL for the
|
||||||
|
* dashboard; change both together.
|
||||||
*/
|
*/
|
||||||
private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool
|
private function assertQuestionnaireAllowed(TokenInterface $token, Disposition $disposition): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -219,6 +219,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# Questionnaire after a bus escort; the edit page forwards to the read-only view once submitted #}
|
||||||
|
{% if is_granted('QUESTIONNAIRE', disposition) %}
|
||||||
|
<h3 class="text-xl font-bold pb-4">
|
||||||
|
Fragebogen zur Busbegleitung
|
||||||
|
</h3>
|
||||||
|
<p class="pb-4">
|
||||||
|
Wie lief dein Einsatz? Dein Feedback hilft uns, besser zu werden.
|
||||||
|
</p>
|
||||||
|
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': disposition.uuid }) }}" class="btn inline-block mb-4">
|
||||||
|
Zum Fragebogen
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<a href="{{ returnUrl }}" class="underline">
|
<a href="{{ returnUrl }}" class="underline">
|
||||||
zurück
|
zurück
|
||||||
|
|||||||
@@ -15,6 +15,24 @@
|
|||||||
{% set alertType = news.status == 2 ? 'warning' : 'info' %}
|
{% set alertType = news.status == 2 ? 'warning' : 'info' %}
|
||||||
{% include '_partials/_alert.html.twig' with { 'type': alertType, 'message': news.message } %}
|
{% include '_partials/_alert.html.twig' with { 'type': alertType, 'message': news.message } %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if pendingQuestionnaires is not empty %}
|
||||||
|
{% embed '_partials/_alert.html.twig' with { 'type': 'info' } %}
|
||||||
|
{% block message %}
|
||||||
|
<p>Bitte fülle den Fragebogen zu deiner Busbegleitung aus:</p>
|
||||||
|
<ul class="pt-1">
|
||||||
|
{% for row in pendingQuestionnaires %}
|
||||||
|
{% set destination = row.disposition.assignment.destination %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ path('app_teamer_questionnaire_edit', { 'uuid': row.disposition.uuid }) }}" class="underline">
|
||||||
|
{{ destination.dateFrom|date('d.m.y') }} - {{ destination.dateTo|date('d.m.y') }}, {{ destination.hotel }}
|
||||||
|
</a>
|
||||||
|
{% if row.draft %}(Entwurf gespeichert){% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
{% endembed %}
|
||||||
|
{% endif %}
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div class="bg-gray-100 border border-gray-200 rounded-md px-4 py-2">
|
<div class="bg-gray-100 border border-gray-200 rounded-md px-4 py-2">
|
||||||
<h2 class="font-bold text-lg pb-2">
|
<h2 class="font-bold text-lg pb-2">
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Repository;
|
||||||
|
|
||||||
|
use App\Config\QuestionnaireCatalog;
|
||||||
|
use App\Entity\Application;
|
||||||
|
use App\Entity\Assignment;
|
||||||
|
use App\Entity\Destination;
|
||||||
|
use App\Entity\Disposition;
|
||||||
|
use App\Entity\QuestionnaireResponse;
|
||||||
|
use App\Entity\Teamer;
|
||||||
|
use App\Model\Questionnaire\Definition;
|
||||||
|
use App\Repository\DispositionRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which questionnaires the dashboard reminds a teamer of. Run against db_test with real rows,
|
||||||
|
* because the question is which rows come back: the response is joined as an arbitrary entity,
|
||||||
|
* and a wrong JOIN condition would quietly let a draft, or a response to another questionnaire,
|
||||||
|
* decide the outcome.
|
||||||
|
*
|
||||||
|
* Every test runs inside a transaction that is rolled back, so db_test - shared between
|
||||||
|
* branches - keeps nothing.
|
||||||
|
*/
|
||||||
|
class DispositionRepositoryQuestionnaireTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
private const string GO_LIVE = '2026-01-01';
|
||||||
|
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
private DispositionRepository $repository;
|
||||||
|
|
||||||
|
private Teamer $teamer;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$this->repository = $this->entityManager->getRepository(Disposition::class);
|
||||||
|
$this->entityManager->getConnection()->beginTransaction();
|
||||||
|
|
||||||
|
$this->teamer = (new Teamer())->setFirstName('Kim')->setLastName('Muster')->setGender('d');
|
||||||
|
$this->entityManager->persist($this->teamer);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->entityManager->getConnection()->rollBack();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEscortWithoutAResponseIsPending(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->escort();
|
||||||
|
|
||||||
|
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testADraftIsStillPendingAndSaysSo(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->escort();
|
||||||
|
$this->respond($disposition, submit: false);
|
||||||
|
|
||||||
|
$this->assertSame([['disposition' => $disposition, 'draft' => true]], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASubmittedQuestionnaireIsNoLongerPending(): void
|
||||||
|
{
|
||||||
|
$this->respond($this->escort(), submit: true);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The questionnaire key belongs in the JOIN condition: in the WHERE clause, a submitted
|
||||||
|
* response to some other questionnaire would hide this one.
|
||||||
|
*/
|
||||||
|
public function testAResponseToAnotherQuestionnaireDoesNotCount(): void
|
||||||
|
{
|
||||||
|
$disposition = $this->escort();
|
||||||
|
$this->respond($disposition, submit: true, questionnaire: 'other');
|
||||||
|
|
||||||
|
$this->assertSame([['disposition' => $disposition, 'draft' => false]], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnAssignmentEndingTodayIsPending(): void
|
||||||
|
{
|
||||||
|
$this->escort(dateTo: 'today');
|
||||||
|
|
||||||
|
$this->assertCount(1, $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Closure(Disposition): mixed $change
|
||||||
|
*/
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('notPending')]
|
||||||
|
public function testIsNotPending(\Closure $change): void
|
||||||
|
{
|
||||||
|
$change($this->escort());
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->assertSame([], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{\Closure(Disposition): mixed}>
|
||||||
|
*/
|
||||||
|
public static function notPending(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'no bus escort' => [static fn (Disposition $d) => $d->getAssignment()->setPickup(0)],
|
||||||
|
'not over yet' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('tomorrow'))],
|
||||||
|
'ended before go-live' => [static fn (Disposition $d) => $d->getAssignment()->getDestination()->setDateTo(new \DateTimeImmutable('2025-12-31'))],
|
||||||
|
'skip formalities' => [static fn (Disposition $d) => $d->getAssignment()->setSkipFormalities(true)],
|
||||||
|
'called off' => [static fn (Disposition $d) => $d->setStatus(Disposition::STATUS_CALLED_OFF)],
|
||||||
|
'assignment called off' => [static fn (Disposition $d) => $d->getAssignment()->setStatus(Assignment::STATUS_CALLED_OFF)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnotherTeamersEscortIsNotListed(): void
|
||||||
|
{
|
||||||
|
$other = (new Teamer())->setFirstName('Alex')->setLastName('Beispiel')->setGender('d');
|
||||||
|
$this->entityManager->persist($other);
|
||||||
|
$this->escort(teamer: $other);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{disposition: Disposition, draft: bool}>
|
||||||
|
*/
|
||||||
|
private function pending(): array
|
||||||
|
{
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->repository->findPendingQuestionnaireByTeamer($this->teamer, new \DateTimeImmutable(self::GO_LIVE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escort(string $dateTo = 'yesterday', ?Teamer $teamer = null): Disposition
|
||||||
|
{
|
||||||
|
$destination = (new Destination())
|
||||||
|
->setProduct('Davos Klosters - Schwendi')
|
||||||
|
->setHotel('Schwendi')
|
||||||
|
->setHotelCode('DKI')
|
||||||
|
->setHotelBusProId(1)
|
||||||
|
->setDateFrom(new \DateTimeImmutable($dateTo.' -7 days'))
|
||||||
|
->setDateTo(new \DateTimeImmutable($dateTo))
|
||||||
|
->setPickups([['busProId' => 4711, 'city' => 'Stuttgart', 'street' => 'Busbahnhof', 'time' => '21:30']])
|
||||||
|
;
|
||||||
|
$assignment = (new Assignment())
|
||||||
|
->setDestination($destination)
|
||||||
|
->setPickup(4711)
|
||||||
|
->setStatus(Assignment::STATUS_STAFFED)
|
||||||
|
;
|
||||||
|
$disposition = (new Disposition(new Application($assignment, $teamer ?? $this->teamer)))
|
||||||
|
->setStatus(Disposition::STATUS_ENDED)
|
||||||
|
;
|
||||||
|
|
||||||
|
$this->entityManager->persist($destination);
|
||||||
|
$this->entityManager->persist($assignment);
|
||||||
|
$this->entityManager->persist($disposition);
|
||||||
|
|
||||||
|
return $disposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function respond(Disposition $disposition, bool $submit, string $questionnaire = QuestionnaireCatalog::TEAMER_ESCORT): void
|
||||||
|
{
|
||||||
|
$definition = new Definition($questionnaire, 1, 'Beispiel', null, []);
|
||||||
|
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
|
||||||
|
|
||||||
|
if ($submit) {
|
||||||
|
$response->submit($definition, [], [], new \DateTimeImmutable());
|
||||||
|
} else {
|
||||||
|
$response->saveDraft([], []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user