From 6d93b2b1b60e80331250625e417fce882504b576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Fri, 5 Dec 2025 17:02:26 +0000 Subject: [PATCH] Add Person's external identifiers to creation and edit form --- .junie/guidelines.md | 6 +- .../Resources/public/vuejs/App.vue | 10 +- .../ChillJobBundle/src/Entity/Immersion.php | 5 +- .../ChillMainBundle/Entity/Location.php | 6 +- src/Bundle/ChillMainBundle/Entity/User.php | 2 +- .../PhoneNumberHelperInterface.php | 2 + .../Phonenumber/PhonenumberHelper.php | 8 +- .../Resources/public/chill/js/date.ts | 15 + .../Resources/public/chill/scss/buttons.scss | 3 - .../Resources/public/chill/scss/forms.scss | 14 +- .../Resources/public/lib/api/apiMethods.ts | 320 +- .../Resources/public/lib/api/genderHelper.ts | 17 + .../ChillMainBundle/Resources/public/types.ts | 150 +- .../vuejs/Address/components/AddAddress.vue | 83 +- .../components/AddAddress/AddressMore.vue | 59 +- .../AddAddress/AddressSelection.vue | 43 +- .../components/AddAddress/CitySelection.vue | 39 +- .../AddAddress/CountrySelection.vue | 31 +- .../vuejs/Address/components/EditPane.vue | 23 +- .../vuejs/Address/components/ShowPane.vue | 60 +- .../vuejs/OnTheFly/components/Create.vue | 97 +- .../vuejs/OnTheFly/components/CreateModal.vue | 71 + .../vuejs/OnTheFly/components/OnTheFly.vue | 333 +- .../public/vuejs/PickEntity/PickEntity.vue | 20 +- .../Entity/GenderIconRenderBox.vue | 36 +- .../public/vuejs/_components/Modal.vue | 18 +- .../vuejs/_composables/violationList.ts | 57 + .../Utils/ExtractPhonenumberFromPattern.php | 4 +- .../Serializer/Normalizer/DateNormalizer.php | 4 +- .../Normalizer/PhonenumberNormalizer.php | 7 +- .../ExtractPhonenumberFromPatternTest.php | 16 +- .../Constraint/PhonenumberConstraint.php | 3 + .../Validation/Validator/ValidPhonenumber.php | 3 + .../translations/messages+intl-icu.fr.yaml | 28 - .../translations/messages.fr.yml | 3 +- .../Actions/PersonCreate/PersonCreateDTO.php | 70 + .../Service/PersonCreateDTOFactory.php | 96 + .../Actions/PersonEdit/PersonEditDTO.php | 108 + .../Service/PersonEditDTOFactory.php | 130 + .../Config/ConfigPersonAltNamesHelper.php | 2 + .../Controller/PersonController.php | 165 - .../Controller/PersonCreateController.php | 205 + .../Controller/PersonEditController.php | 7 +- .../PersonIdentifierListApiController.php | 47 + .../ChillPersonExtension.php | 1 - .../Identifier/IdentifierPresenceEnum.php | 42 + .../Entity/Identifier/PersonIdentifier.php | 15 +- .../Identifier/PersonIdentifierDefinition.php | 45 +- .../ChillPersonBundle/Entity/Person.php | 9 +- .../Form/CreationPersonType.php | 23 +- .../DataMapper/PersonAltNameDataMapper.php | 57 +- .../PersonIdentifiersDataMapper.php | 59 +- .../Form/PersonIdentifiersType.php | 18 +- .../ChillPersonBundle/Form/PersonType.php | 4 +- .../Form/Type/PersonAltNameType.php | 1 + .../Identifier/StringIdentifier.php | 43 +- .../IdentifierViolationDTO.php | 31 + .../PersonIdentifierWorkerNormalizer.php | 39 + .../PersonIdentifierEngineInterface.php | 20 + .../PersonIdentifierManager.php | 11 +- .../PersonIdentifierManagerInterface.php | 9 +- .../PersonIdentifierWorker.php | 23 +- .../RequiredIdentifierConstraint.php | 28 + .../RequiredIdentifierConstraintValidator.php | 53 + .../Validator/UniqueIdentifierConstraint.php | 25 + .../UniqueIdentifierConstraintValidator.php | 53 + .../Validator/ValidIdentifierConstraint.php | 23 + .../ValidIdentifierConstraintValidator.php | 47 + .../Identifier/PersonIdentifierRepository.php | 42 + .../Repository/PersonACLAwareRepository.php | 76 +- .../Resources/public/types.ts | 149 +- .../components/PersonsAssociated.vue | 7 +- .../components/Requestor.vue | 10 +- .../components/Resources.vue | 7 +- .../Resources/public/vuejs/_api/OnTheFly.js | 88 - .../Resources/public/vuejs/_api/OnTheFly.ts | 116 + .../public/vuejs/_components/AddPersons.vue | 619 +-- .../AddPersons/PersonChooseModal.vue | 345 ++ .../AddPersons/PersonSuggestion.vue | 64 +- .../_components/AddPersons/TypeHousehold.vue | 3 +- .../_components/AddPersons/TypePerson.vue | 6 +- .../_components/AddPersons/TypeThirdParty.vue | 60 +- .../vuejs/_components/AddPersons/TypeUser.vue | 12 +- .../_components/AddPersons/TypeUserGroup.vue | 2 +- .../_components/Entity/PersonRenderBox.vue | 311 +- .../vuejs/_components/Entity/PersonText.vue | 18 +- .../vuejs/_components/OnTheFly/Person.vue | 461 +- .../vuejs/_components/OnTheFly/PersonEdit.vue | 695 +++ .../Resources/views/Person/create.html.twig | 9 + .../Resources/views/Person/edit.html.twig | 4 +- .../Normalizer/PersonJsonDenormalizer.php | 155 + .../Normalizer/PersonJsonNormalizer.php | 149 +- .../Normalizer/PersonJsonReadDenormalizer.php | 51 + .../Service/PersonEditDTOFactoryTest.php | 146 + .../PersonIdentifierListApiControllerTest.php | 157 + .../StringIdentifierValidationTest.php | 111 + .../PersonIdentifierWorkerNormalizerTest.php | 133 + .../Rendering/PersonIdRenderingTest.php | 15 + ...uiredIdentifierConstraintValidatorTest.php | 131 + ...niqueIdentifierConstraintValidatorTest.php | 158 + ...ValidIdentifierConstraintValidatorTest.php | 115 + .../PersonIdentifierRepositoryTest.php | 76 + .../PersonACLAwareRepositoryTest.php | 14 +- .../Normalizer/PersonJsonDenormalizerTest.php | 312 ++ .../PersonJsonNormalizerIntegrationTest.php | 63 + .../Normalizer/PersonJsonNormalizerTest.php | 196 +- .../PersonJsonReadDenormalizerTest.php | 86 + .../ChillPersonBundle/chill.api.specs.yaml | 3875 +++++++++-------- .../ChillPersonBundle/config/services.yaml | 6 + .../config/services/actions.yaml | 6 + .../config/services/serializer.yaml | 11 - .../migrations/Version20250918095044.php | 37 + .../migrations/Version20250922151020.php | 33 + .../migrations/Version20250924101621.php | 53 + .../migrations/Version20250926124024.php | 33 + .../translations/messages+intl-icu.fr.yaml | 35 + .../translations/messages.fr.yml | 10 +- .../translations/validators+intl-icu.fr.yaml | 7 + .../translations/validators.fr.yml | 4 + .../Entity/ThirdParty.php | 10 +- .../Resources/public/types.ts | 138 +- .../Resources/public/vuejs/_api/OnTheFly.js | 52 - .../Resources/public/vuejs/_api/OnTheFly.ts | 75 + .../Entity/ThirdPartyRenderBox.vue | 115 +- .../vuejs/_components/OnTheFly/ThirdParty.vue | 417 +- .../_components/OnTheFly/ThirdPartyEdit.vue | 562 +++ .../translations/messages.fr.yml | 6 +- .../src/Controller/FindCallerController.php | 58 - .../PostTicketUpdateMessageHandler.php | 7 +- .../TicketApp/components/BannerComponent.vue | 14 +- .../Controller/FindCallerControllerTest.php | 128 - 131 files changed, 9015 insertions(+), 4954 deletions(-) create mode 100644 src/Bundle/ChillMainBundle/Resources/public/lib/api/genderHelper.ts create mode 100644 src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/CreateModal.vue create mode 100644 src/Bundle/ChillMainBundle/Resources/public/vuejs/_composables/violationList.ts create mode 100644 src/Bundle/ChillPersonBundle/Actions/PersonCreate/PersonCreateDTO.php create mode 100644 src/Bundle/ChillPersonBundle/Actions/PersonCreate/Service/PersonCreateDTOFactory.php create mode 100644 src/Bundle/ChillPersonBundle/Actions/PersonEdit/PersonEditDTO.php create mode 100644 src/Bundle/ChillPersonBundle/Actions/PersonEdit/Service/PersonEditDTOFactory.php create mode 100644 src/Bundle/ChillPersonBundle/Controller/PersonCreateController.php create mode 100644 src/Bundle/ChillPersonBundle/Controller/PersonIdentifierListApiController.php create mode 100644 src/Bundle/ChillPersonBundle/Entity/Identifier/IdentifierPresenceEnum.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/IdentifierViolationDTO.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizer.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraint.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraintValidator.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraint.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraintValidator.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraint.php create mode 100644 src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraintValidator.php create mode 100644 src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierRepository.php delete mode 100644 src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js create mode 100644 src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts create mode 100644 src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue create mode 100644 src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/PersonEdit.vue create mode 100644 src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php create mode 100644 src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php create mode 100644 src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php delete mode 100644 src/Bundle/ChillPersonBundle/config/services/serializer.yaml create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php create mode 100644 src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php create mode 100644 src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml delete mode 100644 src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js create mode 100644 src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts create mode 100644 src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdPartyEdit.vue delete mode 100644 src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php delete mode 100644 src/Bundle/ChillTicketBundle/tests/Controller/FindCallerControllerTest.php diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 170050780..ad355eac7 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -236,13 +236,15 @@ This must be a decision made by a human, not by an AI. Every AI task must abort The tests are run from the project's root (not from the bundle's root: so, do not change the directory to any bundle directory before running tests). +Tests must be run using the `symfony` command: + ```bash # Run a specific test file -vendor/bin/phpunit path/to/TestFile.php +symfony composer exec phpunit -- path/to/TestFile.php # Run a specific test method -vendor/bin/phpunit --filter methodName path/to/TestFile.php +symfony composer exec phpunit -- --filter methodName path/to/TestFile.php ``` When writing tests, only test specific files. Do not run all tests or the full diff --git a/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue b/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue index 89158d742..02d64f838 100644 --- a/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue +++ b/src/Bundle/ChillEventBundle/Resources/public/vuejs/App.vue @@ -1,14 +1,14 @@ diff --git a/src/Bundle/ChillJobBundle/src/Entity/Immersion.php b/src/Bundle/ChillJobBundle/src/Entity/Immersion.php index 5ae19a365..80d05062e 100644 --- a/src/Bundle/ChillJobBundle/src/Entity/Immersion.php +++ b/src/Bundle/ChillJobBundle/src/Entity/Immersion.php @@ -19,7 +19,6 @@ use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Address; use libphonenumber\PhoneNumber; use Symfony\Component\Validator\Constraints as Assert; -use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; /** * Immersion. @@ -86,14 +85,14 @@ class Immersion implements \Stringable * @Assert\NotBlank() */ #[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $tuteurPhoneNumber = null; #[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] private ?string $structureAccName = null; #[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $structureAccPhonenumber = null; #[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] diff --git a/src/Bundle/ChillMainBundle/Entity/Location.php b/src/Bundle/ChillMainBundle/Entity/Location.php index 5705e6a1a..e2605f9a1 100644 --- a/src/Bundle/ChillMainBundle/Entity/Location.php +++ b/src/Bundle/ChillMainBundle/Entity/Location.php @@ -14,9 +14,9 @@ namespace Chill\MainBundle\Entity; use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; use Chill\MainBundle\Repository\LocationRepository; -use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Doctrine\ORM\Mapping as ORM; use libphonenumber\PhoneNumber; +use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; use Symfony\Component\Serializer\Annotation as Serializer; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; @@ -67,12 +67,12 @@ class Location implements TrackCreationInterface, TrackUpdateInterface #[Serializer\Groups(['read', 'write', 'docgen:read'])] #[ORM\Column(type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] private ?PhoneNumber $phonenumber1 = null; #[Serializer\Groups(['read', 'write', 'docgen:read'])] #[ORM\Column(type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] private ?PhoneNumber $phonenumber2 = null; #[Serializer\Groups(['read'])] diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 70b032238..979837ae8 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -119,7 +119,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter * The user's mobile phone number. */ #[ORM\Column(type: 'phone_number', nullable: true)] - #[PhonenumberConstraint] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $phonenumber = null; /** diff --git a/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php b/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php index c7cf1ddfd..ca0e3e8e0 100644 --- a/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php +++ b/src/Bundle/ChillMainBundle/Phonenumber/PhoneNumberHelperInterface.php @@ -31,6 +31,8 @@ interface PhoneNumberHelperInterface /** * Return true if the validation is configured and available. + * + * @deprecated this is an internal behaviour of the helper and should not be taken into account outside of the implementation */ public function isPhonenumberValidationConfigured(): bool; diff --git a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php index 8a7574ac0..9b103f275 100644 --- a/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php +++ b/src/Bundle/ChillMainBundle/Phonenumber/PhonenumberHelper.php @@ -122,7 +122,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface */ public function isValidPhonenumberAny($phonenumber): bool { - if (false === $this->isPhonenumberValidationConfigured()) { + if (false === $this->isConfigured) { return true; } $validation = $this->performTwilioLookup($phonenumber); @@ -142,7 +142,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface */ public function isValidPhonenumberLandOrVoip($phonenumber): bool { - if (false === $this->isPhonenumberValidationConfigured()) { + if (false === $this->isConfigured) { return true; } @@ -163,7 +163,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface */ public function isValidPhonenumberMobile($phonenumber): bool { - if (false === $this->isPhonenumberValidationConfigured()) { + if (false === $this->isConfigured) { return true; } @@ -178,7 +178,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface private function performTwilioLookup($phonenumber) { - if (false === $this->isPhonenumberValidationConfigured()) { + if (false === $this->isConfigured) { return null; } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts b/src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts index 8e9d56695..ce0a9ea7b 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts @@ -166,3 +166,18 @@ export const intervalISOToDays = (str: string | null): number | null => { return days; }; + +export function getTimezoneOffsetString(date: Date, timeZone: string): string { + const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); + const tzDate = new Date(date.toLocaleString("en-US", { timeZone })); + const offsetMinutes = (utcDate.getTime() - tzDate.getTime()) / (60 * 1000); + + // Inverser le signe pour avoir la convention ±HH:MM + const sign = offsetMinutes <= 0 ? "+" : "-"; + const absMinutes = Math.abs(offsetMinutes); + const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0"); + const minutes = String(absMinutes % 60).padStart(2, "0"); + + return `${sign}${hours}:${minutes}`; +} + diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss index 248b32cfc..f7ccb0d8d 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/buttons.scss @@ -54,7 +54,6 @@ $chill-theme-buttons: ( &.btn-unlink, &.btn-action, &.btn-edit, - &.btn-tpchild, &.btn-wopilink, &.btn-update { &, &:hover { @@ -82,7 +81,6 @@ $chill-theme-buttons: ( &.btn-remove::before, &.btn-choose::before, &.btn-notify::before, - &.btn-tpchild::before, &.btn-download::before, &.btn-search::before, &.btn-cancel::before { @@ -112,7 +110,6 @@ $chill-theme-buttons: ( &.btn-choose::before { content: "\f00c"; } // fa-check // f046 fa-check-square-o &.btn-unlink::before { content: "\f127"; } // fa-chain-broken &.btn-notify::before { content: "\f1d8"; } // fa-paper-plane - &.btn-tpchild::before { content: "\f007"; } // fa-user &.btn-download::before { content: "\f019"; } // fa-download &.btn-search::before { content: "\f002"; } // fa-search } diff --git a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss index 28c597bc0..daa22170f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss +++ b/src/Bundle/ChillMainBundle/Resources/public/chill/scss/forms.scss @@ -29,10 +29,14 @@ form { label { display: inline; - &.required:after { - content: " *"; - color: $red; - } + } +} + +label { + display: inline; + &.required:after { + content: " *"; + color: $red; } } @@ -45,4 +49,4 @@ form { .chill_filter_order { background: $gray-100; -} \ No newline at end of file +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts index 887a376f3..43ae42613 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/apiMethods.ts @@ -1,8 +1,14 @@ -import { Scope } from "../../types"; +import { + DynamicKeys, + Scope, + ValidationExceptionInterface, + ValidationProblemFromMap, + ViolationFromMap +} from "../../types"; export type body = Record; export type fetchOption = Record; - +export type Primitive = string | number | boolean | null; export type Params = Record; export interface Pagination { @@ -25,20 +31,115 @@ export interface TransportExceptionInterface { name: string; } -export interface ValidationExceptionInterface - extends TransportExceptionInterface { - name: "ValidationException"; - error: object; - violations: string[]; - titles: string[]; - propertyPaths: string[]; +export class ValidationException< + M extends Record> = Record< + string, + Record + >, + > + extends Error + implements ValidationExceptionInterface +{ + public readonly name = "ValidationException" as const; + public readonly problems: ValidationProblemFromMap; + public readonly violations: string[]; + public readonly violationsList: ViolationFromMap[]; + public readonly titles: string[]; + public readonly propertyPaths: DynamicKeys & string[]; + public readonly byProperty: Record, string[]>; + + constructor(problem: ValidationProblemFromMap) { + const message = [problem.title, problem.detail].filter(Boolean).join(" — "); + super(message); + Object.setPrototypeOf(this, new.target.prototype); + + this.problems = problem; + + this.violationsList = problem.violations; + this.violations = problem.violations.map( + (v) => `${v.title}: ${v.propertyPath}`, + ); + + this.titles = problem.violations.map((v) => v.title); + + this.propertyPaths = problem.violations.map( + (v) => v.propertyPath, + ) as DynamicKeys & string[]; + + this.byProperty = problem.violations.reduce( + (acc, v) => { + const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract; + (acc[key] ||= []).push(v.title); + return acc; + }, + {} as Record, string[]>, + ); + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ValidationException); + } + } + + violationsByNormalizedProperty(property: Extract): ViolationFromMap[] { + return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property); + } + + violationsByNormalizedPropertyAndParams< + P extends Extract, + K extends Extract + >( + property: P, + param: K, + param_value: M[P][K] + ): ViolationFromMap[] + { + const list = this.violationsByNormalizedProperty(property); + + return list.filter( + (v): boolean => + !!v.parameters && + // `with_parameter in v.parameters` check indexing + param in v.parameters && + // the cast is safe, because we have overloading that bind the types + (v.parameters as M[P])[param] === param_value + ); + } } -export interface ValidationErrorResponse extends TransportExceptionInterface { - violations: { - title: string; - propertyPath: string; - }[]; +/** + * Check that the exception is a ValidationExceptionInterface + * @param x + */ +export function isValidationException>>( + x: unknown, +): x is ValidationExceptionInterface { + return ( + x instanceof ValidationException || + (typeof x === "object" && + x !== null && + (x as any).name === "ValidationException") + ); +} + +export function isValidationProblem(x: unknown): x is { + type: string; + title: string; + violations: { propertyPath: string; title: string }[]; +} { + if (!x || typeof x !== "object") return false; + const o = x as any; + return ( + typeof o.type === "string" && + typeof o.title === "string" && + Array.isArray(o.violations) && + o.violations.every( + (v: any) => + v && + typeof v === "object" && + typeof v.propertyPath === "string" && + typeof v.title === "string", + ) + ); } export interface AccessExceptionInterface extends TransportExceptionInterface { @@ -65,12 +166,151 @@ export interface ConflictHttpExceptionInterface } /** - * Generic api method that can be adapted to any fetch request + * Generic api method that can be adapted to any fetch request. * - * This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination - * and use of the @link{fetchResults} method. + * What this does + * - Performs a single HTTP request using fetch and returns the parsed JSON as Output. + * - Interprets common API errors and throws typed exceptions you can catch in your UI. + * - When the server returns a Symfony validation problem (HTTP 422), the error is + * rethrown as a typed ValidationException that is aware of your Violation Map (see below). + * + * Important: For GET endpoints that return lists, prefer using fetchResults, which + * handles pagination and aggregation for you. + * + * Violation Map (M): make your 422 errors strongly typed + * ------------------------------------------------------ + * Symfony’s validation problem+json payload looks like this (simplified): + * + * { + * "type": "https://symfony.com/errors/validation", + * "title": "Validation Failed", + * "violations": [ + * { + * "propertyPath": "mobilenumber", + * "title": "This value is not a valid phone number.", + * "parameters": { + * "{{ value }}": "+33 1 02 03 04 05", + * "{{ types }}": "mobile number" + * }, + * "type": "urn:uuid:..." + * } + * ] + * } + * + * The makeFetch generic type parameter M lets you describe, field by field, which + * parameters may appear for each propertyPath. Doing so gives you full type-safety when + * consuming ValidationException in your UI code. + * + * How to build M (Violation Map) + * - M is a map where each key is a server-side propertyPath (string), and the value is a + * record describing the allowed keys in the parameters object for that property. + * - Keys in parameters are the exact strings you receive from Symfony, including the + * curly-braced placeholders such as "{{ value }}", "{{ types }}", etc. + * + * Example from Person creation (WritePersonViolationMap) + * ----------------------------------------------------- + * In ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts you’ll find: + * + * export type WritePersonViolationMap = { + * gender: { + * "{{ value }}": string | null; + * }; + * mobilenumber: { + * "{{ types }}": string; // ex: "mobile number" + * "{{ value }}": string; // ex: "+33 1 02 03 04 05" + * }; + * }; + * + * This means: + * - If the server reports a violation for propertyPath "gender", the parameters object + * is expected to contain a key "{{ value }}" with a string or null value. + * - If the server reports a violation for propertyPath "mobilenumber", the parameters + * may include "{{ value }}" and "{{ types }}" as strings. + * + * How makeFetch uses M + * - When the response has status 422 and the payload matches a Symfony validation + * problem, makeFetch casts it to ValidationProblemFromMap and throws a + * ValidationException. + * - The ValidationException exposes helpful, pre-computed fields: + * - exception.problem: the full typed payload + * - exception.violations: ["Title: propertyPath", ...] + * - exception.titles: ["Title 1", "Title 2", ...] + * - exception.propertyPaths: ["gender", "mobilenumber", ...] (typed from M) + * - exception.byProperty: { gender: [titles...], mobilenumber: [titles...] } + * + * Typical usage patterns + * ---------------------- + * 1) GET without Validation Map (no 422 expected): + * + * const centers = await makeFetch( + * "GET", + * "/api/1.0/person/creation/authorized-centers", + * null + * ); + * + * 2) POST with body and Violation Map: + * + * type WritePersonViolationMap = { + * gender: { "{{ value }}": string | null }; + * mobilenumber: { "{{ types }}": string; "{{ value }}": string }; + * }; + * + * try { + * const created = await makeFetch( + * "POST", + * "/api/1.0/person/person.json", + * personPayload + * ); + * // Success path + * } catch (e) { + * if (isValidationException(e)) { + * // Fully typed: + * e.propertyPaths.includes("mobilenumber"); + * const firstTitleForMobile = e.byProperty.mobilenumber?.[0]; + * // You can also inspect parameter values: + * const v = e.problem.violations.find(v => v.propertyPath === "mobilenumber"); + * const rawValue = v?.parameters?.["{{ value }}"]; // typed as string + * } else { + * // Other error handling (AccessException, ConflictHttpException, etc.) + * } + * } + * + * Tips to design your Violation Map + * - Use exact propertyPath strings as exposed by the API (they usually match your + * DTO field names or entity property paths used by the validator). + * - Inside each property, list only the placeholders that you actually read in the UI + * (you can always add more later). This keeps your types strict but pragmatic. + * - If a field may not include parameters at all, you can set it to an empty object {}. + * - If you don’t care about parameter typing, you can omit M entirely and rely on the + * default loose typing (Record), but you’ll lose safety. + * + * Error taxonomy thrown by makeFetch + * - ValidationException when status = 422 and payload is a validation problem. + * - AccessException when status = 403. + * - ConflictHttpException when status = 409. + * - A generic error object for other non-ok statuses. + * + * @typeParam Input - Shape of the request body you send (if any) + * @typeParam Output - Shape of the successful JSON response you expect + * @typeParam M - Violation Map describing the per-field parameters you expect + * in Symfony validation violations. See examples above. + * + * @param method The HTTP method to use (POST, GET, PUT, PATCH, DELETE) + * @param url The absolute or relative URL to call + * @param body The request payload. If null/undefined, no body is sent + * @param options Extra fetch options/headers merged into the request + * + * @returns The parsed JSON response typed as Output. For 204 No Content, resolves + * with undefined (void). */ -export const makeFetch = ( +export const makeFetch = async < + Input, + Output, + M extends Record> = Record< + string, + Record + >, +>( method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", url: string, body?: body | Input | null, @@ -90,7 +330,8 @@ export const makeFetch = ( if (typeof options !== "undefined") { opts = Object.assign(opts, options); } - return fetch(url, opts).then((response) => { + + return fetch(url, opts).then(async (response) => { if (response.status === 204) { return Promise.resolve(); } @@ -100,9 +341,20 @@ export const makeFetch = ( } if (response.status === 422) { - return response.json().then((response) => { - throw ValidationException(response); - }); + // Unprocessable Entity -> payload de validation Symfony + const json = await response.json().catch(() => undefined); + + if (isValidationProblem(json)) { + // On ré-interprète le payload selon M (ParamMap) pour typer les violations + const problem = json as unknown as ValidationProblemFromMap; + throw new ValidationException(problem); + } + + const err = new Error( + "Validation failed but payload is not a ValidationProblem", + ); + (err as any).raw = json; + throw err; } if (response.status === 403) { @@ -167,12 +419,6 @@ function _fetchAction( throw NotFoundException(response); } - if (response.status === 422) { - return response.json().then((response) => { - throw ValidationException(response); - }); - } - if (response.status === 403) { throw AccessException(response); } @@ -231,24 +477,6 @@ export const fetchScopes = (): Promise => { return fetchResults("/api/1.0/main/scope.json"); }; -/** - * Error objects to be thrown - */ -const ValidationException = ( - response: ValidationErrorResponse, -): ValidationExceptionInterface => { - const error = {} as ValidationExceptionInterface; - error.name = "ValidationException"; - error.violations = response.violations.map( - (violation) => `${violation.title}: ${violation.propertyPath}`, - ); - error.titles = response.violations.map((violation) => violation.title); - error.propertyPaths = response.violations.map( - (violation) => violation.propertyPath, - ); - return error; -}; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const AccessException = (response: Response): AccessExceptionInterface => { const error = {} as AccessExceptionInterface; diff --git a/src/Bundle/ChillMainBundle/Resources/public/lib/api/genderHelper.ts b/src/Bundle/ChillMainBundle/Resources/public/lib/api/genderHelper.ts new file mode 100644 index 000000000..f913fd194 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/lib/api/genderHelper.ts @@ -0,0 +1,17 @@ +import {Gender, GenderTranslation} from "ChillMainAssets/types"; + +/** + * Translates a given gender object into its corresponding gender translation string. + * + * @param {Gender|null} gender - The gender object to be translated, null values are also supported + * @return {GenderTranslation} Returns the gender translation string corresponding to the provided gender, + * or "unknown" if the gender is null. + */ +export function toGenderTranslation(gender: Gender|null): GenderTranslation +{ + if (null === gender) { + return "unknown"; + } + + return gender.genderTranslation; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/types.ts b/src/Bundle/ChillMainBundle/Resources/public/types.ts index 2c4288e8f..251953679 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/types.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/types.ts @@ -3,6 +3,8 @@ import { isGenericDocWithStoredObject, } from "ChillDocStoreAssets/types/generic_doc"; import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; +import { CreatableEntityType } from "ChillPersonAssets/types"; +import {ThirdpartyCompany} from "../../../ChillThirdPartyBundle/Resources/public/types"; import { Person } from "../../../ChillPersonBundle/Resources/public/types"; export interface DateTime { @@ -10,9 +12,58 @@ export interface DateTime { datetime8601: string; } +/** + * A date representation to use when we create or update a date + */ +export interface DateTimeWrite { + /** + * Must be a string in format Y-m-d\TH:i:sO + */ + datetime: string; +} + export interface Civility { + type: "chill_main_civility"; + id: number; + abbreviation: TranslatableString; + active: boolean; + name: TranslatableString; +} + +/** + * Lightweight reference to Civility, to use in POST or PUT requests. + */ +export interface SetCivility { + type: "chill_main_civility"; + id: number; +} + + +/** + * Gender translation. + * + * Match the GenderEnum in PHP code. + */ +export type GenderTranslation = "male" | "female" | "neutral" | "unknown"; + +/** + * A gender + * + * See also + */ +export interface Gender { + type: "chill_main_gender"; + id: number; + label: TranslatableString; + genderTranslation: GenderTranslation; +} + +/** + * Lightweight reference to a Gender, used in POST / PUT requests. + */ +export interface SetGender { + type: "chill_main_gender"; id: number; - // TODO } export interface Household { @@ -32,6 +83,18 @@ export interface Center { id: number; type: "center"; name: string; + isActive: boolean; +} + +/** + * SetCenter is a lightweight reference used in POST/PUT requests to associate an existing center with a resource. + * It links by id only and does not create or modify centers. + * Expected shape: { type: "center", id: number }. + * Requests will fail if the id is invalid, the center doesn't exist, or permissions are insufficient. + */ +export interface SetCenter { + id: number; + type: "center"; } export interface Scope { @@ -123,6 +186,13 @@ export interface Address { isNoAddress: boolean; } +/** + * Associate an existing address in write operations. + */ +export interface SetAddress { + id: number; +} + export interface AddressWithPoint extends Address { point: Point; } @@ -301,13 +371,63 @@ export interface TransportExceptionInterface { name: string; } -export interface ValidationExceptionInterface - extends TransportExceptionInterface { +type IndexedKey = `${Base}[${number}]`; +type BaseKeys = Extract; + +export type DynamicKeys>> = + | BaseKeys + | { [K in BaseKeys as IndexedKey]: K }[IndexedKey>]; + +type NormalizeKey = K extends `${infer B}[${number}]` ? B : K; + +export type ViolationFromMap>> = { + [K in DynamicKeys & string]: { // <- note le "& string" ici + propertyPath: K; + title: string; + parameters?: M[NormalizeKey]; + type?: string; + } +}[DynamicKeys & string]; + +export type ValidationProblemFromMap< + M extends Record>, +> = { + type: string; + title: string; + detail?: string; + violations: ViolationFromMap[]; +} & Record; + +export interface ValidationExceptionInterface< + M extends Record> = Record< + string, + Record + >, +> extends Error { name: "ValidationException"; - error: object; + /** Full server payload copy */ + problems: ValidationProblemFromMap; + /** A list of all violations, with property key */ + violationsList: ViolationFromMap[]; + /** Compact list "Title: path" */ violations: string[]; + /** Only titles */ titles: string[]; - propertyPaths: string[]; + /** Only property paths */ + propertyPaths: DynamicKeys & string[]; + /** Indexing by property (useful for display by field) */ + byProperty: Record, string[]>; + + violationsByNormalizedProperty(property: Extract): ViolationFromMap[]; + + violationsByNormalizedPropertyAndParams< + P extends Extract, + K extends Extract + >( + property: P, + param: K, + param_value: M[P][K] + ): ViolationFromMap[]; } export interface AccessExceptionInterface extends TransportExceptionInterface { @@ -376,6 +496,26 @@ export interface TabDefinition { counter: () => number; } +export type CreateComponentConfigGeneral = { + action: 'create'; + allowedTypes: CreatableEntityType[]; + query: string; + parent: null; +} + +export type CreateComponentThirdPartyAddContact = { + action: 'addContact'; + allowedTypes: readonly ['thirdparty']; + query: string; + parent: ThirdpartyCompany; +} + +/** + * Configuration for the CreateModal and Create component + */ +export type CreateComponentConfig = CreateComponentConfigGeneral | CreateComponentThirdPartyAddContact; + + /** * Possible states for the WaitingScreen Component. */ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue index 76de138d0..0b3a42e69 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress.vue @@ -12,24 +12,22 @@ ref="showAddress" /> - - - - + + + + @@ -175,9 +171,7 @@ {{ trans(getTextTitle) }} - {{ - trans(ADDRESS_LOADING) - }} + {{ trans(ADDRESS_LOADING) }} @@ -248,14 +242,14 @@ import { } from "../api"; import { CREATE_A_NEW_ADDRESS, - ADDRESS_LOADING, - ACTIVITY_CREATE_ADDRESS, - ACTIVITY_EDIT_ADDRESS, + ADDRESS_LOADING, + ACTIVITY_CREATE_ADDRESS, + ACTIVITY_EDIT_ADDRESS, CANCEL, - SAVE, - PREVIOUS, - NEXT, - trans, + SAVE, + PREVIOUS, + NEXT, + trans, } from "translator"; import ShowPane from "./ShowPane.vue"; import SuggestPane from "./SuggestPane.vue"; @@ -265,16 +259,17 @@ import DatePane from "./DatePane.vue"; export default { name: "AddAddress", setup() { - return { - trans, - CREATE_A_NEW_ADDRESS, - ADDRESS_LOADING, - CANCEL, - SAVE, - PREVIOUS, - NEXT, - }; - },props: ["context", "options", "addressChangedCallback"], + return { + trans, + CREATE_A_NEW_ADDRESS, + ADDRESS_LOADING, + CANCEL, + SAVE, + PREVIOUS, + NEXT, + }; + }, + props: ["context", "options", "addressChangedCallback"], components: { Modal, ShowPane, @@ -394,9 +389,9 @@ export default { ) { console.log("this.options.title", this.options.title); - return this.context.edit - ? ACTIVITY_EDIT_ADDRESS - : ACTIVITY_CREATE_ADDRESS; + return this.context.edit + ? ACTIVITY_EDIT_ADDRESS + : ACTIVITY_CREATE_ADDRESS; } return this.context.edit ? this.defaultz.title.edit diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue index f90db6d05..bfa10fe3a 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/Address/components/AddAddress/AddressMore.vue @@ -55,9 +55,7 @@ :placeholder="trans(ADDRESS_BUILDING_NAME)" v-model="buildingName" /> - +
- +
@@ -89,35 +85,36 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/OnTheFly.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/OnTheFly.vue index dc66a0000..93457ccbf 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/OnTheFly.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/OnTheFly/components/OnTheFly.vue @@ -9,7 +9,7 @@ class="btn btn-sm" target="_blank" :class="classAction" - :title="trans(titleAction)" + :title="titleAction" @click="openModal" > {{ buttonText }} (‡) @@ -23,68 +23,89 @@ > - + - diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue new file mode 100644 index 000000000..8c0ddb802 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue index 4499b40c8..aeb8dcac3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue @@ -1,39 +1,38 @@ @@ -37,15 +35,19 @@ import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import { useToast } from "vue-toast-notification"; import { Result, Suggestion } from "ChillPersonAssets/types"; -import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; +import { + isThirdpartyChild, + isThirdpartyContact, + Thirdparty, ThirdpartyCompany +} from "./../../../../../../ChillThirdPartyBundle/Resources/public/types"; interface TypeThirdPartyProps { - item: Suggestion; + item: Suggestion & {result: Thirdparty}; } const props = defineProps(); -const emit = defineEmits<(e: "newPriorSuggestion", payload: unknown) => void>(); +const emit = defineEmits<(e: "triggerAddContact", payload: {parent: ThirdpartyCompany}) => void>(); const onTheFly = ref | null>(null); const toast = useToast(); @@ -54,47 +56,23 @@ const hasAddress = computed(() => { if (props.item.result.address !== null) { return true; } - if (props.item.result.parent !== null) { - if (props.item.result.parent) { - return props.item.result.parent.address !== null; - } + if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null) { + return props.item.result.parent.address !== null; } - return false; -}); -const hasParent = computed(() => { - return props.item.result.parent !== null; + return false; }); const getAddress = computed(() => { if (props.item.result.address !== null) { return props.item.result.address; } - if (props.item.result.parent && props.item.result.parent.address !== null) { + if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null && props.item.result.parent.address !== null) { return props.item.result.parent.address; } return null; }); -function saveFormOnTheFly({ data }: { data: Thirdparty }) { - makeFetch("POST", "/api/1.0/thirdparty/thirdparty.json", data) - .then((response: unknown) => { - const result = response as Result; - emit("newPriorSuggestion", result); - if (onTheFly.value) onTheFly.value.closeModal(); - }) - .catch((error: unknown) => { - const errorResponse = error as { name: string; violations: string[] }; - if (errorResponse.name === "ValidationException") { - for (let v of errorResponse.violations) { - if (toast) toast.open({ message: v }); - } - } else { - if (toast) toast.open({ message: "An error occurred" }); - } - }); -} - // i18n config (if needed elsewhere) const i18n = { messages: { diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue index 74f80c321..9daf3219f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue @@ -1,11 +1,11 @@ @@ -14,18 +14,14 @@ import { computed, defineProps } from "vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; import { Suggestion } from "ChillPersonAssets/types"; +import {User} from "ChillMainAssets/types"; interface TypeUserProps { - item: Suggestion; + item: Suggestion & {result: User}; } const props = defineProps(); -const hasParent = computed(() => props.item.result.parent !== null); - -defineExpose({ - hasParent, -}); diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig index 1ceb84395..5a17acf18 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig @@ -88,6 +88,15 @@
{% endfor %} {% endif %} + {% if form.identifiers|length > 0 %} + {% for f in form.identifiers %} +
+ {{ form_row(f) }} +
+ {% endfor %} + {% else %} + {{ form_widget(form.identifiers) }} + {% endif %} {{ form_row(form.gender, { 'label' : 'Gender'|trans }) }} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig index fe5dde242..169479854 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig @@ -140,9 +140,7 @@

{{ 'person.Identifiers'|trans }}

- {% for f in form.identifiers %} - {{ form_row(f) }} - {% endfor %} + {{ form_widget(form.identifiers) }}
{% else %} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php new file mode 100644 index 000000000..9f0464a71 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php @@ -0,0 +1,155 @@ +extractObjectToPopulate($type, $context); + + if (null === $person) { + $person = new Person(); + } + + // Setters applied directly per known field for readability + if (\array_key_exists('firstName', $data)) { + $person->setFirstName($data['firstName']); + } + + if (\array_key_exists('lastName', $data)) { + $person->setLastName($data['lastName']); + } + + if (\array_key_exists('phonenumber', $data)) { + $person->setPhonenumber($this->denormalizer->denormalize($data['phonenumber'], PhoneNumber::class, $format, $context)); + } + + if (\array_key_exists('mobilenumber', $data)) { + $person->setMobilenumber($this->denormalizer->denormalize($data['mobilenumber'], PhoneNumber::class, $format, $context)); + } + + if (\array_key_exists('gender', $data) && null !== $data['gender']) { + $gender = $this->denormalizer->denormalize($data['gender'], Gender::class, $format, []); + $person->setGender($gender); + } + + if (\array_key_exists('birthdate', $data)) { + $object = $this->denormalizer->denormalize($data['birthdate'], \DateTime::class, $format, $context); + $person->setBirthdate($object); + } + + if (\array_key_exists('deathdate', $data)) { + $object = $this->denormalizer->denormalize($data['deathdate'], \DateTimeImmutable::class, $format, $context); + $person->setDeathdate($object); + } + + if (\array_key_exists('center', $data)) { + $object = $this->denormalizer->denormalize($data['center'], Center::class, $format, $context); + $person->setCenter($object); + } + + if (\array_key_exists('altNames', $data)) { + foreach ($data['altNames'] as $altNameData) { + if (!array_key_exists('key', $altNameData) + || !array_key_exists('value', $altNameData) + || '' === trim((string) $altNameData['key']) + ) { + throw new UnexpectedValueException('format for alt name is not correct'); + } + $altNameKey = $altNameData['key']; + $altNameValue = $altNameData['value']; + + $altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey); + if (null === $altName) { + $altName = new PersonAltName(); + $person->addAltName($altName); + } + $altName->setKey($altNameKey)->setLabel($altNameValue); + } + } + + if (\array_key_exists('identifiers', $data)) { + foreach ($data['identifiers'] as $identifierData) { + if (!array_key_exists('definition_id', $identifierData) + || !array_key_exists('value', $identifierData) + || !is_int($identifierData['definition_id']) + || !is_array($identifierData['value']) + ) { + throw new UnexpectedValueException('format for identifiers is not correct'); + } + + $definitionId = $identifierData['definition_id']; + $value = $identifierData['value']; + + $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId); + + if (!$worker->getDefinition()->isEditableByUsers()) { + continue; + } + + $personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getDefinition()->getId() === $definitionId); + if (null === $personIdentifier) { + $personIdentifier = new PersonIdentifier($worker->getDefinition()); + $person->addIdentifier($personIdentifier); + } + + $personIdentifier->setValue($value); + $personIdentifier->setCanonical($worker->canonicalizeValue($value)); + + if ($worker->isEmpty($personIdentifier)) { + $person->removeIdentifier($personIdentifier); + } + } + } + + if (\array_key_exists('email', $data)) { + $person->setEmail($data['email']); + } + + if (\array_key_exists('civility', $data) && null !== $data['civility']) { + $civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []); + $person->setCivility($civility); + } + + return $person; + } + + public function supportsDenormalization($data, $type, $format = null): bool + { + return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php index 88c38cfbb..2dcc5bdd9 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php @@ -11,169 +11,33 @@ declare(strict_types=1); namespace Chill\PersonBundle\Serializer\Normalizer; -use Chill\MainBundle\Entity\Center; -use Chill\MainBundle\Entity\Civility; -use Chill\MainBundle\Entity\Gender; use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\PersonAltName; -use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Doctrine\Common\Collections\Collection; -use libphonenumber\PhoneNumber; -use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; -use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; -use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Serialize a Person entity. */ -class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface +class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface { - use DenormalizerAwareTrait; - use NormalizerAwareTrait; - use ObjectToPopulateTrait; - public function __construct( private readonly ChillEntityRenderExtension $render, - /* TODO: replace by PersonRenderInterface, as sthis is the only one required */ - private readonly PersonRepository $repository, private readonly CenterResolverManagerInterface $centerResolverManager, private readonly ResidentialAddressRepository $residentialAddressRepository, private readonly PhoneNumberHelperInterface $phoneNumberHelper, + private readonly \Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface $personIdRendering, ) {} - public function denormalize($data, $type, $format = null, array $context = []) - { - $person = $this->extractObjectToPopulate($type, $context); - - if (\array_key_exists('id', $data) && null === $person) { - $person = $this->repository->find($data['id']); - - if (null === $person) { - throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); - } - - // currently, not allowed to update a person through api - // if instantiated with id - return $person; - } - - if (null === $person) { - $person = new Person(); - } - - $fields = [ - 'firstName', - 'lastName', - 'phonenumber', - 'mobilenumber', - 'gender', - 'birthdate', - 'deathdate', - 'center', - 'altNames', - 'email', - 'civility', - ]; - - $fields = array_filter( - $fields, - static fn (string $field): bool => \array_key_exists($field, $data) - ); - - foreach ($fields as $item) { - switch ($item) { - case 'firstName': - $person->setFirstName($data[$item]); - - break; - - case 'lastName': - $person->setLastName($data[$item]); - - break; - - case 'phonenumber': - $person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); - - break; - - case 'mobilenumber': - $person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); - - break; - - case 'gender': - $gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []); - - $person->setGender($gender); - - break; - - case 'birthdate': - $object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context); - - $person->setBirthdate($object); - - break; - - case 'deathdate': - $object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context); - - $person->setDeathdate($object); - - break; - - case 'center': - $object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context); - $person->setCenter($object); - - break; - - case 'altNames': - foreach ($data[$item] as $altName) { - $oldAltName = $person - ->getAltNames() - ->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first(); - - if (false === $oldAltName) { - $newAltName = new PersonAltName(); - $newAltName->setKey($altName['key']); - $newAltName->setLabel($altName['label']); - $person->addAltName($newAltName); - } else { - $oldAltName->setLabel($altName['label']); - } - } - - break; - - case 'email': - $person->setEmail($data[$item]); - - break; - - case 'civility': - $civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []); - - $person->setCivility($civility); - - break; - } - } - - return $person; - } - /** * @param Person $person * @param string|null $format @@ -204,6 +68,8 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar 'email' => $person->getEmail(), 'gender' => $this->normalizer->normalize($person->getGender(), $format, $context), 'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context), + 'personId' => $this->personIdRendering->renderPersonId($person), + 'identifiers' => $this->normalizer->normalize($person->getIdentifiers(), $format, $context), ]; if (\in_array('minimal', $groups, true) && 1 === \count($groups)) { @@ -215,11 +81,6 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar null]; } - public function supportsDenormalization($data, $type, $format = null) - { - return Person::class === $type && 'person' === ($data['type'] ?? null); - } - public function supportsNormalization($data, $format = null): bool { return $data instanceof Person && 'json' === $format; diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php new file mode 100644 index 000000000..ce8e5ce48 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php @@ -0,0 +1,51 @@ +repository->find($data['id']); + + if (null === $person) { + throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); + } + + return $person; + } + + throw new LogicException(); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return is_array($data) && Person::class === $type && 'person' === ($data['type'] ?? null) && isset($data['id']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php new file mode 100644 index 000000000..28241b14a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php @@ -0,0 +1,146 @@ +createMock(ConfigPersonAltNamesHelper::class); + $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); + $factory = new PersonEditDTOFactory($configHelper, $identifierManager); + + $dto = new PersonEditDTO(); + $dto->firstName = 'John'; + $dto->lastName = 'Doe'; + $dto->birthdate = new \DateTime('1980-05-10'); + $dto->deathdate = new \DateTimeImmutable('2050-01-01'); + $dto->gender = new Gender(); + $dto->genderComment = new CommentEmbeddable('gender comment'); + $dto->numberOfChildren = 2; + $dto->memo = 'Some memo'; + $dto->employmentStatus = new EmploymentStatus(); + $dto->administrativeStatus = new AdministrativeStatus(); + $dto->placeOfBirth = 'Cityville'; + $dto->contactInfo = 'Some contact info'; + $phone = new PhoneNumber(); + $dto->phonenumber = $phone; + $mobile = new PhoneNumber(); + $dto->mobilenumber = $mobile; + $dto->acceptSms = true; + $dto->otherPhonenumbers = new ArrayCollection(); + $dto->email = 'john.doe@example.org'; + $dto->acceptEmail = true; + $dto->countryOfBirth = new Country(); + $dto->nationality = new Country(); + $dto->spokenLanguages = new ArrayCollection([new Language()]); + $dto->civility = new Civility(); + $dto->maritalStatus = new MaritalStatus(); + $dto->maritalStatusDate = new \DateTime('2010-01-01'); + $dto->maritalStatusComment = new CommentEmbeddable('married'); + $dto->cFData = ['foo' => 'bar']; + + $person = new Person(); + + $factory->mapPersonEditDTOtoPerson($dto, $person); + + self::assertSame('John', $person->getFirstName()); + self::assertSame('Doe', $person->getLastName()); + self::assertSame($dto->birthdate, $person->getBirthdate()); + self::assertSame($dto->deathdate, $person->getDeathdate()); + self::assertSame($dto->gender, $person->getGender()); + self::assertSame($dto->genderComment, $person->getGenderComment()); + self::assertSame($dto->numberOfChildren, $person->getNumberOfChildren()); + self::assertSame('Some memo', $person->getMemo()); + self::assertSame($dto->employmentStatus, $person->getEmploymentStatus()); + self::assertSame($dto->administrativeStatus, $person->getAdministrativeStatus()); + self::assertSame('Cityville', $person->getPlaceOfBirth()); + self::assertSame('Some contact info', $person->getcontactInfo()); + self::assertSame($phone, $person->getPhonenumber()); + self::assertSame($mobile, $person->getMobilenumber()); + self::assertTrue($person->getAcceptSMS()); + self::assertSame($dto->otherPhonenumbers, $person->getOtherPhoneNumbers()); + self::assertSame('john.doe@example.org', $person->getEmail()); + self::assertTrue($person->getAcceptEmail()); + self::assertSame($dto->countryOfBirth, $person->getCountryOfBirth()); + self::assertSame($dto->nationality, $person->getNationality()); + self::assertSame($dto->spokenLanguages, $person->getSpokenLanguages()); + self::assertSame($dto->civility, $person->getCivility()); + self::assertSame($dto->maritalStatus, $person->getMaritalStatus()); + self::assertSame($dto->maritalStatusDate, $person->getMaritalStatusDate()); + self::assertSame($dto->maritalStatusComment, $person->getMaritalStatusComment()); + self::assertSame($dto->cFData, $person->getCFData()); + } + + public function testAltNamesHandlingWithConfigHelper(): void + { + $configHelper = $this->createMock(ConfigPersonAltNamesHelper::class); + $configHelper->method('getChoices')->willReturn([ + 'aka' => ['en' => 'Also Known As'], + 'nickname' => ['en' => 'Nickname'], + ]); + + $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); + $identifierManager->method('getWorkers')->willReturn([]); + + $factory = new PersonEditDTOFactory($configHelper, $identifierManager); + $person = new Person(); + + $dto = $factory->createPersonEditDTO($person); + + // Assert DTO has two altNames keys from helper + self::assertCount(2, $dto->altNames); + self::assertContainsOnlyInstancesOf(PersonAltName::class, $dto->altNames); + self::assertSame(['aka', 'nickname'], array_keys($dto->altNames)); + self::assertSame(['aka' => 'aka', 'nickname' => 'nickname'], array_map(fn (PersonAltName $altName) => $altName->getKey(), $dto->altNames)); + + // Fill only one label and leave the other empty + $dto->altNames['aka']->setLabel('The Boss'); + // 'nickname' remains empty by default + + // Map DTO back to person + $factory->mapPersonEditDTOtoPerson($dto, $person); + + // Assert only the filled alt name is persisted on the person + $altNames = $person->getAltNames(); + self::assertCount(1, $altNames); + $altNameArray = $altNames->toArray(); + self::assertSame('aka', $altNameArray[0]->getKey()); + self::assertSame('The Boss', $altNameArray[0]->getLabel()); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php new file mode 100644 index 000000000..e4df5446a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php @@ -0,0 +1,157 @@ +prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce(); + + $serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]); + + $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + + $controller = new PersonIdentifierListApiController( + $security->reveal(), + $serializer, + $personIdentifierManager->reveal(), + $paginatorFactory->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->list(); + } + + public function testListSuccess(): void + { + // Build 3 workers + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + $definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy'); + $definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy'); + $definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy'); + // simulate persisted ids + $r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $r->setAccessible(true); + $r->setValue($definition1, 1); + $r->setValue($definition2, 2); + $r->setValue($definition3, 3); + + $workers = [ + new PersonIdentifierWorker($engine, $definition1), + new PersonIdentifierWorker($engine, $definition2), + new PersonIdentifierWorker($engine, $definition3), + ]; + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce(); + + $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); + $personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce(); + + $paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class); + $paginator->setItemsPerPage(3)->shouldBeCalledOnce(); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(count($workers)); + $paginator->getTotalItems()->willReturn(count($workers)); + $paginator->hasNextPage()->willReturn(false); + $paginator->hasPreviousPage()->willReturn(false); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce(); + + $serializer = new Serializer([ + new PersonIdentifierWorkerNormalizer(), + new CollectionNormalizer(), + ], [new JsonEncoder()]); + + $controller = new PersonIdentifierListApiController( + $security->reveal(), + $serializer, + $personIdentifierManager->reveal(), + $paginatorFactory->reveal(), + ); + + $response = $controller->list(); + self::assertSame(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($body); + self::assertArrayHasKey('count', $body); + self::assertArrayHasKey('pagination', $body); + self::assertArrayHasKey('results', $body); + self::assertSame(3, $body['count']); + self::assertCount(3, $body['results']); + // spot check one item + self::assertSame('person_identifier_worker', $body['results'][0]['type']); + self::assertSame(1, $body['results'][0]['id']); + self::assertSame('dummy', $body['results'][0]['engine']); + self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php new file mode 100644 index 000000000..436c15589 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php @@ -0,0 +1,111 @@ + 'Test'], engine: StringIdentifier::NAME); + if ([] !== $data) { + $definition->setData($data); + } + + return $definition; + } + + private function makeIdentifier(PersonIdentifierDefinition $definition, ?string $content): PersonIdentifier + { + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['content' => $content]); + + return $identifier; + } + + public function testValidateWithoutOptionsHasNoViolations(): void + { + $definition = $this->makeDefinition(); + $identifier = $this->makeIdentifier($definition, 'AB-123'); + + $engine = new StringIdentifier(); + $violations = $engine->validate($identifier, $definition); + + self::assertIsArray($violations); + self::assertCount(0, $violations); + } + + public function testValidateOnlyNumbersOption(): void + { + $definition = $this->makeDefinition(['only_numbers' => true]); + $engine = new StringIdentifier(); + + // valid numeric content + $identifierOk = $this->makeIdentifier($definition, '123456'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid alphanumeric content + $identifierBad = $this->makeIdentifier($definition, '12AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(1, $violationsBad); + self::assertSame('person_identifier.only_number', $violationsBad[0]->message); + self::assertSame('2a3352c0-a2b9-11f0-a767-b7a3f80e52f1', $violationsBad[0]->code); + } + + public function testValidateFixedLengthOption(): void + { + $definition = $this->makeDefinition(['fixed_length' => 5]); + $engine = new StringIdentifier(); + + // valid exact length + $identifierOk = $this->makeIdentifier($definition, 'ABCDE'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid length (too short) + $identifierBad = $this->makeIdentifier($definition, 'AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(1, $violationsBad); + self::assertSame('person_identifier.fixed_length', $violationsBad[0]->message); + self::assertSame('2b02a8fe-a2b9-11f0-bfe5-033300972783', $violationsBad[0]->code); + self::assertSame(['limit' => '5'], $violationsBad[0]->parameters); + } + + public function testValidateOnlyNumbersAndFixedLengthTogether(): void + { + $definition = $this->makeDefinition(['only_numbers' => true, 'fixed_length' => 4]); + $engine = new StringIdentifier(); + + // valid: numeric and correct length + $identifierOk = $this->makeIdentifier($definition, '1234'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid: non-numeric and wrong length -> two violations expected + $identifierBad = $this->makeIdentifier($definition, 'AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(2, $violationsBad); + // Order is defined by implementation: numbers check first, then length + self::assertSame('person_identifier.only_number', $violationsBad[0]->message); + self::assertSame('person_identifier.fixed_length', $violationsBad[1]->message); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php new file mode 100644 index 000000000..c54d360d3 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php @@ -0,0 +1,133 @@ + 'SSN'], engine: 'string'); + $worker = new PersonIdentifierWorker($engine, $definition); + + $normalizer = new PersonIdentifierWorkerNormalizer(); + + self::assertTrue($normalizer->supportsNormalization($worker)); + self::assertFalse($normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + $definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string'); + $definition->setActive(false); + $worker = new PersonIdentifierWorker($engine, $definition); + + $normalizer = new PersonIdentifierWorkerNormalizer(); + $normalized = $normalizer->normalize($worker); + + self::assertSame([ + 'type' => 'person_identifier_worker', + 'definition_id' => null, + 'engine' => 'string', + 'label' => ['en' => 'SSN'], + 'isActive' => false, + 'presence' => 'ON_EDIT', + ], $normalized); + } + + public function testNormalizeThrowsOnInvalidObject(): void + { + $normalizer = new PersonIdentifierWorkerNormalizer(); + $this->expectException(UnexpectedValueException::class); + $normalizer->normalize(new \stdClass()); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php index ce1df3f9d..8f2592665 100644 --- a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php @@ -71,6 +71,21 @@ class PersonIdRenderingTest extends TestCase // same behavior as StringIdentifier::renderAsString return $identifier?->getValue()['content'] ?? ''; } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } }; return new PersonIdentifierWorker($engine, $definition); diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..86201b6fb --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php @@ -0,0 +1,131 @@ +requiredDefinition = new PersonIdentifierDefinition( + label: ['fr' => 'Identifiant requis'], + engine: 'test.engine', + ); + $this->requiredDefinition->setPresence(IdentifierPresenceEnum::REQUIRED); + $reflection = new \ReflectionClass($this->requiredDefinition); + $id = $reflection->getProperty('id'); + $id->setValue($this->requiredDefinition, 1); + + // Mock only the required methods of the engine used by the validator through the worker + $engineProphecy = $this->prophesize(PersonIdentifierEngineInterface::class); + $engineProphecy->isEmpty(Argument::type(PersonIdentifier::class)) + ->will(function (array $args): bool { + /** @var PersonIdentifier $identifier */ + $identifier = $args[0]; + + return '' === trim($identifier->getValue()['content'] ?? ''); + }); + $engineProphecy->renderAsString(Argument::any(), Argument::any()) + ->will(function (array $args): string { + /** @var PersonIdentifier|null $identifier */ + $identifier = $args[0] ?? null; + + return $identifier?->getValue()['content'] ?? ''; + }); + + $worker = new PersonIdentifierWorker($engineProphecy->reveal(), $this->requiredDefinition); + + // Mock only the required method used by the validator + $managerProphecy = $this->prophesize(PersonIdentifierManagerInterface::class); + $managerProphecy->getWorkers()->willReturn([$worker]); + + return new RequiredIdentifierConstraintValidator($managerProphecy->reveal()); + } + + public function testThrowsOnNonCollectionValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(new \stdClass(), new RequiredIdentifierConstraint()); + } + + public function testThrowsOnInvalidConstraintType(): void + { + $this->expectException(UnexpectedTypeException::class); + // Provide a valid Collection value so the type check reaches the constraint check + $this->validator->validate(new ArrayCollection(), new NotBlank()); + } + + public function testNoViolationWhenRequiredIdentifierPresentAndNotEmpty(): void + { + $identifier = new PersonIdentifier($this->requiredDefinition); + $identifier->setValue(['content' => 'ABC']); + + $collection = new ArrayCollection([$identifier]); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->assertNoViolation(); + } + + public function testViolationWhenRequiredIdentifierMissing(): void + { + $collection = new ArrayCollection(); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->buildViolation('person_identifier.This identifier must be set') + ->setParameter('{{ value }}', '') + ->setParameter('definition_id', '1') + ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') + ->assertRaised(); + } + + public function testViolationWhenRequiredIdentifierIsEmpty(): void + { + $identifier = new PersonIdentifier($this->requiredDefinition); + $identifier->setValue(['content' => ' ']); + + $collection = new ArrayCollection([$identifier]); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->buildViolation('person_identifier.This identifier must be set') + ->setParameter('{{ value }}', ' ') + ->setParameter('definition_id', '1') + ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..d872bfc56 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php @@ -0,0 +1,158 @@ +repository = $this->prophesize(PersonIdentifierRepository::class); + $this->personRender = $this->prophesize(PersonRenderInterface::class); + parent::setUp(); + } + + protected function createValidator(): UniqueIdentifierConstraintValidator + { + return new UniqueIdentifierConstraintValidator($this->repository->reveal(), $this->personRender->reveal()); + } + + public function testThrowsOnInvalidConstraintType(): void + { + $this->expectException(UnexpectedTypeException::class); + + // Provide a valid value so execution reaches the constraint type check + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string'); + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'ABC']); + + $this->validator->validate($identifier, new NotBlank()); + } + + public function testThrowsOnInvalidValueType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(new \stdClass(), new UniqueIdentifierConstraint()); + } + + public function testNoViolationWhenNoDuplicate(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string'); + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'UNIQ']); + + // Configure repository mock to return empty array + $this->repository->findByDefinitionAndCanonical($definition, ['value' => 'UNIQ'])->willReturn([]); + + $this->validator->validate($identifier, new UniqueIdentifierConstraint()); + $this->assertNoViolation(); + } + + public function testViolationWhenDuplicateFound(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + $reflectionClass = new \ReflectionClass($definition); + $reflectionId = $reflectionClass->getProperty('id'); + $reflectionId->setValue($definition, 1); + + $personA = new Person(); + $personA->setFirstName('Alice')->setLastName('Anderson'); + $personB = new Person(); + $personB->setFirstName('Bob')->setLastName('Brown'); + + $dup1 = new PersonIdentifier($definition); + $dup1->setPerson($personA); + $dup1->setValue(['value' => '123']); + $dup2 = new PersonIdentifier($definition); + $dup2->setPerson($personB); + $dup2->setValue(['value' => '123']); + + // Repository returns duplicates + $this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1, $dup2]); + + // Person renderer returns names + $this->personRender->renderString($personA, Argument::type('array'))->willReturn('Alice Anderson'); + $this->personRender->renderString($personB, Argument::type('array'))->willReturn('Bob Brown'); + + $identifier = new PersonIdentifier($definition); + $identifier->setPerson(new Person()); + $identifier->setValue(['value' => '123']); + + $constraint = new UniqueIdentifierConstraint(); + + $this->validator->validate($identifier, $constraint); + + $this->buildViolation($constraint->message) + ->setParameter('{{ persons }}', 'Alice Anderson, Bob Brown') + ->setParameter('definition_id', '1') + ->assertRaised(); + } + + public function testViolationWhenDuplicateFoundButForSamePerson(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + $reflectionClass = new \ReflectionClass($definition); + $reflectionId = $reflectionClass->getProperty('id'); + $reflectionId->setValue($definition, 1); + + $personA = new Person(); + $personA->setFirstName('Alice')->setLastName('Anderson'); + + $dup1 = new PersonIdentifier($definition); + $dup1->setPerson($personA); + $dup1->setValue(['value' => '123']); + + // Repository returns duplicates + $this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1]); + + $identifier = new PersonIdentifier($definition); + $identifier->setPerson($personA); + $identifier->setValue(['value' => '123']); + + $constraint = new UniqueIdentifierConstraint(); + + $this->validator->validate($identifier, $constraint); + + $this->assertNoViolation(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..9e4abff3b --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php @@ -0,0 +1,115 @@ +manager = $this->prophesize(PersonIdentifierManagerInterface::class); + parent::setUp(); + } + + protected function createValidator(): ValidIdentifierConstraintValidator + { + return new ValidIdentifierConstraintValidator($this->manager->reveal()); + } + + public function testAddsViolationFromWorker(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + // set definition id via reflection for definition_id parameter + $ref = new \ReflectionClass($definition); + $prop = $ref->getProperty('id'); + $prop->setValue($definition, 1); + + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'bad']); + + $violation = new IdentifierViolationDTO('Invalid Identifier', '0000-1111-2222-3333', ['{{ foo }}' => 'bar']); + + // engine that returns one violation + $engine = new class ([$violation]) implements PersonIdentifierEngineInterface { + public function __construct(private readonly array $violations) {} + + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return $this->violations; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + $worker = new PersonIdentifierWorker($engine, $definition); + + $this->manager + ->buildWorkerByPersonIdentifierDefinition($definition) + ->willReturn($worker); + + $constraint = new ValidIdentifierConstraint(); + $this->validator->validate($identifier, $constraint); + + $this->buildViolation('Invalid Identifier') + ->setParameters(['{{ foo }}' => 'bar']) + ->setParameter('{{ code }}', '0000-1111-2222-3333') + ->setParameter('definition_id', '1') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php new file mode 100644 index 000000000..ab36fd872 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php @@ -0,0 +1,76 @@ +get(PersonIdentifierManagerInterface::class); + + /** @var EntityManagerInterface $em */ + $em = $container->get(EntityManagerInterface::class); + + // Get a random existing person from fixtures + /** @var Person|null $person */ + $person = $em->getRepository(Person::class)->findOneBy([]); + self::assertNotNull($person, 'An existing Person is required for this integration test.'); + + // Create a definition + $definition = new PersonIdentifierDefinition(['en' => 'Test Identifier'], StringIdentifier::NAME); + $em->persist($definition); + $em->flush(); + + // Create an identifier attached to the person + $value = ['content' => 'ABC-'.bin2hex(random_bytes(4))]; + $identifier = new PersonIdentifier($definition); + $identifier->setPerson($person); + $identifier->setValue($value); + $identifier->setCanonical($personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($identifier->getValue())); + $em->persist($identifier); + $em->flush(); + + // Use the repository to find by definition and value + /** @var PersonIdentifierRepository $repo */ + $repo = $container->get(PersonIdentifierRepository::class); + $results = $repo->findByDefinitionAndCanonical($definition, $value); + + self::assertNotEmpty($results, 'Repository should return at least one result.'); + self::assertContainsOnlyInstancesOf(PersonIdentifier::class, $results); + self::assertTrue(in_array($identifier->getId(), array_map(static fn (PersonIdentifier $pi) => $pi->getId(), $results), true)); + + // Cleanup + foreach ($results as $res) { + $em->remove($res); + } + $em->flush(); + $em->remove($definition); + $em->flush(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php index 667242111..20401c1b1 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php @@ -18,6 +18,7 @@ use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\PersonPhone; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; use Chill\PersonBundle\Repository\PersonACLAwareRepository; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Doctrine\ORM\EntityManagerInterface; @@ -42,6 +43,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase private EntityManagerInterface $entityManager; + private PersonIdentifierManagerInterface $personIdentifierManager; + protected function setUp(): void { self::bootKernel(); @@ -49,6 +52,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); $this->countryRepository = self::getContainer()->get(CountryRepository::class); $this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class); + $this->personIdentifierManager = self::getContainer()->get(PersonIdentifierManagerInterface::class); + } public function testCountByCriteria() @@ -66,7 +71,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $security->reveal(), $this->entityManager, $this->countryRepository, - $authorizationHelper->reveal() + $authorizationHelper->reveal(), + $this->personIdentifierManager, ); $number = $repository->countBySearchCriteria('diallo'); @@ -89,7 +95,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $security->reveal(), $this->entityManager, $this->countryRepository, - $authorizationHelper->reveal() + $authorizationHelper->reveal(), + $this->personIdentifierManager, ); $results = $repository->findBySearchCriteria(0, 5, false, 'diallo'); @@ -120,7 +127,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $security->reveal(), $this->entityManager, $this->countryRepository, - $authorizationHelper->reveal() + $authorizationHelper->reveal(), + $this->personIdentifierManager, ); $actual = $repository->findByPhone($phoneNumber, 0, 10); diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php new file mode 100644 index 000000000..aef5ee584 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php @@ -0,0 +1,312 @@ + 'Test'], 'dummy'); + // Force the id for testing purposes + $r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $r->setAccessible(true); + $r->setValue($definition, $personIdentifierDefinition); + } else { + $definition = $personIdentifierDefinition; + } + + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + // trivial canonicalization for tests + return isset($value['content']) ? (string) $value['content'] : null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + $value = $identifier->getValue(); + $content = isset($value['content']) ? trim((string) $value['content']) : ''; + + return '' === $content; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + return new PersonIdentifierWorker($engine, $definition); + } + }; + } + + public function testSupportsDenormalizationReturnsTrueForValidData(): void + { + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + $data = [ + 'type' => 'person', + // important: new Person (creation) must not contain an id + ]; + + self::assertTrue($denormalizer->supportsDenormalization($data, Person::class)); + } + + public function testSupportsDenormalizationReturnsFalseForInvalidData(): void + { + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + // not an array + self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class)); + + // missing type + self::assertFalse($denormalizer->supportsDenormalization([], Person::class)); + + // wrong type value + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person'], Person::class)); + + // id present means it's not a create payload for this denormalizer + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 123], Person::class)); + + // wrong target class + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], \stdClass::class)); + } + + public function testDenormalizeMapsPayloadToPersonProperties(): void + { + $json = <<<'JSON' + { + "type": "person", + "firstName": "Jérome", + "lastName": "diallo", + "altNames": [ + { + "key": "jeune_fille", + "value": "FJ" + } + ], + "birthdate": null, + "deathdate": null, + "phonenumber": "", + "mobilenumber": "", + "email": "", + "gender": { + "id": 5, + "type": "chill_main_gender" + }, + "center": { + "id": 1, + "type": "center" + }, + "civility": null, + "identifiers": [ + { + "type": "person_identifier", + "value": { + "content": "789456" + }, + "definition_id": 5 + } + ] + } + JSON; + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $inner = new class () implements DenormalizerInterface { + public ?Gender $gender = null; + public ?Center $center = null; + + public function denormalize($data, $type, $format = null, array $context = []) + { + if (PhoneNumber::class === $type) { + return '' === $data ? null : new PhoneNumber(); + } + if (\DateTime::class === $type || \DateTimeImmutable::class === $type) { + return null === $data ? null : new \DateTimeImmutable((string) $data); + } + if (Gender::class === $type) { + return $this->gender ??= new Gender(); + } + if (Center::class === $type) { + return $this->center ??= new Center(); + } + if (Civility::class === $type) { + return null; // input is null in our payload + } + + return null; + } + + public function supportsDenormalization($data, $type, $format = null) + { + return true; + } + }; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + $denormalizer->setDenormalizer($inner); + + $person = $denormalizer->denormalize($data, Person::class); + + self::assertInstanceOf(Person::class, $person); + self::assertSame('Jérome', $person->getFirstName()); + self::assertSame('diallo', $person->getLastName()); + + // phone numbers: empty strings map to null via the inner denormalizer stub + self::assertNull($person->getPhonenumber()); + self::assertNull($person->getMobilenumber()); + + // email passes through as is + self::assertSame('', $person->getEmail()); + + // nested objects are provided by our inner denormalizer and must be set back on the Person + self::assertSame($inner->gender, $person->getGender()); + self::assertSame($inner->center, $person->getCenter()); + + // dates are null in the provided payload + self::assertNull($person->getBirthdate()); + self::assertNull($person->getDeathdate()); + + // civility is null as provided + self::assertNull($person->getCivility()); + + // altNames: make sure the alt name with key jeune_fille has label FJ + $found = false; + foreach ($person->getAltNames() as $altName) { + if ('jeune_fille' === $altName->getKey()) { + $found = true; + self::assertSame('FJ', $altName->getLabel()); + } + } + self::assertTrue($found, 'Expected altNames to contain key "jeune_fille"'); + + $found = false; + foreach ($person->getIdentifiers() as $identifier) { + if (5 === $identifier->getDefinition()->getId()) { + $found = true; + self::assertSame(['content' => '789456'], $identifier->getValue()); + } + } + self::assertTrue($found, 'Expected identifiers with definition id 5'); + } + + public function testDenormalizeRemovesEmptyIdentifier(): void + { + $data = [ + 'type' => 'person', + 'firstName' => 'Alice', + 'lastName' => 'Smith', + 'identifiers' => [ + [ + 'type' => 'person_identifier', + 'value' => ['content' => ''], + 'definition_id' => 7, + ], + ], + ]; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + $person = $denormalizer->denormalize($data, Person::class); + + // The identifier with empty content must be considered empty and removed + self::assertSame(0, $person->getIdentifiers()->count(), 'Expected no identifiers to remain on the person'); + } + + public function testDenormalizeRemovesPreviouslyExistingIdentifierWhenIncomingValueIsEmpty(): void + { + // Prepare an existing Person with a pre-existing identifier (definition id = 9) + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy'); + $ref = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $ref->setValue($definition, 9); + + $existingIdentifier = new PersonIdentifier($definition); + $existingIdentifier->setValue(['content' => 'ABC']); + + $person = new Person(); + $person->addIdentifier($existingIdentifier); + + // Also set the identifier's own id = 9 so that the denormalizer logic matches it + // (the current denormalizer matches by PersonIdentifier->getId() === definition_id) + $refId = new \ReflectionProperty(PersonIdentifier::class, 'id'); + $refId->setValue($existingIdentifier, 9); + + // Incoming payload sets the same definition id with an empty value + $data = [ + 'type' => 'person', + 'identifiers' => [ + [ + 'type' => 'person_identifier', + 'value' => ['content' => ''], + 'definition_id' => 9, + ], + ], + ]; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + // Use AbstractNormalizer::OBJECT_TO_POPULATE to update the existing person + $result = $denormalizer->denormalize($data, Person::class, null, [ + AbstractNormalizer::OBJECT_TO_POPULATE => $person, + ]); + + self::assertSame($person, $result, 'Denormalizer should update and return the provided Person instance'); + self::assertSame(0, $person->getIdentifiers()->count(), 'The previously existing identifier should be removed when incoming value is empty'); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php new file mode 100644 index 000000000..3fff8aa97 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php @@ -0,0 +1,63 @@ +get(PersonRepository::class); + $person = $repo->findOneBy([]); + + if (!$person instanceof Person) { + self::markTestSkipped('No person found in test database. Load fixtures to enable this test.'); + } + + /** @var SerializerInterface $serializer */ + $serializer = $container->get(SerializerInterface::class); + + // Should not throw + $data = $serializer->normalize($person, 'json'); + Assert::assertIsArray($data); + + // Spot check some expected keys exist + foreach ([ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'birthdate', 'age', 'gender', 'civility', + ] as $key) { + Assert::assertArrayHasKey($key, $data, sprintf('Expected key %s in normalized payload', $key)); + } + + // Minimal group should also work + $minimal = $serializer->normalize($person, 'json', ['groups' => 'minimal']); + Assert::assertIsArray($minimal); + foreach ([ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', + ] as $key) { + Assert::assertArrayHasKey($key, $minimal, sprintf('Expected key %s in minimal normalized payload', $key)); + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php index af78e2475..41aadf4fb 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php @@ -11,74 +11,186 @@ declare(strict_types=1); namespace Serializer\Normalizer; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\PersonBundle\Entity\Person; -use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Entity\PersonAltName; use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer; +use Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * @internal * - * @coversNothing + * @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer */ -final class PersonJsonNormalizerTest extends KernelTestCase +final class PersonJsonNormalizerTest extends TestCase { use ProphecyTrait; - private PersonJsonNormalizer $normalizer; - - protected function setUp(): void + public function testSupportsNormalization(): void { - self::bootKernel(); + $normalizer = $this->createNormalizer(); - $residentialAddressRepository = $this->prophesize(ResidentialAddressRepository::class); - $residentialAddressRepository - ->findCurrentResidentialAddressByPerson(Argument::type(Person::class), Argument::any()) - ->willReturn([]); - - $this->normalizer = $this->buildPersonJsonNormalizer( - self::getContainer()->get(ChillEntityRenderExtension::class), - self::getContainer()->get(PersonRepository::class), - self::getContainer()->get(CenterResolverManagerInterface::class), - $residentialAddressRepository->reveal(), - self::getContainer()->get(PhoneNumberHelperInterface::class), - self::getContainer()->get(NormalizerInterface::class) - ); + self::assertTrue($normalizer->supportsNormalization(new Person(), 'json')); + self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json')); + self::assertFalse($normalizer->supportsNormalization(new Person(), 'xml')); } - public function testNormalization() + public function testNormalizeWithMinimalGroupReturnsOnlyBaseKeys(): void { - $person = new Person(); - $result = $this->normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => ['read']]); + $person = $this->createSamplePerson(); - $this->assertIsArray($result); + $normalizer = $this->createNormalizer(); + $data = $normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => 'minimal']); + + // Expected base keys + $expectedKeys = [ + 'type', + 'id', + 'text', + 'textAge', + 'firstName', + 'lastName', + 'current_household_address', + 'birthdate', + 'deathdate', + 'age', + 'phonenumber', + 'mobilenumber', + 'email', + 'gender', + 'civility', + 'personId', + ]; + + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + self::assertSame('PERSON-ID-RENDER', $data['personId']); + + // Ensure extended keys are not present in minimal mode + foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) { + self::assertArrayNotHasKey($key, $data, sprintf('Key %s should NOT be present in minimal group', $key)); + } } - private function buildPersonJsonNormalizer( - ChillEntityRenderExtension $render, - PersonRepository $repository, - CenterResolverManagerInterface $centerResolverManager, - ResidentialAddressRepository $residentialAddressRepository, - PhoneNumberHelperInterface $phoneNumberHelper, - NormalizerInterface $normalizer, - ): PersonJsonNormalizer { - $personJsonNormalizer = new PersonJsonNormalizer( - $render, - $repository, - $centerResolverManager, - $residentialAddressRepository, - $phoneNumberHelper - ); - $personJsonNormalizer->setNormalizer($normalizer); + public function testNormalizeWithoutGroupsIncludesExtendedKeys(): void + { + $person = $this->createSamplePerson(withAltNames: true); - return $personJsonNormalizer; + $center1 = (new Center())->setName('c1'); + $center2 = (new Center())->setName('c2'); + + + $normalizer = $this->createNormalizer( + centers: [$center1, $center2], + currentResidentialAddresses: [['addr' => 1]], + ); + + $data = $normalizer->normalize($person, 'json'); + + // Base keys + $baseKeys = [ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'current_household_address', 'birthdate', 'deathdate', 'age', 'phonenumber', 'mobilenumber', 'email', 'gender', 'civility', 'personId', + ]; + foreach ($baseKeys as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + + // Extended keys + foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + + self::assertSame(['c1', 'c2'], $data['centers']); + self::assertIsArray($data['altNames']); + self::assertSame([['key' => 'aka', 'label' => 'Johnny']], $data['altNames']); + self::assertNull($data['current_household_id'], 'No household set so id should be null'); + self::assertSame([['addr' => 1]], $data['current_residential_addresses']); + } + + private function createNormalizer(array $centers = [], array $currentResidentialAddresses = []): PersonJsonNormalizer + { + $render = $this->prophesize(ChillEntityRenderExtension::class); + $render->renderString(Argument::type(Person::class), ['addAge' => false])->willReturn('John Doe'); + $render->renderString(Argument::type(Person::class), ['addAge' => true])->willReturn('John Doe (25)'); + + $centerResolver = $this->prophesize(CenterResolverManagerInterface::class); + $centerResolver->resolveCenters(Argument::type(Person::class))->willReturn($centers); + + $raRepo = $this->prophesize(ResidentialAddressRepository::class); + $raRepo->findCurrentResidentialAddressByPerson(Argument::type(Person::class))->willReturn($currentResidentialAddresses); + + $phoneHelper = $this->prophesize(PhoneNumberHelperInterface::class); + + $personIdRendering = $this->prophesize(PersonIdRenderingInterface::class); + $personIdRendering->renderPersonId(Argument::type(Person::class))->willReturn('PERSON-ID-RENDER'); + + $normalizer = new PersonJsonNormalizer( + $render->reveal(), + $centerResolver->reveal(), + $raRepo->reveal(), + $phoneHelper->reveal(), + $personIdRendering->reveal(), + ); + + // Inner normalizer that echoes values or simple conversions + $inner = new class () implements NormalizerInterface { + public function supportsNormalization($data, $format = null): bool + { + return true; + } + + public function normalize($object, $format = null, array $context = []) + { + // For scalars and arrays, return as-is; for objects, return string or id when possible + if (\is_scalar($object) || null === $object) { + return $object; + } + if ($object instanceof \DateTimeInterface) { + return $object->format('Y-m-d'); + } + if ($object instanceof Center) { + return $object->getName(); + } + if (is_array($object)) { + return array_map(fn ($o) => $this->normalize($o, $format, $context), $object); + } + + // default stub + return (string) (method_exists($object, 'getId') ? $object->getId() : 'normalized'); + } + }; + + $normalizer->setNormalizer($inner); + + return $normalizer; + } + + private function createSamplePerson(bool $withAltNames = false): Person + { + $p = new Person(); + $p->setFirstName('John'); + $p->setLastName('Doe'); + $p->setBirthdate(new \DateTime('2000-01-01')); + $p->setEmail('john@example.test'); + + if ($withAltNames) { + $alt = new PersonAltName(); + $alt->setKey('aka'); + $alt->setLabel('Johnny'); + $p->setAltNames(new ArrayCollection([$alt])); + } + + return $p; } } diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php new file mode 100644 index 000000000..e019a41c6 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php @@ -0,0 +1,86 @@ +getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + $data = [ + 'type' => 'person', + 'id' => 123, + ]; + + self::assertTrue($denormalizer->supportsDenormalization($data, Person::class)); + } + + public function testSupportsDenormalizationReturnsFalseForInvalidData(): void + { + $repository = $this->getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + // not an array + self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class)); + + // missing type + self::assertFalse($denormalizer->supportsDenormalization(['id' => 1], Person::class)); + + // wrong type value + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person', 'id' => 1], Person::class)); + + // missing id + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], Person::class)); + + // wrong target class + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 1], \stdClass::class)); + } + + public function testDenormalizeReturnsPersonFromRepository(): void + { + $person = new Person(); + + $repository = $this->getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['find']) + ->getMock(); + + $repository->expects(self::once()) + ->method('find') + ->with(123) + ->willReturn($person); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + $result = $denormalizer->denormalize(['id' => 123], Person::class); + + self::assertSame($person, $result); + } +} diff --git a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml index c5f244525..1d1a2b1e2 100644 --- a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml @@ -1,1998 +1,1998 @@ components: - schemas: - # should go to main - Date: - type: object - properties: - datetime: - type: string - format: date-time - Scope: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - scope - name: - type: object - additionalProperties: - type: string - example: - fr: Social - ScopeById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - scope - required: - - id - - scope + schemas: + # should go to main + Date: + type: object + properties: + datetime: + type: string + format: date-time + Scope: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - scope + name: + type: object + additionalProperties: + type: string + example: + fr: Social + ScopeById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - scope + required: + - id + - scope - # ok to stay here - Person: - type: object - properties: - id: - type: integer - readOnly: true - type: - type: string - enum: - - "person" - firstName: - type: string - lastName: - type: string - text: - type: string - description: a canonical representation for the person name - readOnly: true - birthdate: - $ref: "#/components/schemas/Date" - deathdate: - $ref: "#/components/schemas/Date" - phonenumber: - type: string - mobilenumber: - type: string - gender: - type: string - enum: - - man - - woman - - both - gender_numeric: - type: integer - description: a numerical representation of gender - readOnly: true - PersonById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "person" - required: - - id - - type - # should go to third party - ThirdParty: - type: object - properties: - text: - type: string - ThirdPartyById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "thirdparty" - required: - - id - - type + # ok to stay here + Person: + type: object + properties: + id: + type: integer + readOnly: true + type: + type: string + enum: + - "person" + firstName: + type: string + lastName: + type: string + text: + type: string + description: a canonical representation for the person name + readOnly: true + birthdate: + $ref: "#/components/schemas/Date" + deathdate: + $ref: "#/components/schemas/Date" + phonenumber: + type: string + mobilenumber: + type: string + gender: + type: string + enum: + - man + - woman + - both + gender_numeric: + type: integer + description: a numerical representation of gender + readOnly: true + PersonById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "person" + required: + - id + - type + # should go to third party + ThirdParty: + type: object + properties: + text: + type: string + ThirdPartyById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "thirdparty" + required: + - id + - type - # ok to stay here - AccompanyingPeriod: - type: object - properties: - type: - type: string - enum: - - accompanying_period - id: - type: integer - requestorAnonymous: - type: boolean - Resource: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_resource" - readOnly: true - id: - type: integer - readOnly: true - resource: - anyOf: - - $ref: "#/components/schemas/PersonById" - - $ref: "#/components/schemas/ThirdPartyById" - ResourceById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_resource" - required: - - id - - type - Comment: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_comment" - readOnly: true - id: - type: integer - readOnly: true - content: - type: string - CommentById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_comment" - required: - - id - - type - SocialIssue: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_issue" - parent_id: - type: integer - readOnly: true - children_ids: - type: array - items: - type: integer - readOnly: true - title: - type: object - additionalProperties: - type: string - example: - fr: Accompagnement Social Adulte - readOnly: true - text: - type: string - readOnly: true - Household: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "household" - HouseholdPosition: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "household_position" - AccompanyingCourseWork: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_work" - note: - type: string - privateComment: - type: string - startDate: - $ref: "#/components/schemas/Date" - endDate: - $ref: "#/components/schemas/Date" - handlingThirdParty: - $ref: "#/components/schemas/ThirdPartyById" - goals: - type: array - items: - $ref: "#/components/schemas/AccompanyingCourseWorkGoal" - results: - type: array - items: - $ref: "#/components/schemas/SocialWorkResultById" - AccompanyingCourseWorkGoal: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_work_goal" - note: - type: string - goal: - $ref: "#/components/schemas/SocialWorkGoalById" - results: - type: array - items: - $ref: "#/components/schemas/SocialWorkGoalById" + # ok to stay here + AccompanyingPeriod: + type: object + properties: + type: + type: string + enum: + - accompanying_period + id: + type: integer + requestorAnonymous: + type: boolean + Resource: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_resource" + readOnly: true + id: + type: integer + readOnly: true + resource: + anyOf: + - $ref: "#/components/schemas/PersonById" + - $ref: "#/components/schemas/ThirdPartyById" + ResourceById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_resource" + required: + - id + - type + Comment: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_comment" + readOnly: true + id: + type: integer + readOnly: true + content: + type: string + CommentById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_comment" + required: + - id + - type + SocialIssue: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_issue" + parent_id: + type: integer + readOnly: true + children_ids: + type: array + items: + type: integer + readOnly: true + title: + type: object + additionalProperties: + type: string + example: + fr: Accompagnement Social Adulte + readOnly: true + text: + type: string + readOnly: true + Household: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "household" + HouseholdPosition: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "household_position" + AccompanyingCourseWork: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_work" + note: + type: string + privateComment: + type: string + startDate: + $ref: "#/components/schemas/Date" + endDate: + $ref: "#/components/schemas/Date" + handlingThirdParty: + $ref: "#/components/schemas/ThirdPartyById" + goals: + type: array + items: + $ref: "#/components/schemas/AccompanyingCourseWorkGoal" + results: + type: array + items: + $ref: "#/components/schemas/SocialWorkResultById" + AccompanyingCourseWorkGoal: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_work_goal" + note: + type: string + goal: + $ref: "#/components/schemas/SocialWorkGoalById" + results: + type: array + items: + $ref: "#/components/schemas/SocialWorkGoalById" - SocialWorkResultById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_work_result" - SocialWorkGoalById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_work_goal" + SocialWorkResultById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_work_result" + SocialWorkGoalById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_work_goal" - RelationById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "relation" - required: - - id - - type - Relationship: - type: object - properties: - type: - type: string - enum: - - "relationship" - id: - type: integer - readOnly: true - fromPerson: - anyOf: - - $ref: "#/components/schemas/PersonById" - toPerson: - anyOf: - - $ref: "#/components/schemas/PersonById" - relation: - anyOf: - - $ref: "#/components/schemas/RelationById" - reverse: - type: boolean + RelationById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "relation" + required: + - id + - type + Relationship: + type: object + properties: + type: + type: string + enum: + - "relationship" + id: + type: integer + readOnly: true + fromPerson: + anyOf: + - $ref: "#/components/schemas/PersonById" + toPerson: + anyOf: + - $ref: "#/components/schemas/PersonById" + relation: + anyOf: + - $ref: "#/components/schemas/RelationById" + reverse: + type: boolean paths: - /1.0/person/person/{id}.json: - get: - tags: - - person - summary: Get a single person - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - 403: - description: "Unauthorized" - patch: - tags: - - person - summary: "Alter a person" - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - examples: - Update a person: - value: - type: "person" - firstName: "string" - lastName: "string" - birthdate: - datetime: "2016-06-01T00:00:00+02:00" - deathdate: - datetime: "2021-06-01T00:00:00+02:00" - phonenumber: "string" - mobilenumber: "string" - gender: "male" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Object with validation errors" + /1.0/person/person/{id}.json: + get: + tags: + - person + summary: Get a single person + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + patch: + tags: + - person + summary: "Alter a person" + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + examples: + Update a person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Object with validation errors" - /1.0/person/person.json: - post: - tags: - - person - summary: Create a single person - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - examples: - Create a new person: - value: - type: "person" - firstName: "string" - lastName: "string" - birthdate: - datetime: "2016-06-01T00:00:00+02:00" - deathdate: - datetime: "2021-06-01T00:00:00+02:00" - phonenumber: "string" - mobilenumber: "string" - gender: "male" - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - 403: - description: "Unauthorized" - 422: - description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" + /1.0/person/person.json: + post: + tags: + - person + summary: Create a single person + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + examples: + Create a new person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + 422: + description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" - /1.0/person/person/{id}/address.json: - post: - tags: - - person - summary: post an address to a person - parameters: - - name: id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - description: The address id to attach to the person - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" + /1.0/person/person/{id}/address.json: + post: + tags: + - person + summary: post an address to a person + parameters: + - name: id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: The address id to attach to the person + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" - /1.0/person/address/suggest/by-person/{id}.json: - get: - tags: - - address - summary: get a list of suggested address for a person - description: > - The address are computed from various source. Currently: + /1.0/person/address/suggest/by-person/{id}.json: + get: + tags: + - address + summary: get a list of suggested address for a person + description: > + The address are computed from various source. Currently: - - the address of course to which the person is participating + - the address of course to which the person is participating - The current person's address is always ignored. - parameters: - - name: id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + The current person's address is always ignored. + parameters: + - name: id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/address/suggest/by-household/{id}.json: - get: - tags: - - address - summary: get a list of suggested address for a household - description: > - The address are computed from various source. Currently: + /1.0/person/address/suggest/by-household/{id}.json: + get: + tags: + - address + summary: get a list of suggested address for a household + description: > + The address are computed from various source. Currently: - - the address of course to which the members is participating + - the address of course to which the members is participating - The current household address is always ignored. - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + The current household address is always ignored. + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}.json: - get: - tags: - - accompanying-course - summary: "Return the description for an accompanying course (accompanying period)" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - patch: - tags: - - person - summary: "Alter an accompanying course (accompanying period)" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "An accompanying period" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccompanyingPeriod" - examples: - Set the requestor as anonymous: - value: - type: accompanying_period - id: 12345 - requestorAnonymous: true - Adding an initial comment: - value: - type: accompanying_period - id: 2668, - pinnedComment: - type: accompanying_period_comment - content: > - This is my an initial comment. + /1.0/person/accompanying-course/{id}.json: + get: + tags: + - accompanying-course + summary: "Return the description for an accompanying course (accompanying period)" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + patch: + tags: + - person + summary: "Alter an accompanying course (accompanying period)" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "An accompanying period" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccompanyingPeriod" + examples: + Set the requestor as anonymous: + value: + type: accompanying_period + id: 12345 + requestorAnonymous: true + Adding an initial comment: + value: + type: accompanying_period + id: 2668, + pinnedComment: + type: accompanying_period_comment + content: > + This is my an initial comment. - Say hello to the new "parcours"! - Setting person with id 8405 as locator: - value: - type: accompanying_period - id: 0 - personLocation: - type: person - id: 8405 - Removing person location for both person and address: - value: - type: accompanying_period - id: 0 - personLocation: null - addressLocation: null - Adding address with id 7960 as temporarily address: - value: - type: accompanying_period - id: 0 - personLocation: null - addressLocation: - id: 7960 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + Say hello to the new "parcours"! + Setting person with id 8405 as locator: + value: + type: accompanying_period + id: 0 + personLocation: + type: person + id: 8405 + Removing person location for both person and address: + value: + type: accompanying_period + id: 0 + personLocation: null + addressLocation: null + Adding address with id 7960 as temporarily address: + value: + type: accompanying_period + id: 0 + personLocation: null + addressLocation: + id: 7960 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/requestor.json: - post: - tags: - - accompanying-course - summary: "Add a requestor to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person or thirdparty" - required: true - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/PersonById" - - $ref: "#/components/schemas/ThirdPartyById" - examples: - add person with id 50: - summary: "a person with id 50" - value: - type: person - id: 50 - add thirdparty with id 100: - summary: "a third party with id 100" - value: - type: thirdparty - id: 100 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the requestor for the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/requestor.json: + post: + tags: + - accompanying-course + summary: "Add a requestor to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person or thirdparty" + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/PersonById" + - $ref: "#/components/schemas/ThirdPartyById" + examples: + add person with id 50: + summary: "a person with id 50" + value: + type: person + id: 50 + add thirdparty with id 100: + summary: "a third party with id 100" + value: + type: thirdparty + id: 100 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the requestor for the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/participation.json: - post: - tags: - - accompanying-course - summary: "Add a participant to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PersonById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the participant for the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PersonById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/participation.json: + post: + tags: + - accompanying-course + summary: "Add a participant to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the participant for the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/resource.json: - post: - tags: - - accompanying-course - summary: "Add a resource to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Resource" - examples: - add person with id 50: - summary: "a person with id 50" - value: - type: accompanying_period_resource - resource: - type: person - id: 50 - add thirdparty with id 100: - summary: "a third party with id 100" - value: - type: accompanying_period_resource - resource: - type: thirdparty - id: 100 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the resource" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ResourceById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/resource.json: + post: + tags: + - accompanying-course + summary: "Add a resource to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + examples: + add person with id 50: + summary: "a person with id 50" + value: + type: accompanying_period_resource + resource: + type: person + id: 50 + add thirdparty with id 100: + summary: "a third party with id 100" + value: + type: accompanying_period_resource + resource: + type: thirdparty + id: 100 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the resource" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/comment.json: - post: - tags: - - accompanying-course - summary: "Add a comment to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Comment" - examples: - a single comment: - summary: "a simple comment" - value: - type: accompanying_period_comment - content: | - This is a funny comment I would like to share with you. + /1.0/person/accompanying-course/{id}/comment.json: + post: + tags: + - accompanying-course + summary: "Add a comment to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + examples: + a single comment: + summary: "a simple comment" + value: + type: accompanying_period_comment + content: | + This is a funny comment I would like to share with you. - Thank you for reading this ! - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the comment" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CommentById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + Thank you for reading this ! + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the comment" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/scope.json: - post: - tags: - - accompanying-course - summary: "Add a scope to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Scope" - examples: - add a scope: - value: - type: scope - id: 5 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the scope" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A scope with his id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ScopeById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/scope.json: + post: + tags: + - accompanying-course + summary: "Add a scope to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Scope" + examples: + add a scope: + value: + type: scope + id: 5 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the scope" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A scope with his id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/socialissue.json: - post: - tags: - - accompanying-course - summary: "Add a social issue to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A social issue by id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - examples: - add a social issue: - value: - type: social_issue - id: 5 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the social issue" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A social issue with his id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - /1.0/person/accompanying-course/{id}/referrers-suggested.json: - get: - tags: - - accompanying-course - summary: "get a list of available referral for a given accompanying cours" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/{id}/socialissue.json: + post: + tags: + - accompanying-course + summary: "Add a social issue to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A social issue by id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + examples: + add a social issue: + value: + type: social_issue + id: 5 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the social issue" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A social issue with his id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + /1.0/person/accompanying-course/{id}/referrers-suggested.json: + get: + tags: + - accompanying-course + summary: "get a list of available referral for a given accompanying cours" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}/works.json: - get: - tags: - - accompanying-course - summary: List of accompanying period works for an accompanying period - description: Gets a list of accompanying period works for an accompanying period - parameters: - - name: id - in: path - required: true - description: The accompanying period id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/{id}/works.json: + get: + tags: + - accompanying-course + summary: List of accompanying period works for an accompanying period + description: Gets a list of accompanying period works for an accompanying period + parameters: + - name: id + in: path + required: true + description: The accompanying period id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}/work.json: - post: - tags: - - accompanying-course-work - summary: "Add a work (AccompanyingPeriodwork) to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A new work" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_work" - startDate: - $ref: "#/components/schemas/Date" - endDate: - $ref: "#/components/schemas/Date" - examples: - create a work: - value: - type: accompanying_period_work - social_action: - id: 0 - type: social_work_social_action - startDate: - datetime: 2021-06-20T15:00:00+0200 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/work.json: + post: + tags: + - accompanying-course-work + summary: "Add a work (AccompanyingPeriodwork) to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A new work" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_work" + startDate: + $ref: "#/components/schemas/Date" + endDate: + $ref: "#/components/schemas/Date" + examples: + create a work: + value: + type: accompanying_period_work + social_action: + id: 0 + type: social_work_social_action + startDate: + datetime: 2021-06-20T15:00:00+0200 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/work/{id}.json: - get: - tags: - - accompanying-course-work - summary: edit an existing accompanying course work - parameters: - - name: id - in: path - required: true - description: The accompanying course social work's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/accompanying-course/work/{id}.json: + get: + tags: + - accompanying-course-work + summary: edit an existing accompanying course work + parameters: + - name: id + in: path + required: true + description: The accompanying course social work's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - put: - tags: - - accompanying-course-work - summary: edit an existing accompanying course work - parameters: - - name: id - in: path - required: true - description: The accompanying course social work's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccompanyingCourseWork" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "Bad Request" + put: + tags: + - accompanying-course-work + summary: edit an existing accompanying course work + parameters: + - name: id + in: path + required: true + description: The accompanying course social work's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccompanyingCourseWork" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "Bad Request" - /1.0/person/accompanying-course/{id}/confirm.json: - post: - tags: - - person - summary: confirm an accompanying course - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "transition cannot be applied" + /1.0/person/accompanying-course/{id}/confirm.json: + post: + tags: + - person + summary: confirm an accompanying course + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "transition cannot be applied" - /1.0/person/accompanying-course/{id}/confidential.json: - post: - tags: - - person - summary: "Toggle confidentiality of accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "Confidentiality toggle" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period" - confidential: - type: boolean - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/confidential.json: + post: + tags: + - person + summary: "Toggle confidentiality of accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "Confidentiality toggle" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period" + confidential: + type: boolean + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/intensity.json: - post: - tags: - - person - summary: "Toggle intensity status of accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "Intensity toggle" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period" - intensity: - type: string - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/intensity.json: + post: + tags: + - person + summary: "Toggle intensity status of accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "Intensity toggle" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period" + intensity: + type: string + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/by-person/{person_id}.json: - get: - tags: - - accompanying period - summary: get a list of accompanying periods for a person - description: Returns a list of the current accompanying periods for a person - parameters: - - name: person_id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/by-person/{person_id}.json: + get: + tags: + - accompanying period + summary: get a list of accompanying periods for a person + description: Returns a list of the current accompanying periods for a person + parameters: + - name: person_id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-period/origin.json: - get: - tags: - - person - summary: Return a list of all origins - responses: - 200: - description: "ok" + /1.0/person/accompanying-period/origin.json: + get: + tags: + - person + summary: Return a list of all origins + responses: + 200: + description: "ok" - /1.0/person/accompanying-period/origin/{id}.json: - get: - tags: - - person - summary: Return an origin by id - parameters: - - name: id - in: path - required: true - description: The origin id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 400: - description: "Bad Request" - 401: - description: "Unauthorized" - 404: - description: "Not found" + /1.0/person/accompanying-period/origin/{id}.json: + get: + tags: + - person + summary: Return an origin by id + parameters: + - name: id + in: path + required: true + description: The origin id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 400: + description: "Bad Request" + 401: + description: "Unauthorized" + 404: + description: "Not found" - /1.0/person/accompanying-period/resource/{id}.json: - patch: - tags: - - accompanying-course-resource - summary: "Alter the resource" - parameters: - - name: id - in: path - required: true - description: The resource's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_resource" - #id: - # type: integer - comment: - type: string - required: - - type - examples: - Set the resource comment: - value: - type: accompanying_period_resource - #id: 0 - comment: my judicious comment - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-period/resource/{id}.json: + patch: + tags: + - accompanying-course-resource + summary: "Alter the resource" + parameters: + - name: id + in: path + required: true + description: The resource's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_resource" + #id: + # type: integer + comment: + type: string + required: + - type + examples: + Set the resource comment: + value: + type: accompanying_period_resource + #id: 0 + comment: my judicious comment + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/household.json: - get: - tags: - - household - summary: Return a list of all household - responses: - 200: - description: "ok" - post: - tags: - - household - requestBody: - description: "A household" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - summary: Post a new household - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household.json: + get: + tags: + - household + summary: Return a list of all household + responses: + 200: + description: "ok" + post: + tags: + - household + requestBody: + description: "A household" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + summary: Post a new household + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/household/{id}.json: - get: - tags: - - household - summary: Return a household by id - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/household/{id}.json: + get: + tags: + - household + summary: Return a household by id + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/by-address-reference/{address_id}.json: - get: - tags: - - household - summary: Return a list of household which are sharing the same address reference - parameters: - - name: address_id - in: path - required: true - description: the address reference id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/household/by-address-reference/{address_id}.json: + get: + tags: + - household + summary: Return a list of household which are sharing the same address reference + parameters: + - name: address_id + in: path + required: true + description: the address reference id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/suggest/by-person/{person_id}/through-accompanying-period-participation.json: - get: - tags: - - household - summary: Return households associated with the given person through accompanying periods - description: | - Return households associated with the given person throught accompanying periods participation. + /1.0/person/household/suggest/by-person/{person_id}/through-accompanying-period-participation.json: + get: + tags: + - household + summary: Return households associated with the given person through accompanying periods + description: | + Return households associated with the given person throught accompanying periods participation. - The current household of the given person is excluded. - parameters: - - name: person_id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + The current household of the given person is excluded. + parameters: + - name: person_id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/members/move.json: - post: - tags: - - household - summary: move one or multiple person from a household to another - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - concerned: - type: array - items: - type: object - properties: - person: - $ref: "#/components/schemas/PersonById" - start_date: - $ref: "#/components/schemas/Date" - position: - $ref: "#/components/schemas/HouseholdPosition" - holder: - type: boolean - comment: - type: string - destination: - $ref: "#/components/schemas/Household" - examples: - Moving person to a new household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: "2021-06-01T00:00:00+02:00" - comment: "This is my comment for moving" - holder: false - destination: - type: household - Moving person to a new household and set an address to this household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: "2021-06-01T00:00:00+02:00" - comment: "This is my comment for moving" - holder: false - destination: - type: household - forceAddress: - id: 0 - Moving person to an existing household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: 2021-06-01T00:00:00+02:00 - comment: "This is my comment for moving" - holder: false - destination: - type: household - id: 54 - Removing a person from any household: - value: - concerned: - - person: - id: 0 - type: person - start_date: - datetime: 2021-06-01T00:00:00+02:00 - destination: null - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household/members/move.json: + post: + tags: + - household + summary: move one or multiple person from a household to another + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + concerned: + type: array + items: + type: object + properties: + person: + $ref: "#/components/schemas/PersonById" + start_date: + $ref: "#/components/schemas/Date" + position: + $ref: "#/components/schemas/HouseholdPosition" + holder: + type: boolean + comment: + type: string + destination: + $ref: "#/components/schemas/Household" + examples: + Moving person to a new household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: "2021-06-01T00:00:00+02:00" + comment: "This is my comment for moving" + holder: false + destination: + type: household + Moving person to a new household and set an address to this household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: "2021-06-01T00:00:00+02:00" + comment: "This is my comment for moving" + holder: false + destination: + type: household + forceAddress: + id: 0 + Moving person to an existing household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: 2021-06-01T00:00:00+02:00 + comment: "This is my comment for moving" + holder: false + destination: + type: household + id: 54 + Removing a person from any household: + value: + concerned: + - person: + id: 0 + type: person + start_date: + datetime: 2021-06-01T00:00:00+02:00 + destination: null + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/household/{id}/address.json: - post: - tags: - - household - summary: post an address to a household - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - description: The address id to attach to the household - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household/{id}/address.json: + post: + tags: + - household + summary: post an address to a household + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: The address id to attach to the household + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/social/social-action.json: - get: - tags: - - social-work-social-action - summary: get a list of social action - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social/social-action.json: + get: + tags: + - social-work-social-action + summary: get a list of social action + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social/social-action/{id}.json: - get: - tags: - - social-work-social-action - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social/social-action/{id}.json: + get: + tags: + - social-work-social-action + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social/social-action/by-social-issue/{id}.json: - get: - tags: - - social-work-social-action - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social/social-action/by-social-issue/{id}.json: + get: + tags: + - social-work-social-action + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/evaluation/by-social-action/{social_action_id}.json: - get: - tags: - - social-work-evaluation - summary: return a list of evaluation which are available for a given social action - parameters: - - name: social_action_id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: ok - 404: - description: not found + /1.0/person/social-work/evaluation/by-social-action/{social_action_id}.json: + get: + tags: + - social-work-evaluation + summary: return a list of evaluation which are available for a given social action + parameters: + - name: social_action_id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: ok + 404: + description: not found - /1.0/person/social-work/social-issue.json: - get: - tags: - - social-issue - summary: Return a list of social work - responses: - 200: - description: "ok" + /1.0/person/social-work/social-issue.json: + get: + tags: + - social-issue + summary: Return a list of social work + responses: + 200: + description: "ok" - /1.0/person/social-work/social-issue/{id}.json: - get: - tags: - - social-issue - summary: Return a social issue by id - parameters: - - name: id - in: path - required: true - description: The social issue's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/social-work/social-issue/{id}.json: + get: + tags: + - social-issue + summary: Return a social issue by id + parameters: + - name: id + in: path + required: true + description: The social issue's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/social-work/result.json: - get: - tags: - - accompanying-course-work - summary: get a list of social work result - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social-work/result.json: + get: + tags: + - accompanying-course-work + summary: get a list of social work result + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social-work/result/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The result's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The result's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/result/by-goal/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The goal's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/by-goal/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The goal's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/result/by-social-action/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/by-social-action/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/goal.json: - get: - tags: - - accompanying-course-work - summary: get a list of social work goal - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social-work/goal.json: + get: + tags: + - accompanying-course-work + summary: get a list of social work goal + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social-work/goal/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The goal's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/goal/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The goal's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/goal/by-social-action/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/goal/by-social-action/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/relations/relationship/by-person/{id}.json: - get: - tags: - - relationships - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/relations/relationship/by-person/{id}.json: + get: + tags: + - relationships + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/relations/relationship.json: - post: - tags: - - relationships - summary: Create a new relationship - requestBody: - description: "A relationship" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - 403: - description: "Unauthorized" - 422: - description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" + /1.0/relations/relationship.json: + post: + tags: + - relationships + summary: Create a new relationship + requestBody: + description: "A relationship" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + 403: + description: "Unauthorized" + 422: + description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" - /1.0/relations/relationship/{id}.json: - patch: - tags: - - relationships - summary: "Alter a relationship" - parameters: - - name: id - in: path - required: true - description: The relationship's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A relationship" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Object with validation errors" - delete: - tags: - - relationships - summary: "Remove the relationship" - parameters: - - name: id - in: path - required: true - description: The relationship's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/relations/relationship/{id}.json: + patch: + tags: + - relationships + summary: "Alter a relationship" + parameters: + - name: id + in: path + required: true + description: The relationship's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A relationship" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Object with validation errors" + delete: + tags: + - relationships + summary: "Remove the relationship" + parameters: + - name: id + in: path + required: true + description: The relationship's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/relations/relation.json: - get: - tags: - - relations - summary: get a list of relations - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/relations/relation.json: + get: + tags: + - relations + summary: get a list of relations + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/config/alt_names.json: - get: - tags: - - person - summary: Return a list of possible altNames that are defined in the config - responses: - 200: - description: "OK" + /1.0/person/config/alt_names.json: + get: + tags: + - person + summary: Return a list of possible altNames that are defined in the config + responses: + 200: + description: "OK" - /1.0/person/creation/authorized-centers: - get: - tags: - - person - - permissions - summary: Return a list of possible centers for person creation - responses: - 200: - description: "OK" + /1.0/person/creation/authorized-centers: + get: + tags: + - person + - permissions + summary: Return a list of possible centers for person creation + responses: + 200: + description: "OK" - /1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate: - post: - tags: - - accompanying-course-work-evaluation-document - summary: Dupliate an an accompanying period work evaluation document - parameters: - - in: path - name: id - required: true - description: The document's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "OK" - content: - application/json: - schema: - type: object + /1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate: + post: + tags: + - accompanying-course-work-evaluation-document + summary: Dupliate an an accompanying period work evaluation document + parameters: + - in: path + name: id + required: true + description: The document's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object /1.0/person/accompanying-course-work-evaluation-document/{document_id}/evaluation/{evaluation_id}/duplicate: post: @@ -2023,3 +2023,16 @@ paths: application/json: schema: type: object + + /1.0/person/identifiers/workers: + get: + tags: + - person + summary: List the person identifiers + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object diff --git a/src/Bundle/ChillPersonBundle/config/services.yaml b/src/Bundle/ChillPersonBundle/config/services.yaml index 1b57721ff..5d258f029 100644 --- a/src/Bundle/ChillPersonBundle/config/services.yaml +++ b/src/Bundle/ChillPersonBundle/config/services.yaml @@ -108,3 +108,9 @@ services: Chill\PersonBundle\PersonIdentifier\Rendering\: resource: '../PersonIdentifier/Rendering' + + Chill\PersonBundle\PersonIdentifier\Normalizer\: + resource: '../PersonIdentifier/Normalizer' + + Chill\PersonBundle\PersonIdentifier\Validator\: + resource: '../PersonIdentifier/Validator' diff --git a/src/Bundle/ChillPersonBundle/config/services/actions.yaml b/src/Bundle/ChillPersonBundle/config/services/actions.yaml index d6e2c80a5..220a7483e 100644 --- a/src/Bundle/ChillPersonBundle/config/services/actions.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/actions.yaml @@ -11,3 +11,9 @@ services: Chill\PersonBundle\Actions\Remove\Handler\: resource: '../../Actions/Remove/Handler' + + Chill\PersonBundle\Actions\PersonEdit\Service\: + resource: '../../Actions/PersonEdit/Service' + + Chill\PersonBundle\Actions\PersonCreate\Service\: + resource: '../../Actions/PersonCreate/Service' diff --git a/src/Bundle/ChillPersonBundle/config/services/serializer.yaml b/src/Bundle/ChillPersonBundle/config/services/serializer.yaml deleted file mode 100644 index 5a1e54400..000000000 --- a/src/Bundle/ChillPersonBundle/config/services/serializer.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -services: - # note: normalizers are loaded from ../services.yaml - - Chill\PersonBundle\Serializer\Normalizer\: - autowire: true - autoconfigure: true - resource: '../../Serializer/Normalizer' - tags: - - { name: 'serializer.normalizer', priority: 64 } - diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php b/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php new file mode 100644 index 000000000..9ec97e337 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php @@ -0,0 +1,37 @@ +addSql('ALTER TABLE chill_person_identifier_definition ADD presence VARCHAR(255) DEFAULT \'ON_EDIT\' NOT NULL'); + $this->addSql('UPDATE chill_person_identifier_definition SET presence = \'NOT_EDITABLE\' WHERE is_editable_by_users IS FALSE'); + $this->addSql('ALTER TABLE chill_person_identifier_definition DROP is_editable_by_users'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_person_identifier_definition ADD is_editable_by_users BOOLEAN DEFAULT false NOT NULL'); + $this->addSql('UPDATE chill_person_identifier_definition SET is_editable_by_users = true WHERE presence <> \'NOT_EDITABLE\' '); + $this->addSql('ALTER TABLE chill_person_identifier_definition DROP presence'); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php b/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php new file mode 100644 index 000000000..709ecbf54 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php @@ -0,0 +1,33 @@ +addSql('CREATE UNIQUE INDEX chill_person_identifier_unique ON chill_person_identifier (definition_id, canonical)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX chill_person_identifier_unique'); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php b/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php new file mode 100644 index 000000000..e6f9fff54 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php @@ -0,0 +1,53 @@ +addSql(<<<'SQL' + ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911 + FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) + on delete restrict + SQL); + + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911 + FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) + on delete cascade + SQL); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php b/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php new file mode 100644 index 000000000..71630d0ad --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php @@ -0,0 +1,33 @@ +addSql('CREATE UNIQUE INDEX chill_person_identifier_unique_person_definition ON chill_person_identifier (definition_id, person_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX chill_person_identifier_unique_person_definition'); + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index c5e8e097c..795d2ddf9 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -265,3 +265,38 @@ add_persons: title: "Centre" error_only_one_person: "Une seule personne peut être sélectionnée !" + +renderbox: + person: "Usager" + birthday_statement: >- + {gender, select, + man {Né le {birthdate, date}} + woman {Née le {birthdate, date}} + other {Né·e le {birthdate, date}} + } + deathdate_statement: >- + {gender, select, + man {Décédé le {deathdate, date}} + woman {Décédée le {deathdate, date}} + other {Décédé·e le {deathdate, date}} + } + household_without_address: "Le ménage de l'usager est sans adresse" + no_data: "Aucune information renseignée" + type: + thirdparty: "Tiers" + person: "Usager" + holder: "Titulaire" + years_old: >- + {n, plural, + =0 {0 an} + one {1 an} + other {# ans} + } + residential_address: "Adresse de résidence" + located_at: "réside chez" + household_number: "Ménage n°{number}" + current_members: "Membres actuels" + no_current_address: "Sans adresse actuellement" + new_household: "Nouveau ménage" + no_members_yet: "Aucun membre actuellement" + diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index 917d9e2cf..2159f32db 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -105,6 +105,8 @@ Administrative status: Situation administrative person: Identifiers: Identifiants +person_edit: + Error while saving: Erreur lors de l'enregistrement # dédoublonnage Old person: Doublon @@ -885,6 +887,12 @@ accompanying_course: administrative_location: Localisation administrative comment is pinned: Le commentaire est épinglé comment is unpinned: Le commentaire est désépinglé + requestor: + add: Ajouter un demandeur + persons_associated: + add_person: Ajouter des usagers + resources: + add_resources: Ajouter des interlocuteurs show: Montrer hide: Masquer @@ -1583,7 +1591,7 @@ person_messages: center_id: "Identifiant du centre" center_type: "Type de centre" center_name: "Territoire" - phonenumber: "Téléphone" + phonenumber: "Téléphone fixe" mobilenumber: "Mobile" altnames: "Autres noms" email: "Courriel" diff --git a/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml new file mode 100644 index 000000000..3b65462e1 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml @@ -0,0 +1,7 @@ +person_identifier: + fixed_length: >- + {limit, plural, + =1 {L'identifier doit contenir exactement 1 caractère} + other {L'identifiant doit contenir exactement # caractères} + } + only_number: "L'identifiant ne doit contenir que des chiffres" diff --git a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml index c6fe0f912..02ad95de7 100644 --- a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml @@ -73,5 +73,9 @@ relationship: person_creation: If you want to create an household, an address is required: Pour la création d'un ménage, une adresse est requise +person_identifier: + This identifier must be set: Cet identifiant doit être présent. + Identifier must be unique. The same identifier already exists for {{ persons }}: Identifiant déjà utilisé pour {{ persons }} + accompanying_course_work: The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début diff --git a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php index e65a06515..480e67cfc 100644 --- a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php +++ b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php @@ -17,7 +17,6 @@ use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Civility; use Chill\MainBundle\Entity\User; -use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ReadableCollection; @@ -70,6 +69,11 @@ use Symfony\Component\Validator\Constraints as Assert; * * The difference between categories and types is transparent for user: they choose the same fields into the UI, without * noticing a difference. + * + * ## Validation + * + * When a validation is inserted / updated, do not forget to update the related ThirdPartyEdit.vue component and the associated + * list of possible violations. */ #[ORM\Entity] #[ORM\Table(name: 'chill_3party.third_party')] @@ -206,12 +210,12 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin #[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])] #[ORM\Column(name: 'telephone', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $telephone = null; #[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])] #[ORM\Column(name: 'telephone2', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $telephone2 = null; #[ORM\Column(name: 'types', type: \Doctrine\DBAL\Types\Types::JSON, nullable: true)] diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts b/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts index 2d2acc42b..a17797e6a 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts @@ -2,39 +2,123 @@ import { Address, Center, Civility, - DateTime, + DateTime, SetAddress, SetCivility, User, } from "ChillMainAssets/types"; -export interface Thirdparty { +export type ThirdPartyKind = "contact" | "child" | "company"; + +export interface BaseThirdParty { type: "thirdparty"; + kind: ""|ThirdPartyKind; text: string; acronym: string | null; active: boolean; address: Address | null; - canonicalized: string | null; - categories: ThirdpartyCategory[]; - centers: Center[]; - children: Thirdparty[]; - civility: Civility | null; - comment: string | null; contactDataAnonymous: boolean; createdAt: DateTime; createdBy: User | null; email: string | null; firstname: string | null; - id: number | null; - kind: string; - name: string; + id: number; nameCompany: string | null; - parent: Thirdparty | null; - profession: string; telephone: string | null; - thirdPartyTypes: ThirdpartyType[] | null; + telephone2: string | null; updatedAt: DateTime | null; updatedBy: User | null; } +function isBaseThirdParty(t: unknown): t is BaseThirdParty { + if (typeof t !== "object" || t === null) return false; + const o = t as Partial; + return ( + (o as any).type === "thirdparty" && + typeof o.id === "number" && + typeof o.text === "string" && + (o.kind === "" || o.kind === "contact" || o.kind === "child" || o.kind === "company") && + typeof o.active === "boolean" + ); +} + +export interface ThirdpartyCompany extends BaseThirdParty { + kind: "company"; + text: string; + acronym: string | null; + children: Thirdparty[]; + category: ThirdpartyCategory[]; + thirdPartyTypes: ThirdpartyType[] | null; + address: Address | null; +} + +// Type guard to distinguish a ThirdpartyCompany +export function isThirdpartyCompany( + t: BaseThirdParty +): t is ThirdpartyCompany { + return ( + t.type === "thirdparty" && + t.kind === "company" + ); +} + +export interface ThirdpartyChild extends BaseThirdParty { + kind: "child"; + civility: Civility | null; + contactDataAnonymous: boolean; + parent: ThirdpartyCompany; + profession: string; + firstname: string; + /** + * the lastname for "Contact" and "Child", the name + */ + name: string; + comment: string | null; +} + +// Type guard to distinguish a ThirdpartyChild +export function isThirdpartyChild( + t: BaseThirdParty +): t is ThirdpartyChild { + return ( + t.type === "thirdparty" && + t.kind === "child" + ); +} + +export interface ThirdpartyContact extends BaseThirdParty { + kind: "contact"; + civility: Civility | null; + category: ThirdpartyCategory[]; + thirdPartyTypes: ThirdpartyType[] | null; + profession: string; + firstname: string; + /** + * the lastname for "Contact" and "Child", the name + */ + name: string; + address: Address | null; +} + +// Type guard to distinguish a ThirdpartyContact +export function isThirdpartyContact( + t: BaseThirdParty +): t is ThirdpartyContact { + return ( + t.type === "thirdparty" && + t.kind === "contact" + ); +} + +export type Thirdparty = ThirdpartyCompany | ThirdpartyContact | ThirdpartyChild; + + +export function isThirdparty(t: unknown): t is Thirdparty { + if (!isBaseThirdParty(t)) { + return false; + } + + return (isThirdpartyCompany(t) || isThirdpartyContact(t) || isThirdpartyChild(t)); +} + interface ThirdpartyType { key: string; value: string; @@ -47,3 +131,29 @@ export interface ThirdpartyCategory { fr: string; }; } + +/** + * Associate an existing ThirdParty during write operation. + */ +export interface SetThirdParty { + readonly type: "thirdparty"; + id: number; +} + +export interface ThirdPartyWrite { + readonly type: "thirdparty"; + kind: ThirdPartyKind; + civility: SetCivility | null; + profession: string; + firstname: string; + /** + * the lastname + */ + name: string; + email: string; + telephone: string; + telephone2: string; + address: null|SetAddress; + comment: string; + parent: SetThirdParty|null; +} diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js deleted file mode 100644 index eb8f11ef8..000000000 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * GET a thirdparty by id - */ -const getThirdparty = (id) => { - const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; - return fetch(url).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -/* - * POST a new thirdparty - */ -const postThirdparty = (body) => { - const url = `/api/1.0/thirdparty/thirdparty.json`; - return fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -/* - * PATCH an existing thirdparty - */ -const patchThirdparty = (id, body) => { - const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; - return fetch(url, { - method: "PATCH", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -export { getThirdparty, postThirdparty, patchThirdparty }; diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts new file mode 100644 index 000000000..2e9f40f00 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts @@ -0,0 +1,75 @@ +/* + * GET a thirdparty by id + */ +import {isThirdpartyChild, isThirdpartyCompany, isThirdpartyContact, Thirdparty, ThirdPartyWrite} from '../../types'; +import {makeFetch} from "ChillMainAssets/lib/api/apiMethods"; + +export const getThirdparty = async (id: number) : Promise => { + const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; + return fetch(url).then((response) => { + if (response.ok) { + return response.json(); + } + throw Error("Error with request resource response"); + }); +}; + +export const thirdpartyToWriteThirdParty = (t: Thirdparty): ThirdPartyWrite => { + // Determine kind-specific fields using available type guards + const isCompany = isThirdpartyCompany(t); + const isContact = isThirdpartyContact(t); + const isChild = isThirdpartyChild(t); + + return { + type: 'thirdparty', + kind: t.kind, + civility: + (isContact || isChild) && t.civility + ? { type: 'chill_main_civility', id: t.civility.id } + : null, + profession: (isContact || isChild) ? (t.profession ?? '') : '', + firstname: isCompany ? '' : (t.firstname ?? ''), + name: isCompany + ? (t.nameCompany ?? '') + : (t.name ?? ''), + email: t.email ?? '', + telephone: t.telephone ?? '', + telephone2: t.telephone2 ?? '', + address: null, + comment: isChild ? (t.comment ?? '') : '', + parent: isChild && t.parent ? { type: 'thirdparty', id: t.parent.id } : null, + }; +}; + +export interface WriteThirdPartyViolationMap +extends Record> { + email: { + "{{ value }}": string; + }, + name: { + "{{ value }}": string; + }, + telephone: { + "{{ value }}": string; + } + telephone2: { + "{{ value }}": string; + } +} + +/* + * POST a new thirdparty + */ +export const createThirdParty = async (body: ThirdPartyWrite) => { + const url = `/api/1.0/thirdparty/thirdparty.json`; + + return makeFetch('POST', url, body); +}; + +/* + * PATCH an existing thirdparty + */ +export const patchThirdparty = async (id: number, body: ThirdPartyWrite): Promise => { + const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; + return makeFetch('PATCH', url, body); +}; diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue index 3413861ae..fb4bf2071 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue @@ -5,7 +5,7 @@
- + {{ thirdparty.text }} {{ thirdparty.text }} @@ -27,7 +27,7 @@ />
-

+

@@ -44,13 +44,13 @@ {{ getProfession[0] }}

-
  • +
  • - {{ $t("child_of") }} + {{ trans(THIRDPARTY_MESSAGES_CHILD_OF)}} @@ -128,65 +128,60 @@ - diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml index 806547a04..6e6e96d7a 100644 --- a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml @@ -68,6 +68,10 @@ Remove a contact: Supprimer Contacts: Contacts No contacts associated: Aucun contact +thirdparty: + addcontact: Ajouter un contact + addcontact_title: Ajouter un contact + No nameCompany given: Aucune raison sociale renseignée No acronym given: Aucun sigle renseigné No phone given: Aucun téléphone renseigné @@ -165,7 +169,7 @@ thirdpartyMessages: comment: "Commentaire" profession: "Qualité" civility: "Civilité" - child_of: "Contact de: " + child_of: "Contact d'une institution" children: "Personnes de contact: " thirdparty_duplicate: diff --git a/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php b/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php deleted file mode 100644 index c5d476361..000000000 --- a/src/Bundle/ChillTicketBundle/src/Controller/FindCallerController.php +++ /dev/null @@ -1,58 +0,0 @@ -query->get('caller', ''); - - if ('' === $caller) { - throw new BadRequestHttpException('Missing "caller" query parameter'); - } - - try { - $phoneNumber = $this->phonenumberHelper->parse($caller); - } catch (NumberParseException $e) { - throw new BadRequestHttpException('Unable to parse number', $e); - } - - $persons = $this->personRepository->findByPhoneNumber($phoneNumber, 0, 2); - - $asArray = match (count($persons)) { - 0 => ['found' => false, 'name' => null], - 1 => ['found' => true, 'name' => $this->personRender->renderString($persons[0], ['addAge' => false])], - default => ['found' => true, 'name' => 'multiple'], - }; - - return new JsonResponse($asArray); - } -} diff --git a/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php b/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php index 9424203e9..88f082b58 100644 --- a/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php +++ b/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php @@ -14,12 +14,13 @@ namespace Chill\TicketBundle\Messenger\Handler; use Chill\TicketBundle\Event\PostTicketUpdateEvent; use Chill\TicketBundle\Messenger\PostTicketUpdateMessage; use Chill\TicketBundle\Repository\TicketRepositoryInterface; -use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; +use Symfony\Component\Messenger\Handler\MessageHandlerInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; #[AsMessageHandler] -final readonly class PostTicketUpdateMessageHandler +final readonly class PostTicketUpdateMessageHandler implements MessageHandlerInterface { public function __construct( private EventDispatcherInterface $eventDispatcher, diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue index 8915514ec..2ec1fe723 100644 --- a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue @@ -55,7 +55,7 @@ }) }} - +
    (() => { + if (null === props.ticket.caller) { + return []; + } + + if (isThirdparty(props.ticket.caller)) { + return [props.ticket.caller]; + } else { + return [props.ticket.caller]; + } +}) const since = computed(() => { return store.getters.getSinceCreated(today.value); diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/FindCallerControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/FindCallerControllerTest.php deleted file mode 100644 index 997017e97..000000000 --- a/src/Bundle/ChillTicketBundle/tests/Controller/FindCallerControllerTest.php +++ /dev/null @@ -1,128 +0,0 @@ -buildController($persons); - - $request = new Request(query: ['caller' => $caller]); - - $response = $controller->findCaller($request); - - $actual = json_decode($response->getContent(), true); - - self::assertEqualsCanonicalizing($expected, $actual); - } - - public static function provideFindCaller(): iterable - { - yield [ - '32486540600', - [], - ['found' => false, 'name' => null], - ]; - - yield [ - '32486540600', - [new Person()], - ['found' => true, 'name' => 'pppp'], - ] - ; - yield [ - '32486540600', - [new Person(), new Person()], - ['found' => true, 'name' => 'multiple'], - ]; - } - - public function testFindCallerWithoutCallerArgument(): void - { - self::expectException(BadRequestHttpException::class); - - $controller = $this->buildController([]); - - $request = new Request(query: []); - - $controller->findCaller($request); - } - - public function testFindCallerWithEmptyCallerArgument(): void - { - self::expectException(BadRequestHttpException::class); - - $controller = $this->buildController([]); - - $request = new Request(query: ['caller' => '']); - - $controller->findCaller($request); - } - - public function testFindCallerWithInvalidCaller(): void - { - self::expectException(BadRequestHttpException::class); - - $controller = $this->buildController([]); - - $request = new Request(query: ['caller' => 'abcde']); - - $controller->findCaller($request); - } - - private function buildController(array $personsFound): FindCallerController - { - $phonenumberHelper = - $subject = new PhonenumberHelper( - new ArrayAdapter(), - new ParameterBag([ - 'chill_main.phone_helper' => [ - 'default_carrier_code' => 'BE', - ], - ]), - new NullLogger() - ); - - $personRepository = $this->prophesize(PersonRepository::class); - $personRepository->findByPhoneNumber(Argument::any(), Argument::type('int'), Argument::type('int'))->willReturn($personsFound); - - $personRender = $this->prophesize(PersonRenderInterface::class); - $personRender->renderString(Argument::type(Person::class), Argument::type('array'))->willReturn('pppp'); - - return new FindCallerController($phonenumberHelper, $personRepository->reveal(), $personRender->reveal()); - } -}