Serialization of tickets with history

This commit is contained in:
Julien Fastré 2024-04-18 11:35:07 +02:00
parent 670b8eb82b
commit 467bea7cde
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
13 changed files with 353 additions and 12 deletions

View File

@ -49,7 +49,6 @@ final readonly class ReplaceMotiveCommandHandler
if ($readyToAdd) {
$history = new MotiveHistory($command->motive, $ticket, $this->clock->now());
$ticket->addMotiveHistory($history);
$this->entityManager->persist($history);
}
}

View File

@ -19,7 +19,7 @@ use Twig\Environment;
class EditTicketController
{
public function __construct(
private Environment $templating,
private Environment $templating
) {}
#[Route('/{_locale}/ticket/ticket/{id}/edit', name: 'chill_ticket_ticket_edit')]
@ -29,6 +29,9 @@ class EditTicketController
return new Response(
$this->templating->render(
'@ChillTicket/Ticket/edit.html.twig',
[
'ticket' => $ticket,
]
)
);
}

View File

@ -14,9 +14,11 @@ namespace Chill\TicketBundle\Entity;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'motives_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive_history' => MotiveHistory::class])]
class MotiveHistory implements TrackCreationInterface
{
use TrackCreationTrait;
@ -24,21 +26,27 @@ class MotiveHistory implements TrackCreationInterface
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: Motive::class)]
#[ORM\JoinColumn(nullable: false)]
#[Serializer\Groups(['read'])]
private Motive $motive,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate = new \DateTimeImmutable('now')
) {}
) {
$ticket->addMotiveHistory($this);
}
public function getEndDate(): ?\DateTimeImmutable
{

View File

@ -16,9 +16,11 @@ use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\Person;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation as Serializer;
#[ORM\Entity]
#[ORM\Table(name: 'person_history', schema: 'chill_ticket')]
#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_person_history' => PersonHistory::class])]
class PersonHistory implements TrackCreationInterface
{
use TrackCreationTrait;
@ -26,22 +28,27 @@ class PersonHistory implements TrackCreationInterface
#[ORM\Id]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)]
#[ORM\GeneratedValue(strategy: 'AUTO')]
#[Serializer\Groups(['read'])]
private ?int $id = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
#[Serializer\Groups(['read'])]
private ?\DateTimeImmutable $endDate = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
#[Serializer\Groups(['read'])]
private ?User $removedBy = null;
public function __construct(
#[ORM\ManyToOne(targetEntity: Person::class, fetch: 'EAGER')]
#[Serializer\Groups(['read'])]
private Person $person,
#[ORM\ManyToOne(targetEntity: Ticket::class)]
#[ORM\JoinColumn(nullable: false)]
private Ticket $ticket,
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)]
#[Serializer\Groups(['read'])]
private \DateTimeImmutable $startDate,
) {
// keep ticket instance in sync with this

View File

@ -176,4 +176,12 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
{
return $this->motiveHistories;
}
/**
* @return ReadableCollection<int, PersonHistory>
*/
public function getPersonHistories(): ReadableCollection
{
return $this->personHistories;
}
}

View File

@ -1,11 +1,52 @@
import {TranslatableString} from "../../../../ChillMainBundle/Resources/public/types";
export interface Ticket {
id: number
}
import {DateTime, TranslatableString, User} from "../../../../ChillMainBundle/Resources/public/types";
import {Person} from "../../../../ChillPersonBundle/Resources/public/types";
export interface Motive {
type: "ticket_motive"
id: number,
active: boolean,
label: TranslatableString
}
interface TicketHistory<T extends string, D extends object> {
event_type: T,
at: DateTime,
by: User,
data: D
}
interface PersonHistory {
type: "ticket_person_history",
id: number,
startDate: DateTime,
endDate: null|DateTime,
person: Person,
removedBy: null,
createdBy: User|null,
createdAt: DateTime|null
}
interface MotiveHistory {
type: "ticket_motive_history",
id: number,
startDate: null,
endDate: null|DateTime,
motive: Motive,
createdBy: User|null,
createdAt: DateTime|null,
}
interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {};
interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {};
type TicketHistoryLine = AddPersonEvent | SetMotiveEvent;
export interface Ticket {
type: "ticket_ticket"
id: number
externalRef: string
currentPersons: Person[]
currentMotive: null|Motive
history: TicketHistoryLine[],
}

