mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-09-10 08:44:58 +00:00
Compare commits
26 Commits
v4.0.1
...
#361-impro
Author | SHA1 | Date | |
---|---|---|---|
e55a7f2993
|
|||
8cade9ac77
|
|||
eb25f1408e
|
|||
279901ac93
|
|||
b1a7680b5e
|
|||
b1703a4187
|
|||
b16e6c4517
|
|||
d4eddaf671
|
|||
a126f2f06d
|
|||
845aa040cc
|
|||
5a284fe6cf
|
|||
a2b8e0e6ae
|
|||
189a26e0e8
|
|||
2e32b8946b
|
|||
7c798e1f63 | |||
ab8da4ab7a | |||
5bdb2df929 | |||
e3a6b60fa2 | |||
5f01673404 | |||
63d0a52ea1 | |||
837089ff5d | |||
f383fab578 | |||
f3cc4a89af | |||
703f5dc32d | |||
b870e71f77 | |||
a7e278204f
|
4
.changes/v4.0.1.md
Normal file
4
.changes/v4.0.1.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## v4.0.1 - 2025-07-08
|
||||
### Fixed
|
||||
* Fix package.json for compilation
|
||||
|
4
.changes/v4.0.2.md
Normal file
4
.changes/v4.0.2.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## v4.0.2 - 2025-07-09
|
||||
### Fixed
|
||||
* Fix add missing translation
|
||||
* Fix the transfer of evaluations and documents during of accompanyingperiodwork
|
@@ -185,14 +185,57 @@ When we need to use a DateTime or DateTimeImmutable that need to express "now",
|
||||
`Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities,
|
||||
where injection does not work when restoring an entity from database, but usually possible in services.
|
||||
|
||||
In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface`
|
||||
where we have full and easy control of the date.
|
||||
|
||||
### Testing Information
|
||||
|
||||
The project uses PHPUnit for testing. Each bundle has its own test suite, and there's also a global test suite at the root level.
|
||||
|
||||
#### Use of mock in tests
|
||||
|
||||
##### General mocking
|
||||
|
||||
For creating mock, we prefer using prophecy (library phpspec/prophecy).
|
||||
|
||||
##### Useful helpers and tips that avoid create a mock
|
||||
|
||||
Some notable implementations that are tests helper, and avoid to create a mock:
|
||||
|
||||
- `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`;
|
||||
- `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above);
|
||||
- `\Symfony\Component\HttpClient\MockHttpClient`, an implementation of `\Symfony\Contracts\HttpClient\HttpClientInterface`;
|
||||
- When using `\Symfony\Component\Mailer\MailerInterface`, we can create the mock with "InMemoryTransport":
|
||||
|
||||
```php
|
||||
use Symfony\Component\Mailer\Transport\InMemoryTransport;
|
||||
use \Symfony\Component\Mailer\Mailer;
|
||||
|
||||
$transport = new InMemoryTransport();
|
||||
$mailer = new Mailer($transport);
|
||||
|
||||
// After sending:
|
||||
$messages = $transport->getSent(); // array of SentMessage
|
||||
```
|
||||
- When using `\Symfony\Contracts\EventDispatcher\EventDispatcherInterface`, we can use directly an instance of `\Symfony\Component\EventDispatcher\EventDispatcher`;
|
||||
|
||||
##### When we prefer not creating a mock
|
||||
|
||||
- When we use Doctrine Entities related to the project, we prefer not to use a mock: we instantiate them directly (unless it requires too much code to write);
|
||||
|
||||
##### Mocking final and readonly classes
|
||||
|
||||
Classes marked as final can't be mocked. To avoid that, either:
|
||||
|
||||
- we remove the `final` keyword from the class;
|
||||
- we extract an interface from the final class.
|
||||
|
||||
This must be a decision made by a human, not by an AI. Every AI task must abort with an explicit message in that case.
|
||||
|
||||
#### Running Tests
|
||||
|
||||
The tests are run from the project's root (not from the bundle's root).
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
vendor/bin/phpunit
|
||||
|
10
CHANGELOG.md
10
CHANGELOG.md
@@ -6,6 +6,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||
|
||||
|
||||
## v4.0.2 - 2025-07-09
|
||||
### Fixed
|
||||
* Fix add missing translation
|
||||
* Fix the transfer of evaluations and documents during of accompanyingperiodwork
|
||||
|
||||
## v4.0.1 - 2025-07-08
|
||||
### Fixed
|
||||
* Fix package.json for compilation
|
||||
|
||||
|
||||
## v4.0.0 - 2025-07-08
|
||||
### Feature
|
||||
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
|
||||
|
@@ -62,8 +62,10 @@ framework:
|
||||
'Chill\MainBundle\Workflow\Messenger\PostSignatureStateChangeMessage': priority
|
||||
'Chill\MainBundle\Workflow\Messenger\PostPublicViewMessage': async
|
||||
'Chill\MainBundle\Service\Workflow\CancelStaleWorkflowMessage': async
|
||||
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage': async
|
||||
'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority
|
||||
'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async
|
||||
'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async
|
||||
# end of routes added by chill-bundles recipes
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
|
@@ -17,3 +17,9 @@ when@dev:
|
||||
defaults:
|
||||
template: '@ChillMain/Dev/dev.assets.test2.html.twig'
|
||||
|
||||
|
||||
sass_address_picker:
|
||||
path: /_dev/address-picker
|
||||
controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController
|
||||
defaults:
|
||||
template: '@ChillMain/Dev/dev.address-picker.html.twig'
|
||||
|
@@ -45,6 +45,7 @@
|
||||
"webpack-cli": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fragaria/address-formatter": "^6.6.1",
|
||||
"@fullcalendar/core": "^6.1.4",
|
||||
"@fullcalendar/daygrid": "^6.1.4",
|
||||
"@fullcalendar/interaction": "^6.1.4",
|
||||
@@ -69,6 +70,7 @@
|
||||
"vue-i18n": "^9.1.6",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-toast-notification": "^3.1.2",
|
||||
"vue-use-leaflet": "^0.1.7",
|
||||
"vuex": "^4.0.0"
|
||||
},
|
||||
"browserslist": [
|
||||
|
@@ -24,7 +24,11 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class CalendarForShortMessageProvider
|
||||
{
|
||||
public function __construct(private readonly CalendarRepository $calendarRepository, private readonly EntityManagerInterface $em, private readonly RangeGeneratorInterface $rangeGenerator) {}
|
||||
public function __construct(
|
||||
private readonly CalendarRepository $calendarRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RangeGeneratorInterface $rangeGenerator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate calendars instance.
|
||||
|
@@ -21,7 +21,6 @@ namespace Chill\CalendarBundle\Tests\Service\ShortMessageNotification;
|
||||
use Chill\CalendarBundle\Entity\Calendar;
|
||||
use Chill\CalendarBundle\Repository\CalendarRepository;
|
||||
use Chill\CalendarBundle\Service\ShortMessageNotification\CalendarForShortMessageProvider;
|
||||
use Chill\CalendarBundle\Service\ShortMessageNotification\DefaultRangeGenerator;
|
||||
use Chill\CalendarBundle\Service\ShortMessageNotification\RangeGeneratorInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -82,10 +81,16 @@ final class CalendarForShortMessageProviderTest extends TestCase
|
||||
$em = $this->prophesize(EntityManagerInterface::class);
|
||||
$em->clear()->shouldBeCalled();
|
||||
|
||||
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
|
||||
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
|
||||
'startDate' => new \DateTimeImmutable('yesterday'),
|
||||
'endDate' => new \DateTimeImmutable('now'),
|
||||
]);
|
||||
|
||||
$provider = new CalendarForShortMessageProvider(
|
||||
$calendarRepository->reveal(),
|
||||
$em->reveal(),
|
||||
new DefaultRangeGenerator()
|
||||
$calendarRangeGenerator->reveal(),
|
||||
);
|
||||
|
||||
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
|
||||
@@ -103,26 +108,32 @@ final class CalendarForShortMessageProviderTest extends TestCase
|
||||
Argument::type(\DateTimeImmutable::class),
|
||||
Argument::type('int'),
|
||||
Argument::exact(0)
|
||||
)->will(static fn ($args) => array_fill(0, 1, new Calendar()))->shouldBeCalledTimes(1);
|
||||
)->will(static fn ($args) => array_fill(0, 10, new Calendar()))->shouldBeCalledTimes(1);
|
||||
$calendarRepository->findByNotificationAvailable(
|
||||
Argument::type(\DateTimeImmutable::class),
|
||||
Argument::type(\DateTimeImmutable::class),
|
||||
Argument::type('int'),
|
||||
Argument::not(0)
|
||||
Argument::exact(10)
|
||||
)->will(static fn ($args) => [])->shouldBeCalledTimes(1);
|
||||
|
||||
$em = $this->prophesize(EntityManagerInterface::class);
|
||||
$em->clear()->shouldBeCalled();
|
||||
|
||||
$calendarRangeGenerator = $this->prophesize(RangeGeneratorInterface::class);
|
||||
$calendarRangeGenerator->generateRange(Argument::any())->willReturn([
|
||||
'startDate' => new \DateTimeImmutable('yesterday'),
|
||||
'endDate' => new \DateTimeImmutable('now'),
|
||||
]);
|
||||
|
||||
$provider = new CalendarForShortMessageProvider(
|
||||
$calendarRepository->reveal(),
|
||||
$em->reveal(),
|
||||
new DefaultRangeGenerator()
|
||||
$calendarRangeGenerator->reveal(),
|
||||
);
|
||||
|
||||
$calendars = iterator_to_array($provider->getCalendars(new \DateTimeImmutable('now')));
|
||||
|
||||
$this->assertEquals(1, \count($calendars));
|
||||
$this->assertEquals(10, \count($calendars));
|
||||
$this->assertContainsOnly(Calendar::class, $calendars);
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ use Chill\MainBundle\DependencyInjection\CompilerPass\SearchableServicesCompiler
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\TimelineCompilerClass;
|
||||
use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass;
|
||||
use Chill\MainBundle\DependencyInjection\ConfigConsistencyCompilerPass;
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
use Chill\MainBundle\Notification\NotificationHandlerInterface;
|
||||
use Chill\MainBundle\Routing\LocalMenuBuilderInterface;
|
||||
use Chill\MainBundle\Search\SearchApiInterface;
|
||||
@@ -61,6 +62,8 @@ class ChillMainBundle extends Bundle
|
||||
->addTag('chill_main.entity_info_provider');
|
||||
$container->registerForAutoconfiguration(ProvideRoleInterface::class)
|
||||
->addTag('chill_main.provide_role');
|
||||
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
|
||||
->addTag('chill_main.notification_flag_provider');
|
||||
|
||||
$container->addCompilerPass(new SearchableServicesCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
$container->addCompilerPass(new ConfigConsistencyCompilerPass(), \Symfony\Component\DependencyInjection\Compiler\PassConfig::TYPE_BEFORE_OPTIMIZATION, 0);
|
||||
|
@@ -0,0 +1,50 @@
|
||||
<?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\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class AddressReferenceAggregatedApiController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private AddressReferenceRepositoryInterface $addressReferenceRepository,
|
||||
) {}
|
||||
|
||||
#[Route(path: '/api/1.0/main/address-reference/aggregated/search')]
|
||||
public function search(Request $request): JsonResponse
|
||||
{
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
if (!$request->query->has('q')) {
|
||||
throw new BadRequestHttpException('Parameter "q" is required.');
|
||||
}
|
||||
|
||||
$q = trim($request->query->get('q'));
|
||||
|
||||
if ('' === $q) {
|
||||
throw new BadRequestHttpException('Parameter "q" is required and cannot be empty.');
|
||||
}
|
||||
|
||||
$result = $this->addressReferenceRepository->findAggregatedBySearchString($q);
|
||||
|
||||
return new JsonResponse(iterator_to_array($result));
|
||||
}
|
||||
}
|
@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\NotificationComment;
|
||||
use Chill\MainBundle\Form\NotificationCommentType;
|
||||
use Chill\MainBundle\Form\NotificationType;
|
||||
use Chill\MainBundle\Notification\Exception\NotificationHandlerNotFound;
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationByUserFlagProvider;
|
||||
use Chill\MainBundle\Notification\NotificationHandlerManager;
|
||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||
use Chill\MainBundle\Repository\NotificationRepository;
|
||||
@@ -57,7 +58,8 @@ class NotificationController extends AbstractController
|
||||
$notification
|
||||
->setRelatedEntityClass($request->query->get('entityClass'))
|
||||
->setRelatedEntityId($request->query->getInt('entityId'))
|
||||
->setSender($this->security->getUser());
|
||||
->setSender($this->security->getUser())
|
||||
->setType(NotificationByUserFlagProvider::FLAG);
|
||||
|
||||
$tos = $request->query->all('tos');
|
||||
|
||||
|
@@ -0,0 +1,47 @@
|
||||
<?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\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepositoryInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
final readonly class PostalCodeForAddressReferenceApiController
|
||||
{
|
||||
public function __construct(
|
||||
private PostalCodeForAddressReferenceRepositoryInterface $postalCodeForAddressReferenceRepository,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
#[Route('/api/1.0/main/address-reference/postal-code/search')]
|
||||
public function findPostalCodeBySearch(Request $request): JsonResponse
|
||||
{
|
||||
|
||||
if (!$this->security->isGranted('IS_AUTHENTICATED')) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
$search = $request->query->get('q');
|
||||
|
||||
if (null === $search || '' === trim($search)) {
|
||||
throw new BadRequestHttpException('No search query provided');
|
||||
}
|
||||
|
||||
$postalCodes = iterator_to_array($this->postalCodeForAddressReferenceRepository->findPostalCode($search));
|
||||
|
||||
return new JsonResponse($postalCodes, json: false);
|
||||
}
|
||||
}
|
@@ -11,14 +11,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\MainBundle\Controller;
|
||||
|
||||
use Chill\MainBundle\Form\UserPhonenumberType;
|
||||
use Chill\MainBundle\Form\UserProfileType;
|
||||
use Chill\MainBundle\Security\ChillSecurity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
@@ -41,16 +38,19 @@ final class UserProfileController extends AbstractController
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$editForm = $this->createPhonenumberEditForm($user);
|
||||
$editForm = $this->createForm(UserProfileType::class, $user);
|
||||
|
||||
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
$phonenumber = $editForm->get('phonenumber')->getData();
|
||||
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
|
||||
$user->setNotificationFlags($notificationFlagsData);
|
||||
|
||||
$user->setPhonenumber($phonenumber);
|
||||
|
||||
$this->managerRegistry->getManager()->flush();
|
||||
$this->addFlash('success', $this->translator->trans('user.profile.Phonenumber successfully updated!'));
|
||||
$em = $this->managerRegistry->getManager();
|
||||
$em->flush();
|
||||
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
|
||||
|
||||
return $this->redirectToRoute('chill_main_user_profile');
|
||||
}
|
||||
@@ -60,13 +60,4 @@ final class UserProfileController extends AbstractController
|
||||
'form' => $editForm->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPhonenumberEditForm(UserInterface $user): FormInterface
|
||||
{
|
||||
return $this->createForm(
|
||||
UserPhonenumberType::class,
|
||||
$user,
|
||||
)
|
||||
->add('submit', SubmitType::class, ['label' => $this->translator->trans('Save')]);
|
||||
}
|
||||
}
|
||||
|
@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Entity;
|
||||
use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -21,10 +22,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'chill_main_notification')]
|
||||
#[ORM\Index(name: 'chill_main_notification_related_entity_idx', columns: ['relatedentityclass', 'relatedentityid'])]
|
||||
#[ORM\Index(columns: ['relatedentityclass', 'relatedentityid'], name: 'chill_main_notification_related_entity_idx')]
|
||||
class Notification implements TrackUpdateInterface
|
||||
{
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false)]
|
||||
#[ORM\Column(type: Types::TEXT, nullable: false)]
|
||||
private string $accessKey;
|
||||
|
||||
private array $addedAddresses = [];
|
||||
@@ -36,12 +37,19 @@ class Notification implements TrackUpdateInterface
|
||||
#[ORM\JoinTable(name: 'chill_main_notification_addresses_user')]
|
||||
private Collection $addressees;
|
||||
|
||||
/**
|
||||
* @var Collection<int, UserGroup>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: UserGroup::class)]
|
||||
#[ORM\JoinTable(name: 'chill_main_notification_addressee_user_group')]
|
||||
private Collection $addresseeUserGroups;
|
||||
|
||||
/**
|
||||
* a list of destinee which will receive notifications.
|
||||
*
|
||||
* @var array|string[]
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, options: ['default' => '[]', 'jsonb' => true])]
|
||||
#[ORM\Column(type: Types::JSON, options: ['default' => '[]', 'jsonb' => true])]
|
||||
private array $addressesEmails = [];
|
||||
|
||||
/**
|
||||
@@ -60,21 +68,21 @@ class Notification implements TrackUpdateInterface
|
||||
#[ORM\OrderBy(['createdAt' => \Doctrine\Common\Collections\Criteria::ASC])]
|
||||
private Collection $comments;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)]
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private \DateTimeImmutable $date;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT)]
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $message = '';
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $relatedEntityClass = '';
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
#[ORM\Column(type: Types::INTEGER)]
|
||||
private int $relatedEntityId;
|
||||
|
||||
private array $removedAddresses = [];
|
||||
@@ -84,7 +92,7 @@ class Notification implements TrackUpdateInterface
|
||||
private ?User $sender = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'notification.Title must be defined')]
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
|
||||
#[ORM\Column(type: Types::TEXT, options: ['default' => ''])]
|
||||
private string $title = '';
|
||||
|
||||
/**
|
||||
@@ -94,31 +102,46 @@ class Notification implements TrackUpdateInterface
|
||||
#[ORM\JoinTable(name: 'chill_main_notification_addresses_unread')]
|
||||
private Collection $unreadBy;
|
||||
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE)]
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
private ?User $updatedBy = null;
|
||||
|
||||
#[ORM\Column(name: 'type', type: Types::STRING, nullable: true)]
|
||||
private string $type = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->addressees = new ArrayCollection();
|
||||
$this->addresseeUserGroups = new ArrayCollection();
|
||||
$this->unreadBy = new ArrayCollection();
|
||||
$this->comments = new ArrayCollection();
|
||||
$this->setDate(new \DateTimeImmutable());
|
||||
$this->accessKey = bin2hex(openssl_random_pseudo_bytes(24));
|
||||
}
|
||||
|
||||
public function addAddressee(User $addressee): self
|
||||
public function addAddressee(User|UserGroup $addressee): self
|
||||
{
|
||||
if (!$this->addressees->contains($addressee)) {
|
||||
$this->addressees[] = $addressee;
|
||||
$this->addedAddresses[] = $addressee;
|
||||
if ($addressee instanceof User) {
|
||||
if (!$this->addressees->contains($addressee)) {
|
||||
$this->addressees->add($addressee);
|
||||
$this->addedAddresses[] = $addressee;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (!$this->addresseeUserGroups->contains($addressee)) {
|
||||
$this->addresseeUserGroups->add($addressee);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
public function addAddressesEmail(string $email)
|
||||
{
|
||||
if (!\in_array($email, $this->addressesEmails, true)) {
|
||||
@@ -152,13 +175,23 @@ class Notification implements TrackUpdateInterface
|
||||
#[Assert\Callback]
|
||||
public function assertCountAddresses(ExecutionContextInterface $context, $payload): void
|
||||
{
|
||||
if (0 === (\count($this->getAddressesEmails()) + \count($this->getAddressees()))) {
|
||||
if (0 === (\count($this->getAddresseeUserGroups()) + \count($this->getAddressees()))) {
|
||||
$context->buildViolation('notification.At least one addressee')
|
||||
->atPath('addressees')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
public function getAddresseeUserGroups(): Collection
|
||||
{
|
||||
return $this->addresseeUserGroups;
|
||||
}
|
||||
|
||||
public function setAddresseeUserGroups(Collection $addresseeUserGroups): void
|
||||
{
|
||||
$this->addresseeUserGroups = $addresseeUserGroups;
|
||||
}
|
||||
|
||||
public function getAccessKey(): string
|
||||
{
|
||||
return $this->accessKey;
|
||||
@@ -182,6 +215,23 @@ class Notification implements TrackUpdateInterface
|
||||
return $this->addressees;
|
||||
}
|
||||
|
||||
public function getAllAddressees(): array
|
||||
{
|
||||
$allUsers = [];
|
||||
|
||||
foreach ($this->getAddressees() as $user) {
|
||||
$allUsers[$user->getId()] = $user;
|
||||
}
|
||||
|
||||
foreach ($this->getAddresseeUserGroups() as $userGroup) {
|
||||
foreach ($userGroup->getUsers() as $user) {
|
||||
$allUsers[$user->getId()] = $user;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($allUsers);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array|string[]
|
||||
*/
|
||||
@@ -303,12 +353,18 @@ class Notification implements TrackUpdateInterface
|
||||
$this->addressesOnLoad = null;
|
||||
}
|
||||
|
||||
public function removeAddressee(User $addressee): self
|
||||
public function removeAddressee(User|UserGroup $addressee): self
|
||||
{
|
||||
if ($this->addressees->removeElement($addressee)) {
|
||||
$this->removedAddresses[] = $addressee;
|
||||
if ($addressee instanceof User) {
|
||||
if ($this->addressees->contains($addressee)) {
|
||||
$this->addressees->removeElement($addressee);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
$this->addresseeUserGroups->removeElement($addressee);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -378,7 +434,7 @@ class Notification implements TrackUpdateInterface
|
||||
|
||||
public function setUpdatedAt(\DateTimeInterface $datetime): self
|
||||
{
|
||||
$this->updatedAt = $datetime;
|
||||
$this->updatedAt = \DateTimeImmutable::createFromInterface($datetime);
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -389,4 +445,16 @@ class Notification implements TrackUpdateInterface
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
}
|
||||
|
@@ -34,6 +34,9 @@ use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||
#[ORM\Table(name: 'users')]
|
||||
class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
public const NOTIF_FLAG_IMMEDIATE_EMAIL = 'immediate-email';
|
||||
public const NOTIF_FLAG_DAILY_DIGEST = 'daily-digest';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)]
|
||||
#[ORM\GeneratedValue(strategy: 'AUTO')]
|
||||
@@ -116,6 +119,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
#[PhonenumberConstraint]
|
||||
private ?PhoneNumber $phonenumber = null;
|
||||
|
||||
/**
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
|
||||
private array $notificationFlags = [];
|
||||
|
||||
/**
|
||||
* User constructor.
|
||||
*/
|
||||
@@ -613,4 +622,57 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current object is an instance of User.
|
||||
*
|
||||
* @return bool returns true if the current object is an instance of User, false otherwise
|
||||
*/
|
||||
public function isUser(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getNotificationFlags(): array
|
||||
{
|
||||
return $this->notificationFlags;
|
||||
}
|
||||
|
||||
public function setNotificationFlags(array $notificationFlags)
|
||||
{
|
||||
$this->notificationFlags = $notificationFlags;
|
||||
}
|
||||
|
||||
public function getNotificationFlagData(string $flag): array
|
||||
{
|
||||
return $this->notificationFlags[$flag] ?? [];
|
||||
}
|
||||
|
||||
public function setNotificationFlagData(string $flag, array $data): void
|
||||
{
|
||||
$this->notificationFlags[$flag] = $data;
|
||||
}
|
||||
|
||||
public function isNotificationSendImmediately(string $type): bool
|
||||
{
|
||||
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function isNotificationDailyDigest(string $type): bool
|
||||
{
|
||||
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getLocale(): string
|
||||
{
|
||||
return 'fr';
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,75 @@
|
||||
<?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\MainBundle\Form\DataMapper;
|
||||
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Symfony\Component\Form\DataMapperInterface;
|
||||
|
||||
final readonly class NotificationFlagDataMapper implements DataMapperInterface
|
||||
{
|
||||
public function __construct(private array $notificationFlagProviders) {}
|
||||
|
||||
public function mapDataToForms($viewData, $forms): void
|
||||
{
|
||||
if (null === $viewData) {
|
||||
$viewData = [];
|
||||
}
|
||||
|
||||
$formsArray = iterator_to_array($forms);
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
|
||||
if (isset($formsArray[$flag])) {
|
||||
$flagForm = $formsArray[$flag];
|
||||
|
||||
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|
||||
|| !array_key_exists($flag, $viewData);
|
||||
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
|
||||
|
||||
if ($flagForm->has('immediate_email')) {
|
||||
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
|
||||
}
|
||||
if ($flagForm->has('daily_email')) {
|
||||
$flagForm->get('daily_email')->setData($dailyEmailChecked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function mapFormsToData($forms, &$viewData): void
|
||||
{
|
||||
$formsArray = iterator_to_array($forms);
|
||||
$viewData = [];
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
|
||||
if (isset($formsArray[$flag])) {
|
||||
$flagForm = $formsArray[$flag];
|
||||
$viewData[$flag] = [];
|
||||
|
||||
if (true === $flagForm['immediate_email']->getData()) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
||||
}
|
||||
|
||||
if (true === $flagForm['daily_email']->getData()) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
|
||||
}
|
||||
|
||||
if ([] === $viewData[$flag]) {
|
||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -12,17 +12,12 @@ declare(strict_types=1);
|
||||
namespace Chill\MainBundle\Form;
|
||||
|
||||
use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Form\Type\ChillCollectionType;
|
||||
use Chill\MainBundle\Form\Type\ChillTextareaType;
|
||||
use Chill\MainBundle\Form\Type\PickUserDynamicType;
|
||||
use Chill\MainBundle\Form\Type\PickUserGroupOrUserDynamicType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Email;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
use Symfony\Component\Validator\Constraints\NotNull;
|
||||
|
||||
class NotificationType extends AbstractType
|
||||
{
|
||||
@@ -33,29 +28,14 @@ class NotificationType extends AbstractType
|
||||
'label' => 'Title',
|
||||
'required' => true,
|
||||
])
|
||||
->add('addressees', PickUserDynamicType::class, [
|
||||
->add('addressees', PickUserGroupOrUserDynamicType::class, [
|
||||
'multiple' => true,
|
||||
'required' => false,
|
||||
'label' => 'notification.Pick user or user group',
|
||||
'empty_data' => '[]',
|
||||
'required' => true,
|
||||
])
|
||||
->add('message', ChillTextareaType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
->add('addressesEmails', ChillCollectionType::class, [
|
||||
'label' => 'notification.dest by email',
|
||||
'help' => 'notification.dest by email help',
|
||||
'by_reference' => false,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'entry_type' => EmailType::class,
|
||||
'button_add_label' => 'notification.Add an email',
|
||||
'button_remove_label' => 'notification.Remove an email',
|
||||
'empty_collection_explain' => 'notification.Any email',
|
||||
'entry_options' => [
|
||||
'constraints' => [
|
||||
new NotNull(), new NotBlank(), new Email(),
|
||||
],
|
||||
'label' => 'Email',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,63 @@
|
||||
<?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\MainBundle\Form\Type;
|
||||
|
||||
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
|
||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class NotificationFlagsType extends AbstractType
|
||||
{
|
||||
private readonly array $notificationFlagProviders;
|
||||
|
||||
public function __construct(NotificationFlagManager $notificationFlagManager)
|
||||
{
|
||||
$this->notificationFlagProviders = $notificationFlagManager->getAllNotificationFlagProviders();
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
|
||||
|
||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||
$flag = $flagProvider->getFlag();
|
||||
$builder->add($flag, FormType::class, [
|
||||
'label' => $flagProvider->getLabel(),
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->get($flag)
|
||||
->add('immediate_email', CheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('daily_email', CheckboxType::class, [
|
||||
'label' => false,
|
||||
'required' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => null,
|
||||
]);
|
||||
}
|
||||
}
|
41
src/Bundle/ChillMainBundle/Form/UserProfileType.php
Normal file
41
src/Bundle/ChillMainBundle/Form/UserProfileType.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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\MainBundle\Form;
|
||||
|
||||
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
|
||||
use Chill\MainBundle\Form\Type\NotificationFlagsType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class UserProfileType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('phonenumber', ChillPhoneNumberType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
->add('notificationFlags', NotificationFlagsType::class, [
|
||||
'label' => false,
|
||||
'mapped' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => \Chill\MainBundle\Entity\User::class,
|
||||
]);
|
||||
}
|
||||
}
|
@@ -0,0 +1,102 @@
|
||||
<?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\MainBundle\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Cron\CronJobInterface;
|
||||
use Chill\MainBundle\Entity\CronJobExecution;
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Exception;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ClockInterface $clock,
|
||||
private Connection $connection,
|
||||
private MessageBusInterface $messageBus,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function canRun(?CronJobExecution $cronJobExecution): bool
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
|
||||
if (null !== $cronJobExecution && $now->sub(new \DateInterval('PT23H45M')) < $cronJobExecution->getLastStart()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run between 6 and 9 AM
|
||||
return in_array((int) $now->format('H'), [6, 7, 8], true);
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return 'daily-notification-digest';
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \DateInvalidOperationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function run(array $lastExecutionData): ?array
|
||||
{
|
||||
$now = $this->clock->now();
|
||||
if (isset($lastExecutionData['last_execution'])) {
|
||||
$lastExecution = \DateTimeImmutable::createFromFormat(
|
||||
\DateTimeImmutable::ATOM,
|
||||
$lastExecutionData['last_execution']
|
||||
);
|
||||
} else {
|
||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||
}
|
||||
|
||||
// Get distinct users who received notifications since the last execution
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT cmnau.user_id
|
||||
FROM chill_main_notification cmn
|
||||
JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id
|
||||
WHERE cmn.date >= :lastExecution AND cmn.date <= :now
|
||||
SQL;
|
||||
|
||||
$sqlStatement = $this->connection->prepare($sql);
|
||||
$sqlStatement->bindValue('lastExecution', $lastExecution->format(\DateTimeInterface::RFC3339));
|
||||
$sqlStatement->bindValue('now', $now->format(\DateTimeInterface::RFC3339));
|
||||
$result = $sqlStatement->executeQuery();
|
||||
|
||||
$count = 0;
|
||||
foreach ($result->fetchAllAssociative() as $row) {
|
||||
$userId = (int) $row['user_id'];
|
||||
|
||||
$message = new ScheduleDailyNotificationDigestMessage(
|
||||
$userId,
|
||||
$lastExecution,
|
||||
$now
|
||||
);
|
||||
|
||||
$this->messageBus->dispatch($message);
|
||||
++$count;
|
||||
}
|
||||
|
||||
$this->logger->info('[DailyNotificationDigestCronjob] Dispatched daily digest messages', [
|
||||
'user_count' => $count,
|
||||
'last_execution' => $lastExecution->format('Y-m-d-H:i:s.u e'),
|
||||
'current_time' => $now->format('Y-m-d-H:i:s.u e'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
|
||||
];
|
||||
}
|
||||
}
|
@@ -0,0 +1,75 @@
|
||||
<?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\MainBundle\Notification\Email\NotificationEmailHandlers;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
|
||||
use Chill\MainBundle\Notification\Email\NotificationMailer;
|
||||
use Chill\MainBundle\Repository\NotificationRepository;
|
||||
use Chill\MainBundle\Repository\UserRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class ScheduleDailyNotificationDigestHandler
|
||||
{
|
||||
public function __construct(
|
||||
private NotificationRepository $notificationRepository,
|
||||
private UserRepository $userRepository,
|
||||
private NotificationMailer $notificationMailer,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function __invoke(ScheduleDailyNotificationDigestMessage $message): void
|
||||
{
|
||||
$userId = $message->getUserId();
|
||||
$lastExecutionDate = $message->getLastExecutionDateTime();
|
||||
$currentDate = $message->getCurrentDateTime();
|
||||
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (null === $user) {
|
||||
$this->logger->warning('[ScheduleDailyNotificationDigestHandler] User not found', [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $userId));
|
||||
}
|
||||
|
||||
// Get all notifications for this user between last execution and current date
|
||||
$notifications = $this->notificationRepository->findNotificationsForUserBetweenDates(
|
||||
$userId,
|
||||
$lastExecutionDate,
|
||||
$currentDate
|
||||
);
|
||||
|
||||
// Filter out notifications that should be sent in a daily digest
|
||||
$dailyNotifications = array_filter($notifications, fn ($notification) => $user->isNotificationDailyDigest($notification->getType()));
|
||||
|
||||
if ([] === $dailyNotifications) {
|
||||
$this->logger->info('[ScheduleDailyNotificationDigestHandler] No daily notifications found for user', [
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationMailer->sendDailyDigest($user, $dailyNotifications);
|
||||
|
||||
$this->logger->info('[ScheduleDailyNotificationDigestHandler] Sent daily digest', [
|
||||
'user_id' => $userId,
|
||||
'notification_count' => count($dailyNotifications),
|
||||
]);
|
||||
}
|
||||
}
|
@@ -0,0 +1,68 @@
|
||||
<?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\MainBundle\Notification\Email\NotificationEmailHandlers;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
|
||||
use Chill\MainBundle\Notification\Email\NotificationMailer;
|
||||
use Chill\MainBundle\Repository\NotificationRepository;
|
||||
use Chill\MainBundle\Repository\UserRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler]
|
||||
readonly class SendImmediateNotificationEmailHandler
|
||||
{
|
||||
public function __construct(
|
||||
private NotificationRepository $notificationRepository,
|
||||
private UserRepository $userRepository,
|
||||
private NotificationMailer $notificationMailer,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws TransportExceptionInterface
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __invoke(SendImmediateNotificationEmailMessage $message): void
|
||||
{
|
||||
$notification = $this->notificationRepository->find($message->getNotificationId());
|
||||
$addressee = $this->userRepository->find($message->getAddresseeId());
|
||||
|
||||
if (null === $notification) {
|
||||
$this->logger->error('[SendImmediateNotificationEmailHandler] Notification not found', [
|
||||
'notification_id' => $message->getNotificationId(),
|
||||
]);
|
||||
|
||||
throw new \InvalidArgumentException(sprintf('Notification with ID %s not found', $message->getNotificationId()));
|
||||
}
|
||||
|
||||
if (null === $addressee) {
|
||||
$this->logger->error('[SendImmediateNotificationEmailHandler] Addressee not found', [
|
||||
'addressee_id' => $message->getAddresseeId(),
|
||||
]);
|
||||
|
||||
throw new \InvalidArgumentException(sprintf('User with ID %s not found', $message->getAddresseeId()));
|
||||
}
|
||||
|
||||
try {
|
||||
$this->notificationMailer->sendEmailToAddressee($notification, $addressee);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('[SendImmediateNotificationEmailHandler] Failed to send email', [
|
||||
'notification_id' => $message->getNotificationId(),
|
||||
'addressee_id' => $message->getAddresseeId(),
|
||||
'stacktrace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
<?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\MainBundle\Notification\Email\NotificationEmailMessages;
|
||||
|
||||
readonly class ScheduleDailyNotificationDigestMessage
|
||||
{
|
||||
public function __construct(
|
||||
private int $userId,
|
||||
private \DateTimeInterface $lastExecutionDate,
|
||||
private \DateTimeInterface $currentDate,
|
||||
) {}
|
||||
|
||||
public function getUserId(): int
|
||||
{
|
||||
return $this->userId;
|
||||
}
|
||||
|
||||
public function getLastExecutionDateTime(): \DateTimeInterface
|
||||
{
|
||||
return $this->lastExecutionDate;
|
||||
}
|
||||
|
||||
public function getCurrentDateTime(): \DateTimeInterface
|
||||
{
|
||||
return $this->currentDate;
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
<?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\MainBundle\Notification\Email\NotificationEmailMessages;
|
||||
|
||||
readonly class SendImmediateNotificationEmailMessage
|
||||
{
|
||||
public function __construct(
|
||||
private int $notificationId,
|
||||
private int $addresseeId,
|
||||
) {}
|
||||
|
||||
public function getNotificationId(): int
|
||||
{
|
||||
return $this->notificationId;
|
||||
}
|
||||
|
||||
public function getAddresseeId(): int
|
||||
{
|
||||
return $this->addresseeId;
|
||||
}
|
||||
}
|
@@ -13,22 +13,32 @@ namespace Chill\MainBundle\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Entity\NotificationComment;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
|
||||
use Doctrine\ORM\Event\PostPersistEventArgs;
|
||||
use Doctrine\ORM\Event\PostUpdateEventArgs;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
class NotificationMailer
|
||||
readonly class NotificationMailer
|
||||
{
|
||||
public function __construct(private readonly MailerInterface $mailer, private readonly LoggerInterface $logger, private readonly TranslatorInterface $translator) {}
|
||||
public function __construct(
|
||||
private MailerInterface $mailer,
|
||||
private LoggerInterface $logger,
|
||||
private MessageBusInterface $messageBus,
|
||||
private TranslatorInterface $translator,
|
||||
) {}
|
||||
|
||||
public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void
|
||||
{
|
||||
$dests = [$comment->getNotification()->getSender(), ...$comment->getNotification()->getAddressees()->toArray()];
|
||||
$dests = [
|
||||
$comment->getNotification()->getSender(),
|
||||
...$comment->getNotification()->getAddressees()->toArray(),
|
||||
];
|
||||
|
||||
$uniqueDests = [];
|
||||
foreach ($dests as $dest) {
|
||||
@@ -69,55 +79,147 @@ class NotificationMailer
|
||||
*/
|
||||
public function postPersistNotification(Notification $notification, PostPersistEventArgs $eventArgs): void
|
||||
{
|
||||
$this->sendNotificationEmailsToAddresses($notification);
|
||||
$this->sendNotificationEmailsToAddressees($notification);
|
||||
$this->sendNotificationEmailsToAddressesEmails($notification);
|
||||
}
|
||||
|
||||
public function postUpdateNotification(Notification $notification, PostUpdateEventArgs $eventArgs): void
|
||||
private function sendNotificationEmailsToAddressees(Notification $notification): void
|
||||
{
|
||||
$this->sendNotificationEmailsToAddressesEmails($notification);
|
||||
}
|
||||
if ('' === $notification->getType()) {
|
||||
$this->logger->warning('[NotificationMailer] Notification has no type, skipping email processing', [
|
||||
'notification_id' => $notification->getId(),
|
||||
]);
|
||||
|
||||
private function sendNotificationEmailsToAddresses(Notification $notification): void
|
||||
{
|
||||
foreach ($notification->getAddressees() as $addressee) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($notification->getAllAddressees() as $addressee) {
|
||||
if (null === $addressee->getEmail()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($notification->isSystem()) {
|
||||
$email = new Email();
|
||||
$email
|
||||
->text($notification->getMessage());
|
||||
} else {
|
||||
$email = new TemplatedEmail();
|
||||
$email
|
||||
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
|
||||
->context([
|
||||
'notification' => $notification,
|
||||
'dest' => $addressee,
|
||||
]);
|
||||
}
|
||||
$this->processNotificationForAddressee($notification, $addressee);
|
||||
}
|
||||
}
|
||||
|
||||
private function processNotificationForAddressee(Notification $notification, User $addressee): void
|
||||
{
|
||||
$notificationType = $notification->getType();
|
||||
|
||||
if ($addressee->isNotificationSendImmediately($notificationType)) {
|
||||
$this->scheduleImmediateEmail($notification, $addressee);
|
||||
}
|
||||
}
|
||||
|
||||
private function scheduleImmediateEmail(Notification $notification, User $addressee): void
|
||||
{
|
||||
$message = new SendImmediateNotificationEmailMessage(
|
||||
$notification->getId(),
|
||||
$addressee->getId()
|
||||
);
|
||||
|
||||
$this->messageBus->dispatch($message);
|
||||
|
||||
$this->logger->info('[NotificationMailer] Scheduled immediate email', [
|
||||
'notification_id' => $notification->getId(),
|
||||
'addressee_email' => $addressee->getEmail(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method sends the email but is now called by the immediate notification email message handler.
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function sendEmailToAddressee(Notification $notification, User $addressee): void
|
||||
{
|
||||
if (null === $addressee->getEmail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($notification->isSystem()) {
|
||||
$email = new Email();
|
||||
$email->text($notification->getMessage());
|
||||
} else {
|
||||
$email = new TemplatedEmail();
|
||||
$email
|
||||
->subject($notification->getTitle())
|
||||
->to($addressee->getEmail());
|
||||
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->warning('[NotificationMailer] could not send an email notification', [
|
||||
'to' => $addressee->getEmail(),
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_trace' => $e->getTraceAsString(),
|
||||
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig')
|
||||
->context([
|
||||
'notification' => $notification,
|
||||
'dest' => $addressee,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$email
|
||||
->subject($notification->getTitle())
|
||||
->to($addressee->getEmail());
|
||||
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
$this->logger->info('[NotificationMailer] Email sent successfully', [
|
||||
'notification_id' => $notification->getId(),
|
||||
'addressee_email' => $addressee->getEmail(),
|
||||
]);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->warning('[NotificationMailer] Could not send an email notification', [
|
||||
'to' => $addressee->getEmail(),
|
||||
'notification_id' => $notification->getId(),
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send daily digest email with multiple notifications to a user.
|
||||
*
|
||||
* @throws TransportExceptionInterface
|
||||
*/
|
||||
public function sendDailyDigest(User $user, array $notifications): void
|
||||
{
|
||||
if (null === $user->getEmail() || [] === $notifications) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = new TemplatedEmail();
|
||||
$email
|
||||
->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig')
|
||||
->context([
|
||||
'user' => $user,
|
||||
'notifications' => $notifications,
|
||||
'notification_count' => count($notifications),
|
||||
])
|
||||
->subject($this->translator->trans('notification.Daily Notification Digest'))
|
||||
->to($user->getEmail());
|
||||
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
$this->logger->info('[NotificationMailer] Daily digest email sent successfully', [
|
||||
'user_email' => $user->getEmail(),
|
||||
'notification_count' => count($notifications),
|
||||
]);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->warning('[NotificationMailer] Could not send daily digest email', [
|
||||
'to' => $user->getEmail(),
|
||||
'notification_count' => count($notifications),
|
||||
'error_message' => $e->getMessage(),
|
||||
'error_trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function sendNotificationEmailsToAddressesEmails(Notification $notification): void
|
||||
{
|
||||
foreach ($notification->getAddressesEmailsAdded() as $emailAddress) {
|
||||
foreach ($notification->getAddresseeUserGroups() as $userGroup) {
|
||||
|
||||
if (!$userGroup->hasEmail()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$emailAddress = $userGroup->getEmail();
|
||||
|
||||
$email = new TemplatedEmail();
|
||||
$email
|
||||
->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig')
|
||||
|
@@ -0,0 +1,30 @@
|
||||
<?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\MainBundle\Notification\FlagProviders;
|
||||
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
class NotificationByUserFlagProvider implements NotificationFlagProviderInterface
|
||||
{
|
||||
public const FLAG = 'notif-by-user';
|
||||
|
||||
public function getFlag(): string
|
||||
{
|
||||
return self::FLAG;
|
||||
}
|
||||
|
||||
public function getLabel(): TranslatableInterface
|
||||
{
|
||||
return new TranslatableMessage('notification.flags.by-user');
|
||||
}
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
<?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\MainBundle\Notification\FlagProviders;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
interface NotificationFlagProviderInterface
|
||||
{
|
||||
public function getFlag(): string;
|
||||
|
||||
public function getLabel(): TranslatableInterface;
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
<?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\MainBundle\Notification\FlagProviders;
|
||||
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
class WorkflowTransitionNotificationFlagProvider implements NotificationFlagProviderInterface
|
||||
{
|
||||
public const FLAG = 'workflow-trans-notif';
|
||||
|
||||
public function getFlag(): string
|
||||
{
|
||||
return self::FLAG;
|
||||
}
|
||||
|
||||
public function getLabel(): TranslatableInterface
|
||||
{
|
||||
return new TranslatableMessage('notification.flags.workflow-trans');
|
||||
}
|
||||
}
|
@@ -0,0 +1,33 @@
|
||||
<?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\MainBundle\Notification;
|
||||
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
|
||||
final readonly class NotificationFlagManager
|
||||
{
|
||||
/**
|
||||
* @var array<NotificationFlagProviderInterface>
|
||||
*/
|
||||
private array $notificationFlagProviders;
|
||||
|
||||
public function __construct(
|
||||
iterable $notificationFlagProviders,
|
||||
) {
|
||||
$this->notificationFlagProviders = iterator_to_array($notificationFlagProviders);
|
||||
}
|
||||
|
||||
public function getAllNotificationFlagProviders(): array
|
||||
{
|
||||
return $this->notificationFlagProviders;
|
||||
}
|
||||
}
|
@@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository;
|
||||
use Chill\MainBundle\Entity\AddressReference;
|
||||
use Chill\MainBundle\Entity\PostalCode;
|
||||
use Chill\MainBundle\Search\SearchApiQuery;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\NativeQuery;
|
||||
use Doctrine\ORM\Query\ResultSetMapping;
|
||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
|
||||
final readonly class AddressReferenceRepository implements ObjectRepository
|
||||
final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface
|
||||
{
|
||||
private EntityManagerInterface $entityManager;
|
||||
|
||||
@@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository
|
||||
return $this->repository->findAll();
|
||||
}
|
||||
|
||||
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
|
||||
{
|
||||
$terms = $this->buildTermsFromSearchString($search);
|
||||
if ([] === $terms) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$connection = $this->entityManager->getConnection();
|
||||
$qb = $connection->createQueryBuilder();
|
||||
|
||||
$qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions')
|
||||
->from('view_chill_main_address_reference', 'var')
|
||||
->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id')
|
||||
->groupBy('cmpc.id', 'var.street')
|
||||
->setFirstResult($firstResult)
|
||||
->setMaxResults($maxResults);
|
||||
|
||||
$paramId = 0;
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$qb->andWhere('var.address like UNACCENT(LOWER(?))');
|
||||
$qb->setParameter(++$paramId, "%{$term}%");
|
||||
}
|
||||
|
||||
if (null !== $postalCode) {
|
||||
$qb->andWhere('var.postcode_id = ?');
|
||||
$qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
foreach ($result->iterateAssociative() as $row) {
|
||||
yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<AddressReference>
|
||||
*/
|
||||
public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable
|
||||
{
|
||||
$terms = $this->buildTermsFromSearchString($search);
|
||||
if ([] === $terms) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rsm = new ResultSetMappingBuilder($this->entityManager);
|
||||
$rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar');
|
||||
$baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN
|
||||
view_chill_main_address_reference var ON var.address_id = ar.id';
|
||||
$nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode);
|
||||
|
||||
$orderBy = [];
|
||||
$pertinence = [];
|
||||
foreach ($terms as $k => $term) {
|
||||
$pertinence[] =
|
||||
"(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int";
|
||||
$pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int";
|
||||
$nql->setParameter('order'.$k, $term);
|
||||
}
|
||||
$orderBy[] = implode(' + ', $pertinence).' ASC';
|
||||
$orderBy[] = implode('row_number ASC', $orderBy);
|
||||
|
||||
$nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy));
|
||||
|
||||
return $nql->toIterable();
|
||||
}
|
||||
|
||||
public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int
|
||||
{
|
||||
$terms = $this->buildTermsFromSearchString($search);
|
||||
if ([] === $terms) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$rsm = new ResultSetMappingBuilder($this->entityManager);
|
||||
$rsm->addScalarResult('c', 'c', Types::INTEGER);
|
||||
$nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode);
|
||||
|
||||
return $nql->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function buildTermsFromSearchString(string $search): array
|
||||
{
|
||||
return array_filter(
|
||||
array_map(
|
||||
static fn (string $term) => trim($term),
|
||||
explode(' ', $search)
|
||||
),
|
||||
static fn (string $term) => '' !== $term
|
||||
);
|
||||
}
|
||||
|
||||
private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery
|
||||
{
|
||||
$nql = $this->entityManager->createNativeQuery('', $rsm);
|
||||
|
||||
$sql = $select.' WHERE ';
|
||||
|
||||
$wheres = [];
|
||||
foreach ($terms as $k => $term) {
|
||||
$wheres[] = "var.address like :w{$k}";
|
||||
$nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING);
|
||||
}
|
||||
|
||||
if (null !== $postalCode) {
|
||||
$wheres[] = 'var.postcode_id = :postalCode';
|
||||
$nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode);
|
||||
}
|
||||
|
||||
$nql->setSQL($sql.implode(' AND ', $wheres));
|
||||
|
||||
return $nql;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed|null $limit
|
||||
* @param mixed|null $offset
|
||||
|
@@ -0,0 +1,20 @@
|
||||
<?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\MainBundle\Repository;
|
||||
|
||||
use Chill\MainBundle\Entity\PostalCode;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
|
||||
interface AddressReferenceRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable;
|
||||
}
|
@@ -290,12 +290,19 @@ final class NotificationRepository implements ObjectRepository
|
||||
return $qb;
|
||||
}
|
||||
|
||||
private function queryByAddressee(User $addressee, bool $countQuery = false): QueryBuilder
|
||||
private function queryByAddressee(User $addressee): QueryBuilder
|
||||
{
|
||||
$qb = $this->repository->createQueryBuilder('n');
|
||||
|
||||
$qb
|
||||
->where($qb->expr()->isMemberOf(':addressee', 'n.addressees'))
|
||||
->leftJoin('n.addresseeUserGroups', 'aug')
|
||||
->leftJoin('aug.users', 'ugu')
|
||||
->where(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isMemberOf(':addressee', 'n.addressees'),
|
||||
$qb->expr()->eq('ugu.id', ':addressee')
|
||||
)
|
||||
)
|
||||
->setParameter('addressee', $addressee);
|
||||
|
||||
return $qb;
|
||||
@@ -393,4 +400,30 @@ final class NotificationRepository implements ObjectRepository
|
||||
|
||||
return $nq->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all notifications for a user that were created between two dates.
|
||||
*
|
||||
* @return array|Notification[]
|
||||
*/
|
||||
public function findNotificationsForUserBetweenDates(int $userId, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array
|
||||
{
|
||||
$rsm = new Query\ResultSetMappingBuilder($this->em);
|
||||
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
|
||||
|
||||
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
|
||||
'FROM chill_main_notification cmn '.
|
||||
'JOIN chill_main_notification_addresses_user cmnau ON cmnau.notification_id = cmn.id '.
|
||||
'WHERE cmnau.user_id = :userId '.
|
||||
'AND cmn.date >= :startDate '.
|
||||
'AND cmn.date <= :endDate '.
|
||||
'ORDER BY cmn.date DESC';
|
||||
|
||||
$nq = $this->em->createNativeQuery($sql, $rsm)
|
||||
->setParameter('userId', $userId)
|
||||
->setParameter('startDate', $startDate, Types::DATETIME_MUTABLE)
|
||||
->setParameter('endDate', $endDate, Types::DATETIME_MUTABLE);
|
||||
|
||||
return $nq->getResult();
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,69 @@
|
||||
<?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\MainBundle\Repository;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface
|
||||
{
|
||||
public function __construct(private Connection $connection) {}
|
||||
|
||||
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable
|
||||
{
|
||||
$terms = $this->buildTermsFromSearchString($search);
|
||||
if ([] === $terms) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->connection->createQueryBuilder();
|
||||
|
||||
$qb->from('chill_main_postal_code', 'cmpc')
|
||||
->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id')
|
||||
->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id')
|
||||
->setFirstResult($firstResult)
|
||||
->setMaxResults($maxResults)
|
||||
;
|
||||
|
||||
$qb->select(
|
||||
'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id',
|
||||
'cmpc.code AS code',
|
||||
'cmpc.label AS label',
|
||||
'country.id AS country_id',
|
||||
'country.countrycode AS country_code',
|
||||
'country.name AS country_name'
|
||||
);
|
||||
|
||||
$paramId = 0;
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$qb->andWhere('vcmar.address like ?');
|
||||
$qb->setParameter(++$paramId, "%{$term}%");
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
|
||||
foreach ($result->iterateAssociative() as $row) {
|
||||
yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildTermsFromSearchString(string $search): array
|
||||
{
|
||||
return array_filter(
|
||||
array_map(
|
||||
static fn (string $term) => trim($term),
|
||||
explode(' ', $search)
|
||||
),
|
||||
static fn (string $term) => '' !== $term
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
<?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\MainBundle\Repository;
|
||||
|
||||
/**
|
||||
* Search for postal code using optimized materialized view.
|
||||
*/
|
||||
interface PostalCodeForAddressReferenceRepositoryInterface
|
||||
{
|
||||
public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable;
|
||||
}
|
@@ -74,6 +74,7 @@ export interface Postcode {
|
||||
name: string;
|
||||
code: string;
|
||||
center: Point;
|
||||
country: Country;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
@@ -89,6 +90,28 @@ export interface Country {
|
||||
|
||||
export type AddressRefStatus = "match" | "to_review" | "reviewed";
|
||||
|
||||
/**
|
||||
* An interface to create an address
|
||||
*/
|
||||
export interface AddressCreation {
|
||||
confidential: boolean;
|
||||
isNoAddress: boolean;
|
||||
street: string;
|
||||
streetNumber: string;
|
||||
postcode: Postcode;
|
||||
point: Point; // [number, number]; // [longitude, latitude]
|
||||
addressReference: AddressReference;
|
||||
validFrom: DateTime|null;
|
||||
floor: string;
|
||||
corridor: string;
|
||||
steps: string;
|
||||
flat: string;
|
||||
buildingName: string;
|
||||
distribution: string;
|
||||
extra: string;
|
||||
}
|
||||
|
||||
|
||||
export interface Address {
|
||||
type: "address";
|
||||
address_id: number;
|
||||
@@ -107,7 +130,7 @@ export interface Address {
|
||||
confidential: boolean;
|
||||
lines: string[];
|
||||
addressReference: AddressReference | null;
|
||||
validFrom: DateTime;
|
||||
validFrom: DateTime | null; // TODO there is no null for validFrom
|
||||
validTo: DateTime | null;
|
||||
point: Point | null;
|
||||
refStatus: AddressRefStatus;
|
||||
|
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Modal from "ChillMainAssets/vuejs/_components/Modal.vue";
|
||||
import AddressPicker from "ChillMainAssets/vuejs/AddressPicker/AddressPicker.vue";
|
||||
import {Ref, ref} from "vue";
|
||||
|
||||
const showModal: Ref<boolean> = ref(false);
|
||||
|
||||
const modalDialogClasses = {"modal-dialog": true, "modal-dialog-scrollable": true, "modal-xl": true};
|
||||
|
||||
const clickButton = () => {
|
||||
showModal.value = true;
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<modal v-if="showModal" :hide-footer="false" :modal-dialog-class="modalDialogClasses" @close="closeModal">
|
||||
<template v-slot:header>TODO</template>
|
||||
<template v-slot:body>
|
||||
<AddressPicker></AddressPicker>
|
||||
</template>
|
||||
</modal>
|
||||
<button class="btn btn-submit" type="button" @click="clickButton">SEARCH ADDRESS</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import {Address, AddressReference} from "ChillMainAssets/types";
|
||||
import SearchBar from "ChillMainAssets/vuejs/AddressPicker/Component/SearchBar.vue";
|
||||
import {
|
||||
AddressAggregated,
|
||||
AssociatedPostalCode, fetchAddressReference,
|
||||
getAddressesAggregated,
|
||||
getPostalCodes,
|
||||
} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
|
||||
import {computed, Ref, ref} from "vue";
|
||||
import AddressAggregatedList from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedList.vue";
|
||||
import AddressDetailsForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressDetailsForm.vue";
|
||||
|
||||
interface AddressPickerProps {
|
||||
suggestions?: Address[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AddressPickerProps>(), {
|
||||
suggestions: () => [],
|
||||
});
|
||||
|
||||
const addresses: Ref<AddressAggregated[]> = ref([]);
|
||||
const postalCodes: Ref<AssociatedPostalCode[]> = ref([]);
|
||||
const searchTokens: Ref<string[]> = ref([]);
|
||||
const addressReference: Ref<AddressReference|null> = ref(null);
|
||||
|
||||
let abortControllerSearchAddress: null | AbortController = null;
|
||||
let abortControllerSearchPostalCode: null | AbortController = null;
|
||||
|
||||
const searchResultsClasses = computed(() => ({
|
||||
"mid-size": addressReference !== null,
|
||||
}));
|
||||
|
||||
|
||||
const onSearch = async function (search: string): Promise<void> {
|
||||
performSearchForAddress(search);
|
||||
performSearchForPostalCode(search);
|
||||
searchTokens.value = [search];
|
||||
};
|
||||
|
||||
const onPickPosition = async (id: string) => {
|
||||
console.log('Pick Position', id);
|
||||
addressReference.value = await fetchAddressReference(id);
|
||||
}
|
||||
|
||||
const performSearchForAddress = async (search: string): Promise<void> => {
|
||||
if (null !== abortControllerSearchAddress) {
|
||||
abortControllerSearchAddress.abort();
|
||||
}
|
||||
|
||||
if ("" === search) {
|
||||
addresses.value = [];
|
||||
abortControllerSearchAddress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerSearchAddress = new AbortController();
|
||||
|
||||
console.log("onSearch", search);
|
||||
try {
|
||||
addresses.value = await getAddressesAggregated(
|
||||
search,
|
||||
abortControllerSearchAddress,
|
||||
);
|
||||
abortControllerSearchAddress = null;
|
||||
|
||||
// check if there is only one result
|
||||
if (addresses.value.length === 1 && Object.keys(addresses.value[0].positions).length === 1) {
|
||||
onPickPosition(Object.keys(addresses.value[0].positions)[0]);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
console.log("search aborted for:", search);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const performSearchForPostalCode = async (search: string): Promise<void> => {
|
||||
if (null !== abortControllerSearchPostalCode) {
|
||||
abortControllerSearchPostalCode.abort();
|
||||
}
|
||||
|
||||
if ("" === search) {
|
||||
addresses.value = [];
|
||||
abortControllerSearchPostalCode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
abortControllerSearchPostalCode = new AbortController();
|
||||
|
||||
console.log("onSearch", search);
|
||||
try {
|
||||
postalCodes.value = await getPostalCodes(
|
||||
search,
|
||||
abortControllerSearchPostalCode,
|
||||
);
|
||||
abortControllerSearchPostalCode = null;
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
console.log("search postal code aborted for:", search);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<search-bar @search="onSearch"></search-bar>
|
||||
<div class="address-pick-content">
|
||||
<div class="search-results" :class="searchResultsClasses">
|
||||
<address-aggregated-list :addresses="addresses" :search-tokens="searchTokens" @pick-position="(id) => onPickPosition(id)"></address-aggregated-list>
|
||||
</div>
|
||||
<div v-if="addressReference !== null" class="address-details-form">
|
||||
<address-details-form :address="addressReference"></address-details-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.address-pick-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
|
||||
.search-results {
|
||||
&.mid-size {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
.address-details-form {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
|
||||
import AddressAggregatedListItem from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedListItem.vue";
|
||||
|
||||
interface AddressAggregatedListProps {
|
||||
addresses: AddressAggregated[];
|
||||
searchTokens: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<AddressAggregatedListProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pickPosition: [id: string]
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="a in props.addresses" :key="a.row_number">
|
||||
<address-aggregated-list-item :address="a" :search-tokens="props.searchTokens" @pick-position="(id) => emit('pickPosition', id)"></address-aggregated-list-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search";
|
||||
import {computed, ref} from "vue";
|
||||
|
||||
interface AddressAggregatedListItemProps {
|
||||
address: AddressAggregated;
|
||||
searchTokens: string[];
|
||||
}
|
||||
|
||||
const props = defineProps<AddressAggregatedListItemProps>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
pickPosition: [id: string]
|
||||
}>();
|
||||
|
||||
const showAllPositions = ref<boolean>(false);
|
||||
const positionsToShow = computed((): Record<string, string> => {
|
||||
const obj: Record<string, any> = {};
|
||||
let count = 0;
|
||||
for (const [id, position] of Object.entries(props.address.positions)) {
|
||||
obj[id] = position;
|
||||
count++;
|
||||
if (count >= 10 && !showAllPositions.value) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
})
|
||||
const needToShowMorePosition = computed(() => {
|
||||
return Object.keys(props.address.positions).length > 10;
|
||||
})
|
||||
|
||||
const onClickButton = (id: string) => {
|
||||
console.log('onClickButton', id);
|
||||
emit('pickPosition', id);
|
||||
}
|
||||
const displayAllPositions = () => {
|
||||
showAllPositions.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="street">
|
||||
<span>{{ props.address.street }}</span>
|
||||
</div>
|
||||
<div class="postcode">
|
||||
<span>{{ props.address.code }}</span> <span>{{ address.label }}</span>
|
||||
</div>
|
||||
<div class="positions">
|
||||
<ul>
|
||||
<li v-for="(position, id) in positionsToShow" :key="id" >
|
||||
<button type="button" @click="onClickButton(id)" >
|
||||
{{ position }}
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="needToShowMorePosition">
|
||||
<button @click="displayAllPositions">show all</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.street {
|
||||
font-variant: small-caps;
|
||||
font-weight: bold;
|
||||
}
|
||||
.postcode {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
.positions ul {
|
||||
list-style-type: none;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import {AddressReference} from "ChillMainAssets/types";
|
||||
import {computed} from "vue";
|
||||
import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper";
|
||||
import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue";
|
||||
|
||||
export interface AddressDetailsFormProps {
|
||||
address: AddressReference;
|
||||
}
|
||||
|
||||
const props = defineProps<AddressDetailsFormProps>();
|
||||
|
||||
const address = computed(() => addressReferenceToAddress(props.address));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
FORM
|
||||
</div>
|
||||
<div>
|
||||
<address-details-content :address="address"></address-details-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ADDRESS_PICKER_SEARCH_FOR_ADDRESSES, trans } from 'translator';
|
||||
const emits = defineEmits<{
|
||||
search: [search: string];
|
||||
}>();
|
||||
|
||||
let searchTimer = 0;
|
||||
let searchString: string;
|
||||
|
||||
const onInput = function (event: InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
searchString = value;
|
||||
|
||||
if (0 === searchTimer) {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = 0;
|
||||
}
|
||||
|
||||
searchTimer = window.setTimeout(() => {
|
||||
if (value === searchString) {
|
||||
emits("search", value);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="input-group mb-3">
|
||||
<span class="input-group-text">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16">
|
||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="search" class="form-control" @input="onInput" :placeholder="trans(ADDRESS_PICKER_SEARCH_FOR_ADDRESSES)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
@@ -0,0 +1,69 @@
|
||||
import {AddressReference, TranslatableString} from "ChillMainAssets/types";
|
||||
|
||||
export interface AddressAggregated {
|
||||
row_number: number;
|
||||
street: string;
|
||||
postcode_id: number;
|
||||
code: string;
|
||||
label: string;
|
||||
positions: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AssociatedPostalCode {
|
||||
postcode_id: number;
|
||||
code: string;
|
||||
label: string;
|
||||
country_id: number;
|
||||
country_code: string;
|
||||
country_name: TranslatableString;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {DOMException} when fetch is aborted, the property name is always equals to 'AbortError'
|
||||
*/
|
||||
export const getAddressesAggregated = async (
|
||||
search: string,
|
||||
abortController: AbortController,
|
||||
): Promise<AddressAggregated[]> => {
|
||||
const params = new URLSearchParams({ q: search.trim() });
|
||||
let response = null;
|
||||
|
||||
response = await fetch(
|
||||
`/api/1.0/main/address-reference/aggregated/search?${params}`,
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
throw new Error(response.statusText);
|
||||
};
|
||||
|
||||
export const getPostalCodes = async (
|
||||
search: string,
|
||||
abortController: AbortController,
|
||||
): Promise<AssociatedPostalCode[]> => {
|
||||
const params = new URLSearchParams({ q: search.trim() });
|
||||
let response = null;
|
||||
|
||||
response = await fetch(
|
||||
`/api/1.0/main/address-reference/postal-code/search?${params}`,
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
throw new Error(response.statusText);
|
||||
};
|
||||
|
||||
export const fetchAddressReference = async (id: string): Promise<AddressReference> => {
|
||||
const response = await fetch(`/api/1.0/main/address-reference/${id}.json`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
throw new Error(response.statusText);
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import {Address, AddressCreation, AddressReference} from "ChillMainAssets/types";
|
||||
|
||||
export const addressReferenceToAddress = (reference: AddressReference): AddressCreation => {
|
||||
return {
|
||||
street: reference.street,
|
||||
streetNumber: reference.streetNumber,
|
||||
postcode: reference.postcode,
|
||||
floor: "",
|
||||
corridor: "",
|
||||
steps: "",
|
||||
flat: "",
|
||||
buildingName: "",
|
||||
distribution: "",
|
||||
extra: "",
|
||||
confidential: false,
|
||||
addressReference: reference,
|
||||
point: reference.point,
|
||||
isNoAddress: false,
|
||||
validFrom: null,
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
import { createApp } from "vue";
|
||||
import AddressButton from "ChillMainAssets/vuejs/AddressPicker/AddressButton.vue";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
document
|
||||
.querySelectorAll<HTMLDivElement>("div[data-address-picker]")
|
||||
.forEach((elem): void => {
|
||||
const app = createApp(AddressButton);
|
||||
|
||||
app.mount(elem);
|
||||
});
|
||||
});
|
@@ -4,24 +4,27 @@
|
||||
:show-button-details="false"
|
||||
></address-render-box>
|
||||
<address-details-ref-matching
|
||||
v-if="isAddress(props.address)"
|
||||
:address="props.address"
|
||||
@update-address="onUpdateAddress"
|
||||
></address-details-ref-matching>
|
||||
<address-details-map :address="props.address"></address-details-map>
|
||||
<address-details-geographical-layers
|
||||
v-if="isAddress(props.address)"
|
||||
:address="props.address"
|
||||
></address-details-geographical-layers>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Address } from "../../../types";
|
||||
import {Address, AddressCreation} from "../../../types";
|
||||
import AddressDetailsMap from "./Parts/AddressDetailsMap.vue";
|
||||
import AddressRenderBox from "../Entity/AddressRenderBox.vue";
|
||||
import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue";
|
||||
import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue";
|
||||
import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
||||
|
||||
interface AddressModalContentProps {
|
||||
address: Address;
|
||||
address: Address|AddressCreation;
|
||||
}
|
||||
|
||||
const props = defineProps<AddressModalContentProps>();
|
||||
|
@@ -12,90 +12,91 @@
|
||||
Voir sur
|
||||
<a :href="makeUrlGoogleMap(props.address)" target="_blank"
|
||||
>Google Maps</a
|
||||
>
|
||||
<a :href="makeUrlOsm(props.address)" target="_blank">OSM</a>
|
||||
> <a
|
||||
:href="makeUrlOsm(props.address)" target="_blank"
|
||||
>OSM</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import markerIconPng from "leaflet/dist/images/marker-icon.png";
|
||||
import L, { LatLngExpression, LatLngTuple } from "leaflet";
|
||||
import { Address, Point } from "../../../../types";
|
||||
import {Address, AddressCreation, Point} from "../../../../types";
|
||||
import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
||||
import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet";
|
||||
|
||||
const lonLatForLeaflet = (point: Point): LatLngTuple => {
|
||||
return [point.coordinates[1], point.coordinates[0]];
|
||||
};
|
||||
|
||||
export interface MapProps {
|
||||
address: Address;
|
||||
address: Address|AddressCreation;
|
||||
}
|
||||
|
||||
const props = defineProps<MapProps>();
|
||||
|
||||
const map_div = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let marker: L.Marker | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (map_div.value === null) {
|
||||
// there is no map div when the address does not have any Point
|
||||
return;
|
||||
const markerIcon = L.icon({
|
||||
iconUrl: markerIconPng,
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
const latLngMarker = computed((): LatLngExpression => {
|
||||
if (props.address === null || props.address.point === null) {
|
||||
return [0, 0, 0];
|
||||
}
|
||||
|
||||
if (props.address.point !== null) {
|
||||
map = L.map(map_div.value);
|
||||
map.setView(lonLatForLeaflet(props.address.point), 18);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
|
||||
const markerIcon = L.icon({
|
||||
iconUrl: markerIconPng,
|
||||
iconAnchor: [12, 41],
|
||||
});
|
||||
|
||||
marker = L.marker(lonLatForLeaflet(props.address.point), {
|
||||
icon: markerIcon,
|
||||
});
|
||||
marker.addTo(map);
|
||||
}
|
||||
return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0]
|
||||
});
|
||||
|
||||
const makeUrlGoogleMap = (address: Address): string => {
|
||||
const map_div = ref<HTMLDivElement | null>(null);
|
||||
const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker});
|
||||
const tileLayer = useLeafletTileLayer(
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
}
|
||||
);
|
||||
useLeafletDisplayLayer(map, tileLayer);
|
||||
|
||||
|
||||
const marker = useLeafletMarker(latLngMarker, {icon: markerIcon});
|
||||
useLeafletDisplayLayer(map, marker);
|
||||
|
||||
|
||||
const makeUrlGoogleMap = (address: Address|AddressCreation): string => {
|
||||
const params = new URLSearchParams();
|
||||
params.append("api", "1");
|
||||
if (address.point !== null && address.addressReference !== null) {
|
||||
const point = getAddressPoint(address);
|
||||
if (point !== null && address.addressReference !== null) {
|
||||
params.append(
|
||||
"query",
|
||||
`${address.point.coordinates[1]} ${address.point.coordinates[0]}`,
|
||||
`${point.coordinates[1]} ${point.coordinates[0]}`,
|
||||
);
|
||||
} else {
|
||||
params.append("query", address.lines.join(", "));
|
||||
params.append("query", buildAddressLines(address).join(", "));
|
||||
}
|
||||
|
||||
return `https://www.google.com/maps/search/?${params.toString()}`;
|
||||
};
|
||||
|
||||
const makeUrlOsm = (address: Address): string => {
|
||||
if (address.point !== null && address.addressReference !== null) {
|
||||
const makeUrlOsm = (address: Address|AddressCreation): string => {
|
||||
const point = getAddressPoint(address);
|
||||
if (point !== null && address.addressReference !== null) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("mlat", `${address.point.coordinates[1]}`);
|
||||
params.append("mlon", `${address.point.coordinates[0]}`);
|
||||
params.append("mlat", `${point.coordinates[1]}`);
|
||||
params.append("mlon", `${point.coordinates[0]}`);
|
||||
const hashParams = new URLSearchParams();
|
||||
hashParams.append(
|
||||
"map",
|
||||
`18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`,
|
||||
`18/${point.coordinates[1]}/${point.coordinates[0]}`,
|
||||
);
|
||||
|
||||
return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("query", address.lines.join(", "));
|
||||
params.append("query", buildAddressLines(address).join(", "));
|
||||
|
||||
return `https://www.openstreetmap.org/search?${params.toString()}`;
|
||||
};
|
||||
|
@@ -0,0 +1,46 @@
|
||||
import {Address, AddressCreation, Point} from "ChillMainAssets/types";
|
||||
import addressFormatter, {Input} from "@fragaria/address-formatter";
|
||||
|
||||
/**
|
||||
* Checks if the given object is of type Address by verifying the existence
|
||||
* of the `lines` property and confirming that it is an array of strings.
|
||||
*
|
||||
* @param {AddressCreation | Address} obj - The object to check.
|
||||
* @return {boolean} Returns true if the object is of type Address, otherwise false.
|
||||
*/
|
||||
export function isAddress(obj: AddressCreation | Address): obj is Address {
|
||||
return (obj as any).lines !== undefined && Array.isArray((obj as any).lines);
|
||||
}
|
||||
|
||||
function buildAddressFormatterObject(address: AddressCreation): Input {
|
||||
return {
|
||||
city: address.postcode.name,
|
||||
postcode: address.postcode.code,
|
||||
countryCode: address.postcode.country.code,
|
||||
street: address.street,
|
||||
houseNumber: address.streetNumber,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const buildAddressLines = (address: AddressCreation|Address): string[] => {
|
||||
if (isAddress(address)) {
|
||||
return address.lines;
|
||||
}
|
||||
|
||||
const lines = addressFormatter.format(buildAddressFormatterObject(address), {output: 'array', countryCode: address.addressReference.postcode.country.code });
|
||||
console.log('lines:', lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export const buildAddressText = (address: AddressCreation|Address): string => {
|
||||
return buildAddressLines(address).join(' - ');
|
||||
}
|
||||
|
||||
export const getAddressPoint = (address: AddressCreation|Address): Point|null => {
|
||||
if (isAddress(address)) {
|
||||
return address.point;
|
||||
}
|
||||
|
||||
return address.addressReference?.point;
|
||||
}
|
@@ -4,14 +4,14 @@
|
||||
<div v-if="isConfidential">
|
||||
<confidential :position-btn-far="true">
|
||||
<template #confidential-content>
|
||||
<div v-if="isMultiline === true">
|
||||
<div v-if="isMultiline">
|
||||
<p
|
||||
v-for="(l, i) in address.lines"
|
||||
v-for="(l, i) in buildAddressLines(address)"
|
||||
:key="`line-${i}`"
|
||||
>
|
||||
{{ l }}
|
||||
</p>
|
||||
<p v-if="showButtonDetails">
|
||||
<p v-if="showButtonDetails && isAddress(address) ">
|
||||
<address-details-button
|
||||
:address_id="address.address_id"
|
||||
:address_ref_status="address.refStatus"
|
||||
@@ -19,8 +19,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p v-if="'' !== address.text" class="street">
|
||||
{{ address.text }}
|
||||
<p v-if="'' !== buildAddressText(address)" class="street">
|
||||
{{ buildAddressText(address) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="null !== address.postcode"
|
||||
@@ -29,8 +29,8 @@
|
||||
{{ address.postcode.code }}
|
||||
{{ address.postcode.name }}
|
||||
</p>
|
||||
<p v-if="null !== address.country" class="country">
|
||||
{{ localizeString(address.country.name) }}
|
||||
<p v-if="null !== address.postcode" class="country">
|
||||
{{ localizeString(address.postcode.country.name) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -38,11 +38,11 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!isConfidential">
|
||||
<div v-if="isMultiline === true">
|
||||
<p v-for="(l, i) in address.lines" :key="`line-${i}`">
|
||||
<div v-if="isMultiline">
|
||||
<p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`">
|
||||
{{ l }}
|
||||
</p>
|
||||
<p v-if="showButtonDetails">
|
||||
<p v-if="showButtonDetails && isAddress(address) ">
|
||||
<address-details-button
|
||||
:address_id="address.address_id"
|
||||
:address_ref_status="address.refStatus"
|
||||
@@ -50,9 +50,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p v-if="address.text" class="street">
|
||||
{{ address.text }}
|
||||
<template v-if="showButtonDetails">
|
||||
<p v-if="'' !== buildAddressText(address)" class="street">
|
||||
{{ buildAddressText(address)}}
|
||||
<template v-if="showButtonDetails && isAddress(address) ">
|
||||
<address-details-button
|
||||
:address_id="address.address_id"
|
||||
:address_ref_status="address.refStatus"
|
||||
@@ -63,68 +63,49 @@
|
||||
</div>
|
||||
</component>
|
||||
|
||||
<div v-if="useDatePane === true" class="address-more">
|
||||
<div v-if="useDatePane" class="address-more">
|
||||
<div v-if="address.validFrom">
|
||||
<span class="validFrom">
|
||||
<b>{{ trans(ADDRESS_VALID_FROM) }}</b
|
||||
>: {{ $d(address.validFrom.date) }}
|
||||
>: {{ address.validFrom?.datetime8601 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="address.validTo">
|
||||
<div v-if="isAddress(address) && address.validTo !== null">
|
||||
<span class="validTo">
|
||||
<b>{{ trans(ADDRESS_VALID_TO) }}</b
|
||||
>: {{ $d(address.validTo.date) }}
|
||||
>: {{ address.validTo?.datetime8601 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue";
|
||||
import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue";
|
||||
import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator";
|
||||
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
|
||||
import {Address, AddressCreation} from "ChillMainAssets/types";
|
||||
import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper";
|
||||
|
||||
export default {
|
||||
name: "AddressRenderBox",
|
||||
methods: { localizeString },
|
||||
components: {
|
||||
Confidential,
|
||||
AddressDetailsButton,
|
||||
},
|
||||
props: {
|
||||
address: {
|
||||
type: Object,
|
||||
},
|
||||
isMultiline: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
useDatePane: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
showButtonDetails: {
|
||||
default: true,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO };
|
||||
},
|
||||
computed: {
|
||||
component() {
|
||||
return this.isMultiline === true ? "div" : "span";
|
||||
},
|
||||
multiline() {
|
||||
return this.isMultiline === true ? "multiline" : "";
|
||||
},
|
||||
isConfidential() {
|
||||
return this.address.confidential;
|
||||
},
|
||||
},
|
||||
};
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
address: Address|AddressCreation;
|
||||
isMultiline?: boolean;
|
||||
useDatePane?: boolean;
|
||||
showButtonDetails?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isMultiline: true,
|
||||
useDatePane: false,
|
||||
showButtonDetails: true,
|
||||
}
|
||||
);
|
||||
|
||||
const component = computed(() => (props.isMultiline ? "div" : "span"));
|
||||
const multiline = computed(() => (props.isMultiline ? "multiline" : ""));
|
||||
const isConfidential = computed(() => Boolean(props.address?.confidential));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@@ -0,0 +1,15 @@
|
||||
{% extends '@ChillMain/layout.html.twig' %}
|
||||
|
||||
{% block css %}
|
||||
{{ encore_entry_link_tags('address_picker') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
{{ encore_entry_script_tags('address_picker') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div data-address-picker="data-address-picker"></div>
|
||||
|
||||
{% endblock %}
|
@@ -18,8 +18,9 @@
|
||||
{%- endif -%}
|
||||
{%- endblock form_label %}
|
||||
|
||||
{# this has been rewritten for chill #}
|
||||
{% block form_label_class -%}
|
||||
col-sm-4
|
||||
{% if 'div_col_width' in label_attr|default({})|keys %}{% if label_attr['div_col_width'] is not same as false %}{{ label_attr['div_col_width'] }}{% endif %}{% else %}col-sm-4{% endif %}
|
||||
{%- endblock form_label_class %}
|
||||
|
||||
{# Rows #}
|
||||
|
@@ -69,41 +69,44 @@
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if c.notification.addressees|length > 0 %}
|
||||
{% if c.notification.addressees|length > 0 or c.notification.addresseeUserGroups|length > 0 %}
|
||||
<li class="notification-to">
|
||||
{% if c.notification_cc is defined %}
|
||||
{% if c.notification_cc %}
|
||||
<span class="item-key">
|
||||
<abbr title="{{ 'notification.sent_cc' | trans }}">
|
||||
{{ "notification.cc" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
<abbr title="{{ 'notification.sent_cc' | trans }}">
|
||||
{{ "notification.cc" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="item-key">
|
||||
<abbr title="{{ 'notification.sent_to' | trans }}">
|
||||
{{ "notification.to" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
<abbr title="{{ 'notification.sent_to' | trans }}">
|
||||
{{ "notification.to" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="item-key">
|
||||
<abbr title="{{ 'notification.sent_to' | trans }}">
|
||||
{{ "notification.to" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
<abbr title="{{ 'notification.sent_to' | trans }}">
|
||||
{{ "notification.to" | trans }} :
|
||||
</abbr>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% for a in c.notification.addressees %}
|
||||
<span class="badge-user">
|
||||
{{ a | chill_entity_render_string({'at_date': c.notification.date}) }}
|
||||
</span>
|
||||
{{ a | chill_entity_render_string({'at_date': c.notification.date}) }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for a in c.notification.addressesEmails %}
|
||||
<span
|
||||
class="badge-user"
|
||||
title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
|
||||
>
|
||||
{{ a }}
|
||||
</span>
|
||||
{{ a }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% for ug in c.notification.addresseeUserGroups %}
|
||||
{{ ug|chill_entity_render_box }}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
@@ -21,8 +21,6 @@
|
||||
{{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
|
||||
{{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
|
||||
|
||||
{{ form_row(form.addressesEmails) }}
|
||||
|
||||
{% include handler.template(notification) with handler.templateData(notification) %}
|
||||
|
||||
<div class="mb-3 row">
|
||||
|
@@ -0,0 +1,24 @@
|
||||
{% apply markdown_to_html %}
|
||||
# {{ 'notification.daily_digest.title'|trans }}
|
||||
|
||||
{{ 'notification.daily_digest.greeting'|trans({'%user%': user.label ?? user.email}) }},
|
||||
|
||||
{{ 'daily_notifications'|trans({'notification_count': notification_count}) }}
|
||||
|
||||
{% for notification in notifications %}
|
||||
## {{ notification.title }}
|
||||
|
||||
{{ notification.message }}
|
||||
|
||||
{{ 'notification.daily_digest.view_notification'|trans }}
|
||||
|
||||
{{ absolute_url(path('chill_main_notification_show', {'_locale': user.locale, 'id': notification.id }, false)) }}
|
||||
|
||||
{% if not loop.last %}
|
||||
---
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
---
|
||||
{{ 'notification.daily_digest.signature'|trans }}
|
||||
{% endapply %}
|
@@ -20,7 +20,7 @@
|
||||
{% extends "@ChillMain/layout.html.twig" %}
|
||||
|
||||
|
||||
{% block title %}{{"My profile"|trans}}{% endblock %}
|
||||
{% block title %}{{"user.profile.title"|trans}}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="justify-content-center col-10">
|
||||
@@ -45,9 +45,35 @@
|
||||
{{ form_start(form) }}
|
||||
{{ form_row(form.phonenumber) }}
|
||||
|
||||
<h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2>
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'notification.flags.type'|trans }}</th>
|
||||
<th class="text-center">{{ 'notification.flags.preferences.immediate_email'|trans }}</th>
|
||||
<th class="text-center">{{ 'notification.flags.preferences.daily_email'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-hover table-group-divider">
|
||||
{% for flag in form.notificationFlags %}
|
||||
<tr>
|
||||
<td class="col-sm-6">
|
||||
{{ form_label(flag, null, {'label_attr': {'div_col_width': false}}) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<ul class="record_actions">
|
||||
<li>
|
||||
{{ form_widget(form.submit, { 'attr': { 'class': 'btn btn-save' } } ) }}
|
||||
<button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
@@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw
|
||||
'name' => $address->getPostCode()->getName(),
|
||||
'code' => $address->getPostCode()->getCode(),
|
||||
'center' => $address->getPostcode()->getCenter(),
|
||||
'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]),
|
||||
],
|
||||
'country' => [
|
||||
'id' => $address->getPostCode()->getCountry()->getId(),
|
||||
|
@@ -0,0 +1,118 @@
|
||||
<?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\MainBundle\Tests\Controller;
|
||||
|
||||
use Chill\MainBundle\Controller\AddressReferenceAggregatedApiController;
|
||||
use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @covers \Chill\MainBundle\Controller\AddressReferenceAggregatedApiController
|
||||
*/
|
||||
final class AddressReferenceAggregatedApiControllerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
public function testAccessDeniedWhenNotAuthenticated(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('IS_AUTHENTICATED')->willReturn(false);
|
||||
|
||||
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
|
||||
|
||||
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
|
||||
|
||||
$request = new Request(query: ['q' => 'anything']);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
|
||||
$controller->search($request);
|
||||
}
|
||||
|
||||
public function testBadRequestWhenQueryIsMissing(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
|
||||
|
||||
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
|
||||
|
||||
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
|
||||
|
||||
$request = new Request();
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Parameter "q" is required.');
|
||||
|
||||
$controller->search($request);
|
||||
}
|
||||
|
||||
public function testBadRequestWhenQueryIsEmpty(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
|
||||
|
||||
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
|
||||
|
||||
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
|
||||
|
||||
$request = new Request(query: ['q' => ' ']);
|
||||
|
||||
$this->expectException(BadRequestHttpException::class);
|
||||
$this->expectExceptionMessage('Parameter "q" is required and cannot be empty.');
|
||||
|
||||
$controller->search($request);
|
||||
}
|
||||
|
||||
public function testSuccessfulSearchReturnsJsonAndCallsRepositoryWithTrimmedQuery(): void
|
||||
{
|
||||
$security = $this->prophesize(Security::class);
|
||||
$security->isGranted('IS_AUTHENTICATED')->willReturn(true);
|
||||
|
||||
$expectedQuery = 'foo';
|
||||
$iterableResult = new \ArrayIterator([
|
||||
[
|
||||
'street' => 'Main Street',
|
||||
'postcode_id' => 123,
|
||||
'code' => '1000',
|
||||
'label' => 'Brussels',
|
||||
'positions' => ['1' => '12', '2' => '14'],
|
||||
'row_number' => 1,
|
||||
],
|
||||
]);
|
||||
|
||||
$repo = $this->prophesize(AddressReferenceRepositoryInterface::class);
|
||||
$repo->findAggregatedBySearchString($expectedQuery)->willReturn($iterableResult)->shouldBeCalledOnce();
|
||||
|
||||
$controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal());
|
||||
|
||||
// Provide spaces around to ensure trimming is applied
|
||||
$request = new Request(query: ['q' => " {$expectedQuery} "]);
|
||||
|
||||
$response = $controller->search($request);
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||
self::assertIsArray($data);
|
||||
self::assertCount(1, $data);
|
||||
self::assertSame('Main Street', $data[0]['street']);
|
||||
self::assertSame(123, $data[0]['postcode_id']);
|
||||
self::assertSame('1000', $data[0]['code']);
|
||||
self::assertSame('Brussels', $data[0]['label']);
|
||||
}
|
||||
}
|
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
||||
* the LICENSE file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Entity;
|
||||
namespace Chill\MainBundle\Tests\Entity;
|
||||
|
||||
use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
@@ -49,8 +49,8 @@ final class NotificationTest extends KernelTestCase
|
||||
$notification = new Notification();
|
||||
$notification->addAddressee($user1 = new User());
|
||||
$notification->addAddressee($user2 = new User());
|
||||
$notification->getAddressees()->add($user3 = new User());
|
||||
$notification->getAddressees()->add($user4 = new User());
|
||||
$notification->addAddressee($user3 = new User());
|
||||
$notification->addAddressee($user4 = new User());
|
||||
|
||||
$this->assertCount(4, $notification->getAddressees());
|
||||
|
||||
@@ -85,6 +85,30 @@ final class NotificationTest extends KernelTestCase
|
||||
$this->assertNotContains('other', $notification->getAddressesEmailsAdded());
|
||||
}
|
||||
|
||||
public function testIsSendImmediately(): void
|
||||
{
|
||||
$notification = new Notification();
|
||||
$notification->setType('test_notification_type');
|
||||
|
||||
$user = new User();
|
||||
|
||||
// no notification flags
|
||||
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email');
|
||||
|
||||
// immediate-email preference
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]);
|
||||
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email');
|
||||
|
||||
// daily-email preference
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]);
|
||||
$this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only');
|
||||
$this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email');
|
||||
|
||||
// a different notification type
|
||||
$notification->setType('other_notification_type');
|
||||
$this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return false when notification type does not match any preference');
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateNotificationData
|
||||
*/
|
||||
|
@@ -0,0 +1,46 @@
|
||||
<?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\MainBundle\Tests\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Run functional test on the cronjob.
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
|
||||
{
|
||||
private DailyNotificationDigestCronjob $dailyNotificationDigestCronjob;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$this->dailyNotificationDigestCronjob = self::getContainer()->get(DailyNotificationDigestCronjob::class);
|
||||
}
|
||||
|
||||
public function testRunWithNullPreviousExecutionData(): void
|
||||
{
|
||||
$actual = $this->dailyNotificationDigestCronjob->run([]);
|
||||
|
||||
self::assertArrayHasKey('last_execution', $actual);
|
||||
self::assertInstanceOf(
|
||||
\DateTimeImmutable::class,
|
||||
\DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
|
||||
'test that the string can be converted to a date'
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,81 @@
|
||||
<?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\MainBundle\Tests\Notification\Email;
|
||||
|
||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Clock\ClockInterface;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class DailyNotificationDigestCronJobTest extends TestCase
|
||||
{
|
||||
private ClockInterface $clock;
|
||||
private Connection $connection;
|
||||
private MessageBusInterface $messageBus;
|
||||
private LoggerInterface $logger;
|
||||
private DailyNotificationDigestCronjob $cronjob;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->clock = $this->createMock(ClockInterface::class);
|
||||
$this->connection = $this->createMock(Connection::class);
|
||||
$this->messageBus = $this->createMock(MessageBusInterface::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->cronjob = new DailyNotificationDigestCronjob(
|
||||
$this->clock,
|
||||
$this->connection,
|
||||
$this->messageBus,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetKey(): void
|
||||
{
|
||||
$this->assertEquals('daily-notification-digest', $this->cronjob->getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider canRunTimeDataProvider
|
||||
*/
|
||||
public function testCanRunWithNullCronJobExecution(int $hour, bool $expected): void
|
||||
{
|
||||
$now = new \DateTimeImmutable("2024-01-01 {$hour}:00:00");
|
||||
$this->clock->expects($this->once())
|
||||
->method('now')
|
||||
->willReturn($now);
|
||||
|
||||
$result = $this->cronjob->canRun(null);
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public static function canRunTimeDataProvider(): array
|
||||
{
|
||||
return [
|
||||
'hour 5 - should not run' => [5, false],
|
||||
'hour 6 - should run' => [6, true],
|
||||
'hour 7 - should run' => [7, true],
|
||||
'hour 8 - should run' => [8, true],
|
||||
'hour 9 - should not run' => [9, false],
|
||||
'hour 10 - should not run' => [10, false],
|
||||
'hour 23 - should not run' => [23, false],
|
||||
];
|
||||
}
|
||||
}
|
@@ -17,13 +17,18 @@ use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Notification\Email\NotificationMailer;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Event\PostPersistEventArgs;
|
||||
use PHPUnit\Framework\MockObject\Exception;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Messenger\Envelope;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Component\Translation\Translator;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\SendImmediateNotificationEmailMessage;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -112,13 +117,149 @@ class NotificationMailerTest extends TestCase
|
||||
$mailer->postPersistComment($comment, new PostPersistEventArgs($comment, $objectManager->reveal()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ReflectionException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function testProcessNotificationForAddresseeWithImmediateEmailPreference(): void
|
||||
{
|
||||
// Create a real notification entity
|
||||
$notification = new Notification();
|
||||
$notification->setType('test_notification_type');
|
||||
|
||||
// Use reflection to set the ID since it's normally generated by the database
|
||||
$reflectionNotification = new \ReflectionClass(Notification::class);
|
||||
$idProperty = $reflectionNotification->getProperty('id');
|
||||
$idProperty->setAccessible(true);
|
||||
$idProperty->setValue($notification, 123);
|
||||
|
||||
// Create a real user entity
|
||||
$user = new User();
|
||||
$user->setEmail('user@example.com');
|
||||
|
||||
// Use reflection to set the ID since it's normally generated by the database
|
||||
$reflectionUser = new \ReflectionClass(User::class);
|
||||
$idProperty = $reflectionUser->getProperty('id');
|
||||
$idProperty->setAccessible(true);
|
||||
$idProperty->setValue($user, 456);
|
||||
|
||||
// Set notification flags for the user
|
||||
$user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]);
|
||||
|
||||
$messageBus = $this->createMock(MessageBusInterface::class);
|
||||
$messageBus->expects($this->once())
|
||||
->method('dispatch')
|
||||
->with($this->callback(fn (SendImmediateNotificationEmailMessage $message) => 123 === $message->getNotificationId()
|
||||
&& 456 === $message->getAddresseeId()))
|
||||
->willReturn(new Envelope(new \stdClass()));
|
||||
|
||||
$mailer = $this->buildNotificationMailer(null, $messageBus);
|
||||
|
||||
// Call the method that processes notifications
|
||||
$reflection = new \ReflectionClass(NotificationMailer::class);
|
||||
$method = $reflection->getMethod('processNotificationForAddressee');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($mailer, $notification, $user);
|
||||
}
|
||||
|
||||
public function testSendDailyDigest(): void
|
||||
{
|
||||
// Create a user
|
||||
$user = new User();
|
||||
$user->setEmail('user@example.com');
|
||||
|
||||
// Create some notifications
|
||||
$notification = $this->prophesize(Notification::class);
|
||||
$notification->getTitle()->willReturn('Notification 1');
|
||||
$notification->getMessage()->willReturn('Message 1');
|
||||
$notification->getId()->willReturn(123);
|
||||
|
||||
$notification2 = $this->prophesize(Notification::class);
|
||||
$notification2->getTitle()->willReturn('Notification 2');
|
||||
$notification2->getMessage()->willReturn('Message 2');
|
||||
$notification2->getId()->willReturn(456);
|
||||
|
||||
$notifications = [$notification, $notification2];
|
||||
|
||||
// Mock the mailer to verify that an email is sent with the correct parameters
|
||||
$mailer = $this->prophesize(MailerInterface::class);
|
||||
$mailer->send(Argument::that(function ($email) use ($user) {
|
||||
// Verify that the email is sent to the correct user
|
||||
foreach ($email->getTo() as $address) {
|
||||
if ($user->getEmail() === $address->getAddress()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}))->shouldBeCalledOnce();
|
||||
|
||||
// Create a translator that returns a fixed string for the subject
|
||||
$translator = $this->prophesize(TranslatorInterface::class);
|
||||
$translator->trans('notification.Daily Notification Digest')->willReturn('Daily Digest');
|
||||
|
||||
// Create the notification mailer with the mocked mailer and translator
|
||||
$notificationMailer = $this->buildNotificationMailer($mailer->reveal(), null, $translator->reveal());
|
||||
|
||||
// Call the sendDailyDigest method
|
||||
$notificationMailer->sendDailyDigest($user, $notifications);
|
||||
}
|
||||
|
||||
public function testSendDailyDigestWithNoNotifications(): void
|
||||
{
|
||||
// Create a user
|
||||
$user = new User();
|
||||
$user->setEmail('user@example.com');
|
||||
|
||||
// Empty notifications array
|
||||
$notifications = [];
|
||||
|
||||
// Mock the mailer to verify that no email is sent
|
||||
$mailer = $this->prophesize(MailerInterface::class);
|
||||
$mailer->send(Argument::any())->shouldNotBeCalled();
|
||||
|
||||
// Create the notification mailer with the mocked mailer
|
||||
$notificationMailer = $this->buildNotificationMailer($mailer->reveal());
|
||||
|
||||
// Call the sendDailyDigest method
|
||||
$notificationMailer->sendDailyDigest($user, $notifications);
|
||||
}
|
||||
|
||||
public function testSendDailyDigestWithUserHavingNoEmail(): void
|
||||
{
|
||||
// Create a user with no email
|
||||
$user = new User();
|
||||
$user->setEmail(null);
|
||||
|
||||
// Create some notifications
|
||||
$notification = $this->prophesize(Notification::class);
|
||||
$notification->getTitle()->willReturn('Notification 1');
|
||||
$notification->getMessage()->willReturn('Message 1');
|
||||
$notification->getId()->willReturn(123);
|
||||
|
||||
$notifications = [$notification];
|
||||
|
||||
// Mock the mailer to verify that no email is sent
|
||||
$mailer = $this->prophesize(MailerInterface::class);
|
||||
$mailer->send(Argument::any())->shouldNotBeCalled();
|
||||
|
||||
// Create the notification mailer with the mocked mailer
|
||||
$notificationMailer = $this->buildNotificationMailer($mailer->reveal());
|
||||
|
||||
// Call the sendDailyDigest method
|
||||
$notificationMailer->sendDailyDigest($user, $notifications);
|
||||
}
|
||||
|
||||
private function buildNotificationMailer(
|
||||
?MailerInterface $mailer = null,
|
||||
?MessageBusInterface $messageBus = null,
|
||||
?TranslatorInterface $translator = null,
|
||||
): NotificationMailer {
|
||||
return new NotificationMailer(
|
||||
$mailer,
|
||||
$mailer ?? $this->prophesize(MailerInterface::class)->reveal(),
|
||||
new NullLogger(),
|
||||
new Translator('fr')
|
||||
$messageBus ?? $this->prophesize(MessageBusInterface::class)->reveal(),
|
||||
$translator ?? new Translator('fr')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,88 @@
|
||||
<?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\MainBundle\Tests\Repository;
|
||||
|
||||
use Chill\MainBundle\Entity\AddressReference;
|
||||
use Chill\MainBundle\Entity\PostalCode;
|
||||
use Chill\MainBundle\Repository\AddressReferenceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class AddressReferenceRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private static AddressReferenceRepository $repository;
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
static::bootKernel();
|
||||
static::$repository = static::getContainer()->get(AddressReferenceRepository::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateSearchString
|
||||
*/
|
||||
public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
|
||||
{
|
||||
$actual = static::$repository->findBySearchString($search, $postalCode);
|
||||
|
||||
self::assertIsIterable($actual, $text);
|
||||
|
||||
if (null !== $expected) {
|
||||
self::assertEquals($expected, iterator_to_array($actual));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateSearchString
|
||||
*/
|
||||
public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
|
||||
{
|
||||
$actual = static::$repository->countBySearchString($search, $postalCode);
|
||||
|
||||
self::assertIsInt($actual, $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider generateSearchString
|
||||
*/
|
||||
public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void
|
||||
{
|
||||
$actual = static::$repository->findAggregatedBySearchString($search, $postalCode);
|
||||
|
||||
self::assertIsIterable($actual, $text);
|
||||
|
||||
if (null !== $expected) {
|
||||
self::assertEquals($expected, iterator_to_array($actual));
|
||||
}
|
||||
}
|
||||
|
||||
public static function generateSearchString(): iterable
|
||||
{
|
||||
self::bootKernel();
|
||||
$em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
/** @var AddressReference $ar */
|
||||
$ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar')
|
||||
->setMaxResults(1)
|
||||
->getSingleResult();
|
||||
|
||||
yield ['', null, 'search with empty string', []];
|
||||
yield [' ', null, 'search with spaces only', []];
|
||||
yield ['rue des moulins', null, 'search contains an empty string'];
|
||||
yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id'];
|
||||
yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance'];
|
||||
}
|
||||
}
|
@@ -0,0 +1,61 @@
|
||||
<?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\MainBundle\Tests\Repository;
|
||||
|
||||
use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepository;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
final class PostalCodeForAddressReferenceRepositoryTest extends KernelTestCase
|
||||
{
|
||||
private Connection $connection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->connection = self::getContainer()->get(Connection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string[]>
|
||||
*/
|
||||
public static function provideSearches(): iterable
|
||||
{
|
||||
yield [''];
|
||||
yield [' '];
|
||||
yield ['hugo'];
|
||||
yield [' hugo'];
|
||||
yield ['hugo '];
|
||||
yield ['rue victor hugo'];
|
||||
yield ['rue victor hugo'];
|
||||
}
|
||||
|
||||
#[DataProvider('provideSearches')]
|
||||
public function testFindPostalCodeDoesNotErrorAndIsIterable(string $search): void
|
||||
{
|
||||
$repository = new PostalCodeForAddressReferenceRepository($this->connection);
|
||||
|
||||
$result = $repository->findPostalCode($search);
|
||||
|
||||
self::assertIsIterable($result);
|
||||
|
||||
// Ensure it can be converted to an array (and iterate without error)
|
||||
$rows = \is_array($result) ? $result : iterator_to_array($result, false);
|
||||
self::assertIsArray($rows);
|
||||
}
|
||||
}
|
@@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Entity\UserGroup;
|
||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||
use Chill\MainBundle\Notification\FlagProviders\WorkflowTransitionNotificationFlagProvider;
|
||||
use Chill\MainBundle\Workflow\EntityWorkflowManager;
|
||||
use Chill\MainBundle\Workflow\Helper\MetadataExtractor;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -125,7 +126,8 @@ class NotificationOnTransition implements EventSubscriberInterface
|
||||
->setRelatedEntityClass(EntityWorkflow::class)
|
||||
->setTitle($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context))
|
||||
->setMessage($this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig', $context))
|
||||
->addAddressee($subscriber);
|
||||
->addAddressee($subscriber)
|
||||
->setType(WorkflowTransitionNotificationFlagProvider::FLAG);
|
||||
$this->entityManager->persist($notification);
|
||||
}
|
||||
}
|
||||
|
@@ -595,6 +595,44 @@ paths:
|
||||
401:
|
||||
description: "Unauthorized"
|
||||
|
||||
/1.0/main/address-reference/aggregated/search:
|
||||
get:
|
||||
tags:
|
||||
- address
|
||||
summary: Search for address reference aggregated
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
description: The search pattern
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "ok"
|
||||
401:
|
||||
description: "Unauthorized"
|
||||
400:
|
||||
description: "Bad Request"
|
||||
/1.0/main/address-reference/postal-code/search:
|
||||
get:
|
||||
tags:
|
||||
- address
|
||||
summary: Search for postal code that can contains the search query
|
||||
parameters:
|
||||
- name: q
|
||||
in: query
|
||||
required: true
|
||||
description: The search pattern
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "ok"
|
||||
401:
|
||||
description: "Unauthorized"
|
||||
400:
|
||||
description: "Bad Request"
|
||||
/1.0/main/postal-code/search.json:
|
||||
get:
|
||||
tags:
|
||||
|
@@ -120,5 +120,9 @@ module.exports = function (encore, entries) {
|
||||
"vue_onthefly",
|
||||
__dirname + "/Resources/public/vuejs/OnTheFly/index.js",
|
||||
);
|
||||
encore.addEntry(
|
||||
'address_picker',
|
||||
__dirname + "/Resources/public/vuejs/AddressPicker/index.ts",
|
||||
)
|
||||
|
||||
};
|
||||
|
@@ -139,6 +139,11 @@ services:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Chill\MainBundle\Form\UserProfileType: ~
|
||||
Chill\MainBundle\Form\AbsenceType: ~
|
||||
Chill\MainBundle\Form\DataMapper\RegroupmentDataMapper: ~
|
||||
Chill\MainBundle\Form\RegroupmentType: ~
|
||||
|
@@ -12,6 +12,10 @@ services:
|
||||
arguments:
|
||||
$routeParameters: '%chill_main.notifications%'
|
||||
|
||||
Chill\MainBundle\Notification\NotificationFlagManager:
|
||||
arguments:
|
||||
$notificationFlagProviders: !tagged_iterator chill_main.notification_flag_provider
|
||||
|
||||
Chill\MainBundle\Notification\NotificationHandlerManager:
|
||||
arguments:
|
||||
$handlers: !tagged_iterator chill_main.notification_handler
|
||||
@@ -55,14 +59,6 @@ services:
|
||||
lazy: true
|
||||
method: 'postPersistNotification'
|
||||
|
||||
-
|
||||
name: 'doctrine.orm.entity_listener'
|
||||
event: 'postUpdate'
|
||||
entity: 'Chill\MainBundle\Entity\Notification'
|
||||
# set the 'lazy' option to TRUE to only instantiate listeners when they are used
|
||||
lazy: true
|
||||
method: 'postUpdateNotification'
|
||||
|
||||
-
|
||||
name: 'doctrine.orm.entity_listener'
|
||||
event: 'postPersist'
|
||||
|
@@ -0,0 +1,52 @@
|
||||
<?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\Migrations\Main;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250214154310 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create view for searching address reference';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
create materialized view public.view_chill_main_address_reference as
|
||||
SELECT row_number() OVER () AS row_number,
|
||||
cmar.street AS street,
|
||||
cmar.streetnumber AS streetnumber,
|
||||
cmar.id AS address_id,
|
||||
lower(unaccent(
|
||||
(((((cmar.street || ' '::text) || cmar.streetnumber) || ' '::text) || cmpc.code::text) || ' '::text) ||
|
||||
cmpc.label::text)) AS address,
|
||||
cmpc.id AS postcode_id
|
||||
FROM chill_main_address_reference cmar
|
||||
JOIN chill_main_postal_code cmpc ON cmar.postcode_id = cmpc.id
|
||||
WHERE cmar.deletedat IS NULL
|
||||
ORDER BY ((cmpc.code::text || ' '::text) || cmpc.label::text), cmar.street, (lpad(cmar.streetnumber, 10, '0'::text));
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
create index if not exists view_chill_internal_address_reference_trgm
|
||||
on view_chill_main_address_reference using gist (postcode_id, address public.gist_trgm_ops);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP MATERIALIZED VIEW view_chill_main_address_reference');
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
<?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\Migrations\Main;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250610102953 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add notification flags property to User';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE users ADD notificationFlags JSONB DEFAULT '[]' NOT NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE users DROP notificationFlags
|
||||
SQL);
|
||||
}
|
||||
}
|
@@ -0,0 +1,37 @@
|
||||
<?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\Migrations\Main;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250618115938 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add type property to notifications';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification ADD type VARCHAR(255) NOT NULL DEFAULT 'default_notification_type'
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification DROP type
|
||||
SQL);
|
||||
}
|
||||
}
|
@@ -0,0 +1,55 @@
|
||||
<?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\Migrations\Main;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250623120824 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add addressee user groups to notifications';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE chill_main_notification_addressee_user_group (notification_id INT NOT NULL, usergroup_id INT NOT NULL, PRIMARY KEY(notification_id, usergroup_id))
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE INDEX IDX_ECF81C07EF1A9D84 ON chill_main_notification_addressee_user_group (notification_id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE INDEX IDX_ECF81C07D2112630 ON chill_main_notification_addressee_user_group (usergroup_id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07EF1A9D84 FOREIGN KEY (notification_id) REFERENCES chill_main_notification (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification_addressee_user_group ADD CONSTRAINT FK_ECF81C07D2112630 FOREIGN KEY (usergroup_id) REFERENCES chill_main_user_group (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07EF1A9D84
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE chill_main_notification_addressee_user_group DROP CONSTRAINT FK_ECF81C07D2112630
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE chill_main_notification_addressee_user_group
|
||||
SQL);
|
||||
}
|
||||
}
|
@@ -49,6 +49,12 @@ notification:
|
||||
other {# commentaires}
|
||||
}
|
||||
|
||||
daily_notifications: >-
|
||||
{notification_count, plural,
|
||||
=1 {Voici votre notification du jour :}
|
||||
other {Voici vos # notifications du jour :}
|
||||
}
|
||||
|
||||
workflow:
|
||||
My workflows with counter: >-
|
||||
{wc, plural,
|
||||
|
@@ -52,9 +52,10 @@ user:
|
||||
current_user: Utilisateur courant
|
||||
profile:
|
||||
title: Mon profil
|
||||
Phonenumber successfully updated!: Numéro de téléphone mis à jour!
|
||||
Profile successfully updated!: Votre profil a été mis à jour!
|
||||
no job: Pas de métier assigné
|
||||
no scope: Pas de cercle assigné
|
||||
notification_preferences: Préférences pour mes notifications
|
||||
|
||||
user_group:
|
||||
inactive: Inactif
|
||||
@@ -180,6 +181,11 @@ address more:
|
||||
buildingName: résidence
|
||||
extra: ""
|
||||
distribution: cedex
|
||||
|
||||
address_picker:
|
||||
# placeholder
|
||||
Search for addresses: Chercher des adresses
|
||||
|
||||
Create a new address: Créer une nouvelle adresse
|
||||
Create an address: Créer une adresse
|
||||
Update address: Modifier l'adresse
|
||||
@@ -674,6 +680,7 @@ Subscribe all steps: Recevoir une notification à chaque étape
|
||||
CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Appliquer les transitions sur tous les workflows
|
||||
|
||||
notification:
|
||||
Daily Notification Digest: Résumé des notifications quotidiennes
|
||||
Notification: Notification
|
||||
Notifications: Notifications
|
||||
My own notifications: Mes notifications
|
||||
@@ -712,13 +719,36 @@ notification:
|
||||
dest by email help: Les adresses email mentionnées ici recevront un lien d'accès. Un compte utilisateur sera toujours nécessaire.
|
||||
Remove an email: Supprimer l'adresse email
|
||||
Email with access link: Adresse email ayant reçu un lien d'accès
|
||||
Pick user or user group: Selectionner un utilisateur / groupe d'utilisateurs
|
||||
|
||||
mark_as_read: Marquer comme lu
|
||||
mark_as_unread: Marquer comme non-lu
|
||||
|
||||
flags:
|
||||
type: Type de notification
|
||||
by-user: Lorsqu'un utilisateur vous envoie une notification personnelle
|
||||
referrer-acc-course: Lorsqu'un autre utilisateur vous désigne comme référent d'un parcours
|
||||
person-address-move: Lorsqu'un autre utilisateur enregistre le déménagement d'un usager concerné par un parcours dont vous êtes le référent.
|
||||
person: Notification sur un usager
|
||||
workflow-trans: Lorsqu'un autre utilisateur applique une transition à un workflow.
|
||||
none selected message: Si vous ne sélectionnez aucune option, vous ne recevrez pas d'email concernant ce type de notification.
|
||||
preferences:
|
||||
column_title: Préférences
|
||||
immediate_email: Email immédiat
|
||||
daily_email: Récapitulatif quotidien
|
||||
no_email: Ne pas recevoir un email
|
||||
|
||||
daily_digest:
|
||||
title: "Résumé quotidien des notifications"
|
||||
greeting: "Bonjour %user%"
|
||||
intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)."
|
||||
view_notification: "Vous pouvez visualiser la notification et y répondre ici:"
|
||||
signature: "Le logiciel Chill"
|
||||
|
||||
CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder
|
||||
CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés
|
||||
|
||||
|
||||
export:
|
||||
role:
|
||||
export_role: Exports
|
||||
|
@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Notification\NotificationPersisterInterface;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||
use Chill\PersonBundle\Event\Person\PersonAddressMoveEvent;
|
||||
use Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
@@ -65,7 +66,8 @@ class PersonAddressMoveEventSubscriber implements EventSubscriberInterface
|
||||
->setMessage($this->engine->render('@ChillPerson/AccompanyingPeriod/notification_location_user_on_period_has_moved.fr.txt.twig', [
|
||||
'oldPersonLocation' => $person,
|
||||
'period' => $period,
|
||||
]));
|
||||
]))
|
||||
->setType(PersonAddressMoveNotificationFlagProvider::FLAG);
|
||||
|
||||
$this->notificationPersister->persist($notification);
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\Notification;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\MainBundle\Notification\NotificationPersisterInterface;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod;
|
||||
use Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider;
|
||||
use Doctrine\Persistence\Event\LifecycleEventArgs;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
@@ -73,7 +74,8 @@ class UserRefEventSubscriber implements EventSubscriberInterface
|
||||
'accompanyingCourse' => $period,
|
||||
]
|
||||
))
|
||||
->addAddressee($period->getUser());
|
||||
->addAddressee($period->getUser())
|
||||
->setType(DesignatedReferrerNotificationFlagProvider::FLAG);
|
||||
|
||||
$this->notificationPersister->persist($notification);
|
||||
}
|
||||
|
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Chill\PersonBundle;
|
||||
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
use Chill\PersonBundle\Actions\Remove\PersonMoveSqlHandlerInterface;
|
||||
use Chill\PersonBundle\DependencyInjection\CompilerPass\AccompanyingPeriodTimelineCompilerPass;
|
||||
use Chill\PersonBundle\Export\Helper\CustomizeListPersonHelperInterface;
|
||||
@@ -35,5 +36,7 @@ class ChillPersonBundle extends Bundle
|
||||
->addTag('chill_person.person_move_handler');
|
||||
$container->registerForAutoconfiguration(CustomizeListPersonHelperInterface::class)
|
||||
->addTag('chill_person.list_person_customizer');
|
||||
$container->registerForAutoconfiguration(NotificationFlagProviderInterface::class)
|
||||
->addTag('chill_main.notification_flag_provider');
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,31 @@
|
||||
<?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\PersonBundle\Notification\FlagProviders;
|
||||
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
class DesignatedReferrerNotificationFlagProvider implements NotificationFlagProviderInterface
|
||||
{
|
||||
public const FLAG = 'referrer-acc-course-notif';
|
||||
|
||||
public function getFlag(): string
|
||||
{
|
||||
return self::FLAG;
|
||||
}
|
||||
|
||||
public function getLabel(): TranslatableInterface
|
||||
{
|
||||
return new TranslatableMessage('notification.flags.referrer-acc-course');
|
||||
}
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
<?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\PersonBundle\Notification\FlagProviders;
|
||||
|
||||
use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface;
|
||||
use Symfony\Component\Translation\TranslatableMessage;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
class PersonAddressMoveNotificationFlagProvider implements NotificationFlagProviderInterface
|
||||
{
|
||||
public const FLAG = 'person-move-notif';
|
||||
|
||||
public function getFlag(): string
|
||||
{
|
||||
return self::FLAG;
|
||||
}
|
||||
|
||||
public function getLabel(): TranslatableInterface
|
||||
{
|
||||
return new TranslatableMessage('notification.flags.person-address-move');
|
||||
}
|
||||
}
|
@@ -17,9 +17,9 @@ use Doctrine\ORM\EntityManagerInterface;
|
||||
/**
|
||||
* Service for merging two AccompanyingPeriodWork entities into a single entity.
|
||||
*/
|
||||
class AccompanyingPeriodWorkMergeService
|
||||
readonly class AccompanyingPeriodWorkMergeService
|
||||
{
|
||||
public function __construct(private readonly EntityManagerInterface $em) {}
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
/**
|
||||
* Merges two AccompanyingPeriodWork entities into one by transferring relevant data and removing the obsolete entity.
|
||||
@@ -35,8 +35,9 @@ class AccompanyingPeriodWorkMergeService
|
||||
$this->alterStartDate($toKeep, $toDelete);
|
||||
$this->alterEndDate($toKeep, $toDelete);
|
||||
$this->concatenateComments($toKeep, $toDelete);
|
||||
$this->transferEvaluationsSQL($toKeep, $toDelete);
|
||||
$this->transferWorkflowsSQL($toKeep, $toDelete);
|
||||
$this->updateReferencesSQL($toKeep, $toDelete);
|
||||
$this->updateReferences($toKeep, $toDelete);
|
||||
$entityManager->remove($toDelete);
|
||||
});
|
||||
|
||||
@@ -54,6 +55,16 @@ class AccompanyingPeriodWorkMergeService
|
||||
);
|
||||
}
|
||||
|
||||
private function transferEvaluationsSQL(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||
{
|
||||
$this->em->getConnection()->executeQuery(
|
||||
'UPDATE chill_person_accompanying_period_work_evaluation cpapwe
|
||||
SET accompanyingperiodwork_id = :toKeepId
|
||||
WHERE cpapwe.accompanyingperiodwork_id = :toDeleteId',
|
||||
['toKeepId' => $toKeep->getId(), 'toDeleteId' => $toDelete->getId()]
|
||||
);
|
||||
}
|
||||
|
||||
private function alterStartDate(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||
{
|
||||
$startDate = min($toKeep->getStartDate(), $toDelete->getStartDate());
|
||||
@@ -74,16 +85,17 @@ class AccompanyingPeriodWorkMergeService
|
||||
|
||||
private function concatenateComments(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||
{
|
||||
$toKeep->setNote($toKeep->getNote()."\n\n-----------------\n\n".$toDelete->getNote());
|
||||
$toKeep->getPrivateComment()->concatenateComments($toDelete->getPrivateComment());
|
||||
}
|
||||
|
||||
private function updateReferencesSQL(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||
{
|
||||
foreach ($toDelete->getAccompanyingPeriodWorkEvaluations() as $evaluation) {
|
||||
$toKeep->addAccompanyingPeriodWorkEvaluation($evaluation);
|
||||
if ('' !== $toDelete->getNote()) {
|
||||
$toKeep->setNote($toKeep->getNote()."\n\n-----------------\n\n".$toDelete->getNote());
|
||||
}
|
||||
|
||||
if (count($toDelete->getPrivateComment()->getComments()) > 0) {
|
||||
$toKeep->getPrivateComment()->concatenateComments($toDelete->getPrivateComment());
|
||||
}
|
||||
}
|
||||
|
||||
private function updateReferences(AccompanyingPeriodWork $toKeep, AccompanyingPeriodWork $toDelete): void
|
||||
{
|
||||
foreach ($toDelete->getReferrers() as $referrer) {
|
||||
// we only keep the current referrer
|
||||
$toKeep->addReferrer($referrer);
|
||||
|
@@ -14,22 +14,20 @@ namespace Chill\PersonBundle\Tests\Service\AccompanyingPeriodWork;
|
||||
use Chill\MainBundle\Entity\User;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
|
||||
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkGoal;
|
||||
use Chill\PersonBundle\Entity\SocialWork\Result;
|
||||
use Chill\PersonBundle\Service\AccompanyingPeriodWork\AccompanyingPeriodWorkMergeService;
|
||||
use Chill\ThirdPartyBundle\Entity\ThirdParty;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Monolog\Test\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @coversNothing
|
||||
*/
|
||||
class AccompanyingPeriodWorkMergeServiceTest extends TestCase
|
||||
class AccompanyingPeriodWorkMergeServiceTest extends KernelTestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
@@ -160,46 +158,62 @@ class AccompanyingPeriodWorkMergeServiceTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
public function testMerge(): void
|
||||
public function testMergeAccompanyingPeriodWorks(): void
|
||||
{
|
||||
$accompanyingPeriodWork = new AccompanyingPeriodWork();
|
||||
$accompanyingPeriodWork->setStartDate(new \DateTime('2022-01-01'));
|
||||
$accompanyingPeriodWork->addReferrer($userA = new User());
|
||||
$accompanyingPeriodWork->addReferrer($userC = new User());
|
||||
$accompanyingPeriodWork->addAccompanyingPeriodWorkEvaluation($evaluationA = new AccompanyingPeriodWorkEvaluation());
|
||||
$accompanyingPeriodWork->setNote('blabla');
|
||||
$accompanyingPeriodWork->addThirdParty($thirdPartyA = new ThirdParty());
|
||||
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
|
||||
$userA = new User();
|
||||
$userA->setUsername('someUser');
|
||||
$userA->setEmail('someUser@example.com');
|
||||
$em->persist($userA);
|
||||
|
||||
$toKeep = new AccompanyingPeriodWork();
|
||||
$toKeep->setStartDate(new \DateTime('2022-01-02'));
|
||||
$toKeep->setNote('Keep note');
|
||||
$toKeep->setCreatedBy($userA);
|
||||
$toKeep->setUpdatedBy($userA);
|
||||
$toKeep->addReferrer($userA);
|
||||
$em->persist($toKeep);
|
||||
|
||||
$userB = new User();
|
||||
$userB->setUsername('anotherUser');
|
||||
$userB->setEmail('anotherUser@example.com');
|
||||
$em->persist($userB);
|
||||
|
||||
$toDelete = new AccompanyingPeriodWork();
|
||||
$toDelete->setStartDate(new \DateTime('2022-01-01'));
|
||||
$toDelete->addReferrer($userB = new User());
|
||||
$toDelete->addReferrer($userC);
|
||||
$toDelete->addAccompanyingPeriodWorkEvaluation($evaluationB = new AccompanyingPeriodWorkEvaluation());
|
||||
$toDelete->setNote('boum');
|
||||
$toDelete->addThirdParty($thirdPartyB = new ThirdParty());
|
||||
$toDelete->addGoal($goalA = new AccompanyingPeriodWorkGoal());
|
||||
$toDelete->addResult($resultA = new Result());
|
||||
$toDelete->setNote('Delete note');
|
||||
$toDelete->setCreatedBy($userB);
|
||||
$toDelete->setUpdatedBy($userB);
|
||||
$toDelete->addReferrer($userB);
|
||||
$em->persist($toDelete);
|
||||
|
||||
$service = $this->buildMergeService($toDelete);
|
||||
$service->merge($accompanyingPeriodWork, $toDelete);
|
||||
$evaluation = new AccompanyingPeriodWorkEvaluation();
|
||||
$evaluation->setAccompanyingPeriodWork($toDelete);
|
||||
$em->persist($evaluation);
|
||||
|
||||
self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userA));
|
||||
self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userB));
|
||||
self::assertTrue($accompanyingPeriodWork->getReferrers()->contains($userC));
|
||||
$em->flush();
|
||||
|
||||
self::assertTrue($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations()->contains($evaluationA));
|
||||
self::assertTrue($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations()->contains($evaluationB));
|
||||
foreach ($accompanyingPeriodWork->getAccompanyingPeriodWorkEvaluations() as $evaluation) {
|
||||
self::assertSame($accompanyingPeriodWork, $evaluation->getAccompanyingPeriodWork());
|
||||
}
|
||||
$service = new AccompanyingPeriodWorkMergeService($em);
|
||||
$merged = $service->merge($toKeep, $toDelete);
|
||||
|
||||
self::assertStringContainsString('blabla', $accompanyingPeriodWork->getNote());
|
||||
self::assertStringContainsString('boum', $toDelete->getNote());
|
||||
$em->refresh($merged);
|
||||
|
||||
self::assertTrue($accompanyingPeriodWork->getThirdParties()->contains($thirdPartyA));
|
||||
self::assertTrue($accompanyingPeriodWork->getThirdParties()->contains($thirdPartyB));
|
||||
// Assertions
|
||||
|
||||
self::assertTrue($accompanyingPeriodWork->getGoals()->contains($goalA));
|
||||
self::assertTrue($accompanyingPeriodWork->getResults()->contains($resultA));
|
||||
$this->assertEquals(new \DateTime('2022-01-01'), $merged->getStartDate());
|
||||
|
||||
$this->assertStringContainsString('Keep note', $merged->getNote());
|
||||
$this->assertStringContainsString('Delete note', $merged->getNote());
|
||||
|
||||
$em->refresh($evaluation);
|
||||
$this->assertEquals($toKeep->getId(), $evaluation->getAccompanyingPeriodWork()->getId());
|
||||
|
||||
$em->remove($evaluation);
|
||||
$em->remove($toKeep);
|
||||
$em->remove($toDelete);
|
||||
$em->remove($userA);
|
||||
$em->remove($userB);
|
||||
$em->flush();
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,8 @@
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
|
||||
Chill\PersonBundle\Notification\AccompanyingPeriodNotificationHandler:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
@@ -8,3 +12,5 @@ services:
|
||||
Chill\PersonBundle\Notification\AccompanyingPeriodWorkEvaluationDocumentNotificationHandler:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
Chill\PersonBundle\Notification\FlagProviders\DesignatedReferrerNotificationFlagProvider: ~
|
||||
Chill\PersonBundle\Notification\FlagProviders\PersonAddressMoveNotificationFlagProvider: ~
|
||||
|
@@ -1513,6 +1513,7 @@ acpw_duplicate:
|
||||
to keep: Action d'accompagnement à conserver
|
||||
to delete: Action d'accompagnement à supprimer
|
||||
Successfully merged: Action d'accompagnement fusionnée avec succès.
|
||||
You cannot merge a accompanying period work with itself. Please choose a different one: Vous ne pouvez pas fusionner un action d'accompagnement avec lui-même. Veuillez en choisir un autre.
|
||||
|
||||
my_parcours_filters:
|
||||
referrer_parcours_and_acpw: Agent traitant ou réferent
|
||||
|
Reference in New Issue
Block a user