View File

@ -4,9 +4,30 @@ import VueToast from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css';
import {messages} from "./i18n/messages";
import App from './App.vue';
import {Ticket} from "../../types";
declare global {
interface Window {
initialTicket: string
}
}
const i18n = _createI18n(messages)
// the initial ticket is serialized there:
console.log(window.initialTicket);
// to have js object
const ticket = JSON.parse(window.initialTicket) as Ticket;
console.log("the ticket for this app (at page loading)", ticket);
for (const eh of ticket.history) {
if (eh.event_type === 'add_person') {
console.log("add_person", eh.data.person);
} else if (eh.event_type === 'set_motive') {
console.log("set_motive", eh.data.motive);
}
}
const _app = createApp({
template: '<app></app>',
})

View File

@ -7,6 +7,9 @@
{% block js %}
{{ parent() }}
<script type="text/javascript">
window.initialTicket = "{{ ticket|serialize('json', {'groups': 'read'})|escape('js') }}";
</script>
{{ encore_entry_script_tags('vue_ticket_app') }}
{% endblock %}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Serializer\Normalizer;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, ?string $format = null, array $context = [])
{
if (!$object instanceof Ticket) {
throw new UnexpectedValueException();
}
return [
'type' => 'ticket_ticket',
'id' => $object->getId(),
'externalRef' => $object->getExternalRef(),
'currentPersons' => $this->normalizer->normalize($object->getPersons(), $format, [
'groups' => 'read',
]),
'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']),
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']),
'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])),
];
}
public function supportsNormalization($data, ?string $format = null)
{
return 'json' === $format && $data instanceof Ticket;
}
private function serializeHistory(Ticket $ticket, string $format, array $context): array
{
$events = [
...array_map(
fn (MotiveHistory $motiveHistory) => [
'event_type' => 'set_motive',
'at' => $motiveHistory->getStartDate(),
'by' => $motiveHistory->getCreatedBy(),
'data' => $motiveHistory,
],
$ticket->getMotiveHistories()->toArray()
),
...array_map(
fn (PersonHistory $personHistory) => [
'event_type' => 'add_person',
'at' => $personHistory->getStartDate(),
'by' => $personHistory->getCreatedBy(),
'data' => $personHistory,
],
$ticket->getPersonHistories()->toArray(),
),
];
usort(
$events,
static function (array $a, array $b): int {
return $a['at'] <=> $b['at'];
}
);
return array_map(
fn ($data) => [
'event_type' => $data['event_type'],
'at' => $this->normalizer->normalize($data['at'], $format, $context),
'by' => $this->normalizer->normalize($data['by'], $format, $context),
'data' => $this->normalizer->normalize($data['data'], $format, $context),
],
$events
);
}
}

View File

@ -11,6 +11,8 @@ services:
Chill\TicketBundle\Action\Ticket\Handler\:
resource: '../Action/Ticket/Handler/'
Chill\TicketBundle\Serializer\:
resource: '../Serializer/'
Chill\TicketBundle\DataFixtures\:
resource: '../DataFixtures/'

View File

@ -64,7 +64,7 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
{
$motive = new Motive();
$ticket = new Ticket();
$ticket->addMotiveHistory(new MotiveHistory(new Motive(), $ticket));
$history = new MotiveHistory(new Motive(), $ticket);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {
@ -87,7 +87,7 @@ final class ReplaceMotiveCommandHandlerTest extends KernelTestCase
{
$motive = new Motive();
$ticket = new Ticket();
$ticket->addMotiveHistory(new MotiveHistory($motive, $ticket));
$history = new MotiveHistory($motive, $ticket);
$entityManager = $this->prophesize(EntityManagerInterface::class);
$entityManager->persist(Argument::that(static function ($arg) use ($motive): bool {

View File

@ -11,8 +11,10 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Tests\Entity;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
@ -31,7 +33,6 @@ class TicketTest extends KernelTestCase
self::assertNull($ticket->getMotive());
$history = new MotiveHistory($motive, $ticket);
$ticket->addMotiveHistory($history);
self::assertSame($motive, $ticket->getMotive());
self::assertCount(1, $ticket->getMotiveHistories());
@ -39,9 +40,22 @@ class TicketTest extends KernelTestCase
// replace motive
$motive2 = new Motive();
$history->setEndDate(new \DateTimeImmutable());
$ticket->addMotiveHistory(new MotiveHistory($motive2, $ticket));
$history2 = new MotiveHistory($motive2, $ticket);
self::assertCount(2, $ticket->getMotiveHistories());
self::assertSame($motive2, $ticket->getMotive());
}
public function testGetPerson(): void
{
$ticket = new Ticket();
$person = new Person();
self::assertEquals([], $ticket->getPersons());
$history = new PersonHistory($person, $ticket, new \DateTimeImmutable('now'));
self::assertCount(1, $ticket->getPersons());
self::assertSame($person, $ticket->getPersons()[0]);
}
}

View File

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\TicketBundle\Tests\Serializer\Normalizer;
use Chill\PersonBundle\Entity\Person;
use Chill\TicketBundle\Entity\Motive;
use Chill\TicketBundle\Entity\MotiveHistory;
use Chill\TicketBundle\Entity\PersonHistory;
use Chill\TicketBundle\Entity\Ticket;
use Chill\TicketBundle\Serializer\Normalizer\TicketNormalizer;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
/**
* @internal
*
* @coversNothing
*/
class TicketNormalizerTest extends KernelTestCase
{
use ProphecyTrait;
/**
* @dataProvider provideTickets
*/
public function testNormalize(Ticket $ticket, array $expected): void
{
$actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read']);
self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual));
foreach (array_keys($expected) as $k) {
if ('history' === $k) {
continue;
}
self::assertEqualsCanonicalizing($expected[$k], $actual[$k], sprintf("assert the content of the '%s' key", $k));
}
self::assertArrayHasKey('history', $actual);
self::assertIsArray($actual['history']);
foreach ($actual['history'] as $k => $eventType) {
self::assertEquals($expected['history'][$k]['event_type'], $eventType['event_type']);
}
}
private function buildNormalizer(): TicketNormalizer
{
$normalizer = $this->prophesize(NormalizerInterface::class);
// empty array
$normalizer->normalize(
Argument::that(fn ($arg) => is_array($arg) && 0 === count($arg)),
'json',
Argument::type('array')
)->willReturn([]);
// array of mixed objects
$normalizer->normalize(
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0])),
'json',
Argument::type('array')
)->will(function ($args) {
return array_fill(0, count($args[0]), 'embedded');
});
// array of event type
$normalizer->normalize(
Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0]) && array_key_exists('event_type', $arg[0])),
'json',
Argument::type('array')
)->will(function ($args): array {
$events = [];
foreach ($args[0] as $event) {
$events[] = $event['event_type'];
}
return $events;
});
// datetime
$normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array'))
->will(function ($args) { return $args[0]->getTimestamp(); });
$normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]);
$normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array'))
->willReturn(['personHistory']);
$normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array'))
->willReturn(['motiveHistory']);
$normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null);
$ticketNormalizer = new TicketNormalizer();
$ticketNormalizer->setNormalizer($normalizer->reveal());
return $ticketNormalizer;
}
public static function provideTickets(): iterable
{
yield [
new Ticket(),
[
'type' => 'ticket_ticket',
'id' => null,
'externalRef' => '',
'currentPersons' => [],
'currentAddressees' => [],
'currentInputs' => [],
'currentMotive' => null,
'history' => [],
],
];
$ticket = new Ticket();
$ticket->setExternalRef('2134');
$personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00'));
$ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00'));
yield [
$ticket,
[
'type' => 'ticket_ticket',
'id' => null,
'externalRef' => '2134',
'currentPersons' => ['embedded'],
'currentAddressees' => [],
'currentInputs' => [],
'currentMotive' => ['type' => 'motive', 'id' => 0],
'history' => [['event_type' => 'add_person'], ['event_type' => 'set_motive']],
],
];
}
}