Nulla pulvinar purus vel metus varius, nec dictum enim faucibus
+
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ Vestibulum vel ultrices enim. Cras maximus eget mauris eget elementum.
+ Nam id justo feugiat ipsum egestas imperdiet in eget erat. Phasellus felis mauris, ultricies eget felis eget, malesuada auctor nisl.
+ Aliquam aliquam lectus at tincidunt tristique. Nulla facilisis, ante vel egestas facilisis, ex odio accumsan urna, eu eleifend nisi dolor in erat.
+ Morbi orci augue, interdum eu faucibus non, vestibulum nec urna. Donec tristique lectus ligula, ac volutpat nisl commodo at.
+
+
+
Pellentesque id facilisis dolor. Praesent libero enim, blandit non ipsum et, dapibus egestas dolor.
+ Etiam a dictum enim. Phasellus feugiat fringilla sollicitudin.
+
+
+
Nullam eget ullamcorper arcu. Praesent in felis justo. Pellentesque feugiat at nisl et ornare. Integer nec imperdiet odio.
+ Fusce interdum scelerisque facilisis. Nulla rutrum mauris orci, at auctor elit laoreet eu. Mauris ut nulla lacus.
+ In quis commodo lacus, at accumsan libero. Duis at porta felis, a accumsan nibh. Donec non lorem non neque lacinia commodo nec eu tortor.
+ Fusce eu velit quis elit dignissim lobortis. Interdum et malesuada fames ac ante ipsum primis in faucibus. Pellentesque sit amet justo quam.
+ Aliquam nec tortor facilisis, tristique massa non, ullamcorper justo.
+
+
+
Sed tincidunt ut nunc lobortis bibendum. Suspendisse fermentum tincidunt quam vel vulputate.
+ Etiam nunc felis, sagittis nec ultrices sed, bibendum nec libero. Ut blandit nibh quam, vel ornare nisl blandit quis.
+ Curabitur malesuada quis mauris a sollicitudin. Sed vel nulla efficitur, commodo orci et, porttitor justo.
+ Cras semper magna scelerisque purus semper lacinia. Nam laoreet auctor eros ut porttitor.
+ Nunc id lacinia sem, eget vestibulum leo. Quisque pulvinar suscipit finibus. Morbi fringilla lobortis enim, et consectetur neque rutrum quis.
+
+
+
Donec ut scelerisque mauris.
+
+
+
+
Lorem ipsum dolor sit amet.
+
+
Mauris augue velit, sollicitudin vel placerat vitae, tincidunt sed mauris. In scelerisque tempus quam at malesuada.
+ Proin sit amet erat ornare ante laoreet iaculis eu et magna. Quisque mi est, laoreet eu orci id, porta pulvinar elit.
+ Duis eu sagittis nisl, ut tempus orci. Phasellus tristique non sapien eu sodales.Vivamus id lectus leo. Nulla facilisi.
+ Nunc eleifend dui vitae augue consequat imperdiet. Mauris nec erat at metus ultricies finibus. Praesent fermentum, turpis pulvinar pulvinar euismod,
+ ligula tortor aliquam metus, a consequat neque ante a magna. Etiam luctus porta augue id pharetra. Maecenas sollicitudin vehicula dui,
+ nec bibendum libero euismod a. Mauris eu elementum nisi. In metus purus, consectetur luctus viverra sed, aliquet a libero.
+ Aenean vulputate elit quis urna molestie rhoncus.
+
Nulla fermentum justo sed magna rutrum, eget fermentum dolor accumsan.
+
+ cgu:
+ id:
+ name: CGU
+ content: |
+ \
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras sit amet nulla non mauris pharetra rhoncus ac non libero. Sed convallis mauris a blandit vehicula.
+
+
Nulla facilisi. In nunc metus, pretium eu fermentum in, scelerisque non tortor. Ut cursus risus ut nulla porta, eget mollis tellus accumsan.
+ Cras euismod est a dui fringilla, eget suscipit sapien lobortis. Sed erat purus, iaculis et libero vitae, interdum sollicitudin magna. Mauris vel feugiat dui.
+
+
Phasellus facilisis sit amet lectus a efficitur. Praesent auctor commodo libero vel scelerisque. Suspendisse commodo consectetur magna ut vestibulum.
+ Mauris quis turpis eget est lobortis ullamcorper. Duis sem arcu, posuere et consectetur ut, maximus ut urna.
+ Nam vitae eleifend dolor. Quisque tempor eleifend tortor id eleifend. Pellentesque varius dolor scelerisque risus posuere euismod.
+
+
+
Etiam eleifend pretium ex, vel euismod lorem ornare vel. Sed sit amet sodales ipsum. Vestibulum eget orci condimentum, commodo augue a, congue metus.
+ Aliquam nisi mi, malesuada quis justo sodales, sodales pellentesque nisi.
+
+
In hac habitasse platea dictumst. Aenean urna arcu, dignissim ut risus in, dignissim gravida mauris. Pellentesque luctus condimentum purus in finibus. Duis suscipit neque in augue mattis facilisis.
+ Phasellus sed euismod neque. In lacus lacus, interdum eget leo vitae, auctor egestas lectus. Nulla in nulla euismod, suscipit nulla ut, cursus nisl.
+
+
Vivamus malesuada a ante eget fermentum. Aliquam euismod vestibulum ipsum, pretium imperdiet risus lobortis et. Integer augue lacus, egestas at ultrices quis, eleifend in risus.
+ Proin suscipit a quam tempor imperdiet. In nisi sem, sodales sed commodo eget, ultrices vel felis.
+
+
Maecenas iaculis risus sed elit semper tempus. Curabitur maximus dapibus rhoncus. Suspendisse nec metus eros. Etiam maximus leo ut sem tempus laoreet.
+
+
Le Site permet aux Adhérents de diffuser leurs offres de Prêts d’objets.
+ L’Adhérent s’engage à ne proposer en Prêt que des objets dont il est propriétaire et en bon état,
+ et à fournir tous renseignements utiles facilitant son usage par l’Adhérent emprunteur tel que mode d’emploi,
+ date de disponibilité, lieu choisi pour le retrait et sa restitution, nombre de Points attendus.
+
+
Vivamus rutrum varius erat. Donec porttitor a purus quis tincidunt. Duis porttitor sodales lobortis. Maecenas tincidunt elit velit. Maecenas consequat urna diam, non porta est consequat vitae. Cras lobortis eu risus at facilisis.
+ Cras tempus vehicula sapien, at vulputate dolor porttitor eu. Fusce et urna non sem laoreet condimentum.
+
diff --git a/fixtures/test/payment.yaml b/fixtures/test/payment.yaml
new file mode 100644
index 0000000..4bb1922
--- /dev/null
+++ b/fixtures/test/payment.yaml
@@ -0,0 +1,27 @@
+App\Entity\Payment:
+ payment_template (template):
+ currencyCode: 'EUR'
+
+ payment_user_16 (extends payment_template):
+ id:
+ user: '@user_16'
+ number: 'payum_64258ad56b1754.38811238'
+ description: >
+ Groupe 1 / Lorem ipsum
+ clientEmail: 'user8@example.com'
+ clientid: '1ed8d15a-fa38-6f54-9188-f15f4a44bdf2'
+ totalAmount: 1000
+ details:
+ method: creditcard
+ paid: true
+ status: captured
+ metadata:
+ groupId: '1ed4bcca-336e-6732-a08c-a15bb85fa24a'
+ groupOfferId: '1edcf019-786a-6ba8-a77d-598378ee78e4'
+ amount: 1000
+ currency: 'EUR'
+ number: 'payum_64259610892935.12964644'
+ description: >
+ Groupe 1 / Lorem ipsum
+ client_email: 'user16@example.com'
+ client_id: '1edae1d3-f66a-6f68-8057-41b63a425612'
diff --git a/fixtures/test/product.yaml b/fixtures/test/product.yaml
new file mode 100644
index 0000000..3a0fc0a
--- /dev/null
+++ b/fixtures/test/product.yaml
@@ -0,0 +1,190 @@
+App\Entity\Product:
+ product (template):
+ status: !php/enum App\Enum\Product\ProductStatus::ACTIVE
+ visibility: !php/enum App\Enum\Product\ProductVisibility::PUBLIC
+
+ object (template, extends product):
+ type: !php/enum App\Enum\Product\ProductType::OBJECT
+
+ service (template, extends product):
+ type: !php/enum App\Enum\Product\ProductType::SERVICE
+
+ # Loic —————————————————————————————————————————————————————————————————————
+ loic_object_1 (extends object):
+ id:
+ owner: '@admin_loic'
+ category: '@category_object_18'
+ name: Vélo Fuji Jari 2.5
+ description: >
+ Très beau vélo Fuji Jari 2.5.
+
+ Taille Cadre 54.
+ age: Acheté neuf en septembre 2022.
+ deposit: 3000
+ images: ["4437be7d-ce40-43f0-99b4-4adddcc3316f.jpg"]
+
+ # Paused object
+ loic_object_2 (extends object):
+ id:
+ status: !php/enum App\Enum\Product\ProductStatus::PAUSED
+ owner: '@admin_loic'
+ category: '@category_object_8'
+ name: Carte vidéo externe AMD Radeon Pro 380
+ description: >
+ Pour pouvoir jouer dans de bonnes conditions (Pour Mac Mini).
+ Prété à mon frère en ce moment (sans le site).
+ age: Acheté début 2019.
+ deposit: 1000
+ preferredLoanDuration: 1 mois mininum svp.
+
+ user_16_object_3 (extends object):
+ owner: '@admin_loic'
+ category: '@category_service_4_0'
+ name: Guitare électrique
+ description: >
+ Guitare en bon état.
+ age: 2010
+
+ loic_service_1 (extends service):
+ id:
+ owner: '@admin_loic'
+ category: '@category_service_2_1'
+ name: Cours d'échecs initiation
+ description: >
+ Initiation aux échecs pour débutants (1 heure).
+ duration: >
+ Une ou deux heures si besoin.
+
+ # Kevin ———————————————————————————————————————————————————————————————————
+ kevin_object_1 (extends object):
+ id:
+ owner: '@admin_kevin'
+ category: '@category_object_17'
+ name: Diable
+ description: >
+ Très bien pour les déménagements, même proches.
+ age: Acheté en 2012.
+
+ kevin_object_2 (extends object):
+ owner: '@admin_kevin'
+ category: '@category_object_17'
+ name: Perceuse
+ description: >
+ Très bien pour percer des trous dans la maison.
+ age: Acheté en 2015.
+
+ # Camille —————————————————————————————————————————————————————————————————————
+ camille_object_1 (extends object):
+ owner: '@admin_camille'
+ category: '@category_object_9'
+ name: Tondeuse à pelouse
+ description: >
+ Très belle Tondeuse pour avoir de l'herbe bien courte.
+ age: 2016
+ deposit: 5000
+
+ camille_object_2 (extends object):
+ owner: '@admin_camille'
+ category: '@category_object_11'
+ name: Une paire de jumelles
+ description: >
+ Très belle paire de jumelles pour bien voir au loin.
+ age: 2017
+ deposit: 1000
+
+ camille_object_3 (extends object):
+ owner: '@admin_camille'
+ category: '@category_object_18'
+ name: Vélo électrique
+ description: >
+ Plus facile pour les longues distances.
+ age: 2022
+ deposit: 2000
+
+ camille_object_4 (extends object):
+ owner: '@admin_camille'
+ category: '@category_object_18'
+ name: Remorque à vélo
+ description: >
+ Pour transporter son enfant ou son chat.
+ age: 2022
+ deposit: 5000
+
+ # user 16 —————————————————————————————————————————————————————————————————————
+ user_16_service_1 (extends service):
+ id:
+ owner: '@user_16'
+ category: '@category_service_1'
+ name: Aide bricolage
+ description: >
+ Petits travaux de peinture et bricolage diverses.
+ duration: Une journée.
+
+ user_16_object_1 (extends object):
+ id:
+ owner: '@user_16'
+ category: '@category_object_4'
+ name: Guitare électrique
+ description: >
+ Très belle guitare électrique en bon état.
+ age: 2013
+ deposit: 2000
+
+ user_16_object_2 (extends object):
+ owner: '@user_16'
+ category: '@category_object_4'
+ name: Piano
+ description: >
+ Piano en bon état.
+ age: 2010
+
+ # place apes —————————————————————————————————————————————————————————————————————
+ place_apes_service_1 (extends service):
+ owner: '@place_apes'
+ category: '@category_service_2'
+ name: Cours de chant
+ description: >
+ Pour débutants, amateurs ou artistes professionnels.
+ duration: Une journée.
+
+ # Sarah —————————————————————————————————————————————————————————————————————
+ place_sarah_service_1 (extends service):
+ owner: '@admin_sarah'
+ category: '@category_service_2_0'
+ name: Cours de piano
+ description: >
+ Pour débutants, amateurs ou artistes professionnels.
+ duration: Une heure.
+
+ place_sarah_service_2 (extends service):
+ owner: '@admin_sarah'
+ category: '@category_service_2_2'
+ name: Cours d'histoire
+ description: >
+ Cours de rattrapage en histoire, aide aux devoirs, remise à niveau.
+ duration: >
+ Une journée.
+
+ # Place 6 ———————————————————————————————————————————————————————————————————
+ place_6_object_1 (extends object):
+ owner: '@place_6'
+ category: '@category_object_1'
+ name: Machine à coudre
+ description: >
+ Machine à coudre ( pas fournie avec les bobines de fil).
+ duration: Une journée.
+ age: De 2 à 10 ans
+ deposit: 0
+
+ place_6_object_2 (extends object):
+ id:
+ owner: '@place_6'
+ category: '@category_object_11'
+ name: Boule à Facette
+ description: >
+ Boule à facette avec moteur rotation et spot directionnel
+ duration: Une journée.
+ age: Moins de 2 ans
+ visibility: !php/enum App\Enum\Product\ProductVisibility::RESTRICTED
+ groups:
+ - '@group_1'
diff --git a/fixtures/test/product_availability.yaml b/fixtures/test/product_availability.yaml
new file mode 100644
index 0000000..3549c12
--- /dev/null
+++ b/fixtures/test/product_availability.yaml
@@ -0,0 +1,34 @@
+App\Entity\ProductAvailability:
+ product_availability (template):
+ mode: !php/enum App\Enum\Product\ProductAvailabilityMode::UNAVAILABLE
+
+ product_availability_user (template, extends product_availability):
+ type: !php/enum App\Enum\Product\ProductAvailabilityType::OWNER
+
+ product_availability_service_request (template, extends product_availability):
+ type: !php/enum App\Enum\Product\ProductAvailabilityType::SERVICE_REQUEST
+
+ # —— Ongoing service request ———————————————————————————————————————————————
+ product_availability_object_sr_1 (extends product_availability_service_request):
+ product: '@loic_object_1'
+ serviceRequest: '@service_request_1'
+ startAt: '@service_request_1->startAt'
+ endAt: '@service_request_1->endAt'
+
+ # —— Owner black list ——————————————————————————————————————————————————————
+ product_availability_user_1 (extends product_availability_user):
+ id:
+ product: '@loic_object_1'
+ startAt: ''
+ endAt: ''
+
+ product_availability_2_user_1 (extends product_availability_user):
+ product: '@loic_object_1'
+ startAt: ''
+ endAt: ''
+
+ product_availability_object_sr_2 (extends product_availability_service_request):
+ product: '@kevin_object_1'
+ serviceRequest: '@service_request_2'
+ startAt: '@service_request_2->startAt'
+ endAt: '@service_request_2->endAt'
diff --git a/fixtures/test/service_request.yaml b/fixtures/test/service_request.yaml
new file mode 100644
index 0000000..95e6c00
--- /dev/null
+++ b/fixtures/test/service_request.yaml
@@ -0,0 +1,67 @@
+App\Entity\ServiceRequest:
+ service_request (template):
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::NEW
+
+ # will start tomorrow
+ # see ServiceRequestStatusWorkflowControllerRefuseTest
+ service_request_1 (extends service_request):
+ id:
+ owner: '@admin_loic'
+ product: '@loic_object_1'
+ recipient: '@user_17'
+ startAt: ''
+ endAt: ''
+
+ # ongoing service request, can be finalized manually
+ # @see ServiceRequestStatusWorkflowControllerFinalizeTest
+ service_request_2 (extends service_request):
+ id:
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::CONFIRMED
+ owner: '@admin_kevin'
+ product: '@kevin_object_1'
+ recipient: '@admin_loic'
+ startAt: ''
+ endAt: ''
+
+ # can be auto-finalized
+ # @see ServiceRequestStatusWorkflowControllerAutoFinalizeTest
+ service_request_3 (extends service_request):
+ id:
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::CONFIRMED
+ owner: '@admin_loic'
+ product: '@loic_service_1'
+ recipient: '@admin_camille'
+ startAt: ''
+ endAt: ''
+
+ # can be confirmed by the recipient
+ # @see ServiceRequestStatusWorkflowModifyRecipientTest
+ service_request_4 (extends service_request):
+ id:
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::TO_CONFIRM
+ owner: '@place_6'
+ product: '@place_6_object_1'
+ recipient: '@admin_loic'
+ startAt: ''
+ endAt: ''
+
+ # can be confirmed by the recipient
+ service_request_5 (extends service_request):
+ id:
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::TO_CONFIRM
+ owner: '@admin_loic'
+ product: '@loic_object_1'
+ recipient: '@user_16'
+ startAt: ''
+ endAt: ''
+
+ # ongoing service request that is about to start and end
+ # it finishes the same day to test noth end and start notifications with the
+ # same record
+ service_request_6 (extends service_request):
+ status: !php/enum App\Enum\ServiceRequest\ServiceRequestStatus::CONFIRMED
+ owner: '@admin_camille'
+ product: '@camille_object_1'
+ recipient: '@user_16'
+ startAt: ''
+ endAt: ''
diff --git a/fixtures/test/user.yaml b/fixtures/test/user.yaml
new file mode 100644
index 0000000..8f41e95
--- /dev/null
+++ b/fixtures/test/user.yaml
@@ -0,0 +1,173 @@
+App\Entity\User:
+ base_user_template (template):
+ enabled: true
+ mainAdminAccount: false
+ password: '\$2y\$13\$LOIpgrMmOysCysIwkILTl.qD8psPxn9U9/V03p3odlqztLb7Aewze' # 35DVDj8ir3Buc7
+ emailConfirmed: true
+ phoneNumber: '+33600000000'
+ smsNotifications: true
+
+ admin_template (template, extends base_user_template):
+ type: !php/enum App\Enum\User\UserType::ADMIN
+ roles: [!php/const App\Entity\User::ROLE_ADMIN]
+
+ user_template (template, extends base_user_template):
+ type: !php/enum App\Enum\User\UserType::USER
+
+ place_template (template, extends base_user_template):
+ type: !php/enum App\Enum\User\UserType::PLACE
+
+ # —— Admins ————————————————————————————————————————————————————————————————
+ admin_camille (extends admin_template):
+ id:
+ email: 'camille@example.com'
+ firstname: 'Camille'
+ lastname: 'Croteau'
+ mainAdminAccount: true
+ address: '@address_camille'
+
+ admin_loic (extends admin_template):
+ id:
+ email: 'loic@example.com'
+ firstname: 'Loïc'
+ lastname: 'Duclos'
+ devAccount: true
+ address: '@address_loic'
+
+ admin_kevin (extends admin_template):
+ id:
+ email: 'kevin@example.com'
+ firstname: 'Kevin'
+ lastname: 'Pirouet'
+
+ admin_apes (extends admin_template):
+ id:
+ address: '@address_loic'
+ email: 'plateformcoop@apes-hdf.org'
+ firstname: 'APES'
+ lastname: 'APES'
+
+ admin_sarah (extends admin_template):
+ id:
+ email: 'sarah@example.com'
+ firstname: 'Sarah'
+ lastname: 'Charest'
+ devAccount: true
+ address: '@address_region_hauts_de_france'
+ category: '@category_object_16'
+
+ # —— Places ————————————————————————————————————————————————————————————————
+ place_{6} (extends place_template):
+ email: 'lieu\@example.com'
+ name: 'Lieu n°'
+ schedule: '9h30 - 17h30'
+
+ place_{7} (extends place_template):
+ id:
+ email: 'lieu\@example.com'
+ name: 'Lieu n°'
+ schedule: '9h - 17h'
+
+ place_apes (extends place_template):
+ id:
+ email: 'compte+lieu@apes-hdf.org'
+ name: 'APES compte lieu'
+ address: '@address_region_hauts_de_france'
+ schedule: '9h30 - 17h30'
+ phoneNumber: null
+
+ # —— Users —————————————————————————————————————————————————————————————————
+ # user with vacation mode
+ user_{9} (extends user_template):
+ id:
+ email: 'user\@example.com'
+ firstname:
+ lastname:
+ vacationMode: true
+
+ # deactivated user
+ user_{10} (extends user_template):
+ id:
+ email: 'user\@example.com'
+ firstname:
+ lastname:
+ enabled: false
+
+ # confirmed user with a pending invitation
+ user_{11} (extends user_template):
+ id:
+ email: 'user\@example.com'
+ firstname:
+ lastname:
+ emailConfirmed: true
+
+ # user ready to access the account creation step 2
+ user_{12} (extends user_template):
+ id:
+ password: null
+ emailConfirmed: true
+ email: 'user\@example.com'
+ confirmationToken: '3PpTWgYdgNZcuRTbqZTS5HRihEGGhw5rCszuo7XYAPJ9dEwttR'
+ confirmationExpiresAt:
+ firstname:
+ lastname:
+
+ # user with an expired confirmation token
+ # user with an unconfirmed email
+ user_{13} (extends user_template):
+ id:
+ password: null
+ emailConfirmed: false
+ email: 'user\@example.com'
+ confirmationToken: 'DrCaEPr3pKM9e8PkfUZiZZsAe5nwcgBDpQjKbuaJ3ukzL5qLv9'
+ confirmationExpiresAt:
+ firstname:
+ lastname:
+
+ # user with a valid lost password token and a confirmation token for an invitation
+ user_{14} (extends user_template):
+ id:
+ password: null
+ emailConfirmed: false
+ email: 'user\@example.com'
+ lostPasswordToken: 'cuYxfS5eCWX2FYtJwWdhHZrGY6W1KT7UBV6CeARK2E2s4V3SKB'
+ lostPasswordExpiresAt:
+ confirmationToken: 'cuYxfS5eCWX2FYtJwWdhHZrGY6W1KT7UBV6CeARK2E2s4V3SKB'
+ confirmationExpiresAt:
+ firstname:
+ lastname:
+
+ # user with an expired lost password token
+ user_{15} (extends user_template):
+ id:
+ password: null
+ emailConfirmed: false
+ email: 'user\@example.com'
+ lostPasswordToken: 'A4QJZqhf3wFnoJCf65xLwce2f7aMWkLEoZHshvHCDWC61vQSAv'
+ lostPasswordExpiresAt:
+ firstname:
+ lastname:
+
+ user_{16} (extends user_template):
+ id:
+ email: 'user\@example.com'
+ firstname:
+ lastname:
+ address: null
+ avatar: 'ba827ea0-b140-4cbf-9786-77ac980c648c.jpg'
+
+ # user with an address and a preferred category set
+ user_{17} (extends user_template):
+ id:
+ email: 'user\@example.com'
+ firstname:
+ lastname:
+ category: '@category_object_1'
+ description: 'description example'
+ address: '@address_user_17'
+
+ # Demo accounts
+ user_apes (extends user_template):
+ email: 'john.doe@example.com'
+ firstname: 'John'
+ lastname: 'Doe'
diff --git a/fixtures/test/user_group.yaml b/fixtures/test/user_group.yaml
new file mode 100644
index 0000000..2eddc14
--- /dev/null
+++ b/fixtures/test/user_group.yaml
@@ -0,0 +1,83 @@
+App\Entity\UserGroup:
+ # Group 7 ——————————————————————————————————————————————————————————————
+ loic_at_group_7:
+ id:
+ user: '@admin_loic'
+ group: '@group_7'
+ membership: !php/enum App\Enum\Group\UserMembership::MEMBER
+
+ kevin_at_group_7:
+ user: '@admin_kevin'
+ group: '@group_7'
+ membership: !php/enum App\Enum\Group\UserMembership::ADMIN
+
+ sarah_at_group_7:
+ user: '@admin_sarah'
+ group: '@group_7'
+ membership: !php/enum App\Enum\Group\UserMembership::MEMBER
+ startAt:
+ endAt:
+ payedAt:
+
+ user11_invitation_at_group_7:
+ user: '@user_11'
+ group: '@group_7'
+ membership: !php/enum App\Enum\Group\UserMembership::INVITATION
+
+ # Group 1 ———————————————————————————————————————————————————————————————————
+ camille_at_group_1:
+ user: '@admin_camille'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::MEMBER
+ payedAt:
+ startAt:
+ endAt:
+
+ user_16_at_group_1:
+ user: '@user_16'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::ADMIN
+
+ # membership expired
+ sarah_at_group_1:
+ user: '@admin_sarah'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::MEMBER
+ mainAdminAccount: true
+ payedAt:
+ startAt:
+ endAt:
+
+ place_apes_at_group_1:
+ user: '@place_apes'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::ADMIN
+
+ user14_invitation_at_group_1:
+ user: '@user_14'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::INVITATION
+
+ user_apes_admin_at_group_1:
+ user: '@user_apes'
+ group: '@group_1'
+ membership: !php/enum App\Enum\Group\UserMembership::ADMIN
+ mainAdminAccount: true
+
+ user11_invitation_at_group_private:
+ user: '@user_11'
+ group: '@group_private'
+ membership: !php/enum App\Enum\Group\UserMembership::INVITATION
+
+ # Private group ————————————————————————————————————————————————————————————
+ sarah_at_group_private:
+ user: '@admin_sarah'
+ group: '@group_private'
+ membership: !php/enum App\Enum\Group\UserMembership::MEMBER
+
+ # Group 2 ——————————————————————————————————————————————————————————————
+ sarah_invitation_at_group_2:
+ user: '@admin_sarah'
+ group: '@group_2'
+ membership: !php/enum App\Enum\Group\UserMembership::INVITATION
+
diff --git a/helm/chart/.gitignore b/helm/chart/.gitignore
new file mode 100644
index 0000000..80bf7fc
--- /dev/null
+++ b/helm/chart/.gitignore
@@ -0,0 +1 @@
+charts
\ No newline at end of file
diff --git a/helm/chart/.helmignore b/helm/chart/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/helm/chart/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/helm/chart/Chart.lock b/helm/chart/Chart.lock
new file mode 100644
index 0000000..f626756
--- /dev/null
+++ b/helm/chart/Chart.lock
@@ -0,0 +1,18 @@
+dependencies:
+- name: postgresql
+ repository: https://charts.bitnami.com/bitnami/
+ version: 11.9.13
+- name: external-dns
+ repository: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami
+ version: 5.4.16
+- name: redis
+ repository: https://charts.bitnami.com/bitnami/
+ version: 17.4.3
+- name: meilisearch
+ repository: https://meilisearch.github.io/meilisearch-kubernetes
+ version: 0.1.46
+- name: maildev
+ repository: https://pando85.github.io/helm-maildev/
+ version: 0.3.1
+digest: sha256:282a6d90f79ac02f80158c66f22939298fa1dfba29ecd4d2885e55597c515500
+generated: "2023-02-01T09:52:52.035346504+01:00"
diff --git a/helm/chart/Chart.yaml b/helm/chart/Chart.yaml
new file mode 100644
index 0000000..df55e68
--- /dev/null
+++ b/helm/chart/Chart.yaml
@@ -0,0 +1,48 @@
+apiVersion: v2
+name: plateformcoop-ebs
+description: A Helm chart for the PlateformCoop-EBS project
+# home: https://plateformcoop-ebs.dev
+# icon: https://plateformcoop-ebs.dev/logo-250x250.png
+
+# A chart can be either an 'application' or a 'library' chart.
+#
+# Application charts are a collection of templates that can be packaged into versioned archives
+# to be deployed.
+#
+# Library charts provide useful utilities or functions for the chart developer. They're included as
+# a dependency of application charts to inject those utilities and functions into the rendering
+# pipeline. Library charts do not define any templates and therefore cannot be deployed.
+type: application
+
+# This is the chart version. This version number should be incremented each time you make changes
+# to the chart and its templates, including the app version.
+# Versions are expected to follow Semantic Versioning (https://semver.org/)
+version: 0.0.1
+
+# This is the version number of the application being deployed. This version number should be
+# incremented each time you make changes to the application. Versions are not expected to
+# follow Semantic Versioning. They should reflect the version the application is using.
+appVersion: 0.0.1
+
+dependencies:
+ # bitnami chart are using the workaround from https://github.com/bitnami/charts/issues/10539
+ - name: postgresql
+ version: ~11.9.13
+ repository: https://charts.bitnami.com/bitnami/
+ condition: postgresql.enabled
+ - name: external-dns
+ version: ~5.4.15
+ repository: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami
+ condition: external-dns.enabled
+ - name: redis
+ version: ~17.4.0
+ repository: https://charts.bitnami.com/bitnami/
+ condition: redis.enabled
+ - name: meilisearch
+ version: ~0.1.46
+ repository: https://meilisearch.github.io/meilisearch-kubernetes
+ condition: meilisearch.enabled
+ - name: maildev
+ version: ~0.3.1
+ repository: https://pando85.github.io/helm-maildev/
+ condition: maildev.enabled
diff --git a/helm/chart/README.md b/helm/chart/README.md
new file mode 100644
index 0000000..00aa363
--- /dev/null
+++ b/helm/chart/README.md
@@ -0,0 +1,21 @@
+# Chart HELM
+
+to test locally with [minikube](https://minikube.sigs.k8s.io/docs/)
+
+```bash
+minikube start
+minikube addons enable ingress
+kubectx minikube
+kubectl create ns plateformcoop-ebs
+kubens plateformcoop-ebs
+```
+
+get minikube ip via `minikube ip`
+
+add in your `/etc/hosts` file:
+
+```
+192.168.x.x ebs.chart-example.local maildev.chart-example.local
+```
+
+Then run `./test_minikube.sh` to build prod images, push them to minikube and deploy the app with helm
diff --git a/helm/chart/templates/NOTES.txt b/helm/chart/templates/NOTES.txt
new file mode 100644
index 0000000..873860f
--- /dev/null
+++ b/helm/chart/templates/NOTES.txt
@@ -0,0 +1,22 @@
+1. Get the application URL by running these commands:
+{{- if .Values.ingress.enabled }}
+{{- range $host := .Values.ingress.hosts }}
+ {{- range .paths }}
+ http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }}
+ {{- end }}
+{{- end }}
+{{- else if contains "NodePort" .Values.service.type }}
+ export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "plateformcoop-ebs" . }})
+ export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
+ echo http://$NODE_IP:$NODE_PORT
+{{- else if contains "LoadBalancer" .Values.service.type }}
+ NOTE: It may take a few minutes for the LoadBalancer IP to be available.
+ You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "plateformcoop-ebs" . }}'
+ export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "plateformcoop-ebs" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
+ echo http://$SERVICE_IP:{{ .Values.service.port }}
+{{- else if contains "ClusterIP" .Values.service.type }}
+ export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "plateformcoop-ebs.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
+ export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
+ echo "Visit http://127.0.0.1:8080 to use your application"
+ kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
+{{- end }}
diff --git a/helm/chart/templates/_helpers.tpl b/helm/chart/templates/_helpers.tpl
new file mode 100644
index 0000000..2d0c2c3
--- /dev/null
+++ b/helm/chart/templates/_helpers.tpl
@@ -0,0 +1,92 @@
+{{/*
+Expand the name of the chart.
+*/}}
+{{- define "plateformcoop-ebs.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Create a default fully qualified app name.
+We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
+If release name contains chart name it will be used as a full name.
+*/}}
+{{- define "plateformcoop-ebs" -}}
+{{- if .Values.fullnameOverride }}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- $name := default .Chart.Name .Values.nameOverride }}
+{{- if contains $name .Release.Name }}
+{{- .Release.Name | trunc 63 | trimSuffix "-" }}
+{{- else }}
+{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
+{{- end }}
+{{- end }}
+{{- end }}
+
+{{/*
+Create chart name and version as used by the chart label.
+*/}}
+{{- define "plateformcoop-ebs.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
+{{- end }}
+
+{{/*
+Common labels
+*/}}
+{{- define "plateformcoop-ebs.labels" -}}
+helm.sh/chart: {{ include "plateformcoop-ebs.chart" . }}
+{{ include "plateformcoop-ebs.selectorLabels" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Common labels PWA
+*/}}
+{{- define "plateformcoop-ebs.labelsPWA" -}}
+helm.sh/chart: {{ include "plateformcoop-ebs.chart" . }}
+{{ include "plateformcoop-ebs.selectorLabelsPWA" . }}
+{{- if .Chart.AppVersion }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+{{- end }}
+app.kubernetes.io/managed-by: {{ .Release.Service }}
+{{- end }}
+
+{{/*
+Selector labels
+*/}}
+{{- define "plateformcoop-ebs.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "plateformcoop-ebs.name" . }}
+app.kubernetes.io/instance: {{ .Release.Name }}
+app.kubernetes.io/part-of: {{ include "plateformcoop-ebs.name" . }}
+{{- end }}
+
+{{/*
+Selector labels PWA
+*/}}
+{{- define "plateformcoop-ebs.selectorLabelsPWA" -}}
+app.kubernetes.io/name: {{ include "plateformcoop-ebs.name" . }}-pwa
+app.kubernetes.io/instance: {{ .Release.Name }}
+app.kubernetes.io/part-of: {{ include "plateformcoop-ebs.name" . }}
+{{- end }}
+
+{{/*
+Selector labels Fixtures job
+*/}}
+{{- define "plateformcoop-ebs.selectorLabelsFixtures" -}}
+app.kubernetes.io/name: {{ include "plateformcoop-ebs.name" . }}-pwa
+app.kubernetes.io/instance: {{ .Release.Name }}
+{{- end }}
+
+{{/*
+Create the name of the service account to use
+*/}}
+{{- define "plateformcoop-ebs.serviceAccountName" -}}
+{{- if .Values.serviceAccount.create }}
+{{- default (include "plateformcoop-ebs" .) .Values.serviceAccount.name }}
+{{- else }}
+{{- default "default" .Values.serviceAccount.name }}
+{{- end }}
+{{- end }}
diff --git a/helm/chart/templates/configmap.yaml b/helm/chart/templates/configmap.yaml
new file mode 100644
index 0000000..2d8a088
--- /dev/null
+++ b/helm/chart/templates/configmap.yaml
@@ -0,0 +1,28 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+data:
+ payum-gateway: {{ .Values.payum.gateway | quote }}
+ php-host: {{ .Values.php.host | quote }}
+ php-app-env: {{ .Values.php.appEnv | quote }}
+ php-app-debug: {{ .Values.php.appDebug | quote }}
+ php-cors-allow-origin: {{ .Values.php.corsAllowOrigin | quote }}
+ php-trusted-hosts: {{ .Values.php.trustedHosts | quote }}
+ php-trusted-proxies: "{{ join "," .Values.php.trustedProxies }}"
+ mercure-url: "http://{{ include "plateformcoop-ebs" . }}/.well-known/mercure"
+ mercure-public-url: {{ .Values.mercure.publicUrl | default "http://127.0.0.1/.well-known/mercure" | quote }}
+ mercure-extra-directives: {{ .Values.mercure.extraDirectives | quote }}
+ {{- if .Values.meilisearch.enabled }}
+ meilisearch-url: "http://{{ include "meilisearch.fullname" .Subcharts.meilisearch }}:7700"
+ {{- end }}
+ {{- if .Values.redis.enabled }}
+ redis-url: "redis://{{ printf "%s-master" (include "common.names.fullname" .Subcharts.redis) }}"
+ {{- end }}
+ payum-gateway: {{ .Values.payum.gateway | quote }}
+ php-storage-bucket: {{ .Values.php.storage.bucket | quote }}
+ php-storage-endpoint: {{ .Values.php.storage.endpoint | quote }}
+ php-storage-region: {{ .Values.php.storage.region | quote }}
+ php-storage-use-path-style-endpoint: {{ .Values.php.storage.usePathStyleEndpoint | quote }}
\ No newline at end of file
diff --git a/helm/chart/templates/cronjobs/cronjobEndMembership.yaml b/helm/chart/templates/cronjobs/cronjobEndMembership.yaml
new file mode 100644
index 0000000..5bef8b7
--- /dev/null
+++ b/helm/chart/templates/cronjobs/cronjobEndMembership.yaml
@@ -0,0 +1,148 @@
+{{- if .Values.dailyCronjobs.enabled }}
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-cronjob-end-membership
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ schedule: '6 1 * * *'
+ jobTemplate:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ template:
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-cronjob-end-membership
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['bin/console app:end-membership --env=prod']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ value: "prod"
+ - name: APP_DEBUG
+ value: "0"
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 16 }}
+{{- end }}
diff --git a/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration1.yaml b/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration1.yaml
new file mode 100644
index 0000000..c783b9a
--- /dev/null
+++ b/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration1.yaml
@@ -0,0 +1,148 @@
+{{- if .Values.dailyCronjobs.enabled }}
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-cronjob-notify-ms-e-1
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ schedule: '12 2 * * *'
+ jobTemplate:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ template:
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-cronjob-notify-ms-e-1
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['bin/console app:notify-membership-expiration 1 --env=prod']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ value: "prod"
+ - name: APP_DEBUG
+ value: "0"
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 16 }}
+{{- end }}
diff --git a/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration7.yaml b/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration7.yaml
new file mode 100644
index 0000000..400b9e5
--- /dev/null
+++ b/helm/chart/templates/cronjobs/cronjobNotifyMembershipExpiration7.yaml
@@ -0,0 +1,148 @@
+{{- if .Values.dailyCronjobs.enabled }}
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-cronjob-notify-ms-e-7
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ schedule: '3 21 * * *'
+ jobTemplate:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ template:
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-cronjob-notify-ms-e-7
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['bin/console app:notify-membership-expiration 7 --env=prod']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ value: "prod"
+ - name: APP_DEBUG
+ value: "0"
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 16 }}
+{{- end }}
diff --git a/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesEnd.yaml b/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesEnd.yaml
new file mode 100644
index 0000000..8431875
--- /dev/null
+++ b/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesEnd.yaml
@@ -0,0 +1,148 @@
+{{- if .Values.dailyCronjobs.enabled }}
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-cronjob-notify-srq-end
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ schedule: '44 4 * * *'
+ jobTemplate:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ template:
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-cronjob-notify-srq-end
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['bin/console app:notify-service-request-dates end --env=prod']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ value: "prod"
+ - name: APP_DEBUG
+ value: "0"
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 16 }}
+{{- end }}
diff --git a/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesStart.yaml b/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesStart.yaml
new file mode 100644
index 0000000..d362f62
--- /dev/null
+++ b/helm/chart/templates/cronjobs/cronjobNotifyServiceRequestDatesStart.yaml
@@ -0,0 +1,148 @@
+{{- if .Values.dailyCronjobs.enabled }}
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-cronjob-notify-srq-start
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ schedule: '2 4 * * *'
+ jobTemplate:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ template:
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 12 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-cronjob-notify-srq-start
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['bin/console app:notify-service-request-dates start --env=prod']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ value: "prod"
+ - name: APP_DEBUG
+ value: "0"
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 16 }}
+{{- end }}
diff --git a/helm/chart/templates/deployment.yaml b/helm/chart/templates/deployment.yaml
new file mode 100644
index 0000000..de00972
--- /dev/null
+++ b/helm/chart/templates/deployment.yaml
@@ -0,0 +1,370 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ {{- if not .Values.autoscaling.enabled }}
+ replicas: {{ .Values.replicaCount }}
+ {{- end }}
+ selector:
+ matchLabels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 8 }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ initContainers:
+ - name: {{ .Chart.Name }}-chown
+ securityContext:
+ runAsUser: 0
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['set -ex; mkdir -p public/storage/uploads/category ; mkdir -p public/storage/uploads/menu ; mkdir -p public/storage/uploads/product ; mkdir -p public/storage/uploads/user ; chown -R www-data: public/storage/']
+ volumeMounts:
+ - mountPath: /srv/app/public/storage
+ name: storage
+ containers:
+ - name: {{ .Chart.Name }}-caddy
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.caddy.image.repository }}:{{ .Values.caddy.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.caddy.image.pullPolicy }}
+ env:
+ - name: SERVER_NAME
+ value: :80
+ - name: PWA_UPSTREAM
+ value: {{ include "plateformcoop-ebs" . }}-pwa:3000
+ - name: MERCURE_EXTRA_DIRECTIVES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-extra-directives
+ - name: MERCURE_PUBLISHER_JWT_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ - name: MERCURE_SUBSCRIBER_JWT_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ ports:
+ - name: http
+ containerPort: 80
+ protocol: TCP
+ - name: admin
+ containerPort: 2019
+ protocol: TCP
+ volumeMounts:
+ - mountPath: /srv/app/public/storage
+ name: storage
+ - mountPath: /var/run/php
+ name: php-socket
+ lifecycle:
+ preStop:
+ exec:
+ command: ["curl", "-XPOST", "http://localhost:2019/stop"]
+ readinessProbe:
+ tcpSocket:
+ port: 80
+ initialDelaySeconds: 3
+ livenessProbe:
+ tcpSocket:
+ port: 80
+ initialDelaySeconds: 3
+ resources:
+ {{- toYaml .Values.resources.caddy | nindent 12 }}
+ - name: {{ .Chart.Name }}-php
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-env
+ - name: APP_DEBUG
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-debug
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MAILER_DSN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mailer-dsn
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ {{- if .Values.meilisearch.enabled }}
+ - name: MEILISEARCH_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ printf "%s-%s" (include "meilisearch.fullname" .Subcharts.meilisearch ) "master-key" }}
+ key: MEILI_MASTER_KEY
+ - name: MEILISEARCH_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: meilisearch-url
+ {{- end }}
+ {{- if .Values.redis.enabled }}
+ - name: REDIS_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: redis-url
+ {{- end }}
+ - name: SMS_DSN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: sms-dsn
+ - name: PAYUM_APIKEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: payum-apikey
+ - name: PAYUM_GATEWAY
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: payum-gateway
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ volumeMounts:
+ - mountPath: /srv/app/public/storage
+ name: storage
+ - mountPath: /var/run/php
+ name: php-socket
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ resources:
+ {{- toYaml .Values.resources.php | nindent 12 }}
+ {{- if .Values.consumer.enabled }}
+ - name: {{ .Chart.Name }}-consumer
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ args: ['bin/console', 'messenger:consume']
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-env
+ - name: APP_DEBUG
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-debug
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ startupProbe:
+ exec:
+ command: ['pgrep', '-f', 'php bin/console messenger:consume']
+ failureThreshold: 40
+ readinessProbe:
+ exec:
+ command: ['pgrep', '-f', 'php bin/console messenger:consume']
+ livenessProbe:
+ exec:
+ command: ['pgrep', '-f', 'php bin/console messenger:consume']
+ resources:
+ {{- toYaml .Values.resources.consumer | nindent 12 }}
+ {{- end }}
+ volumes:
+ - name: storage
+ # TODO: pvc option?
+ emptyDir: {}
+ - name: php-socket
+ emptyDir: {}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/helm/chart/templates/fixtures-job.yaml b/helm/chart/templates/fixtures-job.yaml
new file mode 100644
index 0000000..d142b13
--- /dev/null
+++ b/helm/chart/templates/fixtures-job.yaml
@@ -0,0 +1,210 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}-fixtures
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": "post-install,post-upgrade"
+ "helm.sh/hook-delete-policy": "before-hook-creation"
+spec:
+ template:
+ metadata:
+ annotations:
+ rollme: {{ randAlphaNum 5 | quote }}
+ labels:
+ {{- include "plateformcoop-ebs.selectorLabelsFixtures" . | nindent 8 }}
+ spec:
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ serviceAccountName: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ securityContext:
+ {{- toYaml .Values.podSecurityContext | nindent 8 }}
+ restartPolicy: Never
+ containers:
+ - name: {{ .Chart.Name }}-fixtures
+ securityContext:
+ {{- toYaml .Values.securityContext | nindent 12 }}
+ image: "{{ .Values.php.image.repository }}:{{ .Values.php.image.tag | default .Chart.AppVersion }}"
+ imagePullPolicy: {{ .Values.php.image.pullPolicy }}
+ command: ['/bin/sh', '-c']
+ args: ['
+ set -ex;
+ echo no fixtures job at the moment
+ ']
+ # if [ "$APP_ENV" != "prod" ]; then
+ # composer install --prefer-dist --no-progress --no-suggest --no-interaction;
+ # fi;
+ # bin/console doctrine:database:drop --if-exists --force;
+ # bin/console doctrine:database:create --if-not-exists;
+ # bin/console doctrine:schema:create;
+ # bin/console doctrine:schema:validate;
+ # bin/console messenger:setup-transports;
+ # bin/console hautelook:fixtures:load --no-interaction -vv --no-bundles;
+ # bin/console app:index-products;
+ env:
+ - name: API_ENTRYPOINT_HOST
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-host
+ - name: JWT_PASSPHRASE
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-passphrase
+ - name: JWT_PUBLIC_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-public-key
+ - name: JWT_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-jwt-secret-key
+ - name: TRUSTED_HOSTS
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-hosts
+ - name: TRUSTED_PROXIES
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-trusted-proxies
+ - name: APP_ENV
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-env
+ - name: APP_DEBUG
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-debug
+ - name: APP_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-app-secret
+ - name: CORS_ALLOW_ORIGIN
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-cors-allow-origin
+ - name: DATABASE_URL
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: database-url
+ - name: MERCURE_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-url
+ - name: MERCURE_PUBLIC_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-public-url
+ - name: MERCURE_JWT_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: mercure-jwt-secret
+ {{- if .Values.meilisearch.enabled }}
+ - name: MEILISEARCH_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ printf "%s-%s" (include "meilisearch.fullname" .Subcharts.meilisearch ) "master-key" }}
+ key: MEILI_MASTER_KEY
+ - name: MEILISEARCH_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: meilisearch-url
+ {{- end }}
+ {{- if .Values.redis.enabled }}
+ - name: REDIS_URL
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: redis-url
+ {{- end }}
+ - name: PAYUM_APIKEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: payum-apikey
+ - name: PAYUM_GATEWAY
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: payum-gateway
+ - name: STORAGE_BUCKET
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-bucket
+ - name: STORAGE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-endpoint
+ - name: STORAGE_REGION
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-region
+ - name: STORAGE_USE_PATH_STYLE_ENDPOINT
+ valueFrom:
+ configMapKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-use-path-style-endpoint
+ - name: STORAGE_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-key
+ - name: STORAGE_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "plateformcoop-ebs" . }}
+ key: php-storage-secret
+ lifecycle:
+ preStop:
+ exec:
+ command: ["/bin/sh", "-c", "/bin/sleep 1; kill -QUIT 1"]
+ startupProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ failureThreshold: 40
+ periodSeconds: 3
+ readinessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ livenessProbe:
+ exec:
+ command:
+ - docker-healthcheck
+ periodSeconds: 3
+ resources:
+ {{- toYaml .Values.resources.fixtures | nindent 12 }}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/helm/chart/templates/hpa.yaml b/helm/chart/templates/hpa.yaml
new file mode 100644
index 0000000..d62b748
--- /dev/null
+++ b/helm/chart/templates/hpa.yaml
@@ -0,0 +1,28 @@
+{{- if .Values.autoscaling.enabled }}
+apiVersion: autoscaling/v2beta1
+kind: HorizontalPodAutoscaler
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: {{ include "plateformcoop-ebs" . }}
+ minReplicas: {{ .Values.autoscaling.minReplicas }}
+ maxReplicas: {{ .Values.autoscaling.maxReplicas }}
+ metrics:
+ {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: cpu
+ targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
+ {{- end }}
+ {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ - type: Resource
+ resource:
+ name: memory
+ targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
+ {{- end }}
+{{- end }}
diff --git a/helm/chart/templates/ingress.yaml b/helm/chart/templates/ingress.yaml
new file mode 100644
index 0000000..709664d
--- /dev/null
+++ b/helm/chart/templates/ingress.yaml
@@ -0,0 +1,61 @@
+{{- if .Values.ingress.enabled -}}
+{{- $fullName := include "plateformcoop-ebs" . -}}
+{{- $svcPort := .Values.service.port -}}
+{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
+ {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
+ {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
+ {{- end }}
+{{- end }}
+{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1
+{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
+apiVersion: networking.k8s.io/v1beta1
+{{- else -}}
+apiVersion: extensions/v1beta1
+{{- end }}
+kind: Ingress
+metadata:
+ name: {{ $fullName }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
+ ingressClassName: {{ .Values.ingress.className }}
+ {{- end }}
+ {{- if .Values.ingress.tls }}
+ tls:
+ {{- range .Values.ingress.tls }}
+ - hosts:
+ {{- range .hosts }}
+ - {{ . | quote }}
+ {{- end }}
+ secretName: {{ .secretName }}
+ {{- end }}
+ {{- end }}
+ rules:
+ {{- range .Values.ingress.hosts }}
+ - host: {{ .host | quote }}
+ http:
+ paths:
+ {{- range .paths }}
+ - path: {{ .path }}
+ {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
+ pathType: {{ .pathType }}
+ {{- end }}
+ backend:
+ {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
+ service:
+ name: {{ $fullName }}
+ port:
+ number: {{ $svcPort }}
+ {{- else }}
+ serviceName: {{ $fullName }}
+ servicePort: {{ $svcPort }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
+ {{- end }}
diff --git a/helm/chart/templates/secrets.yaml b/helm/chart/templates/secrets.yaml
new file mode 100644
index 0000000..10f4d72
--- /dev/null
+++ b/helm/chart/templates/secrets.yaml
@@ -0,0 +1,27 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+type: Opaque
+data:
+ {{- if .Values.postgresql.enabled }}
+ database-url: {{ printf "pgsql://%s:%s@%s-postgresql/%s?serverVersion=14&charset=utf8" .Values.postgresql.global.postgresql.auth.username .Values.postgresql.global.postgresql.auth.password .Release.Name .Values.postgresql.global.postgresql.auth.database | b64enc | quote }}
+ {{- else }}
+ database-url: {{ .Values.postgresql.url | b64enc | quote }}
+ {{- end }}
+ php-app-secret: {{ .Values.php.appSecret | default (randAlphaNum 40) | b64enc | quote }}
+ php-jwt-passphrase: {{ .Values.php.jwt.passphrase | b64enc | quote }}
+ php-jwt-public-key: {{ .Values.php.jwt.publicKey | b64enc | quote }}
+ php-jwt-secret-key: {{ .Values.php.jwt.secretKey | b64enc | quote }}
+ mercure-jwt-secret: {{ .Values.mercure.jwtSecret | default (randAlphaNum 40) | b64enc | quote }}
+ {{- if .Values.maildev.enabled }}
+ mailer-dsn: {{ printf "smtp://%s:%s" ( include "maildev.fullname" .Subcharts.maildev ) "1025" | b64enc | quote }}
+ {{- else }}
+ mailer-dsn: {{ .Values.mailer.dsn | b64enc | quote }}
+ {{- end }}
+ sms-dsn: {{ .Values.sms.dsn | b64enc | quote }}
+ payum-apikey: {{ .Values.payum.apikey | b64enc | quote }}
+ php-storage-key: {{ .Values.php.storage.key | b64enc | quote }}
+ php-storage-secret: {{ .Values.php.storage.secret | b64enc | quote }}
\ No newline at end of file
diff --git a/helm/chart/templates/service.yaml b/helm/chart/templates/service.yaml
new file mode 100644
index 0000000..5cc67a1
--- /dev/null
+++ b/helm/chart/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "plateformcoop-ebs" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ ports:
+ - port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ {{- include "plateformcoop-ebs.selectorLabels" . | nindent 4 }}
diff --git a/helm/chart/templates/serviceaccount.yaml b/helm/chart/templates/serviceaccount.yaml
new file mode 100644
index 0000000..da060f6
--- /dev/null
+++ b/helm/chart/templates/serviceaccount.yaml
@@ -0,0 +1,12 @@
+{{- if .Values.serviceAccount.create -}}
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ include "plateformcoop-ebs.serviceAccountName" . }}
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+ {{- with .Values.serviceAccount.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+{{- end }}
diff --git a/helm/chart/templates/tests/test-connection.yaml b/helm/chart/templates/tests/test-connection.yaml
new file mode 100644
index 0000000..020d31e
--- /dev/null
+++ b/helm/chart/templates/tests/test-connection.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Pod
+metadata:
+ name: "{{ include "plateformcoop-ebs" . }}-test-connection"
+ labels:
+ {{- include "plateformcoop-ebs.labels" . | nindent 4 }}
+ annotations:
+ "helm.sh/hook": test
+spec:
+ containers:
+ - name: wget
+ image: busybox
+ command: ['wget']
+ args: ['{{ include "plateformcoop-ebs" . }}:{{ .Values.service.port }}']
+ restartPolicy: Never
diff --git a/helm/chart/test_minikube.sh b/helm/chart/test_minikube.sh
new file mode 100755
index 0000000..acbf009
--- /dev/null
+++ b/helm/chart/test_minikube.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+set -e
+# go to repo root
+cd $(dirname $0)/../../
+
+# build all images, without dev overrides
+docker compose -f docker-compose.yml build
+
+# tag image with sha to force rollingUpdate
+php_sha=$(docker inspect plateformcoop-ebs-php --format='{{.Id}}' | cut -d: -f2)
+caddy_sha=$(docker inspect plateformcoop-ebs-caddy --format='{{.Id}}' | cut -d: -f2)
+docker tag plateformcoop-ebs-php plateformcoop-ebs-php:$php_sha
+docker tag plateformcoop-ebs-caddy plateformcoop-ebs-caddy:$caddy_sha
+
+# push images to minikube
+#minikube image load plateformcoop-ebs-php:$php_sha
+#minikube image load plateformcoop-ebs-caddy:$caddy_sha
+for image in plateformcoop-ebs-php:$php_sha plateformcoop-ebs-caddy:$caddy_sha; do
+ minikube image ls | grep $image || minikube image load $image
+done
+
+# install or update deployment on minikube
+helm upgrade --install demo ./helm/chart \
+ --kube-context minikube \
+ --namespace plateformcoop-ebs --create-namespace \
+ --atomic \
+ --wait \
+ --debug \
+ -f ./helm/chart/values-minikube.yml \
+ --set php.image.tag=$php_sha \
+ --set caddy.image.tag=$caddy_sha
+
+MINIKUBE_IP=$(minikube ip)
+if ! grep -E "^$MINIKUBE_IP\s+(.+\s+)?ebs.chart-example.local" /etc/hosts; then
+ echo Execute \"echo $MINIKUBE_IP ebs.chart-example.local \| sudo tee -a /etc/hosts\"
+ exit=1
+fi
+if ! grep -E "^$MINIKUBE_IP\s+(.+\s+)?maildev.chart-example.local" /etc/hosts; then
+ echo Execute \"echo $MINIKUBE_IP maildev.chart-example.local \| sudo tee -a /etc/hosts\"
+ exit=1
+fi
+
+if [ -n "$exit" ]; then
+ exit 1
+fi
+
+open http://ebs.chart-example.local
+open http://maildev.chart-example.local
diff --git a/helm/chart/values-minikube.yml b/helm/chart/values-minikube.yml
new file mode 100644
index 0000000..5acd85e
--- /dev/null
+++ b/helm/chart/values-minikube.yml
@@ -0,0 +1,44 @@
+meilisearch:
+ persistence:
+ enabled: true
+ size: "1Gi"
+
+redis:
+ master:
+ persistence:
+ enabled: true
+ size: "1Gi"
+
+payum:
+ enabled: true
+ gateway: 'mollie'
+ apikey: 'test'
+
+postgresql:
+ auth:
+ # PostgreSQL password is set only the first time chart in installed
+ postgresPassword: change_me
+
+maildev:
+ enabled: true
+ ingress:
+ enabled: true
+ hosts:
+ - maildev.chart-example.local
+
+php:
+ image:
+ repository: plateformcoop-ebs-php
+ tag: latest
+ storage:
+ bucket: "toto"
+ endpoint: "titi"
+ region: "tata"
+ usePathStyleEndpoint: true
+ key: "tonton"
+ secret: "tomtom"
+
+caddy:
+ image:
+ repository: plateformcoop-ebs-caddy
+ tag: latest
diff --git a/helm/chart/values-prod.yml b/helm/chart/values-prod.yml
new file mode 100644
index 0000000..3c10bd0
--- /dev/null
+++ b/helm/chart/values-prod.yml
@@ -0,0 +1,34 @@
+imagePullSecrets:
+ - name: regcred
+
+ingress:
+ className: nginx
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-production
+ hosts:
+ - host: toset
+ paths:
+ - path: /
+ pathType: Prefix
+ tls:
+ - secretName: toset
+ hosts:
+ - toset
+
+meilisearch:
+ persistence:
+ enabled: true
+ storageClass: "standard"
+ size: "1Gi"
+
+redis:
+ master:
+ persistence:
+ enabled: true
+ storageClass: "standard"
+ size: "1Gi"
+
+postgresql:
+ persistence:
+ enabled: true
+ storageClass: "standard"
\ No newline at end of file
diff --git a/helm/chart/values.yaml b/helm/chart/values.yaml
new file mode 100644
index 0000000..9708d93
--- /dev/null
+++ b/helm/chart/values.yaml
@@ -0,0 +1,219 @@
+# Default values for plateformcoop-ebs.
+# This is a YAML-formatted file.
+# Declare variables to be passed into your templates.
+
+php:
+ image:
+ repository: "ghcr.io/ApesHDF/EBS-php" # CHANGE ME
+ pullPolicy: IfNotPresent
+ # Overrides the image tag whose default is the chart appVersion.
+ tag: ""
+ appEnv: prod
+ appDebug: "0"
+ appSecret: ""
+ corsAllowOrigin: "^https?://.*?\\.chart-example\\.local$"
+ trustedHosts: "^127\\.0\\.0\\.1|localhost|.*\\.chart-example\\.local$"
+ trustedProxies:
+ - "127.0.0.1"
+ - "10.0.0.0/8"
+ - "172.16.0.0/12"
+ - "192.168.0.0/16"
+ host: "ebs.chart-example.local"
+ jwt:
+ secretKey: ""
+ publicKey: ""
+ passphrase: ""
+ storage:
+ bucket: ""
+ endpoint: ""
+ region: ""
+ usePathStyleEndpoint: true
+ publicKey: ""
+ secret: ""
+
+maildev:
+ enabled: false
+
+mailer:
+ # is set automaticaly if maildev is enabled
+ dsn: change_me
+
+sms:
+ dsn: "null://null"
+
+dailyCronjobs:
+ enabled: true
+
+consumer:
+ # We don't use async for now so consumer isn't needed
+ enabled: false
+
+caddy:
+ image:
+ repository: "ghcr.io/ApesHDF/EBS-caddy" # CHANGE ME
+ pullPolicy: IfNotPresent
+ # Overrides the image tag whose default is the chart appVersion.
+ tag: ""
+
+# You may prefer using the managed version in production: https://mercure.rocks
+mercure:
+ publicUrl: https://ghcr.io/.well-known/mercure
+ # Change me!
+ jwtSecret: "!ChangeThisMercureHubJWTSecretKey!"
+ extraDirectives: cors_origins http://ghcr.io https://ghcr.io
+
+# Full configuration: https://github.com/bitnami/charts/tree/master/bitnami/postgresql
+postgresql:
+ enabled: true
+ # If bringing your own PostgreSQL, the full uri to use
+ # url: postgresql://plateformcoop-ebs:!ChangeMe!@database:5432/api?serverVersion=13&charset=utf8
+ global:
+ postgresql:
+ auth:
+ username: "example"
+ password: "!ChangeMe!"
+ database: "api"
+ # Persistent Volume Storage configuration.
+ # ref: https://kubernetes.io/docs/user-guide/persistent-volumes
+ pullPolicy: IfNotPresent
+ image:
+ repository: bitnami/postgresql
+ tag: 14
+ primary:
+ persistence:
+ enabled: true
+ storageClass: standard
+ size: 1Gi
+ resources:
+ requests:
+ memory: 50Mi
+ cpu: 1m
+
+payum:
+ # @see https://my.mollie.com/dashboard/org_XXXXXXXX/developers/api-keys
+ # even it's a fake key it must start with 'test_' or 'live_' and must be at least 30 characters long
+ gateway: "mollie"
+ apikey: "test_FRabcdefghijklmnopqrstuvwxyzab"
+
+external-dns:
+ enabled: false
+ domainFilters:
+ - demo.local
+ provider: cloudflare
+ cloudflare:
+ apiToken: ""
+ zoneIdFilters: []
+ rbac:
+ create: true
+
+# https://artifacthub.io/packages/helm/bitnami/redis
+redis:
+ enabled: true
+ auth:
+ enabled: false
+ master:
+ startupProbe:
+ enabled: true
+ initialDelaySeconds: 1
+ periodSeconds: 1
+ livenessProbe:
+ initialDelaySeconds: 0
+ readinessProbe:
+ initialDelaySeconds: 0
+ replica:
+ replicaCount: 0
+
+# https://github.com/meilisearch/meilisearch-kubernetes/blob/main/charts/meilisearch/README.md
+meilisearch:
+ enabled: true
+ environment:
+ MEILI_ENV: 'production'
+ resources:
+ requests:
+ memory: 40Mi
+
+imagePullSecrets: []
+nameOverride: ""
+fullnameOverride: ""
+
+serviceAccount:
+ # Specifies whether a service account should be created
+ create: true
+ # Annotations to add to the service account
+ annotations: {}
+ # The name of the service account to use.
+ # If not set and create is true, a name is generated using the fullname template
+ name: ""
+
+podAnnotations: {}
+
+podSecurityContext: {}
+ # fsGroup: 2000
+
+securityContext: {}
+ # capabilities:
+ # drop:
+ # - ALL
+ # readOnlyRootFilesystem: true
+ # runAsNonRoot: true
+ # runAsUser: 1000
+
+service:
+ type: ClusterIP
+ port: 80
+
+ingress:
+ enabled: true
+ annotations:
+ nginx.ingress.kubernetes.io/proxy-body-size: 6m
+ # kubernetes.io/ingress.class: nginx
+ # kubernetes.io/tls-acme: "true"
+ hosts:
+ - host: ebs.chart-example.local
+ paths:
+ - path: /
+ pathType: Prefix
+ tls: []
+ # - secretName: chart-example-tls
+ # hosts:
+ # - ghcr.io
+
+resources:
+ # We usually recommend not to specify default resources and to leave this as a conscious
+ # choice for the user. This also increases chances charts run on environments with little
+ # resources, such as Minikube. If you do want to specify resources, uncomment the following
+ # lines, adjust them as necessary, and remove the curly braces after 'resources:'.
+ # limits:
+ # cpu: 100m
+ # memory: 128Mi
+ # requests:
+ # cpu: 100m
+ # memory: 128Mi
+ php:
+ requests:
+ memory: 100Mi
+ caddy:
+ requests:
+ memory: 20Mi
+ consumer:
+ requests:
+ memory: 100Mi
+ fixtures:
+ requests:
+ memory: 100Mi
+
+# If you use Mercure, you need the managed or the On Premise version to deploy more than one pod: https://mercure.rocks/docs/hub/cluster
+replicaCount: 1
+
+autoscaling:
+ enabled: false
+ minReplicas: 1
+ maxReplicas: 100
+ targetCPUUtilizationPercentage: 80
+ # targetMemoryUtilizationPercentage: 80
+
+nodeSelector: {}
+
+tolerations: []
+
+affinity: {}
diff --git a/migrations/.gitignore b/migrations/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/migrations/2023/Version20230103090224.php b/migrations/2023/Version20230103090224.php
new file mode 100755
index 0000000..74e13bf
--- /dev/null
+++ b/migrations/2023/Version20230103090224.php
@@ -0,0 +1,25 @@
+addSql('SELECT 1 as id');
+ }
+
+ public function down(Schema $schema): void
+ {
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6dd248e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,37 @@
+{
+ "devDependencies": {
+ "@babel/core": "^7.17.0",
+ "@babel/preset-env": "^7.16.0",
+ "@hotwired/stimulus": "^3.0.0",
+ "@popperjs/core": "^2.11.6",
+ "@symfony/stimulus-bridge": "^3.2.0",
+ "@symfony/ux-autocomplete": "file:vendor/symfony/ux-autocomplete/assets",
+ "@symfony/webpack-encore": "^4.0.0",
+ "bootstrap": "^5.2.3",
+ "bootstrap-icons": "^1.10.3",
+ "core-js": "^3.23.0",
+ "eslint": "^8.33.0",
+ "regenerator-runtime": "^0.13.9",
+ "sass": "^1.56.1",
+ "sass-loader": "^13.0.0",
+ "stimulus-carousel": "^5.0.1",
+ "stimulus-password-visibility": "^2.0.0",
+ "tom-select": "^2.2.2",
+ "webpack": "^5.74.0",
+ "webpack-cli": "^4.10.0",
+ "webpack-notifier": "^1.15.0"
+ },
+ "license": "UNLICENSED",
+ "private": true,
+ "scripts": {
+ "dev-server": "encore dev-server",
+ "dev": "encore dev",
+ "watch": "encore dev --watch",
+ "build": "encore production --progress",
+ "lint": "eslint assets/ --ext .js"
+ },
+ "dependencies": {
+ "@fortawesome/fontawesome-free": "^6.2.1",
+ "flatpickr": "^4.6.13"
+ }
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..c7664f9
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,24 @@
+#includes:
+ # https://github.com/spaze/phpstan-disallowed-calls
+ #- vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon
+# https://arnaud.le-blanc.net/post/phpstan-generics.html
+# https://phpstan.org/blog/generics-in-php-using-phpdocs
+parameters:
+ level: max
+ paths:
+ - config
+ - tests
+ - public
+ - src
+ symfony:
+ container_xml_path: var/cache/dev/App_KernelDevDebugContainer.xml
+ #bootstrapFiles:
+ #- %rootDir%/../../../vendor/twig/twig/src/Extension/CoreExtension.php
+
+ # https://phpstan.org/config-reference#vague-typehints
+ checkMissingIterableValueType: true # https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type
+
+ # https://phpstan.org/user-guide/ignoring-errors#reporting-unused-ignores
+ # reportUnmatchedIgnoredErrors: true
+ ignoreErrors:
+ # - '#foobar_pattern#'
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..a524fda
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/
+
+
+
+ tests/Unit
+
+
+
+ tests/Integration
+
+
+
+ tests/Api
+
+
+
+ tests/Functional
+
+
+
+ tests/E2E
+
+
+
+
+
+ src
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/.gitignore b/public/.gitignore
new file mode 100644
index 0000000..3f549fa
--- /dev/null
+++ b/public/.gitignore
@@ -0,0 +1 @@
+uploads
diff --git a/public/apes.png b/public/apes.png
new file mode 100644
index 0000000..87e1fe2
Binary files /dev/null and b/public/apes.png differ
diff --git a/public/ckeditor.css b/public/ckeditor.css
new file mode 100644
index 0000000..59ee6fb
--- /dev/null
+++ b/public/ckeditor.css
@@ -0,0 +1,3 @@
+.cke_source {
+ color: black;
+}
diff --git a/public/index.php b/public/index.php
new file mode 100644
index 0000000..508a169
--- /dev/null
+++ b/public/index.php
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/public/svg/chat.svg b/public/svg/chat.svg
new file mode 100644
index 0000000..64854b1
--- /dev/null
+++ b/public/svg/chat.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/svg/funding.png b/public/svg/funding.png
new file mode 100644
index 0000000..b9f4fa1
Binary files /dev/null and b/public/svg/funding.png differ
diff --git a/public/svg/logo-black.svg b/public/svg/logo-black.svg
new file mode 100644
index 0000000..0be538e
--- /dev/null
+++ b/public/svg/logo-black.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/svg/logo.svg b/public/svg/logo.svg
new file mode 100644
index 0000000..3258d11
--- /dev/null
+++ b/public/svg/logo.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/svg/placeholder.svg b/public/svg/placeholder.svg
new file mode 100644
index 0000000..221761e
--- /dev/null
+++ b/public/svg/placeholder.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/svg/product.svg b/public/svg/product.svg
new file mode 100644
index 0000000..40dc859
--- /dev/null
+++ b/public/svg/product.svg
@@ -0,0 +1,6 @@
+
diff --git a/rector.php b/rector.php
new file mode 100644
index 0000000..8b0ae8f
--- /dev/null
+++ b/rector.php
@@ -0,0 +1,16 @@
+import(SetList::PHP_81);
+ $rectorConfig->import(DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES);
+ $rectorConfig->import(SymfonySetList::ANNOTATIONS_TO_ATTRIBUTES);
+ $rectorConfig->import(SensiolabsSetList::FRAMEWORK_EXTRA_61);
+};
diff --git a/src/ApiResource/.gitignore b/src/ApiResource/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/src/ApiResource/GroupResource.php b/src/ApiResource/GroupResource.php
new file mode 100644
index 0000000..ff0538e
--- /dev/null
+++ b/src/ApiResource/GroupResource.php
@@ -0,0 +1,28 @@
+ self::DESCRIPTION],
+ description: self::DESCRIPTION,
+ name: 'group_get_collection_stats',
+ provider: GroupGetStatsProvider::class
+ ),
+ ]
+)]
+class GroupResource
+{
+ final public const DESCRIPTION = 'Retrieve some stats from the group table.';
+
+ public int $count;
+}
diff --git a/src/Command/CommandTrait.php b/src/Command/CommandTrait.php
new file mode 100644
index 0000000..36abbf7
--- /dev/null
+++ b/src/Command/CommandTrait.php
@@ -0,0 +1,46 @@
+info('Memory: '.round(memory_get_usage() / 1024 / 1024, 2)." mb\n");
+ }
+
+ private function done(SymfonyStyle $io): void
+ {
+ $io->success('DONE');
+ }
+
+ protected function configureCommand(string $description): void
+ {
+ [$desc, $class] = [$description, $this::class];
+ $this
+ ->setHelp(
+ <<$class
+
+DEV:
+%command.full_name% -vv
+
+PROD:
+%command.full_name% --env=prod --no-debug
+EOT
+ );
+ }
+}
diff --git a/src/Command/EndMembershipCommand.php b/src/Command/EndMembershipCommand.php
new file mode 100644
index 0000000..f75e1e4
--- /dev/null
+++ b/src/Command/EndMembershipCommand.php
@@ -0,0 +1,97 @@
+configureCommand(self::DESCRIPTION);
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title(self::DESCRIPTION.' ('.$this->environment.' env)');
+ $this->memoryReport($io);
+
+ $io->section('Getting concerned membership...');
+ $query = $this->userGroupRepository->getExpired();
+
+ $io->section('Processing deletions...');
+ $count = 0;
+ foreach ($query->toIterable() as $userGroup) {
+ /** @var UserGroup $userGroup */
+ $user = $userGroup->getUser();
+ $group = $userGroup->getGroup();
+ $io->comment(sprintf(' > deleting membership for %s of %s (%s) (%s)',
+ $group->getName(),
+ $user->getDisplayName(),
+ $userGroup->getMembership()->value,
+ $user->getId()
+ ));
+
+ // we could pass the UserGroup instance, but let's use the same command and handler for now
+ // As it isn't a user action, this command must put the product in vaction
+ // mode to avoid leaving products public as public without the user consent.
+ $quitGroupCommand = new QuitGroupCommand($group->getId(), $user->getId(), QuitGroupCommand::VACATION);
+ $this->commandBus->dispatch($quitGroupCommand);
+ $this->appMailer->send(EndMembershipEmail::class, compact('user', 'group'));
+ $this->sendSms($user, EndMembershipEmail::class, [
+ '%group%' => $group->getName(),
+ ]);
+ ++$count;
+ }
+
+ $io->note(sprintf(' > %d deletion(s) done.', $count));
+ $this->memoryReport($io);
+ $this->done($io);
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/NotifyMembershipExpirationCommand.php b/src/Command/NotifyMembershipExpirationCommand.php
new file mode 100644
index 0000000..64a495b
--- /dev/null
+++ b/src/Command/NotifyMembershipExpirationCommand.php
@@ -0,0 +1,98 @@
+configureCommand(self::DESCRIPTION);
+ $this->addArgument('days', InputArgument::REQUIRED, 'Number of days from tomorrow (1 = notifiy members expiring tomorrow)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title(self::DESCRIPTION.' ('.$this->environment.' env)');
+ $this->memoryReport($io);
+
+ /** @var string $days */
+ $days = $input->getArgument('days');
+ $days = max(1, (int) $days);
+
+ $io->section(sprintf('Getting membership expiring in %d days...', $days));
+ $query = $this->userGroupRepository->getExpiring($days);
+ $io->section('Sending notificaitons...');
+ $count = 0;
+ foreach ($query->toIterable() as $userGroup) {
+ /** @var UserGroup $userGroup */
+ $user = $userGroup->getUser();
+ $group = $userGroup->getGroup();
+ $io->comment(sprintf(' > notifying membership for %s of %s/%s (%s) (%s)',
+ $group->getName(),
+ $user->getDisplayName(),
+ $userGroup->getEndAt()?->format('Y-m-d'),
+ $userGroup->getMembership()->value,
+ $user->getId()
+ ));
+
+ $this->appMailer->send(NotifyMembershipExpirationEmail::class, compact('user', 'group', 'days'));
+ $this->sendSms($user, EndMembershipEmail::class, [
+ '%group%' => $group->getName(),
+ '%days' => $days,
+ ]);
+ ++$count;
+ }
+
+ $io->note(sprintf(' > %d notification(s) sent.', $count));
+
+ $this->memoryReport($io);
+ $this->done($io);
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/NotifyServiceRequestDatesCommand.php b/src/Command/NotifyServiceRequestDatesCommand.php
new file mode 100644
index 0000000..bde2db3
--- /dev/null
+++ b/src/Command/NotifyServiceRequestDatesCommand.php
@@ -0,0 +1,116 @@
+configureCommand(self::DESCRIPTION);
+ $this->addArgument('mode', InputArgument::REQUIRED, 'If the notification is related to the startAt (value = start) date or endAt date (vakue = end) of the service request.');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title(self::DESCRIPTION.' ('.$this->environment.' env)');
+ $this->memoryReport($io);
+
+ /** @var string $mode */
+ $mode = $input->getArgument('mode');
+ $isStartMode = $mode === 'start';
+ $io->note(' > mode : '.$mode);
+ $io->newLine();
+
+ $io->section('Getting services request...');
+ $query = $isStartMode ?
+ $this->serviceRequestRepository->getStartingAtTomorow() :
+ $this->serviceRequestRepository->getEndingAtTomorow()
+ ;
+ $emailClass = $isStartMode ?
+ NotifyServiceRequestStartEmail::class :
+ NotifyServiceRequestEndEmail::class
+ ;
+
+ $io->section('Sending notifications...');
+ $count = 0;
+ foreach ($query->toIterable() as $serviceRequest) {
+ /** @var ServiceRequest $serviceRequest */
+ $referenceDate = $isStartMode ?
+ $serviceRequest->getStartAt() :
+ $serviceRequest->getEndAt()
+ ;
+ $io->comment(sprintf(' > notifying owner and recipient for service request %s (%s) starting on %s.',
+ $serviceRequest->getId(),
+ $serviceRequest->getStatus()->value,
+ $referenceDate->format('Y-m-d')
+ ));
+ $context = [
+ 'service_request' => $serviceRequest,
+ 'user' => $serviceRequest->getOwner(),
+ '%product%' => $serviceRequest->getProduct()->getName(),
+ '%date%' => $referenceDate->format($this->translator->trans('format.date', [], 'date')),
+ ];
+
+ $this->appMailer->send($emailClass, $context);
+ $this->sendSms($serviceRequest->getOwner(), $emailClass, $context);
+
+ $context['user'] = $serviceRequest->getRecipient();
+ $this->appMailer->send($emailClass, $context);
+ $this->sendSms($serviceRequest->getRecipient(), $emailClass, $context);
+
+ ++$count;
+ }
+
+ $io->note(sprintf(' > %d notification(s) sent.', $count * 2)); // owner and recipient
+
+ $this->memoryReport($io);
+ $this->done($io);
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/src/Controller/Admin/AbstractCategoryCrudController.php b/src/Controller/Admin/AbstractCategoryCrudController.php
new file mode 100644
index 0000000..026929d
--- /dev/null
+++ b/src/Controller/Admin/AbstractCategoryCrudController.php
@@ -0,0 +1,279 @@
+setEntityLabelInPlural($this->getEntityLabelInPlural())
+ ->setEntityLabelInSingular($this->getEntityLabelInSingular())
+ ->setSearchFields(['name'])
+ ->setFormThemes([
+ '@EasyAdmin/crud/form_theme.html.twig',
+ 'easy_admin/crud/form_theme.html.twig',
+ ])
+ ->setPaginatorPageSize(100) // display everyhting to have the while hierarchical view
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add('name')
+ ->add('parent')
+ ->add('enabled')
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $moveDownAction = Action::new('down', $this->getI18nPrefix(self::class).'.menu.action.move_down', 'fa-sharp fa-solid fa-arrow-down')
+ ->linkToCrudAction('moveDown');
+ $moveUpAction = Action::new('up', $this->getI18nPrefix(self::class).'.menu.action.move_up', 'fa-sharp fa-solid fa-arrow-up')
+ ->linkToCrudAction('moveUp');
+
+ // don't display the delete link if the item has children
+ $deleteAction = Action::new('delete', 'menu.action.delete')
+ ->linkToCrudAction('delete')
+ ->displayIf(static function (Category $category) {
+ return !$category->hasChildren();
+ })
+ ->setCssClass('dropdown-item action-delete text-danger');
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ->add(Crud::PAGE_INDEX, $moveDownAction)
+ ->add(Crud::PAGE_INDEX, $moveUpAction)
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->add(Crud::PAGE_INDEX, $deleteAction)
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Category::class;
+ }
+
+ public function getEntityLabelInSingular(): string
+ {
+ return 'category';
+ }
+
+ public function createEntity(string $entityFqcn): Category
+ {
+ /** @var Category $category */
+ $category = new $entityFqcn();
+ $category->setType($this->getCategoryType());
+
+ return $category;
+ }
+
+ /**
+ * Only display a given type.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ $alias = $qb->getRootAliases()[0] ?? null;
+ $qb->andWhere($alias.'.type = :type')
+ ->setParameter('type', $this->getCategoryType())
+ ->orderBy($alias.'.lft', 'ASC')
+ ;
+
+ return $qb;
+ }
+
+ /**
+ * Return all possible fields.
+ *
+ * @return array
+ */
+ public function getFields(string $pageName): array
+ {
+ $idField = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+
+ $typeField = ChoiceField::new('type')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', ProductType::class)
+ ->setChoices(ProductType::getAsArray());
+
+ $parentField = AssociationField::new('parent')
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) {
+ return $this->categoryRepository->addTypeFilter($queryBuilder, $this->getCategoryType());
+ })
+ ->setLabel('category.parent')
+ ->setRequired(false)
+ ;
+
+ $nameField = TextField::new('name')
+ ->formatValue(static function ($value, Category $category) {
+ return $category->getNameWithIndent();
+ });
+
+ $enabledField = $this->getSimpleBooleanField('enabled');
+
+ $imageField = ImageField::new('image')
+ ->setLabel('image.default')
+ ->setHelp('image.help')
+ ->setBasePath($this->categoryBasePath) // correctly set the formatted value available in the template
+ ->setUploadDir('public'.$this->categoryBasePath)
+ ->setUploadedFileNamePattern('[uuid].[extension]')
+ ->setFormTypeOption('upload_new', $this->easyAdminHelper->getUploadNewCallback($this->categoryStorage))
+ ->setFormTypeOption('upload_delete', $this->easyAdminHelper->getUploadDeleteCallback($this->categoryStorage))
+ ->setFormTypeOption('constraints', $this->mediaManager->getFileConstraints())
+ ->setTemplatePath('easy_admin/field/flysystem_image.html.twig')
+ ->setHelp($this->mediaManager->getHelpMessage())
+ ;
+
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ return compact(
+ 'idField',
+ 'parentField',
+ 'typeField',
+ 'nameField',
+ 'enabledField',
+ 'imageField',
+ 'createdAt',
+ 'updatedAt'
+ );
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $panels = $this->getPanels();
+
+ [
+ 'idField' => $idField,
+ 'typeField' => $typeField,
+ 'parentField' => $parentField,
+ 'nameField' => $nameField,
+ 'enabledField' => $enabledField,
+ 'imageField' => $imageField,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ ] = $this->getFields($pageName);
+
+ // list
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$nameField, $parentField, $enabledField, $imageField, $createdAt];
+ }
+
+ // forms
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ return [$nameField, $parentField, $enabledField, $imageField];
+ }
+
+ // detail
+
+ return [
+ $panels['information'],
+ $parentField,
+ $typeField,
+ $nameField,
+ $enabledField,
+ $imageField,
+ $panels['tech_information'],
+ $idField,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+
+ public function moveUp(AdminContext $context): Response
+ {
+ /** @var Category $item */
+ $item = $context->getEntity()->getInstance();
+ $this->categoryRepository->moveUp($item);
+
+ return $this->redirectToObjectCrudPage();
+ }
+
+ public function moveDown(AdminContext $context): Response
+ {
+ /** @var Category $item */
+ $item = $context->getEntity()->getInstance();
+ $this->categoryRepository->moveDown($item);
+
+ return $this->redirectToObjectCrudPage();
+ }
+
+ private function redirectToObjectCrudPage(): RedirectResponse
+ {
+ $this->addFlashSuccess($this->getI18nPrefix(self::class).'.move.success');
+ $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getCrudControllerClass())
+ ->setAction('index');
+
+ return $this->redirect($this->adminUrlGenerator->generateUrl());
+ }
+}
diff --git a/src/Controller/Admin/AbstractMenuCrudController.php b/src/Controller/Admin/AbstractMenuCrudController.php
new file mode 100644
index 0000000..afc10cc
--- /dev/null
+++ b/src/Controller/Admin/AbstractMenuCrudController.php
@@ -0,0 +1,168 @@
+setPageTitle(Crud::PAGE_EDIT, t($this->getPageTitle(), [], DashboardController::DOMAIN))
+ ->setPageTitle(Crud::PAGE_DETAIL, t($this->getEntityLabelInSingular(), [], DashboardController::DOMAIN))
+ ->setEntityLabelInPlural($this->getEntityLabelInPlural())
+ ->setEntityLabelInSingular($this->getEntityLabelInSingular())
+ ->setFormThemes([
+ '@EasyAdmin/crud/form_theme.html.twig',
+ 'easy_admin/crud/form_theme.html.twig',
+ ])
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ // Better button label for this kind of page
+ $actions->update(Crud::PAGE_EDIT, Action::SAVE_AND_CONTINUE, function (Action $action) {
+ return $action->setLabel('action.save');
+ });
+
+ $itemsListUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getMenuItemCrudControllerClass())
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', $this->getMenuItemsIndex())
+ ->generateUrl();
+
+ $itemsList = Action::new('editMenuItems', 'menu.action.items_list')
+ ->linkToUrl($itemsListUrl);
+
+ return $actions
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->remove(Crud::PAGE_DETAIL, Action::DELETE)
+ ->remove(Crud::PAGE_DETAIL, Action::INDEX)
+ ->remove(Crud::PAGE_EDIT, Action::SAVE_AND_RETURN)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, $itemsList)
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Menu::class;
+ }
+
+ /**
+ * Return all possible fields.
+ *
+ * @return array
+ */
+ public function getFields(string $pageName): array
+ {
+ $logoField = ImageField::new('logo')
+ ->setBasePath($this->menuBasePath) // correctly set the formatted value available in the template
+ ->setUploadDir('public'.$this->menuBasePath)
+ ->setUploadedFileNamePattern('[uuid].[extension]')
+ ->setFormTypeOption('upload_new', $this->easyAdminHelper->getUploadNewCallback($this->defaultStorage))
+ ->setFormTypeOption('upload_delete', $this->easyAdminHelper->getUploadDeleteCallback($this->defaultStorage))
+ ->setFormTypeOption('constraints', $this->mediaManager->getFileConstraints())
+ ->setTemplatePath('easy_admin/field/flysystem_image.html.twig')
+ ->setHelp($this->mediaManager->getHelpMessage())
+ ;
+
+ $code = TextField::new('code');
+ $items = CollectionField::new('items')
+ ->useEntryCrudForm(MenuItemCrudController::class)
+ ;
+ $itemsCount = IntegerField::new('itemsCount');
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ return compact(
+ 'logoField',
+ 'items',
+ 'itemsCount',
+ 'code',
+ 'createdAt',
+ 'updatedAt',
+ );
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $panels = $this->getPanels();
+
+ [
+ 'logoField' => $logoField,
+ 'itemsCount' => $itemsCount,
+ 'code' => $code,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$code, $logoField, $itemsCount, $createdAt, $updatedAt];
+ }
+
+ if ($pageName === Crud::PAGE_DETAIL) {
+ return [
+ $panels['information'],
+ $logoField,
+ $itemsCount,
+ $panels['tech_information'],
+ $code,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+
+ // edit page
+ return [$logoField];
+ }
+}
diff --git a/src/Controller/Admin/AbstractMenuItemCrudController.php b/src/Controller/Admin/AbstractMenuItemCrudController.php
new file mode 100644
index 0000000..6bc428d
--- /dev/null
+++ b/src/Controller/Admin/AbstractMenuItemCrudController.php
@@ -0,0 +1,284 @@
+ 1,
+ Menu::FOOTER => 2,
+ ];
+
+ public function __construct(
+ private readonly AdminUrlGenerator $adminUrlGenerator,
+ private readonly MenuRepository $menuRepository,
+ private readonly MenuItemRepository $menuItemRepository,
+ ) {
+ }
+
+ public function configureCrud(Crud $crud): Crud
+ {
+ return $crud
+ ->setEntityLabelInPlural($this->getEntityLabelInPlural())
+ ->setEntityLabelInSingular($this->getEntityLabelInSingular())
+ ->setDefaultSort(['parent' => 'DESC', 'position' => 'ASC']);
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add('name')
+ ->add('link')
+ ->add('parent')
+ ->add(EnumFilter::new('mediaType', SocialMediaTypeType::class))
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $editLogoUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getMenuControllerClass())
+ ->setEntityId(self::MENUS[$this->getCode()])
+ ->set('crudAction', Crud::PAGE_EDIT)
+ ->generateUrl();
+
+ $editLogo = Action::new('editMenuItems', 'menu.action.edit_logo', 'fas fa-edit')
+ ->linkToUrl($editLogoUrl)->createAsGlobalAction();
+
+ $moveDownPosition = Action::new('down', 'menu.action.down_item', 'fa-sharp fa-solid fa-arrow-down')
+ ->linkToCrudAction('moveDownPosition');
+
+ $moveUpPosition = Action::new('up', 'menu.action.up_item', 'fa-sharp fa-solid fa-arrow-up')
+ ->linkToCrudAction('moveUpPosition')
+ ->displayIf(static function (MenuItem $item) {
+ return !$item->isFirst();
+ });
+
+ // don't display the delete link if the item has children
+ $deleteAction = Action::new('delete', 'menu.action.delete')
+ ->linkToCrudAction('delete')
+ ->displayIf(static function (MenuItem $item) {
+ return !$item->hasChildren();
+ })
+ ->setCssClass('dropdown-item action-delete text-danger');
+
+ $newMenuItemLinkUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getNewMenuItemLinkController())
+ ->set('crudAction', Crud::PAGE_NEW)
+ ->generateUrl();
+
+ $newLinkGlobalAction = Action::new('link', 'icon.text')
+ ->linkToUrl($newMenuItemLinkUrl)->createAsGlobalAction();
+
+ $newMenuItemIconUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getNewMenuItemIconController())
+ ->set('crudAction', Crud::PAGE_NEW)
+ ->generateUrl();
+
+ $newMenuItemGlobalAction = Action::new('icon', 'icon.menu')
+ ->linkToUrl($newMenuItemIconUrl)->createAsGlobalAction();
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ->add(Crud::PAGE_INDEX, $editLogo)
+ ->add(Crud::PAGE_INDEX, $moveDownPosition)
+ ->add(Crud::PAGE_INDEX, $moveUpPosition)
+ ->add(Crud::PAGE_INDEX, $newLinkGlobalAction)
+ ->add(Crud::PAGE_INDEX, $newMenuItemGlobalAction)
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->add(Crud::PAGE_INDEX, $deleteAction)
+ ;
+ }
+
+ private function redirectToObjectCrudPage(bool $withFlash = true): RedirectResponse
+ {
+ if ($withFlash) {
+ $this->addFlash('success', 'menu_item.update_successful');
+ }
+ $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController($this->getMenuItemsControllerClass())
+ ->setAction('index');
+
+ return $this->redirect($this->adminUrlGenerator->generateUrl());
+ }
+
+ public function moveUpPosition(AdminContext $context): Response
+ {
+ /** @var MenuItem $item */
+ $item = $context->getEntity()->getInstance();
+ $item->setPosition($item->up());
+ $this->menuItemRepository->save($item, true);
+
+ return $this->redirectToObjectCrudPage();
+ }
+
+ public function moveDownPosition(AdminContext $context): Response
+ {
+ /** @var MenuItem $item */
+ $item = $context->getEntity()->getInstance();
+ $oldPosition = $item->getPosition();
+ $item->setPosition($item->down());
+ $this->menuItemRepository->save($item, true);
+ $newPosition = $item->getPosition();
+
+ return $this->redirectToObjectCrudPage($newPosition !== $oldPosition);
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return MenuItem::class;
+ }
+
+ public function createEntity(string $entityFqcn): MenuItem
+ {
+ /** @var MenuItem $menuItem */
+ $menuItem = new $entityFqcn();
+ $menuItem->setMenu($this->getMenu());
+
+ return $menuItem;
+ }
+
+ public function getMenu(): Menu
+ {
+ return $this->menuRepository->getByCode($this->getCode());
+ }
+
+ /**
+ * Only display menu items corresponding to a given menu.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ $alias = $qb->getRootAliases()[0] ?? null;
+ $qb->andWhere($alias.'.menu = :menu')
+ ->setParameter('menu', $this->getMenu());
+
+ return $qb;
+ }
+
+ /**
+ * Return all possible fields.
+ *
+ * @return array
+ */
+ public function getFields(string $pageName): array
+ {
+ $linkTypeField = ChoiceField::new('linkType')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', LinkType::class)
+ ->setChoices(LinkType::getAsArray())
+ ;
+ $nameField = TextField::new('name')
+ ->setRequired(true);
+ $linkField = TextField::new('link');
+
+ $socialMediaTypeField = ChoiceField::new('mediaType')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', SocialMediaType::class)
+ ->setChoices(SocialMediaType::getAsArray())
+ ->setRequired(true);
+
+ $parentField = AssociationField::new('parent')
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) {
+ return $this->menuItemRepository->getLinksByCode($queryBuilder, $this->getCode());
+ })
+ ->setRequired(false);
+ $menuField = AssociationField::new('menu');
+ $positionField = IntegerField::new('position');
+ $positionHumanField = IntegerField::new('positionHuman');
+
+ return compact(
+ 'linkTypeField',
+ 'nameField',
+ 'linkField',
+ 'socialMediaTypeField',
+ 'parentField',
+ 'menuField',
+ 'positionField',
+ 'positionHumanField',
+ );
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ [
+ 'linkTypeField' => $linkTypeField,
+ 'nameField' => $nameField,
+ 'linkField' => $linkField,
+ 'socialMediaTypeField' => $socialMediaTypeField,
+ 'parentField' => $parentField,
+ 'positionHumanField' => $positionHumanField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_EDIT) {
+ /** @var ChoiceField $socialMediaTypeField */
+ $socialMediaTypeField->setChoices(SocialMediaType::cases());
+
+ /** @var MenuItem $item */
+ $item = $this->getContext()?->getEntity()->getInstance();
+
+ if ($item->getLinkType() === LinkType::LINK) {
+ return [$nameField, $linkField, $parentField];
+ }
+
+ // social media
+ return [$socialMediaTypeField, $linkField];
+ }
+
+ // show + list
+
+ return [$nameField, $linkTypeField, $linkField, $parentField, $socialMediaTypeField, $positionHumanField];
+ }
+}
diff --git a/src/Controller/Admin/AbstractProductCrudController.php b/src/Controller/Admin/AbstractProductCrudController.php
new file mode 100755
index 0000000..8db4bf8
--- /dev/null
+++ b/src/Controller/Admin/AbstractProductCrudController.php
@@ -0,0 +1,276 @@
+setEntityLabelInPlural($this->getEntityLabelInPlural())
+ ->setEntityLabelInSingular($this->getEntityLabelInSingular())
+ ->setSearchFields(['name', 'description'])
+ ->setDefaultSort(['createdAt' => 'DESC'])
+ ->setFormThemes([
+ '@EasyAdmin/crud/form_theme.html.twig',
+ 'easy_admin/crud/form_theme.html.twig',
+ ])
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add(EnumFilter::new('status', ProductStatusType::class))
+ ->add(EnumFilter::new('visibility', ProductVisibilityType::class))
+ ->add('category')
+ ->add('owner')
+ ->add('name')
+ ->add('description')
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $onBreak = Action::new('onBreak', 'action.onBreak')
+ ->linkToCrudAction('changeStatus')
+ ->displayIf(static function (Product $product) {
+ return $product->isActive();
+ });
+
+ $activate = Action::new('activate', 'action.activate')
+ ->linkToCrudAction('changeStatus')
+ ->displayIf(static function (Product $product) {
+ return $product->isPaused();
+ });
+
+ $availability = Action::new('availability', 'action.availability')
+ ->linkToCrudAction('linkToProductAvailabilityPage');
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ->add(Crud::PAGE_INDEX, $onBreak)
+ ->add(Crud::PAGE_INDEX, $activate)
+ ->add(Crud::PAGE_DETAIL, $availability);
+ }
+
+ private function redirectToObjectCrudPage(): RedirectResponse
+ {
+ $this->adminUrlGenerator->setController(ObjectCrudController::class)->setAction('index')->removeReferrer()->setEntityId(null);
+
+ return $this->redirect($this->adminUrlGenerator->generateUrl());
+ }
+
+ public function linkToProductAvailabilityPage(): Response
+ {
+ return $this->render('/admin/product/availability_product.html.twig');
+ }
+
+ public function changeStatus(AdminContext $context): Response
+ {
+ /** @var Product $product */
+ $product = $context->getEntity()->getInstance();
+ if ($product->isPaused()) {
+ $product->setStatus(ProductStatus::ACTIVE);
+ } else {
+ $product->setStatus(ProductStatus::PAUSED);
+ }
+ $this->productRepository->save($product, true);
+
+ return $this->redirectToObjectCrudPage();
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Product::class;
+ }
+
+ public function createEntity(string $entityFqcn): Product
+ {
+ /** @var Product $product */
+ $product = new $entityFqcn();
+ $product->setType($this->getProductType());
+
+ return $product;
+ }
+
+ /**
+ * Only display a given product type.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ $alias = $qb->getRootAliases()[0] ?? null;
+ $qb->andWhere($alias.'.type = :type')
+ ->setParameter('type', $this->getProductType());
+
+ return $qb;
+ }
+
+ /**
+ * Return all possible product fields.
+ *
+ * @return array
+ */
+ public function getFields(string $pageName): array
+ {
+ $idField = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+
+ $typeField = ChoiceField::new('type')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', ProductType::class)
+ ->setChoices(ProductType::getAsArray());
+
+ $statusField = ChoiceField::new('status')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', ProductStatus::class)
+ ->setChoices(ProductStatus::getAsArray());
+
+ $visibilityField = ChoiceField::new('visibility')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', ProductVisibility::class)
+ ->setChoices(ProductVisibility::getAsArray());
+ $groupsField = CollectionField::new('groups');
+
+ $ownerField = AssociationField::new('owner');
+ $categoryField = AssociationField::new('category')
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) {
+ return $this->categoryRepository->addTypeFilter($queryBuilder, $this->getProductType());
+ })
+ ;
+
+ $nameField = TextField::new('name');
+ $descriptionField = TextareaField::new('description');
+
+ // objects
+ $ageField = TextField::new('age');
+ $depositField = MoneyField::new('deposit')
+ ->setCurrencyPropertyPath('currency')
+ ->setStoredAsCents()
+ ->setNumDecimals(0)
+ ;
+ $currencyField = CurrencyField::new('currency');
+
+ // services
+ $durationField = TextField::new('duration');
+
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ $imageField = ImageField::new('images')
+ ->setBasePath($this->productBasePath) // correctly set the formatted value available in the template
+ ->setUploadDir('public'.$this->productBasePath)
+ ->setUploadedFileNamePattern('[uuid].[extension]')
+ ->setFormTypeOption('upload_new', $this->easyAdminHelper->getUploadNewCallback($this->productStorage))
+ ->setFormTypeOption('upload_delete', $this->easyAdminHelper->getUploadDeleteCallback($this->productStorage))
+ ->setFormTypeOption('required', false)
+ ->setFormTypeOption('multiple', true)
+ ->setFormTypeOption('constraints', $this->mediaManager->getImageArrayConstraints())
+ ->setTemplatePath('easy_admin/field/flysystem_images.html.twig')
+ ->setCustomOption('first_image_only', true)
+ ->setHelp($this->mediaManager->getHelpMessage())
+ ->setSortable(false)
+ ;
+
+ $addressField = TextField::new('address');
+
+ if ($pageName === Crud::PAGE_DETAIL) {
+ /** @var Product $product */
+ $product = $this->getContext()?->getEntity()->getInstance();
+
+ $addressField = TextField::new('address')->setValue($product->getOwner()->getAddress()?->getDisplayName());
+ }
+
+ $preferredLoanDuration = TextField::new('preferredLoanDuration');
+
+ return compact(
+ 'idField',
+ 'typeField',
+ 'statusField',
+ 'visibilityField',
+ 'groupsField',
+ 'ownerField',
+ 'categoryField',
+ 'nameField',
+ 'descriptionField',
+ 'ageField',
+ 'durationField',
+ 'depositField',
+ 'currencyField',
+ 'createdAt',
+ 'updatedAt',
+ 'imageField',
+ 'addressField',
+ 'preferredLoanDuration',
+ );
+ }
+}
diff --git a/src/Controller/Admin/AbstractUserCrudController.php b/src/Controller/Admin/AbstractUserCrudController.php
new file mode 100755
index 0000000..c914d39
--- /dev/null
+++ b/src/Controller/Admin/AbstractUserCrudController.php
@@ -0,0 +1,446 @@
+mailer = $mailer;
+ }
+
+ public function configureCrud(Crud $crud): Crud
+ {
+ return $crud
+ ->setEntityLabelInPlural($this->getEntityLabelInPlural())
+ ->setEntityLabelInSingular($this->getEntityLabelInSingular())
+ ->setSearchFields(['email', 'firstname', 'lastname', 'name'])
+ ->setDefaultSort(['id' => 'ASC'])
+ ->setFormThemes([
+ '@EasyAdmin/crud/form_theme.html.twig',
+ 'easy_admin/crud/form_theme.html.twig',
+ ])
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add(GroupFilter::new('group'))
+ ->add('email')
+ ->add('firstname')
+ ->add('lastname')
+ ->add('enabled')
+ ->add('createdAt')
+ ->add('updatedAt')
+ ->add(DateTimeFilter::new('loginAt')
+ ->setFormTypeOption('value_type_options', ['widget' => 'choice'])
+ )
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $currentUser = $this->security->getUser();
+
+ $connectAs = Action::new('connectAs', 'action.connectAs', 'fas fa-sign-in-alt')
+ ->linkToCrudAction('connectAs')
+ ->displayIf(fn (User $user) => $currentUser !== $user && $user->isEnabled() && $user->isEmailConfirmed());
+ $actions
+ ->add(Crud::PAGE_INDEX, $connectAs);
+
+ $promoteToAdmin = Action::new('promoteToAdmin', 'action.promoteToAdmin')
+ ->linkToCrudAction('promoteToAdmin')
+ ->displayIf(fn (User $user) => !$user->isAdmin() && !$user->isMainAdminAccount());
+ $actions
+ ->add(Crud::PAGE_INDEX, $promoteToAdmin);
+
+ $deleteCallback = function (Action $action) use ($currentUser) {
+ return $action->displayIf(fn (User $user) => $currentUser !== $user && !$user->isMainAdminAccount());
+ };
+ $actions->update(Crud::PAGE_INDEX, 'delete', $deleteCallback);
+ $actions->update(Crud::PAGE_DETAIL, 'delete', $deleteCallback);
+
+ $exportAction = Action::new('export')
+ ->linkToUrl(function () {
+ /** @var AdminContext $context */
+ $context = $this->getContext();
+
+ return $this->adminUrlGenerator->setAll($context->getRequest()->query->all())
+ ->setEntityId(null)
+ ->setAction('export')
+ ->generateUrl();
+ })
+ ->addCssClass('btn btn-success')
+ ->setIcon('fa fa-download')
+ ->createAsGlobalAction()
+ ;
+ $actions->add(Crud::PAGE_INDEX, $exportAction);
+
+ $viewPayments = Action::new('payments')
+ ->linkToUrl(function () {
+ /** @var AdminContext $context */
+ $context = $this->getContext();
+ /** @var User $user */
+ $user = $context->getEntity()->getInstance();
+
+ return $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(PaymentCrudController::class)
+ ->setAction(Action::INDEX)
+ ->set('filters[user]', $user->getId())
+ ->generateUrl();
+ })
+ ->displayIf(fn (User $user) => !$user->getPayments()->isEmpty())
+ ->setIcon('fas fa-credit-card')
+ ;
+ $actions->add(Crud::PAGE_DETAIL, $viewPayments);
+
+ return $actions
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ;
+ }
+
+ /**
+ * Impersonate action so we can do some more processing before changing user.
+ */
+ public function connectAs(AdminContext $context): Response
+ {
+ /** @var User $targetUser */
+ $targetUser = $context->getEntity()->getInstance();
+
+ $message = new TranslatableMessage(
+ 'flash.warning.connectAs',
+ ['%target_user%' => $targetUser->getUserIdentifier()],
+ DashboardController::DOMAIN
+ );
+
+ $this->addFlash(
+ 'warning',
+ $message
+ );
+ $route = $targetUser->isAdmin() ? 'admin' : 'home'; // if user is not admin redirect to home page
+
+ return $this->redirectToRoute($route, [self::SWITCH_USER_PARAMETER => $targetUser->getUserIdentifier()]);
+ }
+
+ /**
+ * @throws TransportExceptionInterface
+ */
+ public function promoteToAdmin(AdminContext $context): Response
+ {
+ /** @var User $targetUser */
+ $targetUser = $context->getEntity()->getInstance();
+ $targetUser->setType(UserType::ADMIN)->promoteToAdmin();
+ $this->userManager->save($targetUser, true);
+
+ $message = new TranslatableMessage(
+ 'flash.success.promoteToAdmin',
+ ['%target_user%' => $targetUser->getUserIdentifier()],
+ DashboardController::DOMAIN
+ );
+
+ $this->addFlash(
+ 'success',
+ $message
+ );
+
+ $userContext = [];
+ $userContext['user'] = $targetUser;
+ $this->mailer->send(PromoteToAdminEmail::class, $userContext);
+
+ return $this->redirect(
+ $this
+ ->adminUrlGenerator
+ ->unsetAll()
+ ->setController(AdministratorCrudController::class)
+ ->setAction(Action::INDEX)
+ ->generateUrl()
+ );
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return User::class;
+ }
+
+ /**
+ * We consider the admin know what he does, in case of error he can simply modify
+ * the email.
+ */
+ public function createEntity(string $entityFqcn): User
+ {
+ /** @var User $user */
+ $user = new $entityFqcn();
+ $user->setType($this->getUserType());
+ $this->userManager->finalizeAccountCreateStep2($user);
+
+ return $user;
+ }
+
+ /**
+ * Only display a given user type.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ $alias = $qb->getRootAliases()[0] ?? null;
+ $qb->andWhere($alias.'.type = :type')
+ ->setParameter('type', $this->getUserType());
+
+ return $qb;
+ }
+
+ /**
+ * Return all possible user fields.
+ *
+ * @return array
+ */
+ public function getFields(string $pageName): array
+ {
+ $i18prefix = $this->getI18nPrefix(self::class);
+ $idField = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+
+ $emailField = EmailField::new('email');
+ $firstNameField = TextField::new('firstname')->setRequired(true);
+ $lastNameField = TextField::new('lastname')->setRequired(true);
+ $nameField = TextField::new('name')->setRequired(true);
+
+ $plainPassword = TextField::new('plainPassword')
+ ->setFormType(RepeatedType::class)
+ ->setFormTypeOptions([
+ 'type' => PasswordType::class,
+ 'required' => $pageName === Crud::PAGE_NEW,
+ 'first_options' => [
+ 'attr' => [
+ 'autocomplete' => 'new-password',
+ ],
+ ],
+ 'second_options' => [
+ 'attr' => [
+ 'autocomplete' => 'new-password',
+ ],
+ ],
+ ]);
+
+ $enabledField = $this->getSimpleBooleanField('enabled');
+ $emailConfirmedField = $this->getSimpleBooleanField('emailConfirmed');
+
+ $typeField = ChoiceField::new('type')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', UserType::class)
+ ->setChoices(UserType::getAsArray());
+
+ $loginAt = DateTimeField::new('loginAt');
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ $avatarField = ImageField::new('avatar')
+ ->setBasePath($this->userBasePath) // correctly set the formatted value available in the template
+ ->setUploadDir('public'.$this->userBasePath)
+ ->setUploadedFileNamePattern('[uuid].[extension]')
+ ->setFormTypeOption('upload_new', $this->easyAdminHelper->getUploadNewCallback($this->userStorage))
+ ->setFormTypeOption('upload_delete', $this->easyAdminHelper->getUploadDeleteCallback($this->userStorage))
+ ->setFormTypeOption('constraints', $this->mediaManager->getFileConstraints())
+ ->setTemplatePath('easy_admin/field/flysystem_image.html.twig')
+ ->setHelp($this->mediaManager->getHelpMessage())
+ ;
+
+ $phoneNumberField = TextField::new('phone')
+ ->setFormType(PhoneNumberType::class)
+ ->setFormTypeOptions([
+ 'format' => PhoneNumberFormat::INTERNATIONAL,
+ 'required' => false,
+ ])
+ ->setHelp($i18prefix.'.field.phone.help')
+ ;
+
+ $scheduleField = TextField::new('schedule');
+ $categoryField = AssociationField::new('category')
+ ->setFormTypeOption('choice_label', function (Category $category) {
+ return $this->translator->trans($category->getType()->name, [], 'admin').' / '.$category->getName();
+ })
+ ->setRequired(false)
+ ;
+ $descriptionField = TextareaField::new('description');
+ $smsNotificationsField = BooleanField::new('smsNotifications');
+ $vacationModeField = BooleanField::new('vacationMode');
+ $addressField = AssociationField::new('address');
+
+ return compact(
+ 'idField',
+ 'emailField',
+ 'firstNameField',
+ 'lastNameField',
+ 'nameField',
+ 'plainPassword',
+ 'enabledField',
+ 'emailConfirmedField',
+ 'typeField',
+ 'loginAt',
+ 'createdAt',
+ 'updatedAt',
+ 'avatarField',
+ 'phoneNumberField',
+ 'scheduleField',
+ 'categoryField',
+ 'descriptionField',
+ 'smsNotificationsField',
+ 'vacationModeField',
+ 'addressField',
+ );
+ }
+
+ /**
+ * For now, we export exactly what we see in the list to avoid security problems.
+ */
+ public function export(AdminContext $context): Response
+ {
+ $fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
+ /** @var CrudDto $crud Crud is defined here */
+ $crud = $context->getCrud();
+ $filters = $this->filterFactory->create($crud->getFiltersConfig(), $fields, $context->getEntity());
+ /** @var SearchDto $search */
+ $search = $context->getSearch();
+ $queryBuilder = $this->createIndexQueryBuilder($search, $context->getEntity(), $fields, $filters);
+ $fileName = $this->slugger->slug($this->translator->trans($this->getEntityLabelInPlural(), [], DashboardController::DOMAIN));
+
+ return $this->csvExporter->createResponseFromQueryBuilder($queryBuilder, $fields, $fileName.'.csv');
+ }
+
+ /**
+ * We can't delete the main admin account or ourself. This is an additional
+ * protection as the delete button is already disabled in the list.
+ */
+ public function delete(AdminContext $context)
+ {
+ /** @var User $userToDelete */
+ $userToDelete = $context->getEntity()->getInstance();
+ $currentUser = $this->security->getUser();
+ if ($userToDelete === $currentUser || $userToDelete->isMainAdminAccount()) {
+ throw $this->createAccessDeniedException('Cannot delete this user (self or main admin account).');
+ }
+
+ return parent::delete($context);
+ }
+
+ /**
+ * Special process for the phone number as it uses a custom form type.
+ *
+ * @see EditProfileFormType::onPostSubmit
+ */
+ public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
+ {
+ /** @var User $entityInstance */
+ $entityInstance->changePhoneNumber($entityInstance->phone);
+
+ parent::updateEntity($entityManager, $entityInstance);
+ }
+
+ /**
+ * We need to normalize the email to make work the unique entity properly.
+ */
+ public function createNewFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
+ {
+ $builder = $this->container->get(FormFactory::class)->createNewFormBuilder($entityDto, $formOptions, $context);
+ $this->userManager->addEmailNormalizeSubmitEvent($builder);
+
+ return $builder;
+ }
+
+ public function createEditFormBuilder(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormBuilderInterface
+ {
+ $builder = $this->container->get(FormFactory::class)->createEditFormBuilder($entityDto, $formOptions, $context);
+ $this->userManager->addEmailNormalizeSubmitEvent($builder);
+
+ return $builder;
+ }
+}
diff --git a/src/Controller/Admin/AdminSecuredCrudControllerInterface.php b/src/Controller/Admin/AdminSecuredCrudControllerInterface.php
new file mode 100644
index 0000000..a8929ad
--- /dev/null
+++ b/src/Controller/Admin/AdminSecuredCrudControllerInterface.php
@@ -0,0 +1,16 @@
+promoteToAdmin();
+
+ return $user;
+ }
+
+ /**
+ * @throws TransportExceptionInterface
+ */
+ public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
+ {
+ parent::persistEntity($entityManager, $entityInstance);
+
+ $context = [];
+ $context['user'] = $entityInstance;
+ $this->mailer->send(NewAdminEmail::class, $context);
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $panels = $this->getPanels();
+
+ [
+ 'idField' => $idField,
+ 'emailField' => $emailField,
+ 'firstNameField' => $firstNameField,
+ 'lastNameField' => $lastNameField,
+ 'plainPassword' => $plainPassword,
+ 'enabledField' => $enabledField,
+ 'loginAt' => $loginAt,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [
+ $emailField,
+ $firstNameField,
+ $lastNameField,
+ $enabledField,
+ $createdAt,
+ $updatedAt,
+ $loginAt,
+ ];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ return [
+ $emailField,
+ $firstNameField,
+ $lastNameField,
+ $plainPassword,
+ $enabledField,
+ ];
+ }
+
+ // show
+ return [
+ $panels['information'],
+ $emailField,
+ $firstNameField,
+ $lastNameField,
+ $enabledField,
+
+ $panels['tech_information'],
+ $idField,
+ $loginAt,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/CategoryObjectCrudController.php b/src/Controller/Admin/CategoryObjectCrudController.php
new file mode 100644
index 0000000..87d0992
--- /dev/null
+++ b/src/Controller/Admin/CategoryObjectCrudController.php
@@ -0,0 +1,25 @@
+ 1,
+
+ // content
+ MenuItemCrudController::class => 4,
+ MenuItemFooterCrudController::class => 5,
+ CategoryObjectCrudController::class => 6, // +1
+
+ // usage
+ UserCrudController::class => 8,
+ PlaceCrudController::class => 9,
+
+ // Group (+1)
+ ObjectCrudController::class => 11,
+ ];
+
+ public function __construct(
+ private readonly TranslatorInterface $translator,
+ private readonly GroupRepository $groupRepository,
+ private readonly AdminUrlGenerator $adminUrlGenerator,
+ private readonly AuthorizationChecker $authorizationChecker,
+ ) {
+ }
+
+ /**
+ * @see https://symfony.com/bundles/EasyAdminBundle/current/dashboards.html#/translation
+ */
+ public function configureDashboard(): Dashboard
+ {
+ return Dashboard::new()
+ ->setTitle($this->translator->trans('dashboard.title', [], self::DOMAIN))
+ ->setTranslationDomain('admin')
+ ->generateRelativeUrls()
+ ->setLocales([
+ 'en' => '🇬🇧 Anglais',
+ 'fr' => '🇫🇷 Français',
+ ])
+ ;
+ }
+
+ #[Route('/admin', name: 'admin')]
+ public function index(): Response
+ {
+ if (!$this->authorizationChecker->isAdmin()) {
+ $groupUrl = $this->adminUrlGenerator
+ ->setController(GroupCrudController::class)
+ ->generateUrl();
+
+ return $this->redirect($groupUrl);
+ }
+
+ return $this->render('admin/dashboard.html.twig', [
+ 'group_count' => $this->groupRepository->count([]),
+ ]);
+ }
+
+ public function configureMenuItems(): iterable
+ {
+ /** @var User $user */
+ $user = $this->getUser();
+
+ // —————————————————————————————————————————————————————————————————————
+ yield MenuItem::linkToDashboard('menu.dashboard', 'fa fa-home')->setPermission(User::ROLE_ADMIN);
+
+ $url = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(AdministratorCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[AdministratorCrudController::class]);
+
+ yield MenuItem::linkToUrl('menu.administrators', 'fas fa-user-plus', $url->generateUrl())->setPermission(User::ROLE_ADMIN);
+ yield MenuItem::linkToRoute('menu.parameters', 'fas fa-cog', ParametersController::ROUTE_NAME)->setPermission(User::ROLE_ADMIN)->setPermission(User::ROLE_ADMIN);
+
+ // —————————————————————————————————————————————————————————————————————
+ yield MenuItem::section('menu.content');
+
+ $menuConfigUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(MenuItemCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[MenuItemCrudController::class])
+ ->generateUrl();
+ $footerConfigUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(MenuItemFooterCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[MenuItemFooterCrudController::class])
+ ->generateUrl();
+
+ $categoryObjectUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(CategoryObjectCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[CategoryObjectCrudController::class])
+ ->set('submenuIndex', 0)
+ ->generateUrl();
+ $categoryServiceUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(CategoryServiceCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[CategoryObjectCrudController::class])
+ ->set('submenuIndex', 1)
+ ->generateUrl();
+
+ yield MenuItem::linkToUrl('menu.config_menu', 'fa-solid fa-bars', $menuConfigUrl)->setPermission(User::ROLE_ADMIN);
+ yield MenuItem::linkToUrl('menu.config_footer', 'fas fa-ellipsis-h', $footerConfigUrl)->setPermission(User::ROLE_ADMIN);
+
+ yield MenuItem::linkToCrud('menu.pages', 'fas fa-hat-wizard', Page::class)->setPermission(User::ROLE_ADMIN);
+
+ yield MenuItem::subMenu('menu.categories', 'fa-solid fa-folder')->setSubItems([
+ MenuItem::linkToUrl('menu.objects', 'fa-solid fa-box', $categoryObjectUrl)->setPermission(User::ROLE_ADMIN),
+ MenuItem::linkToUrl('menu.services', 'fa-regular fa-handshake', $categoryServiceUrl)->setPermission(User::ROLE_ADMIN),
+ ])->setPermission(User::ROLE_ADMIN);
+
+ // —————————————————————————————————————————————————————————————————————
+ yield MenuItem::section('menu.usage')->setPermission(User::ROLE_ADMIN);
+
+ $url = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(UserCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[UserCrudController::class])
+ ;
+ yield MenuItem::linkToUrl('menu.users', 'fas fa-user', $url->generateUrl())->setPermission(User::ROLE_ADMIN);
+ $url = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(PlaceCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[PlaceCrudController::class])
+ ;
+
+ yield MenuItem::linkToUrl('menu.places', 'fas fa-location-dot', $url->generateUrl())->setPermission(User::ROLE_ADMIN);
+
+ yield MenuItem::subMenu('menu.groups', 'fas fa-users')->setSubItems([
+ MenuItem::linkToCrud('menu.groups', 'fas fa-users', Group::class)->setPermission(User::ROLE_GROUP_ADMIN),
+ MenuItem::linkToCrud('menu.members', 'fas fa-user-friends', UserGroup::class)->setPermission(User::ROLE_GROUP_ADMIN),
+ ])->setPermission(User::ROLE_GROUP_ADMIN);
+
+ $objectUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(ObjectCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[ObjectCrudController::class])
+ ->set('submenuIndex', 0)
+ ->generateUrl();
+
+ $serviceUrl = $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(ServiceCrudController::class)
+ ->set('crudAction', Crud::PAGE_INDEX)
+ ->set('menuIndex', self::MENU_INDEX[ObjectCrudController::class])
+ ->set('submenuIndex', 1)
+ ->generateUrl();
+
+ yield MenuItem::subMenu('menu.articles', 'fa-solid fa-box')->setSubItems([
+ MenuItem::linkToUrl('menu.objects', 'fa-solid fa-box', $objectUrl)->setPermission(User::ROLE_ADMIN),
+ MenuItem::linkToUrl('menu.services', 'fa-regular fa-handshake', $serviceUrl)->setPermission(User::ROLE_ADMIN),
+ ])->setPermission(User::ROLE_ADMIN);
+
+ yield MenuItem::linkToCrud('menu.loans', 'fas fa-link', ServiceRequest::class)->setPermission(User::ROLE_ADMIN);
+
+ // —————————————————————————————————————————————————————————————————————
+ if ($user->isDevAccount()) {
+ yield MenuItem::section('menu.devtools')->setPermission(User::ROLE_ADMIN);
+ yield MenuItem::linkToRoute('menu.dev_tools', 'fas fa-wrench', DevToolsController::ROUTE_NAME)->setPermission(User::ROLE_ADMIN);
+ }
+
+ // —————————————————————————————————————————————————————————————————————
+ yield MenuItem::section('menu.public');
+ yield MenuItem::linkToUrl('menu.home', 'fa fa-home', '/')->setLinkTarget('_blank');
+ yield MenuItem::linkToUrl('menu.user', 'fa fa-user', $this->generateUrl(MyAccountAction::ROUTE))->setLinkTarget('_blank');
+ }
+}
diff --git a/src/Controller/Admin/Dev/DevToolsController.php b/src/Controller/Admin/Dev/DevToolsController.php
new file mode 100755
index 0000000..a56dc93
--- /dev/null
+++ b/src/Controller/Admin/Dev/DevToolsController.php
@@ -0,0 +1,44 @@
+render('admin/dev/dev_tools.html.twig', compact(
+ 'transCodes',
+ 'uuidV4',
+ 'uuidV6',
+ 'encoded',
+ 'urlEncoded',
+ 'confirmationCode1',
+ 'confirmationCode2',
+ ));
+ }
+}
diff --git a/src/Controller/Admin/FooterCrudController.php b/src/Controller/Admin/FooterCrudController.php
new file mode 100644
index 0000000..b98c750
--- /dev/null
+++ b/src/Controller/Admin/FooterCrudController.php
@@ -0,0 +1,33 @@
+setEntityLabelInPlural('groups')
+ ->setSearchFields(['name', 'description'])
+ ->setDefaultSort(['id' => 'ASC'])
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add(EnumFilter::new('type', GroupTypeType::class))
+ ->add(EnumFilter::new('membership', GroupMembershipType::class))
+ ->add('name')
+ ->add('description')
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $exportAction = Action::new('export')
+ ->linkToUrl(function () {
+ /** @var AdminContext $context */
+ $context = $this->getContext();
+
+ return $this->adminUrlGenerator->setAll($context->getRequest()->query->all())
+ ->setEntityId(null)
+ ->setAction('export')
+ ->generateUrl();
+ })
+ ->addCssClass('btn btn-success')
+ ->setIcon('fa fa-download')
+ ->createAsGlobalAction()
+ ;
+
+ $listMembers = Action::new('listMembers')
+ ->linkToUrl(function () {
+ /** @var Group $group */
+ $group = $this->getContext()?->getEntity()->getInstance();
+
+ return $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(UserGroupCrudController::class)
+ ->set('filters[group]', $group->getId())
+ ->setAction('index')
+ ->generateUrl();
+ })
+ ->setIcon('fas fas-user-friends')
+ ;
+ $offersList = Action::new('offersList', 'offers_list')
+ ->linkToUrl(function () {
+ /** @var Group $group */
+ $group = $this->getContext()?->getEntity()->getInstance();
+
+ return $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(GroupOfferCrudController::class)
+ ->set('filters[group]', $group->getId())
+ ->setAction('index')
+ ->generateUrl();
+ })
+ ->displayIf(function () {
+ /** @var Group $group */
+ $group = $this->getContext()?->getEntity()->getInstance();
+
+ return $group->getMembership()->isCharged();
+ });
+
+ $offersListIndexPage = Action::new('offersList', 'offers_list')
+ ->linkToCrudAction('redirectToOffersList')
+ ->displayIf(static function (Group $group) {
+ return $group->getMembership()->isCharged();
+ });
+
+ $actions
+ ->add(Crud::PAGE_INDEX, $exportAction)
+ ->add(Crud::PAGE_EDIT, $listMembers)
+ ->add(Crud::PAGE_DETAIL, $listMembers)
+ ->add(Crud::PAGE_DETAIL, $offersList)
+ ->add(Crud::PAGE_INDEX, $offersListIndexPage);
+
+ /** @var User $user */
+ $user = $this->getUser();
+
+ // display the invite link if we are an admin, the main group admin or the parameter is activated
+ $inviteAction = Action::new('invite', 'invite', 'fa fa-user-plus')
+ ->linkToCrudAction('invite')
+ ->displayIf(fn (Group $group) => $this->authorizationChecker->isAdmin() || $group->isMainAdmin($user) || $group->isInvitationByAdmin())
+ ;
+ $actions
+ ->add(Crud::PAGE_INDEX, $inviteAction);
+
+ // group admin can't create a group from here but he can edit its groups
+ if (!$this->authorizationChecker->isAdmin()) {
+ $actions
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->remove(Crud::PAGE_DETAIL, Action::DELETE)
+ ;
+ }
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ;
+ }
+
+ public function redirectToOffersList(AdminContext $context): Response
+ {
+ $group = $context->getEntity()->getInstance();
+ $this->adminUrlGenerator
+ ->unsetAll()
+ ->setController(GroupOfferCrudController::class)
+ ->set('filters[group]', $group->getId())
+ ->setAction('index');
+
+ return $this->redirect($this->adminUrlGenerator->generateUrl());
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Group::class;
+ }
+
+ /**
+ * When a group admin is logged, we must restrict the groups he can access to.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ // admins can see everything
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ if ($this->authorizationChecker->isAdmin()) {
+ return $qb;
+ }
+
+ /** @var User $user */
+ $user = $this->getUser();
+ $qb->andWhere(sprintf('%s.id IN (:groups)', $qb->getRootAliases()[0] ?? ''))
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+
+ return $qb;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $idFIeld = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+
+ $typeField = ChoiceField::new('type')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', GroupType::class)
+ ->setChoices(GroupType::getAsArray());
+ $membershipField = ChoiceField::new('membership')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', GroupMembership::class)
+ ->setChoices(GroupMembership::getAsArray());
+
+ $parentField = AssociationField::new('parent')
+ ->setRequired(false);
+ $childrenField = AssociationField::new('children');
+ $usersField = AssociationField::new('userGroups')
+ ->setTemplatePath('admin/group/user_groups_field.html.twig');
+
+ $nameField = TextField::new('name');
+ $descriptionField = TextareaField::new('description');
+
+ $url = UrlField::new('url');
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ $invitationByAdminField = BooleanField::new('invitationByAdmin')->renderAsSwitch(false);
+ $panels = $this->getPanels();
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$nameField, $typeField, $parentField, $membershipField, $usersField, $createdAt, $updatedAt];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ $typeField->setChoices(GroupType::cases());
+ $membershipField->setChoices(GroupMembership::cases());
+
+ return [
+ $nameField,
+ $typeField,
+ $membershipField,
+ $parentField,
+ $descriptionField,
+ $url,
+ $invitationByAdminField,
+ $membershipField,
+ ];
+ }
+
+ // show
+
+ return [
+ $panels['information'],
+ $nameField,
+ $parentField,
+ $childrenField,
+ $typeField,
+ $membershipField,
+ $descriptionField,
+ $invitationByAdminField,
+
+ $panels['tech_information'],
+ $idFIeld,
+ $updatedAt,
+ $createdAt,
+ ];
+ }
+
+ /**
+ * Custom action that allows sending invitations to users. We only need the email
+ * here as the rest of the process is handled by the step 2 form of the account
+ * creation workflow.
+ *
+ * @see GroupCrudControllerTest::testInviteActionSuccess()
+ */
+ public function invite(Request $request): Response
+ {
+ /** @var Group $group */
+ $group = $this->getContext()?->getEntity()->getInstance();
+ $user = new User();
+ $form = $this->createForm(GroupInvitationFormType::class, $user)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->commandBus->dispatch(new CreateGroupInvitationMessage($user->getEmail(), $group->getId()));
+ $this->addFlashSuccess($this->getI18nPrefix().'.invite.flash.success');
+
+ return $this->redirect($this->adminUrlGenerator->unsetAll()->setController(self::class)->generateUrl());
+ }
+
+ return $this->render('admin/group/invite.html.twig', compact('form', 'group'));
+ }
+
+ /**
+ * For now we export exactly what we see in the list to avoid seurity problems.
+ */
+ public function export(AdminContext $context): Response
+ {
+ $fields = FieldCollection::new($this->configureFields(Crud::PAGE_INDEX));
+ /** @var CrudDto $crud Crud is defined here */
+ $crud = $context->getCrud();
+
+ $filters = $this->filterFactory->create($crud->getFiltersConfig(), $fields, $context->getEntity());
+ /** @var SearchDto $search */
+ $search = $context->getSearch();
+ $queryBuilder = $this->createIndexQueryBuilder($search, $context->getEntity(), $fields, $filters);
+
+ $fileName = $this->slugger->slug($this->translator->trans('menu.groups', [], DashboardController::DOMAIN));
+
+ return $this->csvExporter->createResponseFromQueryBuilder($queryBuilder, $fields, $fileName.'.csv');
+ }
+}
diff --git a/src/Controller/Admin/GroupOfferCrudController.php b/src/Controller/Admin/GroupOfferCrudController.php
new file mode 100755
index 0000000..1597cca
--- /dev/null
+++ b/src/Controller/Admin/GroupOfferCrudController.php
@@ -0,0 +1,180 @@
+setEntityLabelInPlural('group_offers')
+ ->setSearchFields(['name'])
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add(MyGroupFilter::new('group'))
+ ->add(EnumFilter::new('membership', GroupOfferTypeType::class))
+ ->add('name')
+ ->add('active')
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return GroupOffer::class;
+ }
+
+ /**
+ * When a group admin is logged, we must restrict the groups he can access to.
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ // admins can see everything
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ if ($this->authorizationChecker->isAdmin()) {
+ return $qb;
+ }
+
+ /** @var User $user */
+ $user = $this->getUser();
+ $qb->andWhere(sprintf('%s.group IN (:groups)', $qb->getRootAliases()[0] ?? ''))
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+
+ return $qb;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $idFIeld = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+ $groupField = AssociationField::new('group')
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) {
+ /** @var User $user */
+ $user = $this->getUser();
+
+ $qb = $queryBuilder->andWhere('entity.membership = :membership')
+ ->setParameter('membership', GroupMembership::CHARGED);
+ if (!$user->isAdmin()) {
+ $qb->join('entity.userGroups', 'ug')
+ ->andWhere('ug.membership = :userMembership')
+ ->andWhere('ug.user = :user')
+ ->setParameter('userMembership', UserMembership::ADMIN)
+ ->setParameter('user', $user);
+ }
+ })
+ ->setRequired(false);
+
+ $nameField = TextField::new('name');
+ $typeField = ChoiceField::new('type')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', GroupOfferType::class)
+ ->setChoices(GroupOfferType::getAsArray());
+
+ $priceField = MoneyField::new('price')
+ ->setCurrencyPropertyPath('currency')
+ ->setStoredAsCents();
+ $currencyField = CurrencyField::new('currency');
+
+ $activeField = BooleanField::new('active')
+ ->setTemplatePath('easy_admin/field/boolean.html.twig')
+ ;
+ $createdAtField = DateTimeField::new('createdAt');
+ $updatedAtField = DateTimeField::new('updatedAt');
+
+ $panels = $this->getPanels();
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$groupField, $nameField, $typeField, $priceField, $activeField, $createdAtField, $updatedAtField];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ $typeField->setChoices(GroupOfferType::cases());
+
+ return [
+ $groupField,
+ $nameField,
+ $typeField,
+ $priceField,
+ $currencyField,
+ $activeField,
+ ];
+ }
+
+ // show
+
+ return [
+ $panels['information'],
+ $groupField,
+ $nameField,
+ $typeField,
+ $priceField,
+ $currencyField,
+
+ $panels['tech_information'],
+ $idFIeld,
+ $updatedAtField,
+ $createdAtField,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/MenuCrudController.php b/src/Controller/Admin/MenuCrudController.php
new file mode 100644
index 0000000..eb566d2
--- /dev/null
+++ b/src/Controller/Admin/MenuCrudController.php
@@ -0,0 +1,37 @@
+ $nameField,
+ 'linkField' => $linkField,
+ 'parentField' => $parentField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_NEW) {
+ return [$nameField, $linkField, $parentField];
+ }
+
+ return parent::configureFields($pageName);
+ }
+}
diff --git a/src/Controller/Admin/MenuItemMenuSocialNetwordFooterCrudController.php b/src/Controller/Admin/MenuItemMenuSocialNetwordFooterCrudController.php
new file mode 100644
index 0000000..da97d76
--- /dev/null
+++ b/src/Controller/Admin/MenuItemMenuSocialNetwordFooterCrudController.php
@@ -0,0 +1,39 @@
+setLinkType(LinkType::SOCIAL_NETWORK);
+
+ return $menuItem;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ [
+ 'socialMediaTypeField' => $socialMediaTypeField,
+ 'linkField' => $linkField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_NEW) {
+ /** @var ChoiceField $socialMediaTypeField */
+ $socialMediaTypeField->setChoices(SocialMediaType::cases());
+
+ return [$socialMediaTypeField, $linkField];
+ }
+
+ return parent::configureFields($pageName);
+ }
+}
diff --git a/src/Controller/Admin/MenuItemSocialNetworkCrudController.php b/src/Controller/Admin/MenuItemSocialNetworkCrudController.php
new file mode 100644
index 0000000..bf008c9
--- /dev/null
+++ b/src/Controller/Admin/MenuItemSocialNetworkCrudController.php
@@ -0,0 +1,39 @@
+setLinkType(LinkType::SOCIAL_NETWORK);
+
+ return $menuItem;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ [
+ 'socialMediaTypeField' => $socialMediaTypeField,
+ 'linkField' => $linkField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_NEW) {
+ /** @var ChoiceField $socialMediaTypeField */
+ $socialMediaTypeField->setChoices(SocialMediaType::cases());
+
+ return [$socialMediaTypeField, $linkField];
+ }
+
+ return parent::configureFields($pageName);
+ }
+}
diff --git a/src/Controller/Admin/NewMenuFooterLinkController.php b/src/Controller/Admin/NewMenuFooterLinkController.php
new file mode 100644
index 0000000..a47a25a
--- /dev/null
+++ b/src/Controller/Admin/NewMenuFooterLinkController.php
@@ -0,0 +1,24 @@
+ $nameField,
+ 'linkField' => $linkField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_NEW) {
+ return [$nameField, $linkField];
+ }
+
+ return parent::configureFields($pageName);
+ }
+}
diff --git a/src/Controller/Admin/ObjectCrudController.php b/src/Controller/Admin/ObjectCrudController.php
new file mode 100644
index 0000000..560cabb
--- /dev/null
+++ b/src/Controller/Admin/ObjectCrudController.php
@@ -0,0 +1,100 @@
+getPanels();
+
+ [
+ 'idField' => $idField,
+ 'typeField' => $typeField,
+ 'statusField' => $statusField,
+ 'visibilityField' => $visibilityField,
+ 'groupsField' => $groupsField,
+ 'ownerField' => $ownerField,
+ 'categoryField' => $categoryField,
+ 'nameField' => $nameField,
+ 'descriptionField' => $descriptionField,
+ 'ageField' => $ageField,
+ 'depositField' => $depositField,
+ 'currencyField' => $currencyField,
+ 'imageField' => $imageField,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ 'addressField' => $addressField,
+ 'preferredLoanDuration' => $preferredLoanDuration,
+ ] = $this->getFields($pageName);
+
+ // list
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$nameField, $ownerField, $categoryField, $statusField, $visibilityField, $imageField, $createdAt];
+ }
+
+ /** @var ImageField $imageField */
+ $imageField->setCustomOption('first_image_only', false);
+
+ // forms
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ /** @var ChoiceField $statusField */
+ $statusField->setChoices(ProductStatus::cases());
+ /** @var ChoiceField $visibilityField */
+ $visibilityField->setChoices(ProductVisibility::cases());
+
+ return [$nameField, $ownerField, $categoryField, $statusField, $visibilityField, $descriptionField, $ageField, $depositField, $currencyField, $imageField, $preferredLoanDuration];
+ }
+
+ // detail
+
+ return [
+ $panels['information'],
+ $ownerField,
+ $categoryField,
+ $statusField,
+ $visibilityField,
+ $groupsField,
+ $nameField,
+ $descriptionField,
+ $ageField,
+ $depositField,
+ $currencyField,
+ $imageField,
+ $addressField,
+ $preferredLoanDuration,
+
+ $panels['tech_information'],
+ $idField,
+ $typeField,
+ $createdAt,
+ $updatedAt, ];
+ }
+}
diff --git a/src/Controller/Admin/PageCrudController.php b/src/Controller/Admin/PageCrudController.php
new file mode 100755
index 0000000..5129d86
--- /dev/null
+++ b/src/Controller/Admin/PageCrudController.php
@@ -0,0 +1,114 @@
+setEntityLabelInPlural('pages')
+ ->setSearchFields(['name'])
+ ->setDefaultSort(['name' => 'ASC'])
+ ->addFormTheme('@FOSCKEditor/Form/ckeditor_widget.html.twig');
+ }
+
+ public function __construct(
+ private readonly AdminUrlGenerator $adminUrlGenerator,
+ private readonly PageRepository $pageRepository,
+ ) {
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add('name')
+ ->add('enabled')
+ ->add('home')
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $linkToFrontPage = Action::new('link', 'page.action.link')
+ ->linkToCrudAction('redirectToFrontPage')
+ ->displayAsLink();
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ->add(Crud::PAGE_DETAIL, $linkToFrontPage)
+ ;
+ }
+
+ public function redirectToFrontPage(): RedirectResponse
+ {
+ /** @var Page $page */
+ $page = $this->pageRepository->find($this->adminUrlGenerator->get('entityId'));
+
+ return $this->redirectToRoute('app_cms_page', ['slug' => $page->getSlug()]);
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Page::class;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $idFIeld = IdField::new('id')->setLabel('id');
+ $nameField = TextField::new('name');
+ $contentField = TextEditorField::new('content')->setFormType(CKEditorType::class)->addCssFiles('ckeditor.css');
+ $slugField = TextField::new('slug');
+ $homeField = $this->getSimpleBooleanField('home');
+ $enabledField = $this->getSimpleBooleanField('enabled');
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$nameField, $slugField, $homeField, $enabledField, $createdAt, $updatedAt];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ return [$nameField, $homeField, $enabledField, $contentField];
+ }
+
+ // show
+ $panels = $this->getPanels();
+
+ return [
+ $panels['information'],
+ $nameField,
+ $homeField,
+ $enabledField,
+ $contentField,
+
+ $panels['tech_information'],
+ $idFIeld,
+ $slugField,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/ParametersController.php b/src/Controller/Admin/ParametersController.php
new file mode 100755
index 0000000..5bcfeda
--- /dev/null
+++ b/src/Controller/Admin/ParametersController.php
@@ -0,0 +1,51 @@
+queryBus->query(new ParametersFormQuery());
+ $form = $this->createForm(ParametersFormType::class, $parametersForm)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var ParametersFormCommand $parametersForm */
+ $parametersForm = $form->getData();
+ $this->commandBus->dispatch($parametersForm);
+ $this->addFlash(
+ 'success',
+ 'parameters_controller.form.success',
+ );
+
+ return $this->redirect($this->adminUrlGenerator->setRoute(self::ROUTE_NAME)->generateUrl());
+ }
+
+ return $this->render('admin/parameters.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/Admin/PaymentCrudController.php b/src/Controller/Admin/PaymentCrudController.php
new file mode 100755
index 0000000..90105b8
--- /dev/null
+++ b/src/Controller/Admin/PaymentCrudController.php
@@ -0,0 +1,113 @@
+setEntityLabelInPlural('payments')
+ ->setDefaultSort(['createdAt' => 'DESC']);
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ $filters
+ ->add(UuidFilter::new('id'))
+ ->add(MyUsersFilter::new('user'))
+ ->add('totalAmount')
+ ;
+
+ return $filters;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ return $actions
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->remove(Crud::PAGE_INDEX, Action::EDIT)
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ->remove(Crud::PAGE_DETAIL, Action::EDIT)
+ ->remove(Crud::PAGE_DETAIL, Action::DELETE)
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return Payment::class;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $idFIeld = IdField::new('id');
+ $userField = AssociationField::new('user');
+ $clientEmailField = TextField::new('clientEmail');
+ $clientIdField = TextField::new('clientId');
+ $numberFIeld = TextField::new('number');
+ $descriptionField = TextField::new('description');
+ $totalAmountField = MoneyField::new('totalAmount')
+ ->setCurrencyPropertyPath('currencyCode');
+ $paidField = BooleanField::new('paid')
+ ->setTemplatePath('easy_admin/field/boolean.html.twig');
+ $statusField = TextField::new('status');
+ $methodField = TextField::new('method');
+ $detailField = ArrayField::new('details')
+ ->setTemplatePath('easy_admin/field/json.html.twig');
+
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$userField, $methodField, $totalAmountField, $paidField, $statusField, $descriptionField, $createdAt];
+ }
+
+ // show
+ $panels = $this->getPanels();
+
+ return [
+ $panels['information'],
+ $userField,
+ $clientEmailField,
+ $methodField,
+ $totalAmountField,
+ $paidField,
+ $statusField,
+ $descriptionField,
+
+ $panels['tech_information'],
+ $idFIeld,
+ $numberFIeld,
+ $clientIdField,
+ $detailField,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/PlaceCrudController.php b/src/Controller/Admin/PlaceCrudController.php
new file mode 100755
index 0000000..74be36f
--- /dev/null
+++ b/src/Controller/Admin/PlaceCrudController.php
@@ -0,0 +1,62 @@
+getPanels();
+
+ [
+ 'idField' => $idField,
+ 'emailField' => $emailField,
+ 'nameField' => $nameField,
+ 'phoneNumberField' => $phoneNumberField,
+ 'avatarField' => $avatarField,
+ 'descriptionField' => $descriptionField,
+ 'categoryField' => $categoryField,
+ 'smsNotificationsField' => $smsNotificationsField,
+ 'vacationModeField' => $vacationModeField,
+ 'scheduleField' => $scheduleField,
+ 'plainPassword' => $plainPassword,
+ 'enabledField' => $enabledField,
+ 'emailConfirmedField' => $emailConfirmedField,
+ 'loginAt' => $loginAt,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$emailField, $nameField, $enabledField, $emailConfirmedField, $createdAt, $updatedAt, $loginAt];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ return [$panels['information'], $emailField, $nameField, $phoneNumberField, $avatarField, $descriptionField, $categoryField, $smsNotificationsField, $vacationModeField, $scheduleField, $plainPassword, $panels['tech_information'], $enabledField, $emailConfirmedField];
+ }
+
+ // show
+
+ return [$panels['information'], $emailField, $nameField, $phoneNumberField, $avatarField, $descriptionField, $scheduleField, $enabledField, $panels['tech_information'], $emailConfirmedField, $idField, $createdAt, $updatedAt, $loginAt];
+ }
+}
diff --git a/src/Controller/Admin/ServiceCrudController.php b/src/Controller/Admin/ServiceCrudController.php
new file mode 100644
index 0000000..63754c2
--- /dev/null
+++ b/src/Controller/Admin/ServiceCrudController.php
@@ -0,0 +1,94 @@
+setCurrency(null); // remove the default value which is not needed here
+
+ return $product;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $panels = $this->getPanels();
+
+ [
+ 'idField' => $idField,
+ 'typeField' => $typeField,
+ 'statusField' => $statusField,
+ 'visibilityField' => $visibilityField,
+ 'ownerField' => $ownerField,
+ 'categoryField' => $categoryField,
+ 'nameField' => $nameField,
+ 'descriptionField' => $descriptionField,
+ 'durationField' => $durationField,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ ] = $this->getFields($pageName);
+
+ // list
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$nameField, $ownerField, $categoryField, $statusField, $visibilityField, $createdAt];
+ }
+
+ // forms
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ /** @var ChoiceField $statusField */
+ $statusField->setChoices(ProductStatus::cases());
+ /** @var ChoiceField $visibilityField */
+ $visibilityField->setChoices(ProductVisibility::cases());
+
+ return [$nameField, $ownerField, $categoryField, $statusField, $visibilityField, $descriptionField, $durationField];
+ }
+
+ // detail
+
+ return [
+ $panels['information'],
+ $ownerField,
+ $categoryField,
+ $statusField,
+ $visibilityField,
+ $nameField,
+ $descriptionField,
+ $durationField,
+
+ $panels['tech_information'],
+ $idField,
+ $typeField,
+ $createdAt,
+ $updatedAt,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/ServiceRequestCrudController.php b/src/Controller/Admin/ServiceRequestCrudController.php
new file mode 100755
index 0000000..15f74b2
--- /dev/null
+++ b/src/Controller/Admin/ServiceRequestCrudController.php
@@ -0,0 +1,169 @@
+setEntityLabelInSingular('Loan')
+ ->setPageTitle(Crud::PAGE_DETAIL, fn (ServiceRequest $serviceRequest) => $this->translator->trans(
+ 'loan.title',
+ [
+ '%date%' => $serviceRequest->getStartAt()->format($this->translator->trans('format.date', [], 'date')),
+ '%lender%' => $serviceRequest->getOwner()->getDisplayName(),
+ '%borrower%' => $serviceRequest->getRecipient()->getDisplayName(),
+ ],
+ DashboardController::DOMAIN)
+ )
+ ->setEntityLabelInPlural('loans')
+ ->setDefaultSort(['createdAt' => 'DESC'])
+ ->showEntityActionsInlined()
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ return $filters
+ ->add(UuidFilter::new('id'))
+ ->add('owner')
+ ->add('product')
+ ->add('recipient')
+ ->add(EnumFilter::new('status', LoanStatusType::class))
+ ;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $conversation = Action::new('conversation')
+ ->linkToCrudAction('conversation')
+ ->setIcon('fas fa-comment-dots')
+ ->displayIf(fn () => $this->configurationRepository->getInstanceConfiguration()?->isConversationAdminAccessible())
+ ;
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->remove(Crud::PAGE_INDEX, Action::EDIT)
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ->remove(Crud::PAGE_INDEX, Action::DELETE)
+ ->add(Crud::PAGE_DETAIL, $conversation)
+ ->remove(Crud::PAGE_DETAIL, Action::EDIT)
+ ->remove(Crud::PAGE_DETAIL, Action::DELETE)
+ ->update(Crud::PAGE_INDEX, Action::DETAIL, function (Action $action) {
+ return $action
+ ->setCssClass('btn btn-sm btn-primary')
+ ->setIcon('fa fa-search');
+ })
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return ServiceRequest::class;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ $ownerField = AssociationField::new('owner');
+ $recipientField = AssociationField::new('recipient');
+ $productField = AssociationField::new('product');
+ $productTypeField = AssociationField::new('product')
+ ->setTemplatePath('admin/loan/product_type_field.html.twig')
+ ->setLabel('type');
+ $statusField = ChoiceField::new('status')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', ServiceRequest::class)
+ ->setChoices(ServiceRequestStatus::getAsArray());
+ $messageCountField = IntegerField::new('messagesCount');
+
+ $startAt = DateTimeField::new('startAt');
+ $endAt = DateTimeField::new('endAt');
+ $createdAt = DateTimeField::new('createdAt');
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$ownerField, $productTypeField, $productField, $recipientField, $statusField, $startAt, $endAt, $messageCountField, $createdAt];
+ }
+
+ // no new and edit page for this crud
+
+ // show
+ $ownerField = AssociationField::new('owner')
+ ->setTemplatePath('admin/loan/user_field.html.twig')
+ ->setLabel('');
+ $recipientField = AssociationField::new('recipient')
+ ->setTemplatePath('admin/loan/user_field.html.twig')
+ ->setLabel('');
+ $productField = AssociationField::new('product')
+ ->setTemplatePath('admin/loan/product_field.html.twig')
+ ->setLabel('');
+
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $this->getContext()?->getEntity()->getInstance();
+ $productPanel = FormField::addPanel($serviceRequest->getProduct()->getType()->value, 'fa-solid fa-box');
+ $serviceRequestInformationPanel = FormField::addPanel('panel.loan_information', 'fas fa-info-circle');
+ $ownerPanel = FormField::addPanel('panel.lender', 'fa-solid fa-user');
+ $recipientPanel = FormField::addPanel('panel.borrower', 'fa-solid fa-user');
+
+ return [
+ $serviceRequestInformationPanel,
+ $startAt,
+ $endAt,
+ $statusField,
+ $createdAt,
+
+ $ownerPanel,
+ $ownerField,
+
+ $recipientPanel,
+ $recipientField,
+
+ $productPanel,
+ $productField,
+ ];
+ }
+
+ /**
+ * Entity is accesible thanks to the EA AdminContext.
+ */
+ public function conversation(): Response
+ {
+ if (!$this->configurationRepository->getInstanceConfigurationOrCreate()->isConversationAdminAccessible()) {
+ throw new AccessDeniedHttpException();
+ }
+
+ return $this->render('admin/service_request/conversation.html.twig');
+ }
+}
diff --git a/src/Controller/Admin/UserCrudController.php b/src/Controller/Admin/UserCrudController.php
new file mode 100755
index 0000000..b8ddff9
--- /dev/null
+++ b/src/Controller/Admin/UserCrudController.php
@@ -0,0 +1,100 @@
+ $idField,
+ 'emailField' => $emailField,
+ 'firstNameField' => $firstNameField,
+ 'lastNameField' => $lastNameField,
+ 'plainPassword' => $plainPassword,
+ 'enabledField' => $enabledField,
+ 'emailConfirmedField' => $emailConfirmedField,
+ 'loginAt' => $loginAt,
+ 'createdAt' => $createdAt,
+ 'updatedAt' => $updatedAt,
+ 'avatarField' => $avatarField,
+ 'phoneNumberField' => $phoneNumberField,
+ 'categoryField' => $categoryField,
+ 'descriptionField' => $descriptionField,
+ 'smsNotificationsField' => $smsNotificationsField,
+ 'vacationModeField' => $vacationModeField,
+ 'addressField' => $addressField,
+ ] = $this->getFields($pageName);
+
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$emailField, $firstNameField, $lastNameField, $enabledField, $emailConfirmedField, $avatarField, $createdAt, $updatedAt, $loginAt];
+ }
+
+ $panels = $this->getPanels();
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ return [
+ $panels['information'],
+ $emailField,
+ $firstNameField,
+ $lastNameField,
+ $avatarField,
+ $phoneNumberField,
+ $descriptionField,
+ $categoryField,
+ $smsNotificationsField,
+ $vacationModeField,
+ $plainPassword,
+
+ $panels['tech_information'],
+ $enabledField,
+ $emailConfirmedField,
+ ];
+ }
+
+ return [
+ $panels['information'],
+ $emailField,
+ $firstNameField,
+ $lastNameField,
+ $avatarField,
+ $phoneNumberField,
+ $descriptionField,
+ $addressField,
+ $categoryField,
+ $smsNotificationsField,
+ $vacationModeField,
+
+ $panels['tech_information'],
+ $idField,
+ $enabledField,
+ $emailConfirmedField,
+ $createdAt,
+ $updatedAt,
+ $loginAt,
+ ];
+ }
+}
diff --git a/src/Controller/Admin/UserGroupCrudController.php b/src/Controller/Admin/UserGroupCrudController.php
new file mode 100755
index 0000000..437e765
--- /dev/null
+++ b/src/Controller/Admin/UserGroupCrudController.php
@@ -0,0 +1,260 @@
+setEntityLabelInPlural('user_groups')
+ ->setSearchFields(['group'])
+ ->setDefaultSort(['user' => 'ASC'])
+ ;
+ }
+
+ public function configureFilters(Filters $filters): Filters
+ {
+ $filters
+ ->add(UuidFilter::new('id'))
+ ->add(MyUsersFilter::new('user'))
+ ->add(MyGroupFilter::new('group'))
+ ->add(EnumFilter::new('membership', UserMembershipType::class))
+ ;
+
+ if ($this->authorizationChecker->isAdmin()) {
+ $filters->add('mainAdminAccount');
+ }
+
+ return $filters;
+ }
+
+ public function configureActions(Actions $actions): Actions
+ {
+ $currentUser = $this->security->getUser();
+ $actions->update(Crud::PAGE_INDEX, 'delete', function (Action $action) use ($currentUser) {
+ return $action->displayIf(fn (UserGroup $usergroup) => $currentUser !== $usergroup->getUser() && !$usergroup->isMainAdminAccount());
+ });
+
+ return $actions
+ ->add(Crud::PAGE_INDEX, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::DETAIL)
+ ->add(Crud::PAGE_EDIT, Action::INDEX)
+ ->remove(Crud::PAGE_INDEX, Action::NEW)
+ ;
+ }
+
+ public static function getEntityFqcn(): string
+ {
+ return UserGroup::class;
+ }
+
+ /**
+ * When a group admin is logged, we must restrict the groups he can access to.
+ *
+ * @see GroupCrubController
+ */
+ public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder
+ {
+ // admins can see everything
+ $qb = parent::createIndexQueryBuilder($searchDto, $entityDto, $fields, $filters);
+ if ($this->authorizationChecker->isAdmin()) {
+ return $qb;
+ }
+
+ /** @var User $user */
+ $user = $this->getUser();
+ $qb->andWhere(sprintf('%s.group IN (:groups)', $qb->getRootAliases()[0] ?? ''))
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+
+ return $qb;
+ }
+
+ public function configureFields(string $pageName): iterable
+ {
+ /** @var User $user */
+ $user = $this->getUser();
+
+ $idFIeld = IdField::new('id')
+ ->setLabel('id')
+ ->hideOnForm();
+
+ $userField = AssociationField::new('user');
+ if (!$this->authorizationChecker->isAdmin()) {
+ $userField
+ ->setTemplatePath('easy_admin/field/user_email.html.twig')
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) use ($user) {
+ $queryBuilder
+ ->innerJoin('entity.userGroups', 'ug')
+ ->andWhere('ug.group IN (:groups)')
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+ });
+ }
+
+ $groupField = AssociationField::new('group');
+ if (!$this->authorizationChecker->isAdmin()) {
+ $groupField
+ ->setQueryBuilder(function (QueryBuilder $queryBuilder) use ($user) {
+ $queryBuilder
+ ->andWhere('entity.id IN (:groups)')
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+ });
+ }
+
+ $membershipField = ChoiceField::new('membership')
+ ->setFormType(EnumType::class)
+ ->setFormTypeOption('class', UserMembership::class)
+ ->setChoices(UserMembership::getAsArray());
+ $createdAt = DateTimeField::new('createdAt');
+ $updatedAt = DateTimeField::new('updatedAt');
+ $startAt = DateField::new('startAt');
+ $endAt = DateField::new('endAt');
+ $expiresInField = IntegerField::new('expiresIn')
+ ->formatValue(function ($value) {
+ return $value !== null ? $this->translator->trans($this->getI18nPrefix().'.expires_in.formatted_value', ['%days%' => $value], 'admin') : '';
+ })
+ ;
+ $payedAt = DateTimeField::new('payedAt');
+
+ $mainAdminAccountField = BooleanField::new('mainAdminAccount')
+ ->setTemplatePath('easy_admin/field/boolean_check_only.html.twig');
+
+ $panels = $this->getPanels();
+ if ($pageName === Crud::PAGE_INDEX) {
+ return [$groupField, $userField, $membershipField, $mainAdminAccountField, $payedAt, $startAt, $endAt, $expiresInField, $createdAt, $updatedAt];
+ }
+
+ if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
+ $membershipField->setChoices(UserMembership::cases());
+ $editionFields = [
+ $groupField,
+ $userField,
+ $membershipField,
+ $startAt,
+ $endAt,
+ ];
+
+ if ($this->authorizationChecker->isAdmin()) {
+ $editionFields[] = $mainAdminAccountField;
+ }
+
+ return $editionFields;
+ }
+
+ // show
+
+ return [
+ $panels['information'],
+ $groupField,
+ $userField,
+ $membershipField,
+ $startAt,
+ $endAt,
+ $payedAt,
+ $mainAdminAccountField,
+
+ $panels['tech_information'],
+ $idFIeld,
+ $updatedAt,
+ $createdAt,
+ ];
+ }
+
+ public function createEditForm(EntityDto $entityDto, KeyValueStore $formOptions, AdminContext $context): FormInterface
+ {
+ /** @var UserGroup $previousUserGroup */
+ $previousUserGroup = clone $entityDto->getInstance();
+ $this->previousUserGroup = $previousUserGroup;
+
+ return parent::createEditForm($entityDto, $formOptions, $context);
+ }
+
+ /**
+ * @throws TransportExceptionInterface
+ */
+ public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
+ {
+ /** @var UserGroup $entityInstance */
+ parent::updateEntity($entityManager, $entityInstance);
+ $user = $entityInstance->getUser();
+ $membership = $entityInstance->getMembership();
+ $context = [];
+ $context['group'] = $entityInstance->getGroup();
+ $context['user'] = $user;
+
+ // don't send both emails
+ if ($entityInstance->isMainAdminAccount() && !$this->previousUserGroup->isMainAdminAccount()) {
+ $this->mailer->send(MainAdminPromotionEmail::class, $context);
+ $this->sendSms($user, MainAdminPromotionEmail::class);
+ } elseif ($membership !== $this->previousUserGroup->getMembership() && $membership->isAdmin()) {
+ $this->mailer->send(AdminPromotionEmail::class, $context);
+ $this->sendSms($user, AdminPromotionEmail::class);
+ }
+ }
+}
diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php
new file mode 100644
index 0000000..6958e73
--- /dev/null
+++ b/src/Controller/AppController.php
@@ -0,0 +1,48 @@
+getPreferredLanguage($this->getParameter('kernel.enabled_locales'));
+
+ return $this->redirectToRoute('home', ['_locale' => $locale]);
+ }
+
+ #[Route(path: ['en' => '/en', 'fr' => '/fr'], name: 'home')]
+ public function home(): Response
+ {
+ $page = $this->pageRepository->getHome();
+
+ return $this->render('cms/page.html.twig', [
+ 'page' => $page,
+ 'is_home' => true,
+ ]);
+ }
+
+ #[Route(path: '/ping', name: 'ping')]
+ #[Route(path: '/healthz', name: 'healthz')]
+ public function ping(): Response
+ {
+ return $this->json('OK');
+ }
+}
diff --git a/src/Controller/Cms/CmsController.php b/src/Controller/Cms/CmsController.php
new file mode 100644
index 0000000..44524e2
--- /dev/null
+++ b/src/Controller/Cms/CmsController.php
@@ -0,0 +1,38 @@
+pageRepository->findOneBySlug($slug);
+ if ($page === null || !$page->isEnabled()) {
+ throw $this->createNotFoundException('Page not found.');
+ }
+
+ return $this->render('cms/page.html.twig', compact('page'));
+ }
+}
diff --git a/src/Controller/FlashTrait.php b/src/Controller/FlashTrait.php
new file mode 100644
index 0000000..21ae7d5
--- /dev/null
+++ b/src/Controller/FlashTrait.php
@@ -0,0 +1,18 @@
+addFlash('success', $message);
+ }
+
+ public function addFlashWarning(string $message): void
+ {
+ $this->addFlash('warning', $message);
+ }
+}
diff --git a/src/Controller/Group/CreateGroupAction.php b/src/Controller/Group/CreateGroupAction.php
new file mode 100644
index 0000000..45ce1d3
--- /dev/null
+++ b/src/Controller/Group/CreateGroupAction.php
@@ -0,0 +1,93 @@
+ MyAccountAction::BASE_URL_EN.'/groups/create-my-group',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/groupes/creer-mon-groupe',
+ ],
+ name: 'create',
+ )]
+ public function createGroup(Request $request, #[CurrentUser] User $user): Response
+ {
+ $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate();
+ if (!$configuration->isGroupsCreationForAll()) {
+ throw $this->createAccessDeniedException('Cannot create group with current settings.');
+ }
+
+ $newGroup = (new Group())->setInvitationByAdmin(true);
+ $form = $this->createForm(CreateGroupFormType::class, $newGroup)->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->groupRepository->save($newGroup, true);
+ $newAdminGroup = UserGroup::newUserGroup($user, $newGroup);
+ $this->userGroupRepository->save($newAdminGroup, true);
+
+ // force login to refresh the roles so the user can access its group
+ // in the group admin interface
+ $this->doctrine->refresh($user);
+ $this->security->login($user);
+
+ return $this->redirect(
+ $this->adminUrlGenerator
+ ->setController(GroupCrudController::class)
+ ->setEntityId($newGroup->getId())
+ ->set('crudAction', Crud::PAGE_EDIT)
+ ->generateUrl()
+ );
+ }
+
+ return $this->render('pages/group/create.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/Group/GroupController.php b/src/Controller/Group/GroupController.php
new file mode 100644
index 0000000..f91e8df
--- /dev/null
+++ b/src/Controller/Group/GroupController.php
@@ -0,0 +1,140 @@
+ '/{_locale}/groups',
+ 'fr' => '/{_locale}/groupes',
+ ],
+ name: 'list'
+ )]
+ public function list(Request $request, #[CurrentUser] ?User $user): Response
+ {
+ $page = $this->getPage($request);
+ $form = $this->createForm(GroupSelectFormType::class)->handleRequest($request);
+ $groupName = null;
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var string $groupName */
+ $groupName = $form->get('q')->getData();
+ }
+
+ /** @var Query $query */
+ $query = $this->queryBus->query(new GetGroupsQuery($user, $groupName));
+ $pagination = $this->paginator->paginate($query, $page, self::MAX_ELEMENT_BY_PAGE);
+
+ return $this->render('pages/group/list.html.twig', compact('pagination', 'form'));
+ }
+
+ /**
+ * The slug is only for SEO.
+ */
+ #[Route([
+ 'en' => '/{_locale}/groups/{slug}/{id}',
+ 'fr' => '/{_locale}/groupes/{slug}/{id}',
+ ],
+ name: 'show',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function show(string $id, #[CurrentUser] ?User $user): Response
+ {
+ $group = $this->getGroup($id);
+ $showQuitGroupChoices = $this->getShowQuitGroupsChoices($group, $user);
+
+ return $this->render('pages/group/show.html.twig', compact('group', 'showQuitGroupChoices'));
+ }
+
+ /**
+ * This is the same route as show but it requires to be logged. It is useful to
+ * redirect the user to this page if he isn't logged, so he can accept a pending
+ * invitation for example.
+ */
+ #[IsGranted(User::ROLE_USER)]
+ #[Route([
+ 'en' => '/en/groups/{slug}/{id}/invitation',
+ 'fr' => '/fr/groupes/{slug}/{id}/invitation',
+ ],
+ name: 'show_logged',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function showLogged(string $id, #[CurrentUser] ?User $user): Response
+ {
+ $group = $this->getGroup($id);
+ $showQuitGroupChoices = $this->getShowQuitGroupsChoices($group, $user);
+
+ return $this->render('pages/group/show.html.twig', compact('group', 'showQuitGroupChoices'));
+ }
+
+ private function getShowQuitGroupsChoices(Group $group, ?User $user): bool
+ {
+ return $this->productManager->hasProductsOnlyInGroup($group, $user);
+ }
+
+ #[Route([
+ 'en' => '/{_locale}/groups/{slug}/{id}/members',
+ 'fr' => '/{_locale}/groupes/{slug}/{id}/membres',
+ ],
+ name: 'members',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function showMembers(Request $request, string $id): Response
+ {
+ $group = $this->getGroup($id);
+ $page = $this->getPage($request);
+ $form = $this->createForm(GroupSelectFormType::class)->handleRequest($request);
+
+ $memberName = null;
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var string $memberName */
+ $memberName = $form->get('q')->getData();
+ }
+
+ /** @var Query $query */
+ $query = $this->queryBus->query(new GetGroupMembersQuery(Uuid::fromString($id), $memberName));
+ $pagination = $this->paginator->paginate($query, $page, self::MAX_ELEMENT_BY_PAGE);
+
+ return $this->render('pages/group/members.html.twig', compact('pagination', 'form', 'group'));
+ }
+}
diff --git a/src/Controller/Group/GroupTrait.php b/src/Controller/Group/GroupTrait.php
new file mode 100644
index 0000000..193feb5
--- /dev/null
+++ b/src/Controller/Group/GroupTrait.php
@@ -0,0 +1,25 @@
+queryBus->query(new GetGroupByIdQuery(Uuid::fromString($id)));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+
+ return $group;
+ }
+}
diff --git a/src/Controller/Menu/MenuController.php b/src/Controller/Menu/MenuController.php
new file mode 100644
index 0000000..8998ba6
--- /dev/null
+++ b/src/Controller/Menu/MenuController.php
@@ -0,0 +1,77 @@
+render(
+ 'components/layout/_navbar.html.twig',
+ $this->menuItems($q)
+ );
+ }
+
+ /**
+ * @return array
+ *
+ * @throws NonUniqueResultException
+ */
+ private function menuItems(?string $q): array
+ {
+ $menu = $this->menuRepository->getByCode(Menu::MENU);
+ $firstItems = $this->menuItemRepository->findFirstLevelMenuLinks(Menu::MENU);
+ $configuration = $this->configurationRepository->getInstanceConfiguration();
+ Assert::isInstanceOf($configuration, Configuration::class);
+
+ return [
+ 'menu' => $menu,
+ 'items' => $firstItems,
+ 'contactEnabled' => $configuration->getContactEnabled(),
+ 'contactEmail' => $configuration->getContactEmail(),
+ 'q' => $q,
+ ];
+ }
+
+ /**
+ * @throws NonUniqueResultException
+ */
+ public function footerItems(): Response
+ {
+ $menu = $this->menuRepository->getByCode(Menu::FOOTER);
+ $links = $this->menuItemRepository->getFooterItems(LinkType::LINK->value);
+ $icons = $this->menuItemRepository->getFooterItems(LinkType::SOCIAL_NETWORK->value);
+
+ return $this->render(
+ 'components/layout/_footer.html.twig',
+ [
+ 'menu' => $menu,
+ 'links' => $links,
+ 'icons' => $icons,
+ ]
+ );
+ }
+}
diff --git a/src/Controller/PaginationTrait.php b/src/Controller/PaginationTrait.php
new file mode 100644
index 0000000..19d8529
--- /dev/null
+++ b/src/Controller/PaginationTrait.php
@@ -0,0 +1,34 @@
+
+ *
+ * @return PaginationInterface
+ */
+ private function paginate(SearchResult $searchResult): PaginationInterface
+ {
+ return $this->paginator->paginate(
+ $searchResult,
+ (int) $searchResult->getPage(),
+ (int) $searchResult->getHitsPerPage()
+ );
+ }
+}
diff --git a/src/Controller/Payment/DoneAction.php b/src/Controller/Payment/DoneAction.php
new file mode 100755
index 0000000..13e2d5d
--- /dev/null
+++ b/src/Controller/Payment/DoneAction.php
@@ -0,0 +1,76 @@
+ Requirement::UUID_V6],
+ )]
+ public function __invoke(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ $groupOffer = $this->getGroupOffer($id);
+
+ try {
+ /** @var PaymentToken $token */
+ $token = $this->payum->getHttpRequestVerifier()->verify($request);
+ } catch (\Exception) {
+ throw new UnprocessableEntityHttpException('Cannot verify Payum token.');
+ }
+
+ /** @var GetHumanStatus $status */
+ $status = $this->commandBus->dispatch(new DoneCommand($groupOffer->getId(), $user->getId(), $token));
+ if ($status->isCaptured()) {
+ $this->addFlashSuccess($this->translator->trans($this->getI18nPrefix().'.flash.success', [
+ '%group%' => $groupOffer->getGroup()->getName()],
+ ));
+ } else {
+ $this->addFlashWarning($this->translator->trans($this->getI18nPrefix().'.status.'.$status->getValue()));
+ }
+
+ return $this->redirectToRoute('app_group_show', $groupOffer->getGroup()->getRoutingParameters());
+ }
+}
diff --git a/src/Controller/Payment/GroupOfferTrait.php b/src/Controller/Payment/GroupOfferTrait.php
new file mode 100644
index 0000000..c8b623b
--- /dev/null
+++ b/src/Controller/Payment/GroupOfferTrait.php
@@ -0,0 +1,25 @@
+groupOfferRepository->find(Uuid::fromString($id));
+ if ($groupOffer === null || !$groupOffer->isActive()) {
+ throw $this->createNotFoundException('Group offer not found');
+ }
+
+ return $groupOffer;
+ }
+}
diff --git a/src/Controller/Payment/PrepareAction.php b/src/Controller/Payment/PrepareAction.php
new file mode 100755
index 0000000..af5e4bc
--- /dev/null
+++ b/src/Controller/Payment/PrepareAction.php
@@ -0,0 +1,66 @@
+ Requirement::UUID_V6],
+ methods: ['POST'],
+ )]
+ public function __invoke(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ $groupOffer = $this->getGroupOffer($id);
+
+ /** @var ?string $token */
+ $token = $request->request->get('token');
+ if (!$this->isCsrfTokenValid('payment_prepare', $token)) {
+ throw new UnprocessableEntityHttpException('Invalid CSRF token');
+ }
+
+ // create and save the payment main reference
+ $payment = $this->payumManager->getPayment($groupOffer, $user);
+
+ // create the capture token and redirect to the capture action
+ $captureToken = $this->payumManager->getCaptureToken($payment, DoneAction::ROUTE_NAME, [
+ 'id' => $id,
+ ]);
+
+ return $this->redirect($captureToken->getTargetUrl());
+ }
+}
diff --git a/src/Controller/Product/ProductController.php b/src/Controller/Product/ProductController.php
new file mode 100644
index 0000000..fdd5f30
--- /dev/null
+++ b/src/Controller/Product/ProductController.php
@@ -0,0 +1,91 @@
+ '/{_locale}/product',
+ 'fr' => '/{_locale}/produits',
+ ],
+ name: 'list'
+ )]
+ public function list(Request $request, #[CurrentUser] ?User $user): Response
+ {
+ $page = $this->getPage($request);
+ $q = u($request->query->get('q'))->toString();
+ $searchDto = new Search($q, $page, $user);
+
+ // The DTO is modified with selected values if the form is submitted and valid
+ $searchForm = $this->createForm(SearchFormType::class, $searchDto)->handleRequest($request);
+
+ return $this->render('pages/product/list.html.twig', [
+ 'objects_pagination' => $this->paginate($this->meilisearch->searchObjects($searchDto)),
+ 'services_pagination' => $this->paginate($this->meilisearch->searchServices($searchDto)),
+ 'search_form' => $searchForm,
+ ]);
+ }
+
+ /**
+ * The slug is only for SEO.
+ */
+ #[Route([
+ 'en' => '/{_locale}/product/{slug}/{id}',
+ 'fr' => '/{_locale}/produits/{slug}/{id}',
+ ],
+ name: 'show',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function show(string $slug, string $id): Response
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($id)));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+
+ return $this->render('pages/product/show.html.twig', compact('slug', 'id', 'product'));
+ }
+}
diff --git a/src/Controller/RequestTrait.php b/src/Controller/RequestTrait.php
new file mode 100644
index 0000000..72167f1
--- /dev/null
+++ b/src/Controller/RequestTrait.php
@@ -0,0 +1,26 @@
+query->getInt($key, 1);
+ $page = max($page, 1); // no negative page or 0
+
+ // limit max page to 100000 (2 million products)
+
+ return min($page, 100000);
+ }
+}
diff --git a/src/Controller/Security/AccountCreateController.php b/src/Controller/Security/AccountCreateController.php
new file mode 100644
index 0000000..c541fff
--- /dev/null
+++ b/src/Controller/Security/AccountCreateController.php
@@ -0,0 +1,126 @@
+i18nPrefix = $this->getI18nPrefix();
+ }
+
+ /**
+ * @see AccountCreateStep1CommandHandler
+ */
+ #[Route(path: [
+ 'en' => '/{_locale}/account/create-my-account',
+ 'fr' => '/{_locale}/compte/creer-mon-compte',
+ ], name: 'security_account_create_step1')]
+ public function createStep1(Request $request, string $_route): Response
+ {
+ $form = $this->createForm(AccountCreateStep1FormType::class)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var User $newUser */
+ $newUser = $form->getData();
+ $this->commandBus->dispatch(new AccountCreateStep1Command($newUser));
+ $this->addFlashSuccess($this->i18nPrefix.'.step1.flash.success');
+
+ return $this->redirectToRoute($_route);
+ }
+
+ return $this->render('pages/register/step1.html.twig', compact('form'));
+ }
+
+ /**
+ * @see AccountCreateStep2CommandHandler
+ */
+ #[Route(path: [
+ 'en' => '/{_locale}/account/create-my-account-step2/{token}',
+ 'fr' => '/{_locale}/compte/creer-mon-compte-etape-2/{token}',
+ ], name: 'security_account_create_step2')]
+ public function createStep2(Request $request, string $token): Response
+ {
+ try {
+ /** @var User $user */
+ $user = $this->queryBus->query(new GetUserByTokenQuery($token));
+ } catch (HandlerFailedException $e) {
+ /** @var \Exception $exception */
+ $exception = $e->getPrevious();
+ switch ($exception::class) {
+ case UserNotFoundException::class:
+ $this->addFlashWarning($this->i18nPrefix.'.step2.user_not_found.warning');
+ break;
+
+ case UserConfirmationTokenExpiredException::class:
+ // send a new confirmation email with a new token
+ $this->commandBus->dispatch(new AccountCreateStep2RefreshCommand($exception->id));
+ $this->addFlashWarning($this->i18nPrefix.'.step2.user_confirmation_token_expired.warning');
+ break;
+ }
+
+ return $this->redirectToRoute('app_login');
+ }
+
+ // nominal case: user found and token not expired
+ $form = $this->createForm(AccountCreateStep2FormType::class, $user->setStep2Defaults())->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var User $user */
+ $user = $form->getData();
+ $this->commandBus->dispatch(new AccountCreateStep2Command($user));
+ $this->security->login($user); // auto-log the user
+
+ // If user has pending invitations then redirect them to the first group
+ // found without doing the confirmation stuff, it must be done on the
+ // page group.
+ $group = $user->getMyGroupsAsInvited()->first();
+ if ($group !== false) {
+ $this->addFlashSuccess($this->i18nPrefix.'.step2.with_invitation.flash.success');
+
+ return $this->redirectToRoute('app_group_show_logged', $group->getRoutingParameters());
+ }
+
+ // otherwise go to the address form
+ $this->addFlashSuccess($this->i18nPrefix.'.step2.flash.success');
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+
+ return $this->render('pages/register/step2.html.twig', compact('form', 'user'));
+ }
+}
diff --git a/src/Controller/Security/LostPasswordAction.php b/src/Controller/Security/LostPasswordAction.php
new file mode 100644
index 0000000..8006b53
--- /dev/null
+++ b/src/Controller/Security/LostPasswordAction.php
@@ -0,0 +1,43 @@
+ '/{_locale}/account/lost-password',
+ 'fr' => '/{_locale}/compte/mot-de-passe-oublie',
+ ], name: 'security_lost_password')]
+ public function __invoke(Request $request, string $_route): Response
+ {
+ $form = $this->createForm(LostPasswordFormType::class)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var LostPasswordCommand $lostPasswordCommand */
+ $lostPasswordCommand = $form->getData();
+ $this->commandBus->dispatch($lostPasswordCommand);
+ $this->addFlashSuccess('lost_password.form.success');
+
+ return $this->redirectToRoute($_route);
+ }
+
+ return $this->render('pages/password/lost.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/Security/ResetPasswordAction.php b/src/Controller/Security/ResetPasswordAction.php
new file mode 100644
index 0000000..8686757
--- /dev/null
+++ b/src/Controller/Security/ResetPasswordAction.php
@@ -0,0 +1,70 @@
+ '/{_locale}/account/password-reset/{token}',
+ 'fr' => '/{_locale}/compte/reinitialisation-mot-de-passe/{token}',
+ ], name: 'security_reset_password')]
+ public function __invoke(Request $request, string $token, string $_route): Response
+ {
+ try {
+ /** @var User $user */
+ $user = $this->queryBus->query(new ResetPasswordQuery($token));
+ } catch (HandlerFailedException $e) {
+ /** @var \Exception $exception */
+ $exception = $e->getPrevious();
+
+ if ($exception::class === UserNotFoundException::class) {
+ $this->addFlashWarning('reset_password.user_not_found.exception');
+ }
+
+ // token expired, the user must renew its request
+ if ($exception::class === UserLostPasswordTokenExpiredException::class) {
+ $this->addFlashWarning('reset_password.user_lostpassword_token_expired.exception');
+ }
+
+ return $this->redirectToRoute('security_lost_password');
+ }
+
+ $form = $this->createForm(ResetPasswordFormType::class)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var ResetPasswordCommand $resetPasswordCommand */
+ $resetPasswordCommand = $form->getData();
+ $resetPasswordCommand->id = $user->getId();
+ $this->commandBus->dispatch($resetPasswordCommand);
+ $this->addFlashSuccess('reset_password.form.success');
+
+ return $this->redirectToRoute('app_login');
+ }
+
+ return $this->render('pages/password/reset.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/Security/SecurityController.php b/src/Controller/Security/SecurityController.php
new file mode 100644
index 0000000..8390f01
--- /dev/null
+++ b/src/Controller/Security/SecurityController.php
@@ -0,0 +1,28 @@
+getLastAuthenticationError();
+
+ // last username entered by the user
+ $lastUsername = $authenticationUtils->getLastUsername();
+
+ return $this->render('pages/login.html.twig', [
+ 'last_username' => $lastUsername,
+ 'error' => $error,
+ ]);
+ }
+}
diff --git a/src/Controller/SecurityTrait.php b/src/Controller/SecurityTrait.php
new file mode 100644
index 0000000..716a197
--- /dev/null
+++ b/src/Controller/SecurityTrait.php
@@ -0,0 +1,28 @@
+getUser();
+ Assert::isInstanceOf($user, User::class, 'This function should only be called in an authenticated context (#[isGranted(User::ROLE_)])');
+ /** @var User $user */
+
+ return $user;
+ }
+}
diff --git a/src/Controller/User/Account/ChangeLoginAction.php b/src/Controller/User/Account/ChangeLoginAction.php
new file mode 100644
index 0000000..fd500a5
--- /dev/null
+++ b/src/Controller/User/Account/ChangeLoginAction.php
@@ -0,0 +1,62 @@
+ MyAccountAction::BASE_URL_EN.'/my-email',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mon-email',
+ ], name: 'app_user_change_login')]
+ public function __invoke(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(ChangeLoginFormType::class, $user)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+ /** @var string $email */
+ $email = $form->get('email')->getData();
+ $command = new ChangeLoginCommand($user->getId(), $email);
+ $this->commandBus->dispatch($command);
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+
+ // In case of error, we must reload the original email
+ $this->entityManager->refresh($user);
+
+ return $this->render('pages/user/account/change_login.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/User/Account/ChangePasswordAction.php b/src/Controller/User/Account/ChangePasswordAction.php
new file mode 100644
index 0000000..933c554
--- /dev/null
+++ b/src/Controller/User/Account/ChangePasswordAction.php
@@ -0,0 +1,56 @@
+ MyAccountAction::BASE_URL_EN.'/my-password',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mon-mot-de-passe',
+ ], name: 'app_user_change_password')]
+ public function __invoke(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(ChangePasswordFormType::class)->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var string $plaintextPassword */
+ $plaintextPassword = $form->get('plainPassword')->getData();
+
+ $this->userManager->updatePassword($user->setPlainPassword($plaintextPassword));
+ $this->userRepository->save($user, true);
+
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute('app_user_my_account');
+ }
+
+ return $this->render('pages/user/account/change_password.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/User/Account/DeleteUserAvatarAction.php b/src/Controller/User/Account/DeleteUserAvatarAction.php
new file mode 100644
index 0000000..ea136a0
--- /dev/null
+++ b/src/Controller/User/Account/DeleteUserAvatarAction.php
@@ -0,0 +1,47 @@
+ Requirement::UUID_V6,
+ ]
+ )]
+ public function __invoke(#[CurrentUser] User $user): Response
+ {
+ $this->userManager->deleteAvatar($user);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute('app_user_edit_profile');
+ }
+}
diff --git a/src/Controller/User/Account/EditProfileAction.php b/src/Controller/User/Account/EditProfileAction.php
new file mode 100644
index 0000000..9380580
--- /dev/null
+++ b/src/Controller/User/Account/EditProfileAction.php
@@ -0,0 +1,63 @@
+ MyAccountAction::BASE_URL_EN.'/edit',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/editer',
+ ], name: 'app_user_edit_profile')]
+ public function __invoke(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(EditProfileFormType::class, $user)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var UploadedFile|null $avatar */
+ $avatar = $form->get('avatar')->getData();
+ $this->userManager->upload($avatar, $user);
+ $this->userRepository->save($user, true);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+
+ // In case of error, we must reload the original firstname (to display it in navbar)
+ $this->entityManager->refresh($user);
+
+ return $this->render('pages/user/account/edit_profile.html.twig', compact('form'));
+ }
+}
diff --git a/src/Controller/User/Account/ProfileAction.php b/src/Controller/User/Account/ProfileAction.php
new file mode 100644
index 0000000..9e9b6c5
--- /dev/null
+++ b/src/Controller/User/Account/ProfileAction.php
@@ -0,0 +1,64 @@
+ '/{_locale}/user/{userId}',
+ 'fr' => '/{_locale}/utilisateur/{userId}',
+ ],
+ name: 'app_user_profile',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6, 'userId' => Requirement::UUID_V6]
+ )]
+ public function __invoke(Request $request, string $userId, #[CurrentUser] ?User $currentUser): Response
+ {
+ try {
+ /** @var User $user */
+ $user = $this->queryBus->query(new GetUserQuery(Uuid::fromString($userId)));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+
+ $searchDto = new Search('', $this->getPage($request), $currentUser);
+ $searchDto->place = $user;
+
+ return $this->render('pages/user/account/profile.html.twig', [
+ 'user' => $user,
+ 'objects_pagination' => $this->paginate($this->meilisearch->searchObjects($searchDto)),
+ 'services_pagination' => $this->paginate($this->meilisearch->searchServices($searchDto)),
+ ]);
+ }
+}
diff --git a/src/Controller/User/AddressController.php b/src/Controller/User/AddressController.php
new file mode 100644
index 0000000..79e6c0a
--- /dev/null
+++ b/src/Controller/User/AddressController.php
@@ -0,0 +1,132 @@
+ MyAccountAction::BASE_URL_EN.'/my-address/step-1',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mon-adresse/etape-1',
+ ], name: self::STEP1_ROUTE)]
+ public function step1(Request $request, SessionInterface $session): Response
+ {
+ $form = $this->createForm(AddressStep1FormType::class, $this->getAppUser()->getAddress())->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var Address $address */
+ $address = $form->getData();
+ $userAddressQuery = new UserAddressQuery($address);
+
+ /** @var AddressCollection $addresses */
+ $addresses = $this->queryBus->query($userAddressQuery);
+ if ($addresses->isEmpty()) {
+ $this->addFlashWarning('address.step1_action.no_address.warning');
+
+ return $this->render('pages/account/address/step1.html.twig', compact('form'));
+ }
+
+ $userAddressStep1Data = new UserAddressStep1Data($address, $addresses);
+ $this->saveStep1Data($session, $userAddressStep1Data);
+
+ return $this->redirectToRoute(self::STEP2_ROUTE);
+ }
+
+ return $this->render('pages/account/address/step1.html.twig', compact('form'));
+ }
+
+ #[isGranted(User::ROLE_USER)]
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/my-address/step-2',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mon-adresse/etape-2',
+ ], name: self::STEP2_ROUTE)]
+ public function step2(Request $request, SessionInterface $session): Response
+ {
+ // direct access is forbidden or empty addresses (should not happen)
+ $userAddressStep1Data = $this->getStep1Data($session);
+ if ($userAddressStep1Data === null || $userAddressStep1Data->addresses->isEmpty()) {
+ return $this->redirectToRoute(self::STEP1_ROUTE);
+ }
+
+ // give the form the address choices to use
+ $options = ['addresses' => $userAddressStep1Data->getAddressesAsArray()];
+ $form = $this->createForm(AddressStep2FormType::class, null, $options)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var NominatimAddress $address */
+ $address = $form->get('addresses')->getData(); // the selected address
+ $command = new UpdateAddressCommand($this->getAppUser()->getId(), $userAddressStep1Data->address, $address);
+ $this->commandBus->dispatch($command);
+ $this->resetStep1Data($session);
+ $this->addFlashSuccess('address.step2_action.form.success');
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+
+ $parameters = $userAddressStep1Data->getData();
+ $parameters['form'] = $form;
+
+ return $this->render('pages/account/address/step2.html.twig', $parameters);
+ }
+
+ /**
+ * Save data for step2 so we already have all we need to create the form and
+ * save the data.
+ */
+ private function saveStep1Data(SessionInterface $session, UserAddressStep1Data $userAddressStep1Data): void
+ {
+ $session->set(self::STEP1_DATA_KEY, $userAddressStep1Data);
+ }
+
+ private function getStep1Data(SessionInterface $session): ?UserAddressStep1Data
+ {
+ /** @var ?UserAddressStep1Data $userAddressStep1Data */
+ $userAddressStep1Data = $session->get(self::STEP1_DATA_KEY);
+
+ return $userAddressStep1Data;
+ }
+
+ private function resetStep1Data(SessionInterface $session): void
+ {
+ $session->set(self::STEP1_DATA_KEY, null);
+ }
+}
diff --git a/src/Controller/User/Group/UserGroupController.php b/src/Controller/User/Group/UserGroupController.php
new file mode 100644
index 0000000..d93d672
--- /dev/null
+++ b/src/Controller/User/Group/UserGroupController.php
@@ -0,0 +1,128 @@
+getGroup($id);
+
+ /** @var ?string $token */
+ $token = $request->request->get('token');
+ if (!$this->isCsrfTokenValid($tokenId, $token)) {
+ throw new UnprocessableEntityHttpException('Invalid CSRF token');
+ }
+
+ return $group;
+ }
+
+ private function redirectToGroup(Group $group): RedirectResponse
+ {
+ return $this->redirectToRoute('app_group_show', $group->getRoutingParameters());
+ }
+
+ #[Route(
+ path: MyAccountAction::BASE_URL_EN.'/groups/{id}/join',
+ name: 'app_user_group_join',
+ requirements: ['id' => Requirement::UUID_V6],
+ methods: ['POST'],
+ )]
+ public function join(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ $group = $this->getGroupAndCheckToken($request, $id, 'join_group');
+ $command = new JoinGroupCommand($group->getId(), $user->getId());
+ $this->commandBus->dispatch($command);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToGroup($group);
+ }
+
+ /**
+ * @see UserGroupAcceptInvitationActionTest
+ */
+ #[Route(
+ path: MyAccountAction::BASE_URL_EN.'/groups/{id}/acceptInvitation',
+ name: 'app_user_group_accept_invitation',
+ requirements: ['id' => Requirement::UUID_V6],
+ methods: ['POST'],
+ )]
+ public function acceptInvitation(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ $group = $this->getGroupAndCheckToken($request, $id, 'accept_invitation');
+ $command = new AcceptGroupInvitationCommand($group->getId(), $user->getId());
+ $this->commandBus->dispatch($command);
+ $this->addFlashSuccess($this->getI18nPrefix().'.accept_invitation.flash.success');
+
+ return $this->redirectToGroup($group);
+ }
+
+ /**
+ * @see UserGroupQuitGroupActionTest
+ */
+ #[Route(
+ path: MyAccountAction::BASE_URL_EN.'/groups/{id}/quitGroup',
+ name: 'app_user_group_quit_group',
+ requirements: ['id' => Requirement::UUID_V6],
+ methods: ['POST'],
+ )]
+ public function quitGroup(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ $group = $this->getGroupAndCheckToken($request, $id, 'quit_group');
+ $type = $request->request->getAlpha('type');
+ $command = new QuitGroupCommand($group->getId(), $user->getId(), $type);
+ $this->commandBus->dispatch($command);
+ $this->addFlashSuccess($this->getI18nPrefix().'.quit_group.flash.success');
+
+ return $this->redirectToGroup($group);
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/my-groups',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-groupes',
+ ], name: 'app_user_groups'
+ )]
+ public function list(): Response
+ {
+ return $this->render('pages/user/group/list.html.twig');
+ }
+}
diff --git a/src/Controller/User/MyAccountAction.php b/src/Controller/User/MyAccountAction.php
new file mode 100644
index 0000000..8808169
--- /dev/null
+++ b/src/Controller/User/MyAccountAction.php
@@ -0,0 +1,52 @@
+ self::BASE_URL_EN,
+ 'fr' => self::BASE_URL_FR,
+ ], name: self::ROUTE)]
+ public function __invoke(#[CurrentUser] User $user): Response
+ {
+ $userHasNewLendingMessage = $this->messageRepository->userHasNewMessage($user, true);
+ $userHasNewLoanMessage = $this->messageRepository->userHasNewMessage($user, false);
+ $configuration = $this->configurationRepository->getInstanceConfigurationOrCreate();
+
+ // we can create a group if the settings for all is activated or an administrator
+ $canCreateGroup = $configuration->isGroupsCreationForAll() || $user->isAdmin();
+ $contactEmail = $configuration->getContactEmail();
+
+ return $this->render('pages/account/index.html.twig', compact('userHasNewLoanMessage', 'userHasNewLendingMessage', 'canCreateGroup', 'contactEmail'));
+ }
+}
diff --git a/src/Controller/User/Product/DeleteProductAction.php b/src/Controller/User/Product/DeleteProductAction.php
new file mode 100644
index 0000000..5807855
--- /dev/null
+++ b/src/Controller/User/Product/DeleteProductAction.php
@@ -0,0 +1,62 @@
+ Requirement::UUID_V6,
+ ]
+ )]
+ public function __invoke(string $id): Response
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($id), ProductVoter::DELETE));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+
+ $this->productManager->save($product->delete(), true);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+}
diff --git a/src/Controller/User/Product/DeleteProductPhotoAction.php b/src/Controller/User/Product/DeleteProductPhotoAction.php
new file mode 100644
index 0000000..5f92a11
--- /dev/null
+++ b/src/Controller/User/Product/DeleteProductPhotoAction.php
@@ -0,0 +1,61 @@
+ Requirement::UUID_V6,
+ ]
+ )]
+ public function __invoke(string $productId, string $image): Response
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($productId), ProductVoter::EDIT));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+ $this->productManager->deleteImage($product, $image);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute('app_'.$product->getType()->value.'_edit', ['id' => $product->getId()]);
+ }
+}
diff --git a/src/Controller/User/Product/DeleteProductUnavailabilityAction.php b/src/Controller/User/Product/DeleteProductUnavailabilityAction.php
new file mode 100644
index 0000000..22b63c7
--- /dev/null
+++ b/src/Controller/User/Product/DeleteProductUnavailabilityAction.php
@@ -0,0 +1,59 @@
+ MyAccountAction::BASE_URL_EN.'/products/unavailability/{id}/delete',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/produits/indisponibilite/{id}/supprimer',
+ ],
+ name: 'app_user_product_delete_availability',
+ requirements: [
+ 'id' => Requirement::UUID_V6,
+ ]
+ )]
+ public function __invoke(string $id): Response
+ {
+ $productUnavailability = $this->productAvailabilityRepository->get($id);
+
+ if (!$this->security->isGranted(ProductVoter::EDIT, $productUnavailability->getProduct())) {
+ throw new AccessDeniedHttpException('Unauthorized to delete this product unavailibility');
+ }
+
+ $this->productAvailabilityRepository->deleteProductUnavailability($productUnavailability);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute(ProductAvailabilityController::ROUTE, ['id' => $productUnavailability->getProduct()->getId()]);
+ }
+}
diff --git a/src/Controller/User/Product/DuplicateProductAction.php b/src/Controller/User/Product/DuplicateProductAction.php
new file mode 100644
index 0000000..63d00f6
--- /dev/null
+++ b/src/Controller/User/Product/DuplicateProductAction.php
@@ -0,0 +1,66 @@
+ MyAccountAction::BASE_URL_EN.'/objects/{id}/duplicate',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/objets/{id}/dupliquer',
+ ],
+ name: 'app_user_product_duplicate',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function __invoke(string $id): Response
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($id), ProductVoter::DUPLICATE));
+ } catch (HandlerFailedException $e) {
+ throw $e->getPrevious() instanceof HttpException ? $e->getPrevious() : $this->createNotFoundException($e->getMessage());
+ }
+
+ $command = new DuplicateProductCommand($product->getId(), ProductVoter::DUPLICATE);
+ /** @var Product $duplicatedProduct */
+ $duplicatedProduct = $this->commandBus->dispatch($command);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute('app_'.$duplicatedProduct->getType()->value.'_edit', ['id' => $duplicatedProduct->getId()]);
+ }
+}
diff --git a/src/Controller/User/Product/ObjectController.php b/src/Controller/User/Product/ObjectController.php
new file mode 100644
index 0000000..db8b52d
--- /dev/null
+++ b/src/Controller/User/Product/ObjectController.php
@@ -0,0 +1,89 @@
+createForm(ObjectFormType::class, $product)->handleRequest($request);
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/new-object',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/nouvel-objet',
+ ], name: 'new')]
+ public function new(Request $request, #[CurrentUser] User $user): Response
+ {
+ $product = $this->productManager->initObject($user);
+ $form = $this->getForm($product, $request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var array|null $images */
+ $images = $form->get('images')->getData();
+ $this->productManager->multipleUpload($images, $product);
+ $this->productManager->save($product, true);
+
+ return $this->redirectToRoute('app_product_show', $product->getRoutingParameters());
+ }
+
+ return $this->render('pages/product/new_object.html.twig', compact('form'));
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/objects/{id}/edit',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/objets/{id}/editer',
+ ],
+ name: 'edit',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function edit(string $id, Request $request): Response
+ {
+ $product = $this->getProductForEdit($id);
+ $form = $this->getForm($product, $request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var array|null $images */
+ $images = $form->get('images')->getData();
+ $this->productManager->multipleUpload($images, $product);
+ $this->productManager->save($product, true);
+
+ return $this->redirectToRoute('app_product_show', $product->getRoutingParameters());
+ }
+
+ return $this->render('pages/product/edit_object.html.twig', compact('form', 'product'));
+ }
+}
diff --git a/src/Controller/User/Product/ProductAvailabilityController.php b/src/Controller/User/Product/ProductAvailabilityController.php
new file mode 100644
index 0000000..857a08e
--- /dev/null
+++ b/src/Controller/User/Product/ProductAvailabilityController.php
@@ -0,0 +1,76 @@
+ MyAccountAction::BASE_URL_EN.'/my-products/{id}/availabilities',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-produits/{id}/disponibilites',
+ ],
+ name: self::ROUTE,
+ requirements: ['id' => Requirement::UUID_V6],
+ )]
+ public function __invoke(Request $request, string $id): Response
+ {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($id), ProductVoter::EDIT));
+
+ $unavailabilities = $this->queryBus->query(new GetProductUnavailabilitiesQuery($product->getId()));
+
+ $form = $this->createForm(CreateProductAvailabilityType::class)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var \DateTimeImmutable $startAt */
+ $startAt = $form->get('startAt')->getData();
+ /** @var \DateTimeImmutable $endAt */
+ $endAt = $form->get('endAt')->getData();
+
+ $command = new CreateProductUnavailabilityCommand($product->getId(), $startAt, $endAt);
+ $this->commandBus->dispatch($command);
+
+ $this->addFlashSuccess($this->getI18nPrefix().'.success');
+
+ return $this->redirectToRoute('app_product_show', ['id' => $id, 'slug' => $product->getSlug()]);
+ }
+
+ return $this->render('pages/product/product_availability.html.twig', compact('product', 'id', 'form', 'unavailabilities'));
+ }
+}
diff --git a/src/Controller/User/Product/ProductTrait.php b/src/Controller/User/Product/ProductTrait.php
new file mode 100644
index 0000000..0cbf30e
--- /dev/null
+++ b/src/Controller/User/Product/ProductTrait.php
@@ -0,0 +1,27 @@
+queryBus->query(new GetProductByIdQuery(Uuid::fromString($id), ProductVoter::EDIT));
+ } catch (HandlerFailedException $e) {
+ throw $e->getPrevious() instanceof HttpException ? $e->getPrevious() : $this->createNotFoundException($e->getMessage());
+ }
+
+ return $product;
+ }
+}
diff --git a/src/Controller/User/Product/ServiceController.php b/src/Controller/User/Product/ServiceController.php
new file mode 100644
index 0000000..ba51c18
--- /dev/null
+++ b/src/Controller/User/Product/ServiceController.php
@@ -0,0 +1,90 @@
+createForm(ServiceFormType::class, $product)->handleRequest($request);
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/new-service',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/nouveau-service',
+ ], name: 'new')]
+ public function new(Request $request, #[CurrentUser] User $user): Response
+ {
+ $product = $this->productManager->initService($user);
+ $form = $this->getForm($product, $request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var array|null $images */
+ $images = $form->get('images')->getData();
+ $this->productManager->multipleUpload($images, $product);
+ $this->productManager->save($product, true);
+
+ return $this->redirectToRoute('app_product_show', $product->getRoutingParameters());
+ }
+
+ return $this->render('pages/product/new_service.html.twig', compact('form'));
+ }
+
+ #[Route([
+ 'en' => MyAccountAction::BASE_URL_EN.'/services/{id}/edit',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/services/{id}/editer',
+ ],
+ name: 'edit',
+ requirements: ['slug' => Requirement::ASCII_SLUG, 'id' => Requirement::UUID_V6]
+ )]
+ public function edit(string $id, Request $request): Response
+ {
+ $product = $this->getProductForEdit($id);
+ $form = $this->getForm($product, $request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var array|null $images */
+ $images = $form->get('images')->getData();
+ $this->productManager->multipleUpload($images, $product);
+ $this->productManager->save($product, true);
+
+ return $this->redirectToRoute('app_product_show', $product->getRoutingParameters());
+ }
+
+ return $this->render('pages/product/edit_service.html.twig', compact('form', 'product'));
+ }
+}
diff --git a/src/Controller/User/Product/UserProductsController.php b/src/Controller/User/Product/UserProductsController.php
new file mode 100644
index 0000000..50d9a44
--- /dev/null
+++ b/src/Controller/User/Product/UserProductsController.php
@@ -0,0 +1,94 @@
+
+ *
+ * @return PaginationInterface
+ */
+ private function paginate(Query $query, int $page): PaginationInterface
+ {
+ return $this->paginator->paginate($query, $page, self::MAX_ELEMENT_BY_PAGE);
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/my-objects',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-objets',
+ ], name: 'objects')]
+ public function userObjects(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(ObjectCategorySelectFormType::class)->handleRequest($request);
+ $category = null;
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var Category $category */
+ $category = $form->get('category')->getData();
+ }
+
+ /** @var Query $query */
+ $query = $this->queryBus->query(new GetUserObjectsQuery($user->getId(), $category?->getId()));
+ $pagination = $this->paginate($query, $this->getPage($request));
+
+ return $this->render('pages/account/product/list.html.twig', compact('pagination', 'form'));
+ }
+
+ #[Route(path: [
+ 'en' => MyAccountAction::BASE_URL_EN.'/my-services',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-services',
+ ], name: 'services')]
+ public function userServices(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(ServiceCategorySelectFormType::class)->handleRequest($request);
+ $category = null;
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var ?Category $category */
+ $category = $form->get('category')->getData();
+ }
+ /** @var Query $query */
+ $query = $this->queryBus->query(new GetUserServicesQuery($user->getId(), $category?->getId()));
+ $pagination = $this->paginate($query, $this->getPage($request));
+
+ return $this->render('pages/account/product/list.html.twig', compact('pagination', 'form'));
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/ConversationController.php b/src/Controller/User/ServiceRequest/ConversationController.php
new file mode 100644
index 0000000..a399ca6
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/ConversationController.php
@@ -0,0 +1,79 @@
+ MyAccountAction::BASE_URL_EN.'/service/{id}/conversation',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/service/{id}/conversation',
+ ], name: self::ROUTE, requirements: ['id' => Requirement::UUID_V6])]
+ public function __invoke(Request $request, string $id): Response
+ {
+ $serviceRequest = $this->getMyServiceRequest($id);
+ $this->commandBus->dispatch(new TryAutoFinalizeCommand($serviceRequest->getId()));
+ $this->commandBus->dispatch(new ReadMessagesCommand($serviceRequest->getId(), $this->getAppUser()->getId()));
+
+ // we need to refresh the entity in case it was modified by the commands
+ $serviceRequest = $this->getMyServiceRequest($id);
+
+ // form to add a new message
+ $form = $this->createForm(NewMessageType::class)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var Message $message */
+ $message = $form->getData();
+ $this->commandBus->dispatch(new CreateMessageCommand($serviceRequest->getId(), $this->getAppUser()->getId(), $message->getMessage()));
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute(self::ROUTE, ['id' => $id]);
+ }
+
+ // form to modify the dates of the service request
+ $modifyForm = $this->createForm(ModifyServiceRequestType::class, $serviceRequest)->handleRequest($request);
+
+ return $this->render('pages/account/conversation.html.twig', [
+ 'service_request' => $serviceRequest,
+ 'form' => $form,
+ 'modify_form' => $modifyForm,
+ ]);
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/MyLendingsAction.php b/src/Controller/User/ServiceRequest/MyLendingsAction.php
new file mode 100644
index 0000000..be2cfef
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/MyLendingsAction.php
@@ -0,0 +1,57 @@
+ MyAccountAction::BASE_URL_EN.'/my-lendings',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-prets',
+ ], name: 'app_user_my_lendings')]
+ public function __invoke(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(UserLendingProductSelectFormType::class)->handleRequest($request);
+
+ /** @var ?ArrayCollection $selectedProducts */
+ $selectedProducts = $form->get('product')->getData();
+
+ $query = $this->queryBus->query(new GetLendingsQuery($user->getId(), $selectedProducts));
+ $pagination = $this->paginator->paginate($query, $this->getPage($request), self::MAX_ELEMENT_BY_PAGE);
+
+ return $this->render('pages/account/lendings/list.html.twig', compact('pagination', 'form', 'selectedProducts'));
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/MyLoansAction.php b/src/Controller/User/ServiceRequest/MyLoansAction.php
new file mode 100644
index 0000000..967ff9e
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/MyLoansAction.php
@@ -0,0 +1,55 @@
+ MyAccountAction::BASE_URL_EN.'/my-loans',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mes-emprunts',
+ ], name: 'app_user_my_loans')]
+ public function __invoke(Request $request, #[CurrentUser] User $user): Response
+ {
+ $form = $this->createForm(UserLoansProductSelectFormType::class)->handleRequest($request);
+
+ /** @var ?ArrayCollection $selectedProducts */
+ $selectedProducts = $form->get('product')->getData();
+
+ $query = $this->queryBus->query(new GetLoansQuery($user->getId(), $selectedProducts));
+ $pagination = $this->paginator->paginate($query, $this->getPage($request), self::MAX_ELEMENT_BY_PAGE);
+
+ return $this->render('pages/account/loans/list.html.twig', compact('pagination', 'form'));
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/ServiceRequestController.php b/src/Controller/User/ServiceRequest/ServiceRequestController.php
new file mode 100644
index 0000000..bd835b5
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/ServiceRequestController.php
@@ -0,0 +1,75 @@
+ MyAccountAction::BASE_URL_EN.'/new-service-request/{id}',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/nouvelle-demande-de-service/{id}',
+ ], name: self::ROUTE, requirements: ['id' => Requirement::UUID_V6])]
+ public function __invoke(Request $request, string $id, #[CurrentUser] User $user): Response
+ {
+ try {
+ /** @var Product $product */
+ $product = $this->queryBus->query(new GetProductByIdQuery(Uuid::fromString($id)));
+ } catch (HandlerFailedException $e) {
+ throw $this->createNotFoundException($e->getMessage());
+ }
+
+ $serviceRequest = $this->serviceRequestManager->initFormProductAndRequest($product, $request);
+ $form = $this->createForm(CreateServiceRequestType::class, $serviceRequest)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ /** @var ServiceRequest $newSr */
+ $newSr = $form->getData();
+ $command = new CreateServiceRequestCommand($product->getId(), $user->getId(), $newSr->getStartAt(), $newSr->getEndAt(), $newSr->getMessage());
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $this->commandBus->dispatch($command);
+ $this->addFlashSuccess('loan.new_action.form.success');
+
+ return $this->redirectToRoute(ConversationController::ROUTE, ['id' => (string) $serviceRequest->getId()]);
+ }
+
+ return $this->render('pages/account/loans/new.html.twig', compact('form', 'product'));
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/ServiceRequestStatusWorkflowController.php b/src/Controller/User/ServiceRequest/ServiceRequestStatusWorkflowController.php
new file mode 100644
index 0000000..7d44e1f
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/ServiceRequestStatusWorkflowController.php
@@ -0,0 +1,118 @@
+ Requirement::UUID_V6,
+ 'transition' => new EnumRequirement(ServiceRequestStatusTransition::class),
+ ],
+ methods: ['POST'],
+ )]
+ public function apply(Request $request, string $id, ServiceRequestStatusTransition $transition): Response
+ {
+ /** @var ?string $submittedToken */
+ $submittedToken = $request->request->get('token');
+ if (!$this->isCsrfTokenValid('transition', $submittedToken)) {
+ throw new UnprocessableEntityHttpException('Invalid CSRF token');
+ }
+
+ $serviceRequest = $this->getMyServiceRequest($id);
+
+ return $this->applyAndRedirect($id, $serviceRequest, $transition);
+ }
+
+ /**
+ * Specific controller for transitions having to pass extra information.
+ */
+ #[Route(
+ path: MyAccountAction::BASE_URL_FR.'/service/{id}/transition/modify/{transition}',
+ name: self::ROUTE.'_modify',
+ requirements: [
+ 'id' => Requirement::UUID_V6,
+ 'transition' => new EnumRequirement([
+ ServiceRequestStatusTransition::MODIFY_OWNER,
+ ServiceRequestStatusTransition::MODIFY_RECIPIENT,
+ ]),
+ ],
+ methods: ['POST']
+ )]
+ public function applyModifyOwner(Request $request, string $id, ServiceRequestStatusTransition $transition): Response
+ {
+ $serviceRequest = $this->getMyServiceRequest($id);
+ $form = $this->createForm(ModifyServiceRequestType::class, $serviceRequest)->handleRequest($request);
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->doctrine->flush(); // entity is modified at form submission
+
+ return $this->applyAndRedirect($id, $serviceRequest, $transition);
+ }
+
+ return $this->forward(ConversationController::class, compact('id'));
+ }
+
+ private function applyAndRedirect(string $id, ServiceRequest $serviceRequest, ServiceRequestStatusTransition $transition): Response
+ {
+ try {
+ $this->serviceRequestStatusWorkflow->apply($serviceRequest, $transition);
+ } catch (\LogicException $e) {
+ throw new UnprocessableEntityHttpException($e->getMessage(), $e);
+ }
+ $this->doctrine->flush();
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.'.$serviceRequest->getProduct()->getType()->value.'.'.u($transition->value)->snake());
+
+ return $this->redirectToRoute(ConversationController::ROUTE, compact('id'));
+ }
+}
diff --git a/src/Controller/User/ServiceRequest/ServiceRequestTrait.php b/src/Controller/User/ServiceRequest/ServiceRequestTrait.php
new file mode 100644
index 0000000..2af5b99
--- /dev/null
+++ b/src/Controller/User/ServiceRequest/ServiceRequestTrait.php
@@ -0,0 +1,37 @@
+queryBus->query(new GetServiceRequestByIdQuery(Uuid::fromString($id)));
+ } catch (HandlerFailedException $e) {
+ /** @var \Exception $previous */
+ $previous = $e->getPrevious();
+ throw match (\get_class($previous)) {
+ AccessDeniedException::class => $this->createAccessDeniedException($previous->getMessage()),
+ default => $this->createNotFoundException($previous->getMessage()),
+ };
+ }
+
+ return $serviceRequest;
+ }
+}
diff --git a/src/Controller/User/VacationModeAction.php b/src/Controller/User/VacationModeAction.php
new file mode 100644
index 0000000..38e83aa
--- /dev/null
+++ b/src/Controller/User/VacationModeAction.php
@@ -0,0 +1,43 @@
+ MyAccountAction::BASE_URL_EN.'/vacation-mode',
+ 'fr' => MyAccountAction::BASE_URL_FR.'/mode-vacances',
+ ], name: 'user_toggle_vacation_mode')]
+ public function __invoke(#[CurrentUser] User $user, string $_route): Response
+ {
+ $command = new ChangeVacationModeCommand($user->getId());
+ $this->commandBus->dispatch($command);
+ $this->addFlashSuccess($this->getI18nPrefix().'.flash.success');
+
+ return $this->redirectToRoute(MyAccountAction::ROUTE);
+ }
+}
diff --git a/src/Controller/i18nTrait.php b/src/Controller/i18nTrait.php
new file mode 100644
index 0000000..eb63463
--- /dev/null
+++ b/src/Controller/i18nTrait.php
@@ -0,0 +1,30 @@
+split('\\');
+
+ // apply snake case on each entry (which also applies lower)
+ $hierarchy = array_map(static fn (UnicodeString $string) => $string->snake()->toString(), $hierarchy);
+
+ // then join the folders with a dot
+ return implode('.', $hierarchy);
+ }
+}
diff --git a/src/DataFixtures/Processor/ValidationProcessor.php b/src/DataFixtures/Processor/ValidationProcessor.php
new file mode 100644
index 0000000..b1c2f32
--- /dev/null
+++ b/src/DataFixtures/Processor/ValidationProcessor.php
@@ -0,0 +1,31 @@
+validator->validate($object);
+ if ($violations->count() > 0) {
+ $message = sprintf("Error when validating fixture \"%s\", violation(s) detected:\n%s", $id, $violations);
+ throw new \DomainException($message);
+ }
+ }
+
+ public function postProcess(string $id, object $object): void
+ {
+ }
+}
diff --git a/src/DependencyInjection/LocalesCompilerPass.php b/src/DependencyInjection/LocalesCompilerPass.php
new file mode 100644
index 0000000..10c9ce1
--- /dev/null
+++ b/src/DependencyInjection/LocalesCompilerPass.php
@@ -0,0 +1,23 @@
+getParameter('kernel.enabled_locales');
+ $container->setParameter('requirements_locales', implode('|', $enabledLocales));
+ }
+}
diff --git a/src/Doctrine/Behavior/TimestampableEntity.php b/src/Doctrine/Behavior/TimestampableEntity.php
new file mode 100644
index 0000000..46e9e9d
--- /dev/null
+++ b/src/Doctrine/Behavior/TimestampableEntity.php
@@ -0,0 +1,49 @@
+createdAt = $createdAt;
+
+ return $this;
+ }
+
+ public function getCreatedAt(): \DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+
+ public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
+ {
+ $this->updatedAt = $updatedAt;
+
+ return $this;
+ }
+
+ public function getUpdatedAt(): \DateTimeImmutable
+ {
+ return $this->updatedAt;
+ }
+}
diff --git a/src/Doctrine/Listener/ProductListener.php b/src/Doctrine/Listener/ProductListener.php
new file mode 100644
index 0000000..02188a5
--- /dev/null
+++ b/src/Doctrine/Listener/ProductListener.php
@@ -0,0 +1,43 @@
+isIndexable()) {
+ $this->meilisearch->indexProduct($product);
+ } else {
+ // remove from index
+ $this->meilisearch->deleteProduct($product);
+ }
+ }
+
+ public function postPersist(Product $product): void
+ {
+ $this->meilisearch->indexProduct($product);
+ }
+
+ public function preRemove(Product $product): void
+ {
+ $this->meilisearch->deleteProduct($product);
+ }
+}
diff --git a/src/Doctrine/Listener/UserListener.php b/src/Doctrine/Listener/UserListener.php
new file mode 100644
index 0000000..890f868
--- /dev/null
+++ b/src/Doctrine/Listener/UserListener.php
@@ -0,0 +1,65 @@
+getPlainPassword());
+ if ($plainPassword->isEmpty()) {
+ return;
+ }
+
+ $this->userManager->updatePassword($user);
+ }
+
+ /**
+ * Normalization stuff.
+ */
+ public function preUpdate(User $user): void
+ {
+ $this->normalize($user);
+ }
+
+ /**
+ * Normalization stuff.
+ */
+ public function prePersist(User $user): void
+ {
+ $this->normalize($user);
+ }
+
+ /**
+ * Common normalize method.
+ */
+ private function normalize(User $user): void
+ {
+ $this->userManager->normalizeEmail($user, $user->getEmail());
+ }
+}
diff --git a/src/Doctrine/Manager/MessageManager.php b/src/Doctrine/Manager/MessageManager.php
new file mode 100644
index 0000000..aa93961
--- /dev/null
+++ b/src/Doctrine/Manager/MessageManager.php
@@ -0,0 +1,88 @@
+messageRepository->save($entity, $flush);
+ }
+
+ /**
+ * @param array $parameters
+ */
+ public function getSystemMessage(string $id, array $parameters): string
+ {
+ return $this->translator->trans($this->getI18nPrefix().'.'.$id, $parameters, self::DOMAIN);
+ }
+
+ /**
+ * @param array $messageParameters
+ */
+ public function createSystemMessage(
+ ServiceRequest $serviceRequest,
+ string $messageTemplate,
+ array $messageParameters = [],
+ \DateTimeImmutable $createdAt = null,
+ ): Message {
+ $message = (new Message())
+ ->setServiceRequest($serviceRequest)
+ ->setType(MessageType::SYSTEM)
+ ->setMessageTemplate($messageTemplate)
+ ->setMessageParameters($messageParameters)
+ ->setMessage($this->getSystemMessage($messageTemplate, $messageParameters));
+
+ // allow to force Timestampable dates
+ if ($createdAt !== null) {
+ $message->setCreatedAt($createdAt)
+ ->setUpdatedAt($createdAt);
+ }
+
+ return $message;
+ }
+
+ public function createFromRecipientMessage(ServiceRequest $serviceRequest, string $message, \DateTimeImmutable $createdAt = null): Message
+ {
+ $message = (new Message())
+ ->setServiceRequest($serviceRequest)
+ ->setType(MessageType::FROM_RECIPIENT)
+ ->setMessage($message)
+ ;
+
+ // allow to force Timestampable dates
+ if ($createdAt !== null) {
+ $message->setCreatedAt($createdAt)
+ ->setUpdatedAt($createdAt);
+ }
+
+ return $message;
+ }
+
+ public function createFromOwnerMessage(ServiceRequest $serviceRequest, string $message): Message
+ {
+ return (new Message())
+ ->setServiceRequest($serviceRequest)
+ ->setType(MessageType::FROM_OWNER)
+ ->setMessage($message);
+ }
+}
diff --git a/src/Doctrine/Manager/ProductAvailabilityManager.php b/src/Doctrine/Manager/ProductAvailabilityManager.php
new file mode 100644
index 0000000..b112b09
--- /dev/null
+++ b/src/Doctrine/Manager/ProductAvailabilityManager.php
@@ -0,0 +1,38 @@
+productAvailabilityRepository->save($entity, $flush);
+ }
+
+ /**
+ * Create the unavailability of a product for a given service request.
+ */
+ public function createFromServiceRequest(ServiceRequest $serviceRequest, \DateTimeImmutable $startAt, \DateTimeImmutable $endAt): ProductAvailability
+ {
+ return (new ProductAvailability())
+ ->setMode(ProductAvailabilityMode::UNAVAILABLE)
+ ->setType(ProductAvailabilityType::SERVICE_REQUEST)
+ ->setServiceRequest($serviceRequest)
+ ->setProduct($serviceRequest->getProduct())
+ ->setStartAt($startAt)
+ ->setEndAt($endAt);
+ }
+}
diff --git a/src/Doctrine/Manager/ProductManager.php b/src/Doctrine/Manager/ProductManager.php
new file mode 100644
index 0000000..504115c
--- /dev/null
+++ b/src/Doctrine/Manager/ProductManager.php
@@ -0,0 +1,123 @@
+productRepository->save($entity, $flush);
+ }
+
+ /**
+ * Duplicate product and handle translations.
+ */
+ public function duplicate(Product $product): Product
+ {
+ $duplicated = $product->duplicate();
+ $duplicated->setName($this->translator->trans($this->getI18nPrefix().'.duplicate.copy_of').$product->getName());
+
+ return $duplicated;
+ }
+
+ /**
+ * @param array|null $images
+ */
+ public function multipleUpload(?array $images, Product $newProduct): void
+ {
+ if ($images !== null && \count($images) !== 0) {
+ $imagesUploaded = $this->fileUploader->uploadImageArray($this->productStorage, $images);
+ $newProduct->addImages($imagesUploaded);
+ }
+ }
+
+ /**
+ * Delete a photo in the db and in the storage.
+ */
+ public function deleteImage(Product $product, string $image): Product
+ {
+ try {
+ $this->productStorage->delete($image);
+ } catch (FilesystemException $e) {
+ $this->logger->warning(sprintf('Unable to delete product (%s) image %s: %s', $product->getId(), $image, $e->getMessage()));
+ }
+
+ $product->deleteImage($image);
+ $this->save($product, true);
+
+ return $product;
+ }
+
+ public function hasProductsOnlyInGroup(Group $group, ?User $user): bool
+ {
+ // not logged
+ if ($user === null) {
+ return false;
+ }
+
+ // check the products published in this specific group
+ $productsQuery = $this->productRepository->getUserProductsByType($user, null, null, $group);
+ /** @var array $products */
+ $products = $productsQuery->execute();
+
+ // no product, so nothing to check
+ if (\count($products) === 0) {
+ return false;
+ }
+
+ // now check if those products are published in other groups than this one
+ // as we already know the product is published in at least one group, we
+ // just have to check that the count in greater than 1 (the group + at least
+ // another one)
+ foreach ($products as $product) {
+ if ($product->getGroups()->count() > 1) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function initObject(User $user): Product
+ {
+ return (new Product())
+ ->setOwner($user)
+ ->setType(ProductType::OBJECT)
+ ->setStatus(ProductStatus::ACTIVE);
+ }
+
+ public function initService(User $user): Product
+ {
+ return (new Product())
+ ->setOwner($user)
+ ->setType(ProductType::SERVICE)
+ ->setStatus(ProductStatus::ACTIVE);
+ }
+}
diff --git a/src/Doctrine/Manager/ServiceRequestManager.php b/src/Doctrine/Manager/ServiceRequestManager.php
new file mode 100644
index 0000000..cdbe982
--- /dev/null
+++ b/src/Doctrine/Manager/ServiceRequestManager.php
@@ -0,0 +1,94 @@
+serviceRequestRepository->save($entity, $flush);
+ }
+
+ public function readMessages(ServiceRequest $serviceRequest, User $user): void
+ {
+ foreach ($serviceRequest->getMessages() as $message) {
+ if ($serviceRequest->isOwner($user)) {
+ $message->setOwnerRead(true);
+ $message->setOwnerReadAt(new \DateTimeImmutable('now'));
+ } else {
+ $message->setRecipientRead(true);
+ $message->setRecipientReadAt(new \DateTimeImmutable('now'));
+ }
+ }
+
+ $this->save($serviceRequest, true);
+ }
+
+ /**
+ * Initialize a new service request for a product with sensible default values.
+ */
+ public function initFormProductAndRequest(Product $product, Request $request): ServiceRequest
+ {
+ $serviceRequest = (new ServiceRequest())
+ ->setMessage($this->translator->trans(
+ self::TRANS_PREFIX.'.message.'.$product->getType()->value.'.default',
+ ['%product%' => u($product->getName())->lower()->toString()],
+ ))
+ ->setProduct($product)
+ ;
+
+ $startAt = $request->query->getAlnum('startAt');
+ if (!u($startAt)->isEmpty()) {
+ try {
+ $serviceRequest->setStartAt(new \DateTimeImmutable($startAt));
+ } catch (\Exception) {
+ }
+ }
+ $endAt = $request->query->getAlnum('endAt');
+ if (!u($endAt)->isEmpty()) {
+ try {
+ $serviceRequest->setEndAt(new \DateTimeImmutable($endAt));
+ } catch (\Exception) {
+ }
+ }
+
+ return $serviceRequest;
+ }
+
+ /**
+ * Delete all unavailabilities linked to the service request.
+ */
+ public function deleteUnavailabilities(ServiceRequest $serviceRequest): void
+ {
+ $toDelete = $serviceRequest->getProduct()->getAvailabilities()->filter(
+ fn (ProductAvailability $productAvailability) => $productAvailability->getServiceRequest() === $serviceRequest
+ );
+ foreach ($toDelete as $item) {
+ $this->entityManager->remove($item);
+ }
+ $this->entityManager->flush();
+ }
+}
diff --git a/src/Doctrine/Manager/UserManager.php b/src/Doctrine/Manager/UserManager.php
new file mode 100644
index 0000000..ee8d0ad
--- /dev/null
+++ b/src/Doctrine/Manager/UserManager.php
@@ -0,0 +1,210 @@
+userRepository->save($entity, $flush);
+ }
+
+ /**
+ * Shortcut.
+ */
+ public function remove(User $entity, bool $flush = false): void
+ {
+ $this->userRepository->remove($entity, $flush);
+ }
+
+ public function updatePassword(User $user): void
+ {
+ Assert::stringNotEmpty((string) $user->getPlainPassword(), 'The plainPassword property should be set and not empty.');
+ $password = $this->userPasswordHasher->hashPassword($user, (string) $user->getPlainPassword());
+ $user->setPassword($password);
+ }
+
+ public function updateLoginAt(User $user): void
+ {
+ $user->setLoginAt($this->clock->now());
+ $this->save($user, true);
+ }
+
+ /**
+ * Normalize email in a reliable way.
+ */
+ public function normalizeEmail(User $user, string $email): void
+ {
+ $user->setEmail($this->stringHelper->normalizeEmail($email));
+ }
+
+ /**
+ * Generate a random token that will be used for the user to confirm its email.
+ */
+ public function generateConfirmationToken(User $user): void
+ {
+ $confirmationToken = ByteString::fromRandom(self::CONFIRMATION_TOKEN_LENGTH);
+ $user->setConfirmationToken($confirmationToken->toString());
+ }
+
+ /**
+ * Generate a random token that will be used for the user to confirm its email.
+ */
+ public function generateLostPasswordToken(User $user): void
+ {
+ $token = ByteString::fromRandom(self::LOST_PASSWORD_TOKEN_LENGTH);
+ $user->setLostPasswordToken($token->toString());
+ }
+
+ /**
+ * Set the expiration date of the confirmation token.
+ */
+ public function setConfirmationTokenExpirationDate(User $user): void
+ {
+ $expiresAt = $this->clock->now()->modify(self::CONFIRMATION_TOKEN_EXPIRATION_TIME);
+ $user->setConfirmationExpiresAt($expiresAt);
+ }
+
+ /**
+ * Set the expiration date of the lost password token.
+ */
+ public function setLostPasswordExpirationDate(User $user): void
+ {
+ $expiresAt = $this->clock->now()->modify(self::LOST_PASSWORD_TOKEN_EXPIRATION_TIME);
+ $user->setLostPasswordExpiresAt($expiresAt);
+ }
+
+ public function refreshConfirmationToken(User $user): void
+ {
+ $this->generateConfirmationToken($user);
+ $this->setConfirmationTokenExpirationDate($user);
+ }
+
+ public function getStep1User(string $email): User
+ {
+ $user = new User();
+ $user->setEmail($email);
+ $this->refreshConfirmationToken($user);
+
+ return $user;
+ }
+
+ public function refreshLostPasswordToken(User $user): void
+ {
+ $this->generateLostPasswordToken($user);
+ $this->setLostPasswordExpirationDate($user);
+ }
+
+ /**
+ * Finalization process and cleanup for step 2 of the account creation.
+ */
+ public function finalizeAccountCreateStep2(User $user): void
+ {
+ $user->confirmEmail();
+ $user->resetConfirmation();
+ }
+
+ /**
+ * Set the user's new email.
+ */
+ public function changeLogin(User $user, string $email): void
+ {
+ $user->setEmail($email);
+ }
+
+ /**
+ * Add a membership for a free group.
+ */
+ public function addToGroup(User $user, Group $group, UserMembership $userMembership = UserMembership::MEMBER): void
+ {
+ $userGroup = (new UserGroup())
+ ->setUser($user)
+ ->setGroup($group)
+ ->setMembership($userMembership);
+ $user->addUserGroup($userGroup);
+ }
+
+ public function addInvitation(User $user, Group $group): void
+ {
+ $this->addToGroup($user, $group, UserMembership::INVITATION);
+ }
+
+ public function upload(?UploadedFile $image, User $user): void
+ {
+ if ($image !== null) {
+ $imageUploaded = $this->fileUploader->uploadImage($this->userStorage, $image);
+ $user->setAvatar($imageUploaded);
+ }
+ }
+
+ public function deleteAvatar(User $user): User
+ {
+ try {
+ $this->userStorage->delete((string) $user->getAvatar());
+ } catch (FilesystemException $e) {
+ $this->logger->warning(sprintf('Unable to avatar of user (%s) image %s: %s', $user->getId(), $user->getAvatar(), $e->getMessage()));
+ }
+ $user->deleteAvatar();
+ $this->save($user, true);
+
+ return $user;
+ }
+
+ /**
+ * Add the email normalization step when submitting a form implying a user so
+ * the unique constraint on the email can work properly.
+ */
+ public function addEmailNormalizeSubmitEvent(FormBuilderInterface $builder): void
+ {
+ $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
+ /** @var User $user */
+ $user = $event->getData();
+ $this->normalizeEmail($user, $user->getEmail());
+ $event->setData($user);
+ });
+ }
+}
diff --git a/src/Dto/Product/Search.php b/src/Dto/Product/Search.php
new file mode 100644
index 0000000..c570464
--- /dev/null
+++ b/src/Dto/Product/Search.php
@@ -0,0 +1,94 @@
+q = $q;
+ $this->page = $page;
+ $this->user = $user;
+ }
+
+ /**
+ * Search query. Eg: "vélo".
+ */
+ public string $q = '';
+
+ /**
+ * Requested page for paginated results.
+ */
+ public int $page = 1;
+
+ /**
+ * Category filter.
+ */
+ public ?Category $category = null;
+
+ /**
+ * Place filter.
+ */
+ public ?User $place = null;
+
+ /**
+ * Current logged user.
+ */
+ public ?User $user = null;
+
+ /**
+ * City filter Eg: "Lille". The distance filter is only applied when we have
+ * bother a city and a distance.
+ */
+ public ?Address $city = null;
+
+ /**
+ * Distance filter related to the city.
+ */
+ public ?int $distance = null;
+
+ public function hasQuery(): bool
+ {
+ return $this->q !== '';
+ }
+
+ public function hasCity(): bool
+ {
+ return $this->city !== null;
+ }
+
+ public function hasDistance(): bool
+ {
+ return $this->distance !== null;
+ }
+
+ /**
+ * Test if we have both a city (as an address with a non empty locality) and
+ * a distance.
+ */
+ public function hasProximity(): bool
+ {
+ return $this->hasCity() &&
+ ($this->city?->hasLocality() ?? false) &&
+ $this->hasDistance()
+ ;
+ }
+
+ /**
+ * If the user is not null, then it is logged.
+ */
+ public function isLogged(): bool
+ {
+ return $this->user !== null;
+ }
+}
diff --git a/src/Dto/User/UserAddressStep1Data.php b/src/Dto/User/UserAddressStep1Data.php
new file mode 100644
index 0000000..98715ab
--- /dev/null
+++ b/src/Dto/User/UserAddressStep1Data.php
@@ -0,0 +1,58 @@
+ $this->address,
+ 'addresses' => $this->addresses,
+ ];
+ }
+
+ /**
+ * @return array
+ *
+ * @throws \Exception
+ */
+ public function getAddressesAsArray(): array
+ {
+ /** @var array $array */
+ $array = iterator_to_array($this->addresses->getIterator());
+
+ return $array;
+ }
+}
diff --git a/src/EasyAdmin/Field/FieldTrait.php b/src/EasyAdmin/Field/FieldTrait.php
new file mode 100644
index 0000000..d84fe46
--- /dev/null
+++ b/src/EasyAdmin/Field/FieldTrait.php
@@ -0,0 +1,35 @@
+renderAsSwitch(false)
+ ->setTemplatePath('easy_admin/field/boolean.html.twig');
+ }
+
+ /**
+ * @return array
+ */
+ public function getPanels(): array
+ {
+ return [
+ 'information' => FormField::addPanel('panel.information', 'fas fa-info-circle'),
+ 'tech_information' => FormField::addPanel('panel.tech_information', 'fas fa-history'),
+ ];
+ }
+}
diff --git a/src/EasyAdmin/Filter/EnumFilter.php b/src/EasyAdmin/Filter/EnumFilter.php
new file mode 100644
index 0000000..7ade70f
--- /dev/null
+++ b/src/EasyAdmin/Filter/EnumFilter.php
@@ -0,0 +1,38 @@
+setFilterFqcn(__CLASS__)
+ ->setProperty($propertyName)
+ ->setLabel($label)
+ ->setFormType($formType);
+ }
+
+ /**
+ * Applie an exact match filter for an enum value.
+ */
+ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
+ {
+ $queryBuilder->andWhere(sprintf('%s.%s = :value', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty()))
+ ->setParameter('value', $filterDataDto->getValue());
+ }
+}
diff --git a/src/EasyAdmin/Filter/User/GroupFilter.php b/src/EasyAdmin/Filter/User/GroupFilter.php
new file mode 100644
index 0000000..92fe638
--- /dev/null
+++ b/src/EasyAdmin/Filter/User/GroupFilter.php
@@ -0,0 +1,45 @@
+setFilterFqcn(__CLASS__)
+ ->setProperty($propertyName)
+ ->setLabel($label)
+ ->setFormType(GroupType::class);
+ }
+
+ /**
+ * The join is done on userGroups.
+ */
+ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
+ {
+ /** @var Group $group */
+ $group = $filterDataDto->getValue();
+ $queryBuilder
+ ->innerJoin(sprintf('%s.userGroups', $filterDataDto->getEntityAlias()), 'ug')
+ ->andWhere('ug.group = :group')
+ ->setParameter(':group', $group->getId())
+ ;
+ }
+}
diff --git a/src/EasyAdmin/Filter/User/MyUsersFilter.php b/src/EasyAdmin/Filter/User/MyUsersFilter.php
new file mode 100644
index 0000000..4b2b8de
--- /dev/null
+++ b/src/EasyAdmin/Filter/User/MyUsersFilter.php
@@ -0,0 +1,42 @@
+setFilterFqcn(__CLASS__)
+ ->setProperty($propertyName)
+ ->setLabel($label)
+ ->setFormType(UserType::class)
+ ;
+ }
+
+ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
+ {
+ /** @var User $user */
+ $user = $filterDataDto->getValue();
+ $queryBuilder
+ ->andWhere('entity.user = :user')
+ ->setParameter(':user', $user)
+ ;
+ }
+}
diff --git a/src/EasyAdmin/Filter/UserGroup/MyGroupFilter.php b/src/EasyAdmin/Filter/UserGroup/MyGroupFilter.php
new file mode 100644
index 0000000..4ccd28c
--- /dev/null
+++ b/src/EasyAdmin/Filter/UserGroup/MyGroupFilter.php
@@ -0,0 +1,42 @@
+setFilterFqcn(__CLASS__)
+ ->setProperty($propertyName)
+ ->setLabel($label)
+ ->setFormType(GroupType::class)
+ ;
+ }
+
+ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
+ {
+ /** @var Group $group */
+ $group = $filterDataDto->getValue();
+ $queryBuilder
+ ->andWhere('entity.group = :group')
+ ->setParameter(':group', $group->getId())
+ ;
+ }
+}
diff --git a/src/EasyAdmin/Filter/UuidFilter.php b/src/EasyAdmin/Filter/UuidFilter.php
new file mode 100644
index 0000000..6a96a43
--- /dev/null
+++ b/src/EasyAdmin/Filter/UuidFilter.php
@@ -0,0 +1,43 @@
+add(UuidFilter::new('id', TextType::class))
+ */
+final class UuidFilter implements FilterInterface
+{
+ use FilterTrait;
+
+ public static function new(string $propertyName, string $label = null): self
+ {
+ return (new self())
+ ->setFilterFqcn(__CLASS__)
+ ->setProperty($propertyName)
+ ->setLabel($label)
+ ->setFormType(TextType::class);
+ }
+
+ /**
+ * Apply an exact match filter for the uuid. Doctrine can handle the value as
+ * a string and we don't have to convert it in a uuid object.
+ */
+ public function apply(QueryBuilder $queryBuilder, FilterDataDto $filterDataDto, ?FieldDto $fieldDto, EntityDto $entityDto): void
+ {
+ $queryBuilder->andWhere(sprintf('%s.%s = :value', $filterDataDto->getEntityAlias(), $filterDataDto->getProperty()))
+ ->setParameter('value', $filterDataDto->getValue());
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/GroupMembershipType.php b/src/EasyAdmin/Form/Type/GroupMembershipType.php
new file mode 100644
index 0000000..343f6e3
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/GroupMembershipType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => GroupMembership::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/GroupOfferTypeType.php b/src/EasyAdmin/Form/Type/GroupOfferTypeType.php
new file mode 100644
index 0000000..719e9fb
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/GroupOfferTypeType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => GroupOfferType::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/GroupType.php b/src/EasyAdmin/Form/Type/GroupType.php
new file mode 100644
index 0000000..e49c8e3
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/GroupType.php
@@ -0,0 +1,52 @@
+setDefaults([
+ 'class' => Group::class,
+ 'className' => Group::class,
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+
+ // restrict to allowed groups only
+ if (!$this->authorizationChecker->isAdmin()) {
+ /** @var User $user */
+ $user = $this->security->getUser();
+ $resolver->setDefault('query_builder', function (GroupRepository $repo) use ($user) {
+ return $repo->createQueryBuilder('entity')
+ ->andWhere('entity.id IN (:groups)')
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+ });
+ }
+ }
+
+ public function getParent(): string
+ {
+ return EntityType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/GroupTypeType.php b/src/EasyAdmin/Form/Type/GroupTypeType.php
new file mode 100644
index 0000000..462e9df
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/GroupTypeType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => GroupType::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/LoanStatusType.php b/src/EasyAdmin/Form/Type/LoanStatusType.php
new file mode 100644
index 0000000..095015b
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/LoanStatusType.php
@@ -0,0 +1,27 @@
+setDefaults([
+ 'choices' => ServiceRequestStatus::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/ProductStatusType.php b/src/EasyAdmin/Form/Type/ProductStatusType.php
new file mode 100644
index 0000000..65fb755
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/ProductStatusType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => ProductStatus::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/ProductVisibilityType.php b/src/EasyAdmin/Form/Type/ProductVisibilityType.php
new file mode 100644
index 0000000..5b991d6
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/ProductVisibilityType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => ProductVisibility::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/SocialMediaTypeType.php b/src/EasyAdmin/Form/Type/SocialMediaTypeType.php
new file mode 100644
index 0000000..9aeee21
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/SocialMediaTypeType.php
@@ -0,0 +1,27 @@
+setDefaults([
+ 'choices' => SocialMediaType::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/UserMembershipType.php b/src/EasyAdmin/Form/Type/UserMembershipType.php
new file mode 100644
index 0000000..000817b
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/UserMembershipType.php
@@ -0,0 +1,30 @@
+setDefaults([
+ 'choices' => UserMembership::getAsArray(),
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+
+ public function getParent(): string
+ {
+ return ChoiceType::class;
+ }
+}
diff --git a/src/EasyAdmin/Form/Type/UserType.php b/src/EasyAdmin/Form/Type/UserType.php
new file mode 100644
index 0000000..4389e64
--- /dev/null
+++ b/src/EasyAdmin/Form/Type/UserType.php
@@ -0,0 +1,53 @@
+setDefaults([
+ 'class' => User::class,
+ 'className' => User::class,
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+
+ // restrict to allowed groups only
+ if (!$this->authorizationChecker->isAdmin()) {
+ /** @var User $user */
+ $user = $this->security->getUser();
+
+ $resolver->setDefault('query_builder', function (UserRepository $repo) use ($user) {
+ return $repo->createQueryBuilder('entity')
+ ->innerJoin('entity.userGroups', 'ug')
+ ->andWhere('ug.group IN (:groups)')
+ ->setParameter(':groups', $user->getMyGroupsAsAdmin());
+ });
+ }
+ }
+
+ public function getParent(): string
+ {
+ return EntityType::class;
+ }
+}
diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/src/Entity/Address.php b/src/Entity/Address.php
new file mode 100644
index 0000000..262aebe
--- /dev/null
+++ b/src/Entity/Address.php
@@ -0,0 +1,389 @@
+displayName;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getAddress(): string
+ {
+ return $this->address;
+ }
+
+ public function setAddress(string $address): self
+ {
+ $this->address = $address;
+
+ return $this;
+ }
+
+ public function getAddressSupplement(): ?string
+ {
+ return $this->addressSupplement;
+ }
+
+ public function setAddressSupplement(?string $addressSupplement): Address
+ {
+ $this->addressSupplement = $addressSupplement;
+
+ return $this;
+ }
+
+ public function getDisplayName(): ?string
+ {
+ return $this->displayName;
+ }
+
+ public function setDisplayName(?string $displayName): self
+ {
+ $this->displayName = $displayName;
+
+ return $this;
+ }
+
+ public function getStreetNumber(): string
+ {
+ return $this->streetNumber;
+ }
+
+ public function setStreetNumber(string $streetNumber): self
+ {
+ $this->streetNumber = $streetNumber;
+
+ return $this;
+ }
+
+ public function getStreetName(): string
+ {
+ return $this->streetName;
+ }
+
+ public function setStreetName(string $streetName): self
+ {
+ $this->streetName = $streetName;
+
+ return $this;
+ }
+
+ public function getLocality(): string
+ {
+ return $this->locality;
+ }
+
+ public function hasLocality(): bool
+ {
+ return $this->locality !== '';
+ }
+
+ public function setLocality(string $locality): self
+ {
+ $this->locality = $locality;
+
+ return $this;
+ }
+
+ public function getSubLocality(): ?string
+ {
+ return $this->subLocality;
+ }
+
+ public function setSubLocality(?string $subLocality): self
+ {
+ $this->subLocality = $subLocality;
+
+ return $this;
+ }
+
+ public function getPostalCode(): string
+ {
+ return $this->postalCode;
+ }
+
+ public function setPostalCode(string $postalCode): self
+ {
+ $this->postalCode = $postalCode;
+
+ return $this;
+ }
+
+ public function getCountry(): string
+ {
+ return $this->country;
+ }
+
+ public function setCountry(string $country): self
+ {
+ $this->country = $country;
+
+ return $this;
+ }
+
+ public function getLatitude(): string
+ {
+ return $this->latitude;
+ }
+
+ public function setLatitude(string $latitude): self
+ {
+ $this->latitude = $latitude;
+
+ return $this;
+ }
+
+ public function getLongitude(): string
+ {
+ return $this->longitude;
+ }
+
+ public function setLongitude(string $longitude): self
+ {
+ $this->longitude = $longitude;
+
+ return $this;
+ }
+
+ public function getProvidedBy(): string
+ {
+ return $this->providedBy;
+ }
+
+ public function setProvidedBy(string $providedBy): Address
+ {
+ $this->providedBy = $providedBy;
+
+ return $this;
+ }
+
+ public function getAttribution(): string
+ {
+ return $this->attribution;
+ }
+
+ public function setAttribution(string $attribution): Address
+ {
+ $this->attribution = $attribution;
+
+ return $this;
+ }
+
+ public function getOsmType(): ?string
+ {
+ return $this->osmType;
+ }
+
+ public function setOsmType(?string $osmType): Address
+ {
+ $this->osmType = $osmType;
+
+ return $this;
+ }
+
+ public function getOsmId(): int
+ {
+ return $this->osmId;
+ }
+
+ public function setOsmId(int $osmId): Address
+ {
+ $this->osmId = $osmId;
+
+ return $this;
+ }
+
+ // End of basic setters/getters ————————————————————————————————————————————
+
+ /**
+ * Format a full address with the user input (for address update step2).
+ */
+ public function getFullAddress(): string
+ {
+ $addressSupplement = u($this->addressSupplement)->isEmpty() ? '' : ', '.$this->addressSupplement;
+
+ return $this->address.$addressSupplement.', '.$this->postalCode.', '.$this->locality.', '.$this->country;
+ }
+
+ /**
+ * Override the properties of the old address.
+ */
+ public function setFromAddressUpdateStep1(Address $newAddress): self
+ {
+ $this->setAddress($newAddress->getAddress());
+ $this->setAddressSupplement($newAddress->getAddressSupplement());
+ $this->setPostalCode($newAddress->getPostalCode());
+ $this->setLocality($newAddress->getLocality());
+ $this->setCountry($newAddress->getCountry());
+
+ return $this;
+ }
+
+ public function getSubAndLocality(): string
+ {
+ if (u($this->subLocality)->isEmpty()) {
+ return $this->locality;
+ }
+
+ return $this->locality.' ('.$this->subLocality.')';
+ }
+}
diff --git a/src/Entity/Category.php b/src/Entity/Category.php
new file mode 100644
index 0000000..b44f33d
--- /dev/null
+++ b/src/Entity/Category.php
@@ -0,0 +1,252 @@
+ $children
+ */
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ private Collection $children;
+
+ #[Gedmo\TreeLeft]
+ #[ORM\Column(name: 'lft', type: Types::INTEGER)]
+ private int $lft;
+
+ #[Gedmo\TreeLevel]
+ #[ORM\Column(name: 'lvl', type: Types::INTEGER)]
+ private int $lvl;
+
+ #[Gedmo\TreeRight]
+ #[ORM\Column(name: 'rgt', type: Types::INTEGER)]
+ private int $rgt;
+
+ /**
+ * Associated product type. Object or service.
+ */
+ #[ORM\Column(name: 'type', type: 'string', nullable: false, enumType: ProductType::class)]
+ #[Assert\NotBlank]
+ #[Gedmo\TreeRoot(identifierMethod: 'getType')]
+ protected ProductType $type;
+
+ /**
+ * Short and main name of the category.
+ */
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
+ #[Assert\NotBlank]
+ #[Assert\Length(max: 255)]
+ private string $name;
+
+ /**
+ * SEO friendly name for URLs.
+ */
+ #[ORM\Column(length: 255, unique: false)]
+ #[Gedmo\Slug(fields: ['name'])]
+ private string $slug;
+
+ /**
+ * Tells if the category is visible on the search form.
+ */
+ #[ORM\Column(type: 'boolean', nullable: false)]
+ protected bool $enabled = true;
+
+ /**
+ * Default image for the objects associated to the category when not having
+ * specific images.
+ */
+ #[ORM\Column(nullable: true)]
+ private ?string $image = null;
+
+ public function __construct()
+ {
+ $this->children = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->name;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getParent(): ?self
+ {
+ return $this->parent;
+ }
+
+ public function setParent(?self $parent): self
+ {
+ $this->parent = $parent;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getChildren(): Collection
+ {
+ return $this->children;
+ }
+
+ public function hasChildren(): bool
+ {
+ return !$this->children->isEmpty();
+ }
+
+ public function addChild(self $category): self
+ {
+ if (!$this->children->contains($category)) {
+ $this->children->add($category);
+ $category->setParent($this);
+ }
+
+ return $this;
+ }
+
+ public function removeChild(self $category): self
+ {
+ if ($this->children->removeElement($category)) {
+ // set the owning side to null (unless already changed)
+ if ($category->getParent() === $this) {
+ $category->setParent(null);
+ }
+ }
+
+ return $this;
+ }
+
+ public function getType(): ProductType
+ {
+ return $this->type;
+ }
+
+ public function setType(ProductType $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function setSlug(string $slug): self
+ {
+ $this->slug = $slug;
+
+ return $this;
+ }
+
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function setEnabled(bool $enabled): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function getImage(): ?string
+ {
+ return $this->image;
+ }
+
+ public function setImage(?string $image): self
+ {
+ $this->image = $image;
+
+ return $this;
+ }
+
+ /**
+ * Create a dummy empty object with non nullable fields initialized.
+ */
+ public static function getForEmptyData(): Category
+ {
+ return (new self())
+ ->setId(Uuid::v6())
+ ->setType(ProductType::OBJECT)
+ ->setName('')
+ ->setSlug('');
+ }
+
+ /** End of basic 'etters ———————————————————————————————————————————————— */
+ public function hasParent(): bool
+ {
+ return $this->parent !== null;
+ }
+
+ public function getNameWithIndent(): string
+ {
+ return $this->hasParent() ? str_repeat('—', $this->lvl).'> '.$this->getName() : $this->getName();
+ }
+}
diff --git a/src/Entity/Configuration.php b/src/Entity/Configuration.php
new file mode 100644
index 0000000..89415b4
--- /dev/null
+++ b/src/Entity/Configuration.php
@@ -0,0 +1,151 @@
+>
+ */
+ #[ORM\Column(type: 'json')]
+ private array $configuration = [];
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getType(): ConfigurationType
+ {
+ return $this->type;
+ }
+
+ public function setType(ConfigurationType $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ /**
+ * @return array>
+ */
+ public function getConfiguration(): array
+ {
+ return $this->configuration;
+ }
+
+ /**
+ * @param array> $configuration
+ */
+ public function setConfiguration(array $configuration): self
+ {
+ $this->configuration = $configuration;
+
+ return $this;
+ }
+
+ public static function getInstanceConfiguration(): Configuration
+ {
+ return (new self())->setType(ConfigurationType::INSTANCE);
+ }
+
+ /** end of basic getters and setters ------------------------------------------------ */
+
+ /**
+ * @return array
+ */
+ public function getNotificationsSender(): array
+ {
+ /** @var array $notificationsSender */
+ $notificationsSender = $this->configuration['notificationsSender'] ?? [];
+
+ return $notificationsSender;
+ }
+
+ public function getNotificationsSenderEmail(): string
+ {
+ $notificationsSender = $this->getNotificationsSender();
+
+ return $notificationsSender['notificationsSenderEmail'] ?? '';
+ }
+
+ public function getNotificationsSenderName(): string
+ {
+ $notificationsSender = $this->getNotificationsSender();
+
+ return $notificationsSender['notificationsSenderName'] ?? '';
+ }
+
+ /**
+ * @return array
+ */
+ public function getContactInformations(): array
+ {
+ /** @var array $contactInfo */
+ $contactInfo = $this->configuration['contact'] ?? [];
+
+ return $contactInfo;
+ }
+
+ public function getContactEnabled(): bool
+ {
+ /** @var array $contactEnabled */
+ $contactEnabled = $this->getContactInformations();
+
+ return $contactEnabled['contactFormEnabled'];
+ }
+
+ public function getContactEmail(): string
+ {
+ /** @var array $contactEnabled
+ */
+ $contactEnabled = $this->getContactInformations();
+
+ return $contactEnabled['contactFormEmail'];
+ }
+
+ public function isConversationAdminAccessible(): bool
+ {
+ /** @var array $config */
+ $config = $this->configuration['confidentiality'] ?? [];
+
+ return $config['confidentialityConversationAdminAccess'];
+ }
+
+ public function isGroupsCreationForAll(): bool
+ {
+ return $this->configuration['groups']['groupsCreationMode'] === ParametersFormCommand::ALL;
+ }
+
+ // for test only
+ public function setGroupsCreationModeToAdminOnly(): self
+ {
+ $this->configuration['groups']['groupsCreationMode'] = ParametersFormCommand::ONLY_ADMIN;
+
+ return $this;
+ }
+}
diff --git a/src/Entity/Group.php b/src/Entity/Group.php
new file mode 100755
index 0000000..fa86764
--- /dev/null
+++ b/src/Entity/Group.php
@@ -0,0 +1,387 @@
+ $children
+ */
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ private Collection $children;
+
+ /**
+ * membership of the group. It can be free or paying.
+ */
+ #[ORM\Column(name: 'membership', type: 'string', nullable: false, enumType: GroupMembership::class)]
+ protected GroupMembership $membership = GroupMembership::FREE;
+
+ /**
+ * If true, administrators can send invitations.
+ */
+ #[ORM\Column(type: 'boolean', nullable: false)]
+ private bool $invitationByAdmin = false;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'group', targetEntity: UserGroup::class, orphanRemoval: true)]
+ private Collection $userGroups;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'group', targetEntity: GroupOffer::class, orphanRemoval: true)]
+ #[ORM\OrderBy(['price' => 'ASC'])]
+ private Collection $offers;
+
+ /**
+ * List of visible product in the group.
+ *
+ * @var Collection
+ */
+ #[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'groups')]
+ private Collection $products;
+
+ public function __construct()
+ {
+ $this->children = new ArrayCollection();
+ $this->userGroups = new ArrayCollection();
+ $this->offers = new ArrayCollection();
+ $this->products = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->name;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getParent(): ?self
+ {
+ return $this->parent;
+ }
+
+ public function setParent(?self $parent): self
+ {
+ $this->parent = $parent;
+
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function setSlug(string $slug): self
+ {
+ $this->slug = $slug;
+
+ return $this;
+ }
+
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ public function getType(): GroupType
+ {
+ return $this->type;
+ }
+
+ public function setType(GroupType $type): Group
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getUrl(): ?string
+ {
+ return $this->url;
+ }
+
+ public function setUrl(?string $url): self
+ {
+ $this->url = $url;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getChildren(): Collection
+ {
+ return $this->children;
+ }
+
+ public function addChild(self $group): self
+ {
+ if (!$this->children->contains($group)) {
+ $this->children->add($group);
+ $group->setParent($this);
+ }
+
+ return $this;
+ }
+
+ public function removeChild(self $group): self
+ {
+ // set the owning side to null (unless already changed)
+ if ($this->children->removeElement($group) && $group->getParent() === $this) {
+ $group->setParent(null);
+ }
+
+ return $this;
+ }
+
+ public function getMembership(): GroupMembership
+ {
+ return $this->membership;
+ }
+
+ public function setMembership(GroupMembership $membership): void
+ {
+ $this->membership = $membership;
+ }
+
+ public function isInvitationByAdmin(): bool
+ {
+ return $this->invitationByAdmin;
+ }
+
+ public function setInvitationByAdmin(bool $invitationByAdmin): self
+ {
+ $this->invitationByAdmin = $invitationByAdmin;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getUserGroups(): Collection
+ {
+ return $this->userGroups;
+ }
+
+ public function addUserGroup(UserGroup $userGroup): self
+ {
+ if (!$this->userGroups->contains($userGroup)) {
+ $this->userGroups->add($userGroup);
+ $userGroup->setGroup($this);
+ }
+
+ return $this;
+ }
+
+ public function removeUserGroup(UserGroup $userGroup): self
+ {
+ $this->userGroups->removeElement($userGroup);
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getOffers(): Collection
+ {
+ return $this->offers;
+ }
+
+ /**
+ * @param Collection $offers
+ */
+ public function setOffers(Collection $offers): Group
+ {
+ $this->offers = $offers;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getProducts(): Collection
+ {
+ return $this->products;
+ }
+
+ public function addProduct(Product $product): self
+ {
+ if (!$this->products->contains($product)) {
+ $this->products->add($product);
+ $product->addGroup($this);
+ }
+
+ return $this;
+ }
+
+ public function removeProduct(Product $product): self
+ {
+ if ($this->products->removeElement($product)) {
+ $product->removeGroup($this);
+ }
+
+ return $this;
+ }
+
+ // End of basic 'etters ----------------------------------------------------
+
+ /**
+ * @return array
+ */
+ public function getRoutingParameters(): array
+ {
+ return [
+ 'id' => (string) $this->getId(),
+ 'slug' => $this->getSlug(),
+ ];
+ }
+
+ /**
+ * Test if a given user is main admin of the group.
+ */
+ public function isMainAdmin(User $user): bool
+ {
+ $mainAdminUserGroups = $this->userGroups->filter(
+ static fn (UserGroup $userGroup) => $userGroup->getUser() === $user && $userGroup->isMainAdminAccount()
+ );
+
+ return !$mainAdminUserGroups->isEmpty();
+ }
+
+ /**
+ * Get active offers only.
+ *
+ * @return Collection
+ */
+ public function getActiveOffers(): Collection
+ {
+ /** @var Collection $collection */
+ $collection = $this->offers->filter(
+ static fn (GroupOffer $groupOffer) => $groupOffer->isActive()
+ );
+
+ return $collection;
+ }
+
+ public function hasActiveOffers(): bool
+ {
+ return !$this->getActiveOffers()->isEmpty();
+ }
+}
diff --git a/src/Entity/GroupOffer.php b/src/Entity/GroupOffer.php
new file mode 100644
index 0000000..87263c1
--- /dev/null
+++ b/src/Entity/GroupOffer.php
@@ -0,0 +1,172 @@
+ 'ASC'])]
+ private Group $group;
+
+ /**
+ * Short name of the offer.
+ */
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
+ #[Assert\NotBlank]
+ #[Assert\Length(max: 255)]
+ private string $name;
+
+ /**
+ * Type of offer.
+ */
+ #[ORM\Column(name: 'type', type: 'string', nullable: false, enumType: GroupOfferType::class)]
+ #[Assert\NotBlank]
+ protected GroupOfferType $type;
+
+ /**
+ * Price, we stored the amount multiplied by 100 so we can use an integer for
+ * this property.
+ */
+ #[ORM\Column(type: Types::INTEGER, nullable: false)]
+ protected int $price;
+
+ /**
+ * Associated currency for the price property.
+ *
+ * @see https://en.wikipedia.org/wiki/ISO_4217
+ */
+ #[ORM\Column(type: Types::STRING, nullable: false)]
+ protected string $currency = self::DEFAULT_CURRENCY;
+
+ /**
+ * If the offer is visible on the front site. Can be use to deactivate offers
+ * for some time.
+ */
+ #[ORM\Column(type: 'boolean', nullable: false)]
+ protected bool $active = true;
+
+ public function __toString()
+ {
+ return $this->name.' ('.$this->type->value.')';
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getGroup(): Group
+ {
+ return $this->group;
+ }
+
+ public function setGroup(Group $group): GroupOffer
+ {
+ $this->group = $group;
+
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): GroupOffer
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getType(): GroupOfferType
+ {
+ return $this->type;
+ }
+
+ public function setType(GroupOfferType $type): GroupOffer
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setPrice(int $price): self
+ {
+ $this->price = $price;
+
+ return $this;
+ }
+
+ public function getPrice(): int
+ {
+ return $this->price;
+ }
+
+ public function getActualPrice(): int
+ {
+ return $this->price / 100;
+ }
+
+ public function getCurrency(): string
+ {
+ return $this->currency;
+ }
+
+ public function setCurrency(string $currency): self
+ {
+ $this->currency = $currency;
+
+ return $this;
+ }
+
+ public function isActive(): bool
+ {
+ return $this->active;
+ }
+
+ public function setActive(bool $active): GroupOffer
+ {
+ $this->active = $active;
+
+ return $this;
+ }
+}
diff --git a/src/Entity/ImageInterface.php b/src/Entity/ImageInterface.php
new file mode 100644
index 0000000..7587843
--- /dev/null
+++ b/src/Entity/ImageInterface.php
@@ -0,0 +1,10 @@
+|null
+ */
+ public function getImages(): ?array;
+}
diff --git a/src/Entity/Menu.php b/src/Entity/Menu.php
new file mode 100644
index 0000000..ac66fee
--- /dev/null
+++ b/src/Entity/Menu.php
@@ -0,0 +1,144 @@
+ $items
+ */
+ #[ORM\OneToMany(mappedBy: 'menu', targetEntity: MenuItem::class, cascade: ['persist', 'remove', 'detach'])]
+ private Collection $items;
+
+ public function __construct()
+ {
+ $this->items = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->code;
+ }
+
+ public function getId(): int
+ {
+ return $this->id;
+ }
+
+ public function setId(int $id): self
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getLogo(): ?string
+ {
+ return $this->logo;
+ }
+
+ public function setLogo(?string $logo): self
+ {
+ $this->logo = $logo;
+
+ return $this;
+ }
+
+ public function getCode(): string
+ {
+ return $this->code;
+ }
+
+ public function setCode(string $code): self
+ {
+ $this->code = $code;
+
+ return $this;
+ }
+
+ public function getImage(): ?string
+ {
+ return $this->getLogo();
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getItems(): Collection
+ {
+ return $this->items;
+ }
+
+ /**
+ * @param Collection $items
+ */
+ public function setItems(Collection $items): self
+ {
+ $this->items = $items;
+
+ return $this;
+ }
+
+ public function addItem(MenuItem $item): self
+ {
+ if (!$this->items->contains($item)) {
+ $this->items->add($item);
+ $item->setMenu($this);
+ }
+
+ return $this;
+ }
+
+ public function removeItem(MenuItem $item): self
+ {
+ $this->items->removeElement($item);
+// if ($this->items->removeElement($item)) {
+ // set the owning side to null (unless already changed)
+// if ($item->getMenu() === $this) {
+// $item->setMenu(null);
+// }
+// }
+
+ return $this;
+ }
+
+ public function itemsCount(): int
+ {
+ return $this->items->count();
+ }
+}
diff --git a/src/Entity/MenuItem.php b/src/Entity/MenuItem.php
new file mode 100644
index 0000000..e92983a
--- /dev/null
+++ b/src/Entity/MenuItem.php
@@ -0,0 +1,255 @@
+ $children
+ */
+ #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
+ #[ORM\OrderBy(['position' => 'ASC'])]
+ private Collection $children;
+
+ /**
+ * Menu related to items.
+ */
+ #[Gedmo\SortableGroup]
+ #[ORM\ManyToOne(targetEntity: Menu::class, inversedBy: 'items')]
+ private Menu $menu;
+
+ /**
+ * Position of the item in the front menu.
+ */
+ #[Gedmo\SortablePosition]
+ #[ORM\Column(name: 'position', type: 'integer')]
+ private int $position;
+
+ public function __construct()
+ {
+ $this->children = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->name ?? $this->link;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getLinkType(): LinkType
+ {
+ return $this->linkType;
+ }
+
+ public function setLinkType(LinkType $linkType): void
+ {
+ $this->linkType = $linkType;
+ }
+
+ public function getMediaType(): ?SocialMediaType
+ {
+ return $this->mediaType;
+ }
+
+ public function setMediaType(?SocialMediaType $mediaType): void
+ {
+ $this->mediaType = $mediaType;
+ }
+
+ public function getLink(): string
+ {
+ return $this->link;
+ }
+
+ public function setLink(string $link): self
+ {
+ $this->link = $link;
+
+ return $this;
+ }
+
+ public function getParent(): ?self
+ {
+ return $this->parent;
+ }
+
+ public function setParent(?self $parent): self
+ {
+ $this->parent = $parent;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getChildren(): Collection
+ {
+ return $this->children;
+ }
+
+ public function hasChildren(): bool
+ {
+ return !$this->children->isEmpty();
+ }
+
+ /**
+ * @param Collection $children
+ */
+ public function setChildren(Collection $children): self
+ {
+ $this->children = $children;
+
+ return $this;
+ }
+
+ public function getMenu(): Menu
+ {
+ return $this->menu;
+ }
+
+ public function setMenu(Menu $menu): MenuItem
+ {
+ $this->menu = $menu;
+
+ return $this;
+ }
+
+ public function getPosition(): int
+ {
+ return $this->position;
+ }
+
+ public function setPosition(int $position): void
+ {
+ $this->position = $position;
+ }
+
+ public function getPositionHuman(): int
+ {
+ return $this->position + 1;
+ }
+
+ public function isFirst(): bool
+ {
+ return $this->position === self::POSITION_FIRST;
+ }
+
+ public function up(): int
+ {
+ return $this->position - 1;
+ }
+
+ public function down(): int
+ {
+ return $this->position + 1;
+ }
+
+ public function isLink(): bool
+ {
+ return $this->linkType === LinkType::LINK;
+ }
+
+ public function isSocialNetwork(): bool
+ {
+ return $this->linkType === LinkType::SOCIAL_NETWORK;
+ }
+}
diff --git a/src/Entity/Message.php b/src/Entity/Message.php
new file mode 100644
index 0000000..eb12afe
--- /dev/null
+++ b/src/Entity/Message.php
@@ -0,0 +1,267 @@
+
+ */
+ #[ORM\Column(type: 'json')]
+ protected array $messageParameters = [];
+
+ /**
+ * Final message content.
+ */
+ #[ORM\Column(type: 'text', nullable: false)]
+ #[Assert\NotBlank(groups: [NewMessageType::class])]
+ #[Assert\Length(max: 1000, groups: [NewMessageType::class])]
+ protected string $message;
+
+ /**
+ * If the message was read by the owner.
+ */
+ #[ORM\Column]
+ protected bool $ownerRead = false;
+
+ /**
+ * Date the message was read by the owner.
+ */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ protected ?\DateTimeImmutable $ownerReadAt = null;
+
+ /**
+ * If the message was read by the recipient.
+ */
+ #[ORM\Column]
+ protected bool $recipientRead = false;
+
+ /**
+ * Date the message was read by the owner.
+ */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
+ protected ?\DateTimeImmutable $recipientReadAt = null;
+
+ public function __toString(): string
+ {
+ return $this->message;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $id): Message
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getServiceRequest(): ServiceRequest
+ {
+ return $this->serviceRequest;
+ }
+
+ public function setServiceRequest(ServiceRequest $serviceRequest): Message
+ {
+ $this->serviceRequest = $serviceRequest;
+
+ return $this;
+ }
+
+ public function getType(): MessageType
+ {
+ return $this->type;
+ }
+
+ public function setType(MessageType $type): Message
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getMessage(): string
+ {
+ return $this->message;
+ }
+
+ public function setMessage(string $message): Message
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ public function getMessageTemplate(): ?string
+ {
+ return $this->messageTemplate;
+ }
+
+ public function setMessageTemplate(?string $messageTemplate): Message
+ {
+ $this->messageTemplate = $messageTemplate;
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ public function getMessageParameters(): array
+ {
+ return $this->messageParameters;
+ }
+
+ /**
+ * @param array $messageParameters
+ */
+ public function setMessageParameters(array $messageParameters): Message
+ {
+ $this->messageParameters = $messageParameters;
+
+ return $this;
+ }
+
+ public function isOwnerRead(): bool
+ {
+ return $this->ownerRead;
+ }
+
+ public function setOwnerRead(bool $ownerRead): Message
+ {
+ $this->ownerRead = $ownerRead;
+
+ return $this;
+ }
+
+ public function getOwnerReadAt(): ?\DateTimeImmutable
+ {
+ return $this->ownerReadAt;
+ }
+
+ public function setOwnerReadAt(?\DateTimeImmutable $ownerReadAt): Message
+ {
+ $this->ownerReadAt = $ownerReadAt;
+
+ return $this;
+ }
+
+ public function isRecipientRead(): bool
+ {
+ return $this->recipientRead;
+ }
+
+ public function setRecipientRead(bool $recipientRead): Message
+ {
+ $this->recipientRead = $recipientRead;
+
+ return $this;
+ }
+
+ public function getRecipientReadAt(): ?\DateTimeImmutable
+ {
+ return $this->recipientReadAt;
+ }
+
+ public function setRecipientReadAt(?\DateTimeImmutable $recipientReadAt): Message
+ {
+ $this->recipientReadAt = $recipientReadAt;
+
+ return $this;
+ }
+
+ // end of basic 'etters ————————————————————————————————————————————————————
+
+ /**
+ * Get the recipient of the message dependning on this type.
+ */
+ public function getSender(): User
+ {
+ if ($this->type->isFromOwner()) {
+ return $this->serviceRequest->getOwner();
+ }
+
+ if ($this->type->isFromRecipient()) {
+ return $this->serviceRequest->getRecipient();
+ }
+
+ throw new \LogicException('Cannot get recipient for a system message');
+ }
+
+ /**
+ * Get the sender of the message depending on this type.
+ */
+ public function getRecipient(): User
+ {
+ if ($this->type->isFromOwner()) {
+ return $this->serviceRequest->getRecipient();
+ }
+
+ if ($this->type->isFromRecipient()) {
+ return $this->serviceRequest->getOwner();
+ }
+
+ throw new \LogicException('Cannot get recipient for a system message');
+ }
+}
diff --git a/src/Entity/Page.php b/src/Entity/Page.php
new file mode 100644
index 0000000..c46d330
--- /dev/null
+++ b/src/Entity/Page.php
@@ -0,0 +1,144 @@
+name;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function setSlug(string $slug): self
+ {
+ $this->slug = $slug;
+
+ return $this;
+ }
+
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ public function getContent(): string
+ {
+ return $this->content;
+ }
+
+ public function setContent(string $content): Page
+ {
+ $this->content = $content;
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function setEnabled(bool $enabled): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function isHome(): bool
+ {
+ return $this->home;
+ }
+
+ public function setHome(bool $home): self
+ {
+ $this->home = $home;
+
+ return $this;
+ }
+}
diff --git a/src/Entity/Payment.php b/src/Entity/Payment.php
new file mode 100644
index 0000000..5effc21
--- /dev/null
+++ b/src/Entity/Payment.php
@@ -0,0 +1,91 @@
+id;
+ }
+
+ public function setId(string $id): Payment
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getUser(): User
+ {
+ return $this->user;
+ }
+
+ public function setUser(User $user): Payment
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ public function getMethod(): string
+ {
+ return $this->details['method'] ?? 'NA';
+ }
+
+ public function isPaid(): bool
+ {
+ // offline payment
+ if (\array_key_exists(Constants::FIELD_PAID, $this->details)) {
+ return $this->details[Constants::FIELD_PAID] ?? false;
+ }
+
+ // test and prod mode
+
+ return $this->getStatus() === Constants::FIELD_PAID;
+ }
+
+ public function getStatus(): ?string
+ {
+ // offline
+ if (\array_key_exists(Constants::FIELD_STATUS, $this->details)) {
+ return $this->details[Constants::FIELD_STATUS] ?? null;
+ }
+
+ // test and prod mode
+
+ return $this->details['payment'][Constants::FIELD_STATUS] ?? null;
+ }
+}
diff --git a/src/Entity/PaymentToken.php b/src/Entity/PaymentToken.php
new file mode 100644
index 0000000..1d3896d
--- /dev/null
+++ b/src/Entity/PaymentToken.php
@@ -0,0 +1,21 @@
+ 'Swicth the status of the product'],
+ normalizationContext: ['groups' => [ProductSwitchProcessor::class]],
+ security: "is_granted('".ProductVoter::EDIT."', object)",
+ input: false,
+ processor: ProductSwitchProcessor::class,
+ ),
+ ]
+)]
+class Product implements \Stringable, ImagesInterface
+{
+ use TimestampableEntity;
+ use ProductObjectTrait;
+ use ProductServiceTrait;
+ use i18nTrait;
+
+ final public const DEFAULT_CURRENCY = 'EUR';
+
+ /**
+ * Generates a V6 uuid.
+ */
+ #[ORM\Id]
+ #[ORM\Column(type: 'uuid', unique: true)]
+ #[ORM\GeneratedValue(strategy: 'CUSTOM')]
+ #[ORM\CustomIdGenerator(class: UuidGenerator::class)]
+ #[ApiProperty(identifier: true)]
+ #[Groups([ProductSwitchProcessor::class])]
+ private Uuid $id;
+
+ /**
+ * Type of the product. It can be an object to lend or a service.
+ */
+ #[ORM\Column(name: 'type', type: 'string', nullable: false, enumType: ProductType::class)]
+ #[Assert\NotBlank]
+ protected ProductType $type;
+
+ /**
+ * Main category of the product (1st or second level).
+ */
+ #[ORM\ManyToOne(targetEntity: Category::class)]
+ #[ORM\JoinColumn(referencedColumnName: 'id', nullable: false)]
+ #[Assert\NotBlank(groups: [AbstractProductFormType::class])]
+ private Category $category;
+
+ /**
+ * Status of the product.
+ */
+ #[ORM\Column(name: 'status', type: 'string', nullable: false, enumType: ProductStatus::class)]
+ #[Assert\NotBlank]
+ #[Groups([ProductSwitchProcessor::class])]
+ protected ProductStatus $status;
+
+ /**
+ * Visibility of the product.
+ */
+ #[ORM\Column(name: 'visibility', type: 'string', nullable: false, enumType: ProductVisibility::class)]
+ #[Assert\NotBlank]
+ protected ProductVisibility $visibility = ProductVisibility::PUBLIC;
+
+ /**
+ * User that owns the product or propose the service.
+ */
+ #[ORM\ManyToOne(targetEntity: User::class)]
+ #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] // if the owner is deleted then the product will also be deleted without constraint error
+ #[Assert\NotBlank]
+ protected User $owner;
+
+ /**
+ * Short and main name of the product.
+ */
+ #[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
+ #[Assert\NotBlank(groups: [AbstractProductFormType::class])]
+ #[Assert\Length(max: 255, groups: [AbstractProductFormType::class, 'default'])]
+ private string $name;
+
+ /**
+ * SEO friendly name for URLs.
+ */
+ #[ORM\Column(length: 255, unique: false)]
+ #[Gedmo\Slug(fields: ['name'])]
+ private string $slug;
+
+ /**
+ * Longer description of the product.
+ */
+ #[ORM\Column(type: Types::TEXT, nullable: true)]
+ #[Assert\NotBlank(groups: [AbstractProductFormType::class])]
+ #[Assert\Length(max: 2000, groups: [AbstractProductFormType::class])]
+ private ?string $description = null;
+
+ /**
+ * User images for the product.
+ *
+ * @var array
+ */
+ #[ORM\Column(type: 'json', nullable: true)]
+ private ?array $images = null;
+
+ /**
+ * @var Collection $availabilities
+ */
+ #[ORM\OneToMany(mappedBy: 'product', targetEntity: ProductAvailability::class, cascade: ['persist', 'remove', 'detach'])]
+ #[ORM\OrderBy(['startAt' => 'ASC'])]
+ private Collection $availabilities;
+
+ /**
+ * @var Collection $serviceRequests
+ */
+ #[ORM\OneToMany(mappedBy: 'product', targetEntity: ServiceRequest::class)]
+ private Collection $serviceRequests;
+
+ /**
+ * If the product in not public then the list of group the product is visible.
+ *
+ * @var Collection $groups
+ */
+ #[ORM\ManyToMany(targetEntity: Group::class, inversedBy: 'products')]
+ #[Assert\When(
+ expression: '!this.getVisibility().isPublic()',
+ constraints: [
+ new Assert\Count(min: 1, minMessage: 'app.entity.product.groups.constraints.count.min_message'),
+ ],
+ groups: [AbstractProductFormType::class],
+ )]
+ private Collection $groups;
+
+ /**
+ * This is a virtual field to store the distance with a given location when
+ * using a proximity filter.
+ */
+ private ?int $geoDistance = null;
+
+ /**
+ * Get distance in meters.
+ */
+ public function setGeoDistance(?int $geoDistance): self
+ {
+ $this->geoDistance = $geoDistance;
+
+ return $this;
+ }
+
+ /**
+ * Return kilometers.
+ */
+ public function getGeoDistance(): ?float
+ {
+ return $this->geoDistance !== null ? $this->geoDistance / 1000 : null;
+ }
+
+ public function __construct()
+ {
+ $this->availabilities = new ArrayCollection();
+ $this->serviceRequests = new ArrayCollection();
+ $this->groups = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->name;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getType(): ProductType
+ {
+ return $this->type;
+ }
+
+ public function setType(ProductType $type): self
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getCategory(): Category
+ {
+ return $this->category;
+ }
+
+ public function setCategory(Category $category): self
+ {
+ $this->category = $category;
+
+ return $this;
+ }
+
+ public function getStatus(): ProductStatus
+ {
+ return $this->status;
+ }
+
+ public function setStatus(ProductStatus $status): Product
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ public function getVisibility(): ProductVisibility
+ {
+ return $this->visibility;
+ }
+
+ public function setVisibility(ProductVisibility $visibility): self
+ {
+ $this->visibility = $visibility;
+
+ return $this;
+ }
+
+ public function getOwner(): User
+ {
+ return $this->owner;
+ }
+
+ public function isOwner(User $user): bool
+ {
+ return $this->owner === $user;
+ }
+
+ public function setOwner(User $owner): self
+ {
+ $this->owner = $owner;
+
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function setName(string $name): self
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function setSlug(string $slug): self
+ {
+ $this->slug = $slug;
+
+ return $this;
+ }
+
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+
+ public function getImages(): ?array
+ {
+ return $this->images;
+ }
+
+ public function getFirstImage(): ?string
+ {
+ return $this->images[0] ?? null;
+ }
+
+ /**
+ * @param array|null $images
+ */
+ public function setImages(?array $images): self
+ {
+ $this->images = array_values(array_filter($images ?? [])); // make sure we don't save null or empty values
+
+ return $this;
+ }
+
+ /**
+ * @param array $images
+ */
+ public function addImages(array $images): self
+ {
+ $this->images = array_merge($this->images ?? [], $images);
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getAvailabilities(): Collection
+ {
+ return $this->availabilities;
+ }
+
+ /**
+ * @param Collection $availabilities
+ */
+ public function setAvailabilities(Collection $availabilities): self
+ {
+ $this->availabilities = $availabilities;
+
+ return $this;
+ }
+
+ public function addAvailability(ProductAvailability $productAvailability): self
+ {
+ if (!$this->availabilities->contains($productAvailability)) {
+ $this->availabilities->add($productAvailability);
+ $productAvailability->setProduct($this);
+ }
+
+ return $this;
+ }
+
+ public function removeAvailability(ProductAvailability $productAvailability): self
+ {
+ $this->availabilities->removeElement($productAvailability);
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getServiceRequests(): Collection
+ {
+ return $this->serviceRequests;
+ }
+
+ /**
+ * @param Collection $serviceRequests
+ */
+ public function setServiceRequests(Collection $serviceRequests): void
+ {
+ $this->serviceRequests = $serviceRequests;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getGroups(): Collection
+ {
+ return $this->groups;
+ }
+
+ /**
+ * @return array
+ */
+ public function getGroupsIds(): array
+ {
+ return $this->getGroups()->map(fn (Group $group) => (string) $group->getId())->toArray();
+ }
+
+ public function addGroup(Group $group): self
+ {
+ if (!$this->groups->contains($group)) {
+ $this->groups->add($group);
+ }
+
+ return $this;
+ }
+
+ public function removeGroup(Group $group): self
+ {
+ $this->groups->removeElement($group);
+
+ return $this;
+ }
+
+ /**
+ * Remove all associated groups.
+ */
+ public function removeGroups(): self
+ {
+ $this->groups->clear();
+
+ return $this;
+ }
+
+ public function setPublic(): self
+ {
+ $this->visibility = ProductVisibility::PUBLIC;
+
+ return $this;
+ }
+
+ public function setPaused(): self
+ {
+ $this->status = ProductStatus::PAUSED;
+
+ return $this;
+ }
+
+ public function setActive(): self
+ {
+ $this->status = ProductStatus::ACTIVE;
+
+ return $this;
+ }
+
+ /* End of basic getters/setters ========================================= */
+
+ public function isActive(): bool
+ {
+ return $this->getStatus()->isActive();
+ }
+
+ public function isPaused(): bool
+ {
+ return $this->getStatus()->isPaused();
+ }
+
+ public function switchStatus(): self
+ {
+ $this->setStatus($this->isActive() ? ProductStatus::PAUSED : ProductStatus::ACTIVE);
+
+ return $this;
+ }
+
+ /**
+ * Return the unavailabilities of the product as a simple array of dates "2023-02-09".
+ *
+ * @return array
+ */
+ public function getUnavailabilities(?ServiceRequest $serviceRequest = null): array
+ {
+ /** @var array $resultArray */
+ $resultArray = [];
+ $today = CarbonImmutable::today(); // start of day 00:00:00
+ $unavailabilities = $this->getAvailabilities()->filter(
+ fn (ProductAvailability $pa) => $pa->getMode()->isUnavailable() && // of the good type
+ ($serviceRequest === null || $pa->getServiceRequest() !== $serviceRequest) && // exclude the dates of the current service request (modify dates)
+ $pa->getEndAt() >= $today // passed dates are useless but the start date can be in the past
+ );
+
+ foreach ($unavailabilities as $unavailability) {
+ /** @var ProductAvailability $unavailability */
+ $period = CarbonInterval::days()->toPeriod($unavailability->getStartAt(), $unavailability->getEndAt());
+ $resultArray = array_merge($resultArray, $period->toArray());
+ }
+ $resultArray = array_map(static fn (CarbonInterface $date) => $date->format('Y-m-d'), $resultArray);
+ sort($resultArray);
+
+ return array_unique($resultArray);
+ }
+
+ /**
+ * Get 1st level category.
+ */
+ public function getMainCategory(): Category
+ {
+ return $this->category->getParent() ?? $this->category;
+ }
+
+ /**
+ * Get subcategory, it is the current category if it is a child.
+ */
+ public function getSubCategory(): ?Category
+ {
+ return $this->category->hasParent() ? $this->category : null;
+ }
+
+ public function createServiceRequest(User $recipient, \DateTimeImmutable $startAt, \DateTimeImmutable $endAt): ServiceRequest
+ {
+ return (new ServiceRequest())
+ ->setOwner($this->getOwner())
+ ->setProduct($this)
+ ->setRecipient($recipient)
+ ->setStartAt($startAt)
+ ->setEndAt($endAt);
+ }
+
+ public function duplicate(): self
+ {
+ return (new Product())
+ ->setType($this->getType())
+ ->setCategory($this->getCategory())
+ ->setOwner($this->getOwner())
+ ->setType($this->getType())
+ ->setStatus($this->getStatus())
+ ->setVisibility($this->getVisibility())
+ ->setDescription($this->getDescription())
+ ->setAge($this->getAge())
+ ->setDeposit($this->getDeposit())
+ ->setCurrency($this->getCurrency())
+ ->setPreferredLoanDuration($this->getPreferredLoanDuration())
+ ->setDuration($this->getDuration());
+ }
+
+ public function deleteImage(string $image): self
+ {
+ $images = array_flip($this->images ?? []);
+ unset($images[$image]);
+ $this->images = array_values(array_flip($images));
+
+ return $this;
+ }
+
+ public function delete(): self
+ {
+ $this->status = ProductStatus::DELETED;
+
+ return $this;
+ }
+
+ public function hasServiceRequests(): bool
+ {
+ return !$this->serviceRequests->isEmpty();
+ }
+
+ public function hasOngoingServiceRequests(): bool
+ {
+ $ongoing = $this->serviceRequests->filter(
+ fn (ServiceRequest $serviceRequest) => $serviceRequest->getStatus()->isOngoing()
+ );
+
+ return !$ongoing->isEmpty();
+ }
+
+ /**
+ * A product is indexable if it is active and the owner has not activated the
+ * vacation mode.
+ */
+ public function isIndexable(): bool
+ {
+ return $this->status->isIndexable()
+ && $this->owner->isIndexable()
+ ;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRoutingParameters(): array
+ {
+ return [
+ 'id' => (string) $this->getId(),
+ 'slug' => $this->getSlug(),
+ ];
+ }
+}
diff --git a/src/Entity/ProductAvailability.php b/src/Entity/ProductAvailability.php
new file mode 100644
index 0000000..62a9dee
--- /dev/null
+++ b/src/Entity/ProductAvailability.php
@@ -0,0 +1,173 @@
+= startDate
+ */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
+ protected \DateTimeImmutable $endAt;
+
+ public function __toString(): string
+ {
+ return $this->product->getName().' / '.$this->startAt->format('Y-m-d').' / '.$this->endAt->format('Y-m-d');
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $id): ProductAvailability
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getProduct(): Product
+ {
+ return $this->product;
+ }
+
+ public function setProduct(Product $product): ProductAvailability
+ {
+ $this->product = $product;
+
+ return $this;
+ }
+
+ public function getServiceRequest(): ?ServiceRequest
+ {
+ return $this->serviceRequest;
+ }
+
+ public function setServiceRequest(?ServiceRequest $serviceRequest): ProductAvailability
+ {
+ $this->serviceRequest = $serviceRequest;
+
+ return $this;
+ }
+
+ public function getMode(): ProductAvailabilityMode
+ {
+ return $this->mode;
+ }
+
+ public function setMode(ProductAvailabilityMode $mode): ProductAvailability
+ {
+ $this->mode = $mode;
+
+ return $this;
+ }
+
+ public function getType(): ProductAvailabilityType
+ {
+ return $this->type;
+ }
+
+ public function setType(ProductAvailabilityType $type): ProductAvailability
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getStartAt(): \DateTimeImmutable
+ {
+ return $this->startAt;
+ }
+
+ public function setStartAt(\DateTimeImmutable $startAt): ProductAvailability
+ {
+ $this->startAt = $startAt;
+
+ return $this;
+ }
+
+ public function getEndAt(): \DateTimeImmutable
+ {
+ return $this->endAt;
+ }
+
+ public function setEndAt(\DateTimeImmutable $endAt): ProductAvailability
+ {
+ $this->endAt = $endAt;
+
+ return $this;
+ }
+
+ public static function productAvailabilityCreationByOwner(Product $product, \DateTimeImmutable $startAt, \DateTimeImmutable $endAt): ProductAvailability
+ {
+ $productAvailability = new self();
+
+ return $productAvailability
+ ->setProduct($product)
+ ->setType(ProductAvailabilityType::OWNER)
+ ->setStartAt($startAt)
+ ->setEndAt($endAt);
+ }
+}
diff --git a/src/Entity/ProductObjectTrait.php b/src/Entity/ProductObjectTrait.php
new file mode 100644
index 0000000..b520eb7
--- /dev/null
+++ b/src/Entity/ProductObjectTrait.php
@@ -0,0 +1,104 @@
+age;
+ }
+
+ public function setAge(?string $age): self
+ {
+ $this->age = $age;
+
+ return $this;
+ }
+
+ public function getDeposit(): ?int
+ {
+ return $this->deposit;
+ }
+
+ public function setDeposit(?int $deposit): self
+ {
+ $this->deposit = $deposit;
+
+ return $this;
+ }
+
+ public function getCurrency(): ?string
+ {
+ return $this->currency;
+ }
+
+ public function setCurrency(?string $currency): self
+ {
+ $this->currency = $currency;
+
+ return $this;
+ }
+
+ public function getPreferredLoanDuration(): ?string
+ {
+ return $this->preferredLoanDuration;
+ }
+
+ public function setPreferredLoanDuration(?string $preferredLoanDuration): self
+ {
+ $this->preferredLoanDuration = $preferredLoanDuration;
+
+ return $this;
+ }
+
+ public function getDepositReal(): ?int
+ {
+ if ($this->deposit === null) {
+ return $this->deposit;
+ }
+
+ return $this->deposit / 100;
+ }
+}
diff --git a/src/Entity/ProductServiceTrait.php b/src/Entity/ProductServiceTrait.php
new file mode 100644
index 0000000..2e6d3b1
--- /dev/null
+++ b/src/Entity/ProductServiceTrait.php
@@ -0,0 +1,34 @@
+duration;
+ }
+
+ public function setDuration(?string $duration): self
+ {
+ $this->duration = $duration;
+
+ return $this;
+ }
+}
diff --git a/src/Entity/ServiceRequest.php b/src/Entity/ServiceRequest.php
new file mode 100644
index 0000000..63fc9b8
--- /dev/null
+++ b/src/Entity/ServiceRequest.php
@@ -0,0 +1,334 @@
+= startDate (can be the same day)
+ */
+ #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
+ protected \DateTimeImmutable $endAt;
+
+ /**
+ * Virtual field for the request service creation. This is the optional message
+ * sent by the borrower to the lender a the request service creation. It is stored
+ * in the conversation thread of the request service not the request service itself.
+ */
+ protected ?string $message = null;
+
+ /**
+ * @var Collection $messages
+ */
+ #[ORM\OneToMany(mappedBy: 'serviceRequest', targetEntity: Message::class, cascade: ['persist', 'remove', 'detach'])]
+ #[ORM\OrderBy(['createdAt' => 'ASC'])]
+ private Collection $messages;
+
+ public function __construct()
+ {
+ $this->messages = new ArrayCollection();
+ }
+
+ public function __toString()
+ {
+ return (string) $this->id;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $id): ServiceRequest
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ public function getOwner(): User
+ {
+ return $this->owner;
+ }
+
+ public function isOwner(?User $user): bool
+ {
+ return $this->owner === $user;
+ }
+
+ public function setOwner(User $owner): ServiceRequest
+ {
+ $this->owner = $owner;
+
+ return $this;
+ }
+
+ public function getProduct(): Product
+ {
+ return $this->product;
+ }
+
+ public function setProduct(Product $product): ServiceRequest
+ {
+ $this->product = $product;
+
+ return $this;
+ }
+
+ public function getRecipient(): User
+ {
+ return $this->recipient;
+ }
+
+ public function isRecipient(User $user): bool
+ {
+ return $this->recipient === $user;
+ }
+
+ public function setRecipient(User $recipient): ServiceRequest
+ {
+ $this->recipient = $recipient;
+
+ return $this;
+ }
+
+ public function getStatus(): ServiceRequestStatus
+ {
+ return $this->status;
+ }
+
+ /**
+ * This function should never be called manually, except in tests.
+ *
+ * @internal
+ */
+ public function setStatus(ServiceRequestStatus $status): ServiceRequest
+ {
+ $this->status = $status;
+
+ return $this;
+ }
+
+ /**
+ * This is needed for the workflow that works with strings, not enum.
+ *
+ * @internal
+ */
+ public function getStatusRaw(): string
+ {
+ return $this->status->value;
+ }
+
+ /**
+ * This function should never be called manually.
+ *
+ * @internal
+ */
+ public function setStatusRaw(string $status): ServiceRequest
+ {
+ $this->status = ServiceRequestStatus::from($status);
+
+ return $this;
+ }
+
+ public function getStartAt(): \DateTimeImmutable
+ {
+ return $this->startAt;
+ }
+
+ public function setStartAt(\DateTimeImmutable $startAt): ServiceRequest
+ {
+ $this->startAt = $startAt;
+
+ return $this;
+ }
+
+ public function getEndAt(): \DateTimeImmutable
+ {
+ return $this->endAt;
+ }
+
+ public function setEndAt(\DateTimeImmutable $endAt): ServiceRequest
+ {
+ $this->endAt = $endAt;
+
+ return $this;
+ }
+
+ public function getMessage(): ?string
+ {
+ return $this->message;
+ }
+
+ public function setMessage(?string $message): ServiceRequest
+ {
+ $this->message = $message;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getMessages(): Collection
+ {
+ return $this->messages;
+ }
+
+ /**
+ * @param Collection $messages
+ */
+ public function setMessages(Collection $messages): self
+ {
+ $this->messages = $messages;
+
+ return $this;
+ }
+
+ public function addMessage(Message $message): self
+ {
+ if (!$this->messages->contains($message)) {
+ $this->messages->add($message);
+ $message->setServiceRequest($this);
+ }
+
+ return $this;
+ }
+
+ public function removeMessage(Message $item): self
+ {
+ $this->messages->removeElement($item);
+
+ return $this;
+ }
+
+ public function messagesCount(): int
+ {
+ return $this->messages->count();
+ }
+
+ public function isLoan(): bool
+ {
+ return $this->product->getType()->isObject();
+ }
+
+ public function isService(): bool
+ {
+ return $this->product->getType()->isService();
+ }
+
+ // end of basic 'etters ————————————————————————————————————————————————————
+
+ public function isOwnerOrRecipient(User $user): bool
+ {
+ return $this->owner === $user || $this->recipient === $user;
+ }
+
+ public function hasUnreadMessages(User $user): bool
+ {
+ $messages = $this->messages->filter(
+ fn (Message $message) => $this->isOwner($user) ?
+ !$message->isOwnerRead() :
+ !$message->isRecipientRead());
+
+ return !$messages->isEmpty();
+ }
+
+ /**
+ * The the other user involved in the service request.
+ */
+ public function getOtherUser(?User $actor): User
+ {
+ return $this->isOwner($actor) ? $this->getRecipient() : $this->getOwner();
+ }
+
+ /**
+ * Get the finalized date from the end date. We consider a service request is
+ * finished the day just after the end date to avoid overlap problems.
+ */
+ public function getFinalizedAt(): \DateTimeImmutable
+ {
+ return $this->getEndAt()->modify('tomorrow midnight');
+ }
+}
diff --git a/src/Entity/User.php b/src/Entity/User.php
new file mode 100755
index 0000000..132004d
--- /dev/null
+++ b/src/Entity/User.php
@@ -0,0 +1,897 @@
+
+ */
+ #[ORM\Column]
+ private array $roles = [];
+
+ /**
+ * The hashed password.
+ */
+ #[ORM\Column(nullable: true)]
+ private ?string $password = null;
+
+ /**
+ * The password before it is encrypted.
+ */
+ #[Assert\Length(min: UserManager::PASWWORD_MIN_LENGTH, max: UserManager::PASWWORD_MAX_LENGTH, groups: [AccountCreateStep2FormType::class, ChangePasswordFormType::class, 'Default'])]
+ // #[Assert\NotCompromisedPassword] // enable to check the password with the https://haveibeenpwned.com/ service
+ #[Assert\NotBlank(groups: [AccountCreateStep2FormType::class, ChangePasswordFormType::class])]
+ private ?string $plainPassword = null;
+
+ #[SecurityAssert\UserPassword(groups: [ChangePasswordFormType::class])]
+ private ?string $oldPassword = null;
+
+ /**
+ * Last login date of the user, null if has never logged in. The email confirmation
+ * does not count as a valid login.
+ */
+ #[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
+ private ?\DateTimeInterface $loginAt = null;
+
+ /**
+ * Tells if the user wants to receive sms notifications.
+ */
+ #[ORM\Column(type: 'boolean', nullable: true)]
+ #[Assert\Type('bool')]
+ private bool $smsNotifications = false;
+
+ /**
+ * If it is a place, it tells its schedules.
+ */
+ #[ORM\Column(length: self::SCHEDULE_LENGTH, nullable: true)]
+ #[Assert\Length(max: self::SCHEDULE_LENGTH)]
+ private ?string $schedule = null;
+
+ /**
+ * User's favorite category.
+ */
+ #[ORM\ManyToOne(targetEntity: Category::class)]
+ #[ORM\JoinColumn(referencedColumnName: 'id')]
+ private ?Category $category = null;
+
+ /**
+ * User's description.
+ */
+ #[ORM\Column(type: 'string', nullable: true, )]
+ private ?string $description = null;
+
+ /**
+ * Tells if the user in on vacation.
+ */
+ #[ORM\Column(type: 'boolean')]
+ #[Assert\Type('bool')]
+ private bool $vacationMode = false;
+
+ /**
+ * Main address of the user/place.
+ */
+ #[ORM\ManyToOne(targetEntity: Address::class, cascade: ['persist'])]
+ #[ORM\JoinColumn(referencedColumnName: 'id')]
+ private ?Address $address = null;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: UserGroup::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $userGroups;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(mappedBy: 'user', targetEntity: Payment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
+ private Collection $payments;
+
+ #[Assert\IsTrue(groups: [AccountCreateStep2FormType::class])]
+ public bool $gdpr = true;
+
+ /**
+ * Local cache to store groups (extracted from related userGroups).
+ *
+ * @var Collection|null
+ */
+ private ?Collection $groups = null;
+
+ public function __construct()
+ {
+ $this->userGroups = new ArrayCollection();
+ $this->payments = new ArrayCollection();
+ }
+
+ public function __toString(): string
+ {
+ return $this->email;
+ }
+
+ public function getId(): Uuid
+ {
+ return $this->id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getType(): ?UserType
+ {
+ return $this->type;
+ }
+
+ public function setType(?UserType $type): User
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getEmail(): string
+ {
+ return $this->email;
+ }
+
+ public function setEmail(string $email): self
+ {
+ $this->email = $email;
+
+ return $this;
+ }
+
+ public function isEmailConfirmed(): bool
+ {
+ return $this->emailConfirmed;
+ }
+
+ public function setEmailConfirmed(bool $emailConfirmed): User
+ {
+ $this->emailConfirmed = $emailConfirmed;
+
+ return $this;
+ }
+
+ public function getLastname(): ?string
+ {
+ return $this->lastname;
+ }
+
+ public function setLastname(?string $lastname): User
+ {
+ $this->lastname = $lastname;
+
+ return $this;
+ }
+
+ public function getFirstname(): ?string
+ {
+ return $this->firstname;
+ }
+
+ public function setFirstname(?string $firstname): User
+ {
+ $this->firstname = $firstname;
+
+ return $this;
+ }
+
+ public function getName(): ?string
+ {
+ return $this->name;
+ }
+
+ public function setName(?string $name): User
+ {
+ $this->name = $name;
+
+ return $this;
+ }
+
+ public function getPhoneNumber(): ?string
+ {
+ return $this->phoneNumber;
+ }
+
+ public function setPhoneNumber(?string $phoneNumber): void
+ {
+ $this->phoneNumber = $phoneNumber;
+ }
+
+ /**
+ * Transforms the user phone number string into a phone object.
+ */
+ public function getPhone(): ?PhoneNumber
+ {
+ if (u($this->phoneNumber)->isEmpty()) {
+ return null;
+ }
+ \Webmozart\Assert\Assert::notEmpty($this->phoneNumber);
+
+ try {
+ return PhoneNumberUtil::getInstance()->parse($this->phoneNumber, PhoneNumberUtil::UNKNOWN_REGION);
+ } catch (\Exception) {
+ // wrong data in the database, then ignore and return null so a new number can be put
+ return null;
+ }
+ }
+
+ public function setPhone(?PhoneNumber $phone): void
+ {
+ $this->phone = $phone;
+ }
+
+ public function getAvatar(): ?string
+ {
+ return $this->avatar;
+ }
+
+ public function setAvatar(?string $avatar): self
+ {
+ $this->avatar = $avatar;
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ public function setEnabled(bool $enabled): self
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function isMainAdminAccount(): bool
+ {
+ return $this->mainAdminAccount;
+ }
+
+ public function setMainAdminAccount(bool $mainAdminAccount): self
+ {
+ $this->mainAdminAccount = $mainAdminAccount;
+
+ return $this;
+ }
+
+ public function isDevAccount(): bool
+ {
+ return $this->devAccount;
+ }
+
+ public function setDevAccount(bool $devAccount): User
+ {
+ $this->devAccount = $devAccount;
+
+ return $this;
+ }
+
+ /**
+ * A visual identifier that represents this user.
+ *
+ * @see UserInterface
+ */
+ public function getUserIdentifier(): string
+ {
+ return $this->email;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function getRoles(): array
+ {
+ $roles = $this->roles;
+ // guarantee every user at least has ROLE_USER
+ $roles[] = self::ROLE_USER;
+
+ // add specific group roles
+ foreach ($this->userGroups as $userGroup) {
+ if ($userGroup->getMembership()->isAdmin()) {
+ $roles[] = self::ROLE_GROUP_ADMIN;
+ }
+ }
+
+ return array_unique($roles);
+ }
+
+ /**
+ * @param array $roles
+ */
+ public function setRoles(array $roles): self
+ {
+ $this->roles = $roles;
+
+ return $this;
+ }
+
+ /**
+ * @see PasswordAuthenticatedUserInterface
+ */
+ public function getPassword(): string
+ {
+ return (string) $this->password;
+ }
+
+ public function setPassword(?string $password): self
+ {
+ $this->password = $password;
+
+ return $this;
+ }
+
+ public function getPlainPassword(): ?string
+ {
+ return $this->plainPassword;
+ }
+
+ public function setPlainPassword(?string $plainPassword): self
+ {
+ $this->plainPassword = $plainPassword;
+
+ return $this;
+ }
+
+ public function getOldPassword(): ?string
+ {
+ return $this->oldPassword;
+ }
+
+ public function setOldPassword(?string $oldPassword): void
+ {
+ $this->oldPassword = $oldPassword;
+ }
+
+ public function getLoginAt(): ?\DateTimeInterface
+ {
+ return $this->loginAt;
+ }
+
+ public function setLoginAt(?\DateTimeInterface $loginAt): User
+ {
+ $this->loginAt = $loginAt;
+
+ return $this;
+ }
+
+ public function getSmsNotifications(): bool
+ {
+ return $this->smsNotifications;
+ }
+
+ public function setSmsNotifications(bool $smsNotifications): self
+ {
+ $this->smsNotifications = $smsNotifications;
+
+ return $this;
+ }
+
+ public function canBeNotifiedBySms(): bool
+ {
+ return $this->getSmsNotifications()
+ && !u($this->phoneNumber)->isEmpty();
+ }
+
+ public function getSchedule(): ?string
+ {
+ return $this->schedule;
+ }
+
+ public function setSchedule(?string $schedule): void
+ {
+ $this->schedule = $schedule;
+ }
+
+ public function getCategory(): ?Category
+ {
+ return $this->category;
+ }
+
+ public function setCategory(?Category $category): void
+ {
+ $this->category = $category;
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): void
+ {
+ $this->description = $description;
+ }
+
+ public function getVacationMode(): bool
+ {
+ return $this->vacationMode;
+ }
+
+ public function isInVacation(): bool
+ {
+ return $this->vacationMode;
+ }
+
+ public function setVacationMode(bool $vacationMode): void
+ {
+ $this->vacationMode = $vacationMode;
+ }
+
+ public function switchVacationMode(bool $vacationMode): void
+ {
+ $this->vacationMode = !$vacationMode;
+ }
+
+ /**
+ * @see UserInterface
+ */
+ public function eraseCredentials(): void
+ {
+ // If you store any temporary, sensitive data on the user, clear it here
+ $this->plainPassword = null;
+ }
+
+ public function isAdmin(): bool
+ {
+ return $this->type === UserType::ADMIN;
+ }
+
+ public function isPlace(): bool
+ {
+ return $this->type === UserType::PLACE;
+ }
+
+ public function setStep2Defaults(): self
+ {
+ $this->type = UserType::USER;
+
+ return $this;
+ }
+
+ public function getImage(): ?string
+ {
+ return $this->getAvatar();
+ }
+
+ public function getAddress(): ?Address
+ {
+ return $this->address;
+ }
+
+ public function hasAddress(): bool
+ {
+ return $this->address !== null;
+ }
+
+ public function setAddress(?Address $address): User
+ {
+ $this->address = $address;
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getUserGroups(): Collection
+ {
+ return $this->userGroups;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getUserGroupsConfirmed(): Collection
+ {
+ /** @var Collection $collection */
+ $collection = $this->userGroups->filter(fn (UserGroup $userGroup) => !$userGroup->getMembership()->isInvited());
+
+ return $collection;
+ }
+
+ /**
+ * @return array
+ */
+ public function getUserGroupsIds(): array
+ {
+ return $this->getUserGroupsConfirmed()->map(fn (UserGroup $userGroup) => (string) $userGroup->getGroup()->getId())->toArray();
+ }
+
+ public function addUserGroup(UserGroup $userGroup): self
+ {
+ if (!$this->userGroups->contains($userGroup)) {
+ $this->userGroups->add($userGroup);
+ $userGroup->setUser($this);
+ }
+
+ return $this;
+ }
+
+ public function removeUserGroup(UserGroup $userGroup): self
+ {
+ $this->userGroups->removeElement($userGroup);
+
+ return $this;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getPayments(): Collection
+ {
+ return $this->payments;
+ }
+
+ /**
+ * @param Collection $payments
+ */
+ public function setPayments(Collection $payments): User
+ {
+ $this->payments = $payments;
+
+ return $this;
+ }
+
+ // —— end of basic 'etters —————————————————————————————————————————————————
+
+ public function promoteToAdmin(): self
+ {
+ $this->setRoles([self::ROLE_ADMIN]);
+
+ return $this;
+ }
+
+ public function getDisplayName(): string
+ {
+ if ($this->isPlace()) {
+ $shortName = $this->getName();
+ } else {
+ $shortName = $this->getFirstname();
+ }
+
+ return (string) $shortName;
+ }
+
+ /**
+ * @return class-string
+ */
+ public function getAdminCrudClass(): string
+ {
+ return match ($this->type) {
+ UserType::USER => UserCrudController::class,
+ UserType::ADMIN => AdministratorCrudController::class,
+ UserType::PLACE => PlaceCrudController::class,
+ default => throw new \LogicException('No type assigned to user yet.')
+ };
+ }
+
+ /**
+ * Get the list of groups the user belong to as a collection (with local cache).
+ *
+ * @return Collection
+ */
+ public function getMyGroups(): Collection
+ {
+ if ($this->groups !== null) {
+ return $this->groups;
+ }
+
+ $this->groups = new ArrayCollection(
+ array_map(static fn (UserGroup $userGroup) => $userGroup->getGroup(), $this->userGroups->toArray())
+ );
+
+ return $this->groups;
+ }
+
+ /**
+ * Get the groups only where the user has the group admin role.
+ *
+ * @return Collection
+ */
+ public function getMyGroupsAsAdmin(): Collection
+ {
+ $adminUserGroups = $this->userGroups->filter(
+ static fn (UserGroup $userGroup) => $userGroup->getMembership()->isAdmin() || $userGroup->isMainAdminAccount()
+ );
+
+ return new ArrayCollection(
+ array_map(static fn (UserGroup $userGroup) => $userGroup->getGroup(), $adminUserGroups->toArray())
+ );
+ }
+
+ /**
+ * Get the groups only where the user has invitations.
+ *
+ * @return Collection
+ */
+ public function getMyUserGroupsAsInvited(): Collection
+ {
+ /** @var Collection $collection */
+ $collection = $this->userGroups->filter(
+ static fn (UserGroup $userGroup) => $userGroup->getMembership()->isInvited()
+ );
+
+ return $collection;
+ }
+
+ /**
+ * Get the groups only where the user is confirmed (member or admin).
+ *
+ * @return Collection
+ */
+ public function getMyUserGroupsAsConfirmed(): Collection
+ {
+ /** @var Collection $collection */
+ $collection = $this->userGroups->filter(
+ static fn (UserGroup $userGroup) => $userGroup->getMembership()->isConfirmed()
+ );
+
+ return $collection;
+ }
+
+ /**
+ * Get the groups only where the user has invitations.
+ *
+ * @return Collection
+ */
+ public function getMyGroupsAsInvited(): Collection
+ {
+ return new ArrayCollection(
+ array_map(static fn (UserGroup $userGroup) => $userGroup->getGroup(), $this->getMyUserGroupsAsInvited()->toArray())
+ );
+ }
+
+ /**
+ * The invitation status is excluded because, we are only member of the group
+ * once the invitation is accepted. We consider we are also a member even we
+ * are an admin of the group.
+ */
+ public function isMemberOf(Group $group): bool
+ {
+ $notInvited = $this->userGroups->filter(
+ fn (UserGroup $userGroup) => $userGroup->getGroup() === $group && !$userGroup->getMembership()->isInvited()
+ );
+
+ return !$notInvited->isEmpty();
+ }
+
+ /**
+ * Tells if the user has already an association with the group whatever the
+ * membership status is.
+ */
+ public function hasLink(Group $group): bool
+ {
+ return $this->getMyGroups()->contains($group);
+ }
+
+ /**
+ * Return the membership for a given group if it exists for the user, null otherwise.
+ * We can safely use the first() function here. Because of Doctrine constraints,
+ * it's impossible to have 2 records for the same group and user.
+ *
+ * @see UserGroup
+ */
+ public function getGroupMembership(Group $group): ?UserGroup
+ {
+ /** @var Collection $contextUserGroup */
+ $contextUserGroup = $this->userGroups->filter(
+ static fn (UserGroup $userGroup) => $userGroup->getGroup() === $group
+ );
+
+ return $contextUserGroup->isEmpty() ? null : $contextUserGroup->first();
+ }
+
+ public function isGroupAdmin(Group $group): bool
+ {
+ $groupAdmin = $group->getUserGroups()->filter(
+ fn (UserGroup $userGroup) => $userGroup->getUser()->getId() === $this->getId() && $userGroup->getMembership()->isAdmin()
+ );
+
+ return !$groupAdmin->isEmpty();
+ }
+
+ public function isIndexable(): bool
+ {
+ return !$this->isInVacation();
+ }
+
+ public function deleteAvatar(): self
+ {
+ $this->avatar = null;
+
+ return $this;
+ }
+
+ public function changePhoneNumber(?PhoneNumber $phone): self
+ {
+ if ($phone === null) {
+ $this->setPhoneNumber(null);
+ } else {
+ $this->setPhoneNumber('+'.$phone->getCountryCode().$phone->getNationalNumber());
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Entity/UserConfirmationTrait.php b/src/Entity/UserConfirmationTrait.php
new file mode 100644
index 0000000..24d7053
--- /dev/null
+++ b/src/Entity/UserConfirmationTrait.php
@@ -0,0 +1,80 @@
+confirmationToken;
+ }
+
+ public function setConfirmationToken(?string $confirmationToken): self
+ {
+ $this->confirmationToken = $confirmationToken;
+
+ return $this;
+ }
+
+ public function getConfirmationExpiresAt(): ?\DateTimeInterface
+ {
+ return $this->confirmationExpiresAt;
+ }
+
+ public function setConfirmationExpiresAt(?\DateTimeInterface $confirmationExpiresAt): self
+ {
+ $this->confirmationExpiresAt = $confirmationExpiresAt;
+
+ return $this;
+ }
+
+ /**
+ * Test if the token is still valid.
+ */
+ public function isConfirmationTokenExpired(\DateTimeInterface $now): bool
+ {
+ return $now > $this->getConfirmationExpiresAt();
+ }
+
+ /**
+ * Reset all properties after a successful confirmation.
+ */
+ public function resetConfirmation(): void
+ {
+ $this->setConfirmationToken(null);
+ $this->setConfirmationExpiresAt(null);
+ }
+
+ /**
+ * Mark the email as confirmed, user can now login.
+ */
+ public function confirmEmail(): void
+ {
+ $this->setEmailConfirmed(true);
+ }
+}
diff --git a/src/Entity/UserGroup.php b/src/Entity/UserGroup.php
new file mode 100644
index 0000000..8400882
--- /dev/null
+++ b/src/Entity/UserGroup.php
@@ -0,0 +1,216 @@
+id;
+ }
+
+ public function setId(Uuid $uuid): self
+ {
+ $this->id = $uuid;
+
+ return $this;
+ }
+
+ public function getUser(): User
+ {
+ return $this->user;
+ }
+
+ public function setUser(User $user): self
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ public function getGroup(): Group
+ {
+ return $this->group;
+ }
+
+ public function setGroup(Group $group): self
+ {
+ $this->group = $group;
+
+ return $this;
+ }
+
+ public function getMembership(): UserMembership
+ {
+ return $this->membership;
+ }
+
+ public function setMembership(UserMembership $membership): UserGroup
+ {
+ $this->membership = $membership;
+
+ return $this;
+ }
+
+ public function isMainAdminAccount(): bool
+ {
+ return $this->mainAdminAccount;
+ }
+
+ public function setMainAdminAccount(bool $mainAdminAccount): self
+ {
+ $this->mainAdminAccount = $mainAdminAccount;
+
+ return $this;
+ }
+
+ public function getStartAt(): ?\DateTimeImmutable
+ {
+ return $this->startAt;
+ }
+
+ public function setStartAt(?\DateTimeImmutable $startAt): UserGroup
+ {
+ $this->startAt = $startAt;
+
+ return $this;
+ }
+
+ public function getEndAt(): ?\DateTimeImmutable
+ {
+ return $this->endAt;
+ }
+
+ public function setEndAt(?\DateTimeImmutable $endAt): UserGroup
+ {
+ $this->endAt = $endAt;
+
+ return $this;
+ }
+
+ public function getPayedAt(): ?\DateTimeImmutable
+ {
+ return $this->payedAt;
+ }
+
+ public function setPayedAt(?\DateTimeImmutable $payedAt): UserGroup
+ {
+ $this->payedAt = $payedAt;
+
+ return $this;
+ }
+
+ /** -- End of basic getters and setters ----------------------------------------------*/
+
+ /**
+ * Don't remove the admin role if set.
+ */
+ public function setMember(): self
+ {
+ if (!$this->membership->isAdmin()) {
+ $this->membership = UserMembership::MEMBER;
+ }
+
+ return $this;
+ }
+
+ public static function newUserGroup(User $user, Group $group): UserGroup
+ {
+ return (new self())
+ ->setUser($user)
+ ->setMembership(UserMembership::ADMIN)
+ ->setMainAdminAccount(true)
+ ->setGroup($group);
+ }
+
+ /**
+ * Return the number of days the mmebership will expires in relative days without
+ * taking take of the hour.
+ */
+ public function expiresIn(): ?int
+ {
+ $today = Carbon::today();
+ if ($this->endAt === null || $this->endAt < $today) {
+ return null;
+ }
+
+ $endAt = new Carbon($this->endAt);
+
+ return $today->diffInDays($endAt);
+ }
+}
diff --git a/src/Entity/UserLostPasswordTrait.php b/src/Entity/UserLostPasswordTrait.php
new file mode 100644
index 0000000..ad83643
--- /dev/null
+++ b/src/Entity/UserLostPasswordTrait.php
@@ -0,0 +1,72 @@
+lostPasswordToken;
+ }
+
+ public function setLostPasswordToken(?string $lostPasswordToken): self
+ {
+ $this->lostPasswordToken = $lostPasswordToken;
+
+ return $this;
+ }
+
+ public function getLostPasswordExpiresAt(): ?\DateTimeInterface
+ {
+ return $this->lostPasswordExpiresAt;
+ }
+
+ public function setLostPasswordExpiresAt(?\DateTimeInterface $lostPasswordExpiresAt): self
+ {
+ $this->lostPasswordExpiresAt = $lostPasswordExpiresAt;
+
+ return $this;
+ }
+
+ /**
+ * Reset all properties after a successful reset.
+ */
+ public function resetLostPawword(): void
+ {
+ $this->setLostPasswordToken(null);
+ $this->setLostPasswordExpiresAt(null);
+ }
+
+ /**
+ * Test if the token is still valid.
+ */
+ public function isLostPasswordTokenExpired(\DateTimeInterface $now): bool
+ {
+ return $now > $this->getLostPasswordExpiresAt();
+ }
+}
diff --git a/src/Enum/AsArrayTrait.php b/src/Enum/AsArrayTrait.php
new file mode 100644
index 0000000..bca6a3b
--- /dev/null
+++ b/src/Enum/AsArrayTrait.php
@@ -0,0 +1,20 @@
+
+ */
+ public static function getAsArray(): array
+ {
+ return array_reduce(
+ self::cases(),
+ static fn (array $choices, self $type) => $choices + [$type->name => $type->value],
+ [],
+ );
+ }
+}
diff --git a/src/Enum/ConfigurationType.php b/src/Enum/ConfigurationType.php
new file mode 100644
index 0000000..5cf58f8
--- /dev/null
+++ b/src/Enum/ConfigurationType.php
@@ -0,0 +1,10 @@
+isYearly() || $this->isMonthly();
+ }
+
+ public function getEndAtInterval(): string
+ {
+ return match ($this) {
+ self::YEARLY => '+1 year midnight',
+ self::MONTHLY => '+1 month midnight',
+ self::ONESHOT => '',
+ };
+ }
+}
diff --git a/src/Enum/Group/GroupType.php b/src/Enum/Group/GroupType.php
new file mode 100644
index 0000000..7989422
--- /dev/null
+++ b/src/Enum/Group/GroupType.php
@@ -0,0 +1,27 @@
+getI18nPrefix().'.'.$this->value;
+ }
+}
diff --git a/src/Enum/Group/UserMembership.php b/src/Enum/Group/UserMembership.php
new file mode 100644
index 0000000..bdcc536
--- /dev/null
+++ b/src/Enum/Group/UserMembership.php
@@ -0,0 +1,36 @@
+isMember() || $this->isAdmin();
+ }
+}
diff --git a/src/Enum/Menu/LinkType.php b/src/Enum/Menu/LinkType.php
new file mode 100644
index 0000000..fbae90e
--- /dev/null
+++ b/src/Enum/Menu/LinkType.php
@@ -0,0 +1,16 @@
+isActive();
+ }
+}
diff --git a/src/Enum/Product/ProductType.php b/src/Enum/Product/ProductType.php
new file mode 100644
index 0000000..de86d71
--- /dev/null
+++ b/src/Enum/Product/ProductType.php
@@ -0,0 +1,25 @@
+isNew() || $this->isToConfirm() || $this->isConfirmed();
+ }
+}
diff --git a/src/Enum/ServiceRequest/ServiceRequestStatusTransition.php b/src/Enum/ServiceRequest/ServiceRequestStatusTransition.php
new file mode 100644
index 0000000..7a789bc
--- /dev/null
+++ b/src/Enum/ServiceRequest/ServiceRequestStatusTransition.php
@@ -0,0 +1,20 @@
+
+ */
+ public static function getForFront(): array
+ {
+ return [
+ self::USER->name => self::USER->value,
+ self::PLACE->name => self::PLACE->value,
+ ];
+ }
+}
diff --git a/src/Exception/Group/GroupNotFoundException.php b/src/Exception/Group/GroupNotFoundException.php
new file mode 100644
index 0000000..7529b98
--- /dev/null
+++ b/src/Exception/Group/GroupNotFoundException.php
@@ -0,0 +1,15 @@
+id = $id;
+ parent::__construct('The email confirmation token is expired for this user');
+ }
+}
diff --git a/src/Exception/UserLostPasswordTokenExpiredException.php b/src/Exception/UserLostPasswordTokenExpiredException.php
new file mode 100644
index 0000000..c0c2b20
--- /dev/null
+++ b/src/Exception/UserLostPasswordTokenExpiredException.php
@@ -0,0 +1,16 @@
+write($fileName, $file->getContent());
+ };
+ }
+
+ /**
+ * Callack used when deleting a file.
+ */
+ public function getUploadDeleteCallback(FilesystemOperator $storage): callable
+ {
+ return static function (File $file) use ($storage) {
+ $storage->delete($file->getFilename());
+ };
+ }
+}
diff --git a/src/Flysystem/MediaManager.php b/src/Flysystem/MediaManager.php
new file mode 100644
index 0000000..edfe889
--- /dev/null
+++ b/src/Flysystem/MediaManager.php
@@ -0,0 +1,80 @@
+ $uploadImagesAllowedExtensions
+ */
+ public function __construct(
+ #[Autowire('%upload_images_allowed_extensions%')]
+ public readonly array $uploadImagesAllowedExtensions,
+ #[Autowire('%upload_maxsize_by_file%')]
+ public readonly int $uploadMaxsizeByFile,
+ ) {
+ $this->uploadImagesAllowedExtensionsMsg = implode(', ', $uploadImagesAllowedExtensions);
+ }
+
+ /**
+ * To validate a property containing a single image.
+ *
+ * @see https://symfony.com/doc/current/reference/constraints/File.html
+ */
+ public function getFileConstraints(?string $validationGroup = null): AppAssert\File
+ {
+ return new AppAssert\File(
+ maxSize: $this->uploadMaxsizeByFile.'mi',
+ groups: $validationGroup !== null ? [$validationGroup] : null,
+ extensions: $this->uploadImagesAllowedExtensions,
+ extensionsMessage: 'validator.extensions_message',
+ );
+ }
+
+ /**
+ * To validate a property containing a collection of images (as an array).
+ *
+ * @see https://symfony.com/doc/current/reference/constraints/All.html
+ */
+ public function getImageArrayConstraints(?string $validationGroup = null): Assert\All
+ {
+ return new Assert\All([
+ $this->getFileConstraints($validationGroup),
+ ]);
+ }
+
+ /**
+ * Help message that displays the allowed extension and the maximum size by
+ * image.
+ */
+ public function getHelpMessage(): TranslatableMessage
+ {
+ return
+ t('images.help', [
+ '%extensions%' => $this->uploadImagesAllowedExtensionsMsg,
+ '%upload_maxsize_by_file%' => $this->uploadMaxsizeByFile,
+ ], DashboardController::DOMAIN);
+ }
+}
diff --git a/src/Form/Type/Admin/ParametersFormType.php b/src/Form/Type/Admin/ParametersFormType.php
new file mode 100755
index 0000000..91ae35a
--- /dev/null
+++ b/src/Form/Type/Admin/ParametersFormType.php
@@ -0,0 +1,96 @@
+add('notificationsSenderEmail', EmailType::class, [
+ 'label' => 'parameter.mail',
+ 'label_attr' => ['class' => 'col-sm-2 col-form-label'],
+ ])
+ ->add('notificationsSenderName', TextType::class, [
+ 'label' => 'parameter.name',
+ ])
+
+ ->add('contactFormEnabled', CheckboxType::class, [
+ 'label' => 'parameter.formVisibility',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline checkbox-switch',
+ ],
+ ])
+ ->add('contactFormEmail', EmailType::class, [
+ 'label' => 'parameter.receptionEmail',
+ ])
+
+ ->add('groupsEnabled', CheckboxType::class, [
+ 'label' => 'parameter.groups',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline checkbox-switch',
+ ],
+ ])
+
+ ->add('groupsCreationMode', ChoiceType::class, [
+ 'label' => 'parameter.groupsCreation',
+ 'expanded' => true,
+ 'choices' => [
+ 'parameter.groupsCreationForAll' => ParametersFormCommand::ALL,
+ 'parameter.groupsCreationOnlyForAdmin' => ParametersFormCommand::ONLY_ADMIN,
+ ],
+ 'label_attr' => [
+ 'class' => 'radio-inline',
+ ],
+ ])
+
+ ->add('groupsPaying', CheckboxType::class, [
+ 'label' => 'parameter.paidGroupsCreation',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline checkbox-switch',
+ ],
+ ])
+
+ ->add('confidentialityConversationAdminAccess', CheckboxType::class, [
+ 'label' => 'parameter.conversationsVisibility',
+ 'label_attr' => [
+ 'class' => 'checkbox-inline checkbox-switch',
+ ],
+ ])
+
+ ->add('submit', SubmitType::class, [
+ 'label' => 'parameter.save',
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => true,
+ 'data_class' => ParametersFormCommand::class,
+ 'attr' => [
+ 'novalidate' => 'novalidate', // test constraintes HTML
+ ],
+ 'translation_domain' => DashboardController::DOMAIN,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Group/CreateGroupFormType.php b/src/Form/Type/Group/CreateGroupFormType.php
new file mode 100644
index 0000000..565827a
--- /dev/null
+++ b/src/Form/Type/Group/CreateGroupFormType.php
@@ -0,0 +1,78 @@
+security->getUser();
+
+ $builder
+ ->add('name', TextType::class, [
+ 'label' => 'templates.pages.group.create.form.name',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ ],
+ ])
+ ->add('type', EnumType::class, [
+ 'class' => GroupType::class,
+ 'label' => 'templates.pages.group.create.form.type',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'expanded' => true,
+ 'choice_label' => 'transKey',
+ ])
+ ->add('membership', EnumType::class, [
+ 'class' => GroupMembership::class,
+ 'label' => 'templates.pages.group.create.form.membership',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'expanded' => true,
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'templates.pages.group.create.form.submit',
+ 'attr' => ['class' => 'btn btn-primary btn-sm d-grid col-12 my-5'],
+ ]);
+
+ $myGroups = $user->getMyGroupsAsAdmin();
+ if (!$myGroups->isEmpty()) {
+ $builder
+ ->add('parent', EntityType::class, [
+ 'class' => Group::class,
+ 'choices' => $myGroups,
+ 'label' => 'templates.pages.group.create.form.subgroup',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'required' => false,
+ ]);
+ }
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => Group::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Product/AbstractProductCategorySelectFormType.php b/src/Form/Type/Product/AbstractProductCategorySelectFormType.php
new file mode 100644
index 0000000..6426b54
--- /dev/null
+++ b/src/Form/Type/Product/AbstractProductCategorySelectFormType.php
@@ -0,0 +1,52 @@
+security->getUser();
+ Assert::isInstanceOf($user, User::class);
+
+ return $builder
+ ->add('category', EntityType::class, [
+ 'class' => Category::class,
+ 'query_builder' => fn (CategoryRepository $er) => $er->getHierarchy($this->getProductType(), $user),
+ 'required' => false,
+ 'placeholder' => 'select_placeholder',
+ 'label' => false,
+ ])
+ ->add('submit', SubmitType::class)
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => false,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Product/AbstractProductFormType.php b/src/Form/Type/Product/AbstractProductFormType.php
new file mode 100644
index 0000000..52dd3b0
--- /dev/null
+++ b/src/Form/Type/Product/AbstractProductFormType.php
@@ -0,0 +1,138 @@
+ $options
+ */
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $i18nPrefix = $this->getI18nPrefix();
+ /** @var User $user */
+ $user = $this->security->getUser();
+
+ $builder
+ ->add('category', EntityType::class, [
+ 'class' => Category::class,
+ 'query_builder' => fn (CategoryRepository $er) => $er->getHierarchy($this->getType()),
+ 'label' => 'product.form.category',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'choice_label' => 'getNameWithIndent',
+ 'empty_data' => Category::getForEmptyData(), // trick to avoid type error when submitting a blank value in tests
+ ])
+ ->add('name', TextType::class, [
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'empty_data' => '',
+ ])
+ ->add('description', TextareaType::class, [
+ 'label' => 'product.form.description',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => ['style' => 'height: 120px'],
+ ])
+ ->add('images', FileType::class, [
+ 'label' => 'product.form.images',
+ 'multiple' => true,
+ 'mapped' => false,
+ 'required' => false,
+ 'constraints' => [
+ new Assert\Count(
+ max: MediaManager::MAX_PHOTO_COUNT,
+ groups: [self::class]
+ ),
+ $this->mediaManager->getImageArrayConstraints(self::class),
+ ],
+ 'help' => 'product.form.upload_infos',
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'product.form.submit',
+ 'attr' => ['class' => 'btn-sm btn-primary'],
+ ]);
+
+ // only if the user has groups
+ if (!$user->getUserGroupsConfirmed()->isEmpty()) {
+ $builder
+ ->add('visibility', EnumType::class, [
+ 'class' => ProductVisibility::class,
+ 'label' => 'product.form.visibility',
+ 'expanded' => true,
+ 'label_attr' => [
+ 'class' => 'radio-inline text-black fs-6 fw-normal',
+ ],
+ // check if we can do simpler (@see productvisibility_controller.js)
+ 'choice_attr' => [
+ 0 => [
+ 'data-productvisibility-target' => ProductVisibility::PUBLIC->value,
+ 'data-action' => 'click->productvisibility#hideGroups',
+ ],
+ 1 => [
+ 'data-productvisibility-target' => ProductVisibility::RESTRICTED->value,
+ 'data-action' => 'click->productvisibility#showGroups',
+ ],
+ ],
+ ])
+ ->add('groups', EntityType::class, [
+ 'class' => Group::class,
+ 'query_builder' => $this->groupRepository->getUserGroups($user),
+ 'label' => $i18nPrefix.'.form.groups',
+ 'expanded' => true,
+ 'multiple' => true,
+ 'required' => false,
+ ]);
+ }
+
+ // remove all group associations if public to avoid side effects
+ $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event): void {
+ /** @var Product $product */
+ $product = $event->getData();
+ if ($product->getVisibility()->isPublic()) {
+ $product->removeGroups();
+ }
+ });
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Product/CreateProductAvailabilityType.php b/src/Form/Type/Product/CreateProductAvailabilityType.php
new file mode 100644
index 0000000..c204a3b
--- /dev/null
+++ b/src/Form/Type/Product/CreateProductAvailabilityType.php
@@ -0,0 +1,45 @@
+add('startAt', DateType::class, [
+ 'label' => $this->getI18nPrefix().'.startAt',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => '',
+ 'placeholder' => '',
+ ],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ ])
+ ->add('endAt', DateType::class, [
+ 'label' => $this->getI18nPrefix().'.endAt',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => '',
+ 'placeholder' => '',
+ ],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => $this->getI18nPrefix().'.submit',
+ ])
+ ;
+ }
+}
diff --git a/src/Form/Type/Product/GroupSelectFormType.php b/src/Form/Type/Product/GroupSelectFormType.php
new file mode 100644
index 0000000..95b7dfb
--- /dev/null
+++ b/src/Form/Type/Product/GroupSelectFormType.php
@@ -0,0 +1,28 @@
+setMethod('GET')
+ ->add('q', SearchType::class, [
+ 'label' => false,
+ 'required' => false,
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'templates.pages.user.group.list.form.submit',
+ 'label_html' => true,
+ 'attr' => ['class' => 'search-input-button input-group-text'],
+ ]);
+ }
+}
diff --git a/src/Form/Type/Product/ObjectCategorySelectFormType.php b/src/Form/Type/Product/ObjectCategorySelectFormType.php
new file mode 100644
index 0000000..701e92f
--- /dev/null
+++ b/src/Form/Type/Product/ObjectCategorySelectFormType.php
@@ -0,0 +1,15 @@
+add('age', TextType::class, [
+ 'label' => 'object.form.age',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'required' => false,
+ ])
+ ->add('deposit', MoneyType::class, [
+ 'label' => 'object.form.deposit',
+ 'divisor' => 100,
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'required' => false,
+ ])
+ ->add('preferredLoanDuration', TextType::class, [
+ 'label' => 'object.form.preferredLoanDuration',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'required' => false,
+ ]);
+ }
+
+ public function getType(): ProductType
+ {
+ return ProductType::OBJECT;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+ $resolver->setDefaults([
+ 'data_class' => Product::class,
+ 'attr' => [
+ 'novalidate' => 'novalidate',
+ ],
+ ]);
+ }
+}
diff --git a/src/Form/Type/Product/SearchFormType.php b/src/Form/Type/Product/SearchFormType.php
new file mode 100644
index 0000000..8d70f1a
--- /dev/null
+++ b/src/Form/Type/Product/SearchFormType.php
@@ -0,0 +1,123 @@
+ $options
+ */
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $distanceChoicesAttr = array_flip(self::DISTANCE_CHOICES);
+ $distanceChoicesAttr = array_map(static fn () => ['class' => 'form-check-input border border-2 mx-auto'], $distanceChoicesAttr);
+ $i18nPrefix = $this->getI18nPrefix();
+
+ $builder
+ ->add('q', TextType::class, [
+ 'empty_data' => '',
+ 'attr' => [
+ 'placeholder' => $this->translator->trans($i18nPrefix.'.q.placeholder'),
+ ],
+ ])
+ ->add('category', EntityType::class, [
+ 'class' => Category::class,
+ 'required' => false,
+ 'placeholder' => $this->translator->trans($i18nPrefix.'.category.placeholder'),
+ 'query_builder' => fn (CategoryRepository $er) => $er->getHierarchy(),
+ 'group_by' => fn (Category $category) => $this->translator->trans($i18nPrefix.'.'.$category->getType()->value),
+ 'choice_label' => 'getNameWithIndent',
+ ])
+ ->add('place', EntityType::class, [
+ 'required' => false,
+ 'placeholder' => $this->translator->trans($i18nPrefix.'.place.placeholder'),
+ 'class' => User::class,
+ 'query_builder' => fn (UserRepository $userRepository) => $userRepository->getPlacesQueryBuilder(),
+ 'group_by' => fn (User $user) => u($user->getAddress()?->getLocality())->lower()->title()->toString(),
+ 'choice_label' => fn (User $user) => $user->getDisplayName(),
+ ])
+ ->add('city', TextType::class)
+ ->add('distance', ChoiceType::class, [
+ 'required' => false,
+ 'placeholder' => false,
+ 'expanded' => true,
+ 'choices' => array_combine(self::DISTANCE_CHOICES, self::DISTANCE_CHOICES),
+ 'choice_label' => fn (string $choice) => $choice.' km',
+ 'choice_attr' => $distanceChoicesAttr,
+ 'label' => $i18nPrefix.'.proximity.label',
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => $i18nPrefix.'.submit.label',
+ 'attr' => ['class' => 'btn-sm btn-primary'],
+ ]);
+
+ $builder->get('city')
+ ->addModelTransformer(new CallbackTransformer(
+ function ($city): string {
+ return '';
+ },
+ function ($city): ?Address {
+ // transform the string back to an address
+ /** @var string $city */
+ if (u($city)->isEmpty()) {
+ return null;
+ }
+
+ return $this->geoProvider->getAddress($city);
+ }
+ ))
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'method' => 'GET',
+ 'csrf_protection' => false,
+ 'attr' => [
+ 'novalidate' => 'novalidate',
+ 'data-search-target' => 'form',
+ ],
+ 'data_class' => Search::class,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return 'p';
+ }
+}
diff --git a/src/Form/Type/Product/ServiceCategorySelectFormType.php b/src/Form/Type/Product/ServiceCategorySelectFormType.php
new file mode 100644
index 0000000..6fe1c1e
--- /dev/null
+++ b/src/Form/Type/Product/ServiceCategorySelectFormType.php
@@ -0,0 +1,15 @@
+add('duration', TextType::class, [
+ 'label' => 'new_service.form.serviceDuration',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'required' => false,
+ ]);
+ }
+
+ public function getType(): ProductType
+ {
+ return ProductType::SERVICE;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+ $resolver->setDefaults([
+ 'data_class' => Product::class,
+ 'attr' => [
+ 'novalidate' => 'novalidate',
+ ],
+ ]);
+ }
+}
diff --git a/src/Form/Type/Security/AccountCreateStep1FormType.php b/src/Form/Type/Security/AccountCreateStep1FormType.php
new file mode 100755
index 0000000..dc7b2a3
--- /dev/null
+++ b/src/Form/Type/Security/AccountCreateStep1FormType.php
@@ -0,0 +1,55 @@
+add('email', EmailType::class, [
+ 'label' => 'Adresse e-mail',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => 'monemail@domain.com',
+ ],
+ 'empty_data' => '', // allow to have a non nullable type for the email
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'account_create_action.title',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+
+ // normalize the email for the Unique constraint to work properly
+ $this->userManager->addEmailNormalizeSubmitEvent($builder);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'translation_domain' => 'security',
+ 'validation_groups' => $this::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Security/AccountCreateStep2FormType.php b/src/Form/Type/Security/AccountCreateStep2FormType.php
new file mode 100755
index 0000000..94e677b
--- /dev/null
+++ b/src/Form/Type/Security/AccountCreateStep2FormType.php
@@ -0,0 +1,140 @@
+add('type', ChoiceType::class, [
+ 'label' => 'account_create_action.account_type',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'choices' => UserType::getForFront(),
+ 'choice_attr' => function () {
+ return [
+ 'data-controller' => 'account',
+ 'data-action' => 'click->account#choosenType',
+ ];
+ },
+ 'expanded' => true,
+ ])
+
+ ->add('firstname', TextType::class, [
+ 'label' => 'account_create_action.firsname',
+ 'label_attr' => ['class' => 'text-black fw-light required'],
+ 'attr' => [
+ 'class' => 'form-control-sm input-firstname',
+ 'placeholder' => 'account_create_action.firsname.placeholder',
+ ],
+ 'required' => false,
+ ])
+
+ ->add('lastname', TextType::class, [
+ 'label' => 'account_create_action.lastname',
+ 'label_attr' => ['class' => 'text-black fw-light required'],
+ 'attr' => [
+ 'class' => 'form-control-sm input-lastname',
+ 'placeholder' => 'account_create_action.lastname.placeholder',
+ ],
+ 'required' => false,
+ ])
+
+ ->add('name', TextType::class, [
+ 'label' => 'account_create_action.name',
+ 'label_attr' => ['class' => 'text-black fw-light required'],
+ 'attr' => [
+ 'class' => 'form-control-sm input-name',
+ 'placeholder' => 'account_create_action.name.placeholder',
+ ],
+ 'required' => false,
+ ])
+
+ ->add('plainPassword', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'invalid_message' => 'account_create_action.password.invalid_message',
+ 'options' => ['attr' => ['class' => 'password-field']],
+ 'required' => true,
+ 'first_options' => [
+ 'label' => 'account_create_action.password.first',
+ 'help' => 'account_create_action.password.help',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ 'data-password-visibility-target' => 'input',
+ 'spellcheck' => false,
+ ],
+ 'required' => true,
+ ],
+ 'second_options' => [
+ 'label' => 'account_create_action.password.second',
+ 'help' => 'account_create_action.password.help',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ 'data-password-visibility-target' => 'input',
+ 'spellcheck' => false,
+ ],
+ 'required' => true,
+ ],
+ ])
+
+ ->add('gdpr', CheckboxType::class, [
+ 'label' => 'account_create_action.gdpr',
+ 'label_translation_parameters' => [
+ '%link%' => '/fr/cgu',
+ ],
+ 'label_html' => true,
+ 'label_attr' => ['class' => 'fw-light text-black gdpr'],
+ 'required' => true,
+ 'data' => false,
+ 'validation_groups' => [self::class],
+ ])
+
+ ->add('submit', SubmitType::class, [
+ 'label' => 'account_create_action.title',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+
+ $builder->get('type')->addModelTransformer(new CallbackTransformer(
+ function (?UserType $enumToString) {
+ return $enumToString === null ? '' : $enumToString->value;
+ },
+ function (string $stringToEnum) {
+ return UserType::from($stringToEnum);
+ }
+ ));
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'translation_domain' => 'security',
+ 'validation_groups' => ['Default', self::class],
+ ]);
+ }
+}
diff --git a/src/Form/Type/Security/GroupInvitationFormType.php b/src/Form/Type/Security/GroupInvitationFormType.php
new file mode 100755
index 0000000..5804f2d
--- /dev/null
+++ b/src/Form/Type/Security/GroupInvitationFormType.php
@@ -0,0 +1,42 @@
+add('email', EmailType::class, [
+ 'label' => $this->getI18nPrefix().'.email',
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => $this->getI18nPrefix().'.submit',
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'translation_domain' => 'admin',
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/Security/LostPasswordFormType.php b/src/Form/Type/Security/LostPasswordFormType.php
new file mode 100644
index 0000000..a02c4b5
--- /dev/null
+++ b/src/Form/Type/Security/LostPasswordFormType.php
@@ -0,0 +1,42 @@
+add('email', EmailType::class, [
+ 'label' => 'Adresse e-mail',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => 'lost_password.form.email.placeholder',
+ ],
+ ])
+
+ ->add('submit', SubmitType::class, [
+ 'label' => 'lost_password.form.submit',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => LostPasswordCommand::class,
+ 'translation_domain' => 'security',
+ ]);
+ }
+}
diff --git a/src/Form/Type/Security/ResetPasswordFormType.php b/src/Form/Type/Security/ResetPasswordFormType.php
new file mode 100644
index 0000000..cc382d8
--- /dev/null
+++ b/src/Form/Type/Security/ResetPasswordFormType.php
@@ -0,0 +1,60 @@
+add('password', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'invalid_message' => 'account_create_action.password.invalid_message',
+ 'options' => ['attr' => ['class' => 'password-field']],
+ 'required' => true,
+ 'first_options' => [
+ 'label' => 'account_create_action.password.first',
+ 'help' => 'account_create_action.password.help',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'data-password-visibility-target' => 'input',
+ ],
+ 'required' => true,
+ ],
+ 'second_options' => [
+ 'label' => 'account_create_action.password.second',
+ 'help' => 'account_create_action.password.help',
+ 'label_attr' => ['class' => 'text-black fw-light'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'data-password-visibility-target' => 'input',
+ ],
+ 'required' => true,
+ ],
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'Réinitialiser mon mot de passe',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => ResetPasswordCommand::class,
+ 'translation_domain' => 'security',
+ ]);
+ }
+}
diff --git a/src/Form/Type/ServiceRequest/AbstractUserProductSelectFormType.php b/src/Form/Type/ServiceRequest/AbstractUserProductSelectFormType.php
new file mode 100644
index 0000000..aa6651a
--- /dev/null
+++ b/src/Form/Type/ServiceRequest/AbstractUserProductSelectFormType.php
@@ -0,0 +1,54 @@
+setMethod('GET')
+ ->add('product', EntityType::class, [
+ 'class' => Product::class,
+ 'query_builder' => function (EntityRepository $entityRepository) {
+ $qb = $entityRepository->createQueryBuilder('p')
+ ->from(ServiceRequest::class, 'sr')
+ ->andWhere('p = sr.product');
+
+ return $qb->andWhere(sprintf('sr.%s = :user', $this->isOwner() ? 'owner' : 'recipient'))
+ ->setParameter('user', $this->security->getUser());
+ },
+ 'required' => false,
+ 'label' => false,
+ 'multiple' => true,
+ 'autocomplete' => true,
+ ])
+ ->add('submit', SubmitType::class);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => false,
+ ]);
+ }
+}
diff --git a/src/Form/Type/ServiceRequest/CreateServiceRequestType.php b/src/Form/Type/ServiceRequest/CreateServiceRequestType.php
new file mode 100755
index 0000000..966d3ba
--- /dev/null
+++ b/src/Form/Type/ServiceRequest/CreateServiceRequestType.php
@@ -0,0 +1,68 @@
+add('message', TextareaType::class, [
+ 'label' => 'loan.new_action.form.message',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => 'form-control',
+ 'style' => 'height: 120px',
+ 'placeholder' => '',
+ ],
+ 'required' => false,
+ ])
+ ->add('startAt', DateType::class, [
+ 'label' => 'loan.new_action.form.startAt',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => '',
+ 'placeholder' => '',
+ ],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ 'html5' => true, // turn this to false to apply a custom date picker
+ ])
+ ->add('endAt', DateType::class, [
+ 'label' => 'loan.new_action.form.endAt',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => '',
+ 'placeholder' => '',
+ ],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ 'html5' => true, // turn this to false to apply a custom date picker
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'loan.new_action.form.submit',
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => ServiceRequest::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/ServiceRequest/ModifyServiceRequestType.php b/src/Form/Type/ServiceRequest/ModifyServiceRequestType.php
new file mode 100755
index 0000000..01fda94
--- /dev/null
+++ b/src/Form/Type/ServiceRequest/ModifyServiceRequestType.php
@@ -0,0 +1,45 @@
+add('startAt', DateType::class, [
+ 'label' => 'loan.new_action.form.startAt',
+ 'label_attr' => ['class' => ''],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ 'html5' => true,
+ ])
+ ->add('endAt', DateType::class, [
+ 'label' => 'loan.new_action.form.endAt',
+ 'label_attr' => ['class' => ''],
+ 'input' => 'datetime_immutable',
+ 'widget' => 'single_text',
+ 'html5' => true,
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => ServiceRequest::class,
+ 'validation_groups' => CreateServiceRequestType::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/ServiceRequest/UserLendingProductSelectFormType.php b/src/Form/Type/ServiceRequest/UserLendingProductSelectFormType.php
new file mode 100644
index 0000000..fe6ebe9
--- /dev/null
+++ b/src/Form/Type/ServiceRequest/UserLendingProductSelectFormType.php
@@ -0,0 +1,13 @@
+add('address', TextType::class, [
+ 'label' => 'address.step1_action.form.address',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ ],
+ ])
+
+ ->add('addressSupplement', TextType::class, [
+ 'label' => 'address.step1_action.form.address_supplement',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ ],
+ 'required' => false,
+ ])
+
+ ->add('postalCode', TextType::class, [
+ 'label' => 'address.step1_action.form.postal_code',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ ],
+ ])
+
+ ->add('locality', TextType::class, [
+ 'label' => 'address.step1_action.form.locality',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ 'placeholder' => '',
+ ],
+ ])
+
+ ->add('country', CountryType::class, [
+ 'label' => 'address.step1_action.form.country',
+ 'preferred_choices' => [GeoProviderInterface::DEFAULT_COUNTRY],
+ 'attr' => ['class' => 'form-control-sm'],
+ ])
+
+ ->add('submit', SubmitType::class, [
+ 'label' => 'address.step1_action.form.submit',
+ 'attr' => ['class' => 'btn-sm btn-primary'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => Address::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/User/AddressStep2FormType.php b/src/Form/Type/User/AddressStep2FormType.php
new file mode 100755
index 0000000..38e9c2d
--- /dev/null
+++ b/src/Form/Type/User/AddressStep2FormType.php
@@ -0,0 +1,55 @@
+getI18nPrefix();
+ /** @var array $addresses */
+ $addresses = $options['addresses'];
+
+ $builder
+ ->add('addresses', ChoiceType::class, [
+ 'label' => false,
+ 'label_attr' => [],
+ 'choices' => $addresses,
+ 'expanded' => true,
+ 'choice_label' => 'displayName',
+ 'constraints' => [
+ new NotNull(message: $i18nPrefix.'.addresses.not_null'),
+ new Choice(choices: $addresses),
+ ],
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => $i18nPrefix.'.submit.label',
+ 'attr' => ['class' => 'btn-sm btn-primary'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'addresses' => [],
+ ]);
+ }
+}
diff --git a/src/Form/Type/User/ChangeLoginFormType.php b/src/Form/Type/User/ChangeLoginFormType.php
new file mode 100644
index 0000000..7eda9eb
--- /dev/null
+++ b/src/Form/Type/User/ChangeLoginFormType.php
@@ -0,0 +1,58 @@
+add('email', RepeatedType::class, [
+ 'type' => EmailType::class,
+ 'required' => true,
+ 'first_options' => [
+ 'label' => self::I18N_PREFIX.'.form.email',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control form-control-sm',
+ 'placeholder' => self::I18N_PREFIX.'.form.email_placeholder',
+ ],
+ 'required' => true,
+ ],
+ 'second_options' => [
+ 'label' => self::I18N_PREFIX.'.form.email_repeat',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control form-control-sm',
+ 'placeholder' => self::I18N_PREFIX.'.form.email_placeholder',
+ ],
+ 'required' => true,
+ ],
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => self::I18N_PREFIX.'.form.submit',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/User/ChangePasswordFormType.php b/src/Form/Type/User/ChangePasswordFormType.php
new file mode 100644
index 0000000..49b3fff
--- /dev/null
+++ b/src/Form/Type/User/ChangePasswordFormType.php
@@ -0,0 +1,68 @@
+add('oldPassword', PasswordType::class, [
+ 'label' => self::I18N_PREFIX.'.old',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control form-control-sm',
+ ],
+ 'required' => true,
+ ])
+ ->add('plainPassword', RepeatedType::class, [
+ 'type' => PasswordType::class,
+ 'required' => true,
+ 'first_options' => [
+ 'label' => self::I18N_PREFIX.'.new',
+ 'help' => self::I18N_PREFIX.'.help',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control form-control-sm',
+ 'data-password-visibility-target' => 'input',
+ ],
+ 'required' => true,
+ ],
+ 'second_options' => [
+ 'label' => self::I18N_PREFIX.'.confirm',
+ 'help' => self::I18N_PREFIX.'.help',
+ 'label_attr' => ['class' => 'fs-6 text-black'],
+ 'attr' => [
+ 'class' => 'form-control form-control-sm',
+ 'data-password-visibility-target' => 'input',
+ ],
+ 'required' => true,
+ ],
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => self::I18N_PREFIX.'.submit',
+ 'attr' => ['class' => 'btn btn-primary btn-sm'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/User/EditProfileFormType.php b/src/Form/Type/User/EditProfileFormType.php
new file mode 100644
index 0000000..28e8562
--- /dev/null
+++ b/src/Form/Type/User/EditProfileFormType.php
@@ -0,0 +1,142 @@
+security->getUser();
+
+ $builder
+ ->add('avatar', FileType::class, [
+ 'label' => self::I18N_PREFIX.'.image',
+ 'mapped' => false,
+ 'required' => false,
+ 'help' => 'product.form.upload_infos',
+ ])
+ ->add('phone', PhoneNumberType::class, [
+ 'label' => self::I18N_PREFIX.'.phone',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'format' => PhoneNumberFormat::INTERNATIONAL,
+ 'widget' => PhoneNumberType::WIDGET_COUNTRY_CHOICE,
+ 'preferred_country_choices' => ['FR'],
+ 'required' => false,
+ ])
+
+ ->add('smsNotifications', CheckboxType::class, [
+ 'label' => self::I18N_PREFIX.'.sms',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => self::I18N_PREFIX.'.submit',
+ 'attr' => ['class' => 'btn-sm btn-primary'],
+ ]);
+
+ if ($user->isPlace()) {
+ $builder
+ ->add('name', TextType::class, [
+ 'label' => self::I18N_PREFIX.'.name',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ ],
+ ])
+ ->add('schedule', TextareaType::class, [
+ 'label' => self::I18N_PREFIX.'.schedule',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => ['style' => 'height: 120px'],
+ 'required' => false,
+ ]);
+ } else {
+ $builder
+ ->add('firstname', TextType::class, [
+ 'label' => self::I18N_PREFIX.'.firstname',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ ],
+ ])
+ ->add('lastname', TextType::class, [
+ 'label' => self::I18N_PREFIX.'.lastname',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => [
+ 'class' => 'form-control-sm',
+ ],
+ ])
+ ->add('category', EntityType::class, [
+ 'class' => Category::class,
+ 'query_builder' => fn (CategoryRepository $er) => $er->getHierarchy(),
+ 'group_by' => fn (Category $category) => $this->translator->trans(self::I18N_PREFIX.'.'.$category->getType()->value),
+ 'label' => self::I18N_PREFIX.'.category',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'choice_label' => 'getNameWithIndent',
+ 'expanded' => false,
+ 'required' => false,
+ ])
+ ->add('description', TextareaType::class, [
+ 'label' => self::I18N_PREFIX.'.description',
+ 'label_attr' => ['class' => 'text-black fs-6 fw-normal'],
+ 'attr' => ['style' => 'height: 120px'],
+ 'required' => false,
+ ]);
+ }
+
+ $builder
+ ->addEventListener(
+ FormEvents::POST_SUBMIT,
+ [$this, 'onPostSubmit']
+ );
+ }
+
+ /**
+ * Transforms the phone object to a string if validation is OK.
+ *
+ * @see AbstractUserCrudController::updateEntity
+ */
+ public function onPostSubmit(FormEvent $event): void
+ {
+ /** @var User $user */
+ $user = $event->getData();
+ $user->changePhoneNumber($user->phone);
+ $event->setData($user);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => User::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Form/Type/User/ServiceRequest/NewMessageType.php b/src/Form/Type/User/ServiceRequest/NewMessageType.php
new file mode 100755
index 0000000..90d96cb
--- /dev/null
+++ b/src/Form/Type/User/ServiceRequest/NewMessageType.php
@@ -0,0 +1,43 @@
+add('message', TextareaType::class, [
+ 'label' => 'new_message.form.message',
+ 'label_attr' => ['class' => ''],
+ 'attr' => [
+ 'class' => '',
+ 'placeholder' => 'new_message.form.message.placeholder',
+ ],
+ 'required' => false,
+ ])
+ ->add('submit', SubmitType::class, [
+ 'label' => 'new_message.form.submit',
+ 'label_html' => true,
+ 'attr' => ['class' => 'btn btn-primary'],
+ ])
+ ;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => Message::class,
+ 'validation_groups' => self::class,
+ ]);
+ }
+}
diff --git a/src/Geocoder/Adapter/NominatimToAddressAdapter.php b/src/Geocoder/Adapter/NominatimToAddressAdapter.php
new file mode 100644
index 0000000..ef3085d
--- /dev/null
+++ b/src/Geocoder/Adapter/NominatimToAddressAdapter.php
@@ -0,0 +1,37 @@
+setAddress($nominatimAddress->getStreetNumber().' '.$nominatimAddress->getStreetName());
+ if ($nominatimAddress->getCoordinates() !== null) {
+ $address->setLatitude((string) $nominatimAddress->getCoordinates()->getLatitude());
+ $address->setLongitude((string) $nominatimAddress->getCoordinates()->getLongitude());
+ }
+
+ $address->setStreetName((string) $nominatimAddress->getStreetName());
+ $address->setStreetNumber((string) $nominatimAddress->getStreetNumber());
+ $address->setSubLocality((string) $nominatimAddress->getSubLocality());
+ $address->setLocality((string) $nominatimAddress->getLocality());
+ $address->setPostalCode((string) $nominatimAddress->getPostalCode());
+ $address->setProvidedBy($nominatimAddress->getProvidedBy());
+ $address->setAttribution((string) $nominatimAddress->getAttribution());
+ $address->setDisplayName((string) $nominatimAddress->getDisplayName());
+ $address->setOsmType((string) $nominatimAddress->getOSMType());
+
+ if ($nominatimAddress->getOSMId() !== null) {
+ $address->setOsmId($nominatimAddress->getOSMId());
+ }
+ }
+}
diff --git a/src/Geocoder/GeoProvider.php b/src/Geocoder/GeoProvider.php
new file mode 100644
index 0000000..f112516
--- /dev/null
+++ b/src/Geocoder/GeoProvider.php
@@ -0,0 +1,61 @@
+withLimit($limit)
+ ->withLocale($country)
+ ;
+ try {
+ /** @var AddressCollection $collection */
+ $collection = $this->nominatimGeocoder->geocodeQuery($query);
+ } catch (Exception $e) {
+ throw new \RuntimeException(sprintf('Unable to get geoloc of %s: %s', $text, $e->getMessage()));
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Get the first result of a query as an Address entity instance.
+ */
+ public function getAddress(string $text): ?Address
+ {
+ $collection = $this->getAddressCollection($text, 1);
+
+ // invalid address
+ if ($collection->isEmpty()) {
+ return null;
+ }
+
+ $address = new Address();
+ /** @var NominatimAddress $geoAddress */
+ $geoAddress = $collection->first();
+ $this->adapter->fill($address, $geoAddress);
+
+ return $address;
+ }
+}
diff --git a/src/Geocoder/GeoProviderInterface.php b/src/Geocoder/GeoProviderInterface.php
new file mode 100644
index 0000000..469ed19
--- /dev/null
+++ b/src/Geocoder/GeoProviderInterface.php
@@ -0,0 +1,17 @@
+getQuery()->getArrayResult();
+ $fieldNames = array_values(array_map(static fn (FieldDto $dto) => $dto->getProperty(), iterator_to_array($fields->getIterator())));
+
+ $data = [];
+ foreach ($result as $index => $row) {
+ /** @var array $row */
+ foreach ($row as $columnKey => $columnValue) {
+ // only allow fields on list
+
+ if (!\in_array($columnKey, $fieldNames, true)) {
+ continue;
+ }
+
+ if ($columnValue instanceof \DateTimeInterface) {
+ $columnValue = $columnValue->format('Y-m-d H:i:s'); // @todo use a parameter
+ }
+
+ if ($columnValue instanceof \UnitEnum) {
+ $columnValue = $this->translator->trans($columnValue->name, [], DashboardController::DOMAIN);
+ }
+
+ /*
+ if ($columnValue instanceof AbstractUid) {
+ $columnValue = (string) $columnValue;
+ }
+
+ if (\is_array($columnValue)) {
+ $columnValue = implode(' ,', $columnValue);
+ }
+ */
+
+ if (\is_bool($columnValue)) {
+ $columnValue = $this->translator->trans($columnValue ? 'yes' : 'no', [], DashboardController::DOMAIN);
+ }
+
+ $data[$index][$columnKey] = $columnValue;
+ }
+ }
+
+ // Preserve the fields' order
+ $orderedFieldNames = array_keys($data[0] ?? []);
+
+ // Humanize headers based on translations (same as EA would do)
+ $headers = [];
+ foreach ($orderedFieldNames as $property) {
+ $headers[$property] = $this->translator->trans($this->stringHelper->humanize($property), [], DashboardController::DOMAIN);
+ }
+ array_unshift($data, $headers);
+
+ $response = new StreamedResponse(function () use ($data) {
+ $config = new ExporterConfig();
+ $exporter = new Exporter($config);
+ $exporter->export('php://output', $data);
+ });
+ $dispositionHeader = $response->headers->makeDisposition(
+ HeaderUtils::DISPOSITION_ATTACHMENT,
+ $filename
+ );
+ $response->headers->set('Content-Disposition', $dispositionHeader);
+ $response->headers->set('Content-Type', 'text/csv; charset=utf-8');
+
+ return $response;
+ }
+}
diff --git a/src/Helper/FileUploader.php b/src/Helper/FileUploader.php
new file mode 100644
index 0000000..67f0cab
--- /dev/null
+++ b/src/Helper/FileUploader.php
@@ -0,0 +1,40 @@
+ $images
+ *
+ * @return array
+ */
+ public function uploadImageArray(FilesystemOperator $storage, array $images): array
+ {
+ $imagesUploaded = [];
+ foreach ($images as $image) {
+ $imagesUploaded[] = $this->uploadImage($storage, $image);
+ }
+
+ return $imagesUploaded;
+ }
+
+ /**
+ * To upload a single image.
+ */
+ public function uploadImage(FilesystemOperator $storage, UploadedFile $image): string
+ {
+ $newFilename = Uuid::v4().'.'.$image->guessExtension();
+ $storage->write($newFilename, $image->getContent());
+
+ return $newFilename;
+ }
+}
diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php
new file mode 100644
index 0000000..625cf89
--- /dev/null
+++ b/src/Helper/StringHelper.php
@@ -0,0 +1,41 @@
+upper()->toString();
+
+ // this prevents humanizing all-uppercase labels (e.g. 'UUID' -> 'U u i d')
+ // and other special labels which look better in uppercase
+ if ($uString->toString() === $upperString || \in_array($upperString, ['ID', 'URL'], true)) {
+ return $upperString;
+ }
+
+ return $uString
+ ->replaceMatches('/([A-Z])/', '_$1')
+ ->replaceMatches('/[_\s]+/', ' ')
+ ->trim()
+ ->lower()
+ ->title(true)
+ ->toString();
+ }
+
+ /**
+ * Make the stored email consistent.
+ */
+ public function normalizeEmail(string $email): string
+ {
+ return u($email)->trim()->lower()->toString();
+ }
+}
diff --git a/src/Helper/VarDumperHelper.php b/src/Helper/VarDumperHelper.php
new file mode 100644
index 0000000..09160a1
--- /dev/null
+++ b/src/Helper/VarDumperHelper.php
@@ -0,0 +1,26 @@
+dump($cloner->cloneVar($var));
+ });
+ }
+}
diff --git a/src/Kernel.php b/src/Kernel.php
new file mode 100644
index 0000000..9429265
--- /dev/null
+++ b/src/Kernel.php
@@ -0,0 +1,48 @@
+getConfigDir();
+
+ // packages
+ $container->import($configDir.'/{packages}/*.{php,yaml}');
+ $container->import($configDir.'/{packages}/'.$this->environment.'/*.{php,yaml}');
+
+ // services
+ $container->import($configDir.'/services.yaml');
+ $container->import($configDir.'/{services}_'.$this->environment.'.yaml');
+
+ // custom extra configuration for packages
+ $container->import($configDir.'/{packages_extra}/*.{php,yaml}');
+ }
+
+ /**
+ * Additional container stuff.
+ */
+ protected function build(ContainerBuilder $container): void
+ {
+ $container->addCompilerPass(new LocalesCompilerPass(), PassConfig::TYPE_OPTIMIZE);
+ }
+}
diff --git a/src/Mailer/AppMailer.php b/src/Mailer/AppMailer.php
new file mode 100644
index 0000000..3f30a64
--- /dev/null
+++ b/src/Mailer/AppMailer.php
@@ -0,0 +1,58 @@
+ $context
+ *
+ * @throws TransportExceptionInterface
+ */
+ public function send(string $emailCode, array $context): void
+ {
+ $email = null;
+ foreach ($this->emailCollection->getEmails() as $appEmail) {
+ /** @var EmailInterface $appEmail */
+ if ($appEmail->supports($emailCode)) {
+ $email = $appEmail->getEmail($context);
+ break;
+ }
+ }
+
+ if ($email === null) {
+ throw new \LogicException("No email found to process the $emailCode email");
+ }
+
+ $configuration = $this->configurationRepository->getInstanceConfiguration();
+ Assert::isInstanceOf($configuration, Configuration::class);
+ $from = new Address($configuration->getNotificationsSenderEmail(), $configuration->getNotificationsSenderName());
+ $email->from($from);
+
+ $this->mailer->send($email);
+ }
+}
diff --git a/src/Mailer/Email/Admin/Group/GroupInvitationEmail.php b/src/Mailer/Email/Admin/Group/GroupInvitationEmail.php
new file mode 100644
index 0000000..37b6f49
--- /dev/null
+++ b/src/Mailer/Email/Admin/Group/GroupInvitationEmail.php
@@ -0,0 +1,56 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+ /** @var Group $group */
+ $group = $context['group'];
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [
+ '%brand%' => $this->brand,
+ '%group%' => $group->getName(),
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/admin/group/group_invitation.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Admin/New/NewAdminEmail.php b/src/Mailer/Email/Admin/New/NewAdminEmail.php
new file mode 100644
index 0000000..4b01f69
--- /dev/null
+++ b/src/Mailer/Email/Admin/New/NewAdminEmail.php
@@ -0,0 +1,45 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/admin/new/new_admin.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Admin/PromoteToAdmin/PromoteToAdminEmail.php b/src/Mailer/Email/Admin/PromoteToAdmin/PromoteToAdminEmail.php
new file mode 100644
index 0000000..e90c228
--- /dev/null
+++ b/src/Mailer/Email/Admin/PromoteToAdmin/PromoteToAdminEmail.php
@@ -0,0 +1,45 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/admin/promote_to_admin/promote_to_admin.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Admin/UserGroup/AdminPromotionEmail.php b/src/Mailer/Email/Admin/UserGroup/AdminPromotionEmail.php
new file mode 100644
index 0000000..a2b7bb0
--- /dev/null
+++ b/src/Mailer/Email/Admin/UserGroup/AdminPromotionEmail.php
@@ -0,0 +1,48 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/admin/user_group/admin_promotion.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Admin/UserGroup/MainAdminPromotionEmail.php b/src/Mailer/Email/Admin/UserGroup/MainAdminPromotionEmail.php
new file mode 100644
index 0000000..861a901
--- /dev/null
+++ b/src/Mailer/Email/Admin/UserGroup/MainAdminPromotionEmail.php
@@ -0,0 +1,48 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/admin/user_group/main_admin_promotion.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Command/EndMembershipEmail.php b/src/Mailer/Email/Command/EndMembershipEmail.php
new file mode 100644
index 0000000..6ad663d
--- /dev/null
+++ b/src/Mailer/Email/Command/EndMembershipEmail.php
@@ -0,0 +1,59 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?User $user */
+ $user = $context['user'] ?? null;
+ Assert::isInstanceOf($user, User::class);
+
+ /** @var ?Group $group */
+ $group = $context['group'] ?? null;
+ Assert::isInstanceOf($group, Group::class);
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [
+ '%brand%' => $this->brand,
+ '%group%' => $group->getName(),
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/command/end_membership.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Command/NotifyMembershipExpirationEmail.php b/src/Mailer/Email/Command/NotifyMembershipExpirationEmail.php
new file mode 100644
index 0000000..b6360dc
--- /dev/null
+++ b/src/Mailer/Email/Command/NotifyMembershipExpirationEmail.php
@@ -0,0 +1,59 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?User $user */
+ $user = $context['user'] ?? null;
+ Assert::isInstanceOf($user, User::class);
+
+ /** @var ?Group $group */
+ $group = $context['group'] ?? null;
+ Assert::isInstanceOf($group, Group::class);
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [
+ '%brand%' => $this->brand,
+ '%group%' => $group->getName(),
+ '%days%' => $context['days'],
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/command/notify_membership_expiration.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Command/NotifyServiceRequestEndEmail.php b/src/Mailer/Email/Command/NotifyServiceRequestEndEmail.php
new file mode 100644
index 0000000..7570bb5
--- /dev/null
+++ b/src/Mailer/Email/Command/NotifyServiceRequestEndEmail.php
@@ -0,0 +1,59 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $sr */
+ $sr = $context['service_request'] ?? null;
+ Assert::isInstanceOf($sr, ServiceRequest::class);
+
+ /** @var ?User $user */
+ $user = $context['user'] ?? null;
+ Assert::isInstanceOf($user, User::class);
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [
+ '%brand%' => $this->brand,
+ '%product%' => $sr->getProduct()->getName(),
+ '%date%' => $sr->getEndAt()->format($this->translator->trans('format.date', [], 'date')),
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/command/notify_service_request_end.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Command/NotifyServiceRequestStartEmail.php b/src/Mailer/Email/Command/NotifyServiceRequestStartEmail.php
new file mode 100644
index 0000000..42b44da
--- /dev/null
+++ b/src/Mailer/Email/Command/NotifyServiceRequestStartEmail.php
@@ -0,0 +1,59 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $sr */
+ $sr = $context['service_request'] ?? null;
+ Assert::isInstanceOf($sr, ServiceRequest::class);
+
+ /** @var ?User $user */
+ $user = $context['user'] ?? null;
+ Assert::isInstanceOf($user, User::class);
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', [
+ '%brand%' => $this->brand,
+ '%product%' => $sr->getProduct()->getName(),
+ '%date%' => $sr->getStartAt()->format($this->translator->trans('format.date', [], 'date')),
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/command/notify_service_request_start.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/EmailInterface.php b/src/Mailer/Email/EmailInterface.php
new file mode 100644
index 0000000..c99946b
--- /dev/null
+++ b/src/Mailer/Email/EmailInterface.php
@@ -0,0 +1,24 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail;
+}
diff --git a/src/Mailer/Email/EmailTrait.php b/src/Mailer/Email/EmailTrait.php
new file mode 100644
index 0000000..c31afc7
--- /dev/null
+++ b/src/Mailer/Email/EmailTrait.php
@@ -0,0 +1,16 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+ Assert::stringNotEmpty($user->getConfirmationToken(), 'Cannot sent the confirmation email for a user without confirmationToken.');
+
+ // Is it an email for an invitation? Yes, if the context has a group object associated.
+ /** @var ?Group $group */
+ $group = $context['group'] ?? null;
+ $subjectKey = $this->getI18nPrefix().'.subject';
+ if (isset($context['group'])) {
+ $subjectKey .= '.invitation';
+ }
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($subjectKey, [
+ '%brand%' => $this->brand,
+ '%group%' => $group?->getName(),
+ ], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/security/create_account_step1.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/Security/LostPasswordEmail.php b/src/Mailer/Email/Security/LostPasswordEmail.php
new file mode 100644
index 0000000..ee47ea4
--- /dev/null
+++ b/src/Mailer/Email/Security/LostPasswordEmail.php
@@ -0,0 +1,45 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var User $user */
+ $user = $context['user'];
+ $token = $user->getLostPasswordToken();
+ Assert::stringNotEmpty($token, 'Cannot sent the email for a user without lost password token');
+
+ return (new TemplatedEmail())
+ ->to($user->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans('lost_password.email.subject', [], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/security/lost_password.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/NewMessageEmail.php b/src/Mailer/Email/ServiceRequest/NewMessageEmail.php
new file mode 100644
index 0000000..c66fc4c
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/NewMessageEmail.php
@@ -0,0 +1,53 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+
+ /** @var ?Message $message */
+ $message = $context['message'] ?? null;
+ Assert::isInstanceOf($message, Message::class);
+
+ return (new TemplatedEmail())
+ ->to($message->getRecipient()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans('new_message.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/message/new.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/NewServiceRequest.php b/src/Mailer/Email/ServiceRequest/NewServiceRequest.php
new file mode 100644
index 0000000..f6f4e02
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/NewServiceRequest.php
@@ -0,0 +1,51 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getOwner()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/new.html.twig')
+ ->context($context)
+ ;
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/ServiceRequestAccepted.php b/src/Mailer/Email/ServiceRequest/ServiceRequestAccepted.php
new file mode 100644
index 0000000..f6d7f47
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/ServiceRequestAccepted.php
@@ -0,0 +1,52 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getRecipient()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/accepted.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/ServiceRequestConfirmed.php b/src/Mailer/Email/ServiceRequest/ServiceRequestConfirmed.php
new file mode 100644
index 0000000..614818c
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/ServiceRequestConfirmed.php
@@ -0,0 +1,53 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getOwner()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/confirmed.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByOwner.php b/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByOwner.php
new file mode 100644
index 0000000..7c6393e
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByOwner.php
@@ -0,0 +1,54 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+ $context['modified_by'] = $serviceRequest->getOwner()->getDisplayName();
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getRecipient()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/modified_by.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByRecipient.php b/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByRecipient.php
new file mode 100644
index 0000000..f5c4e10
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/ServiceRequestModifiedByRecipient.php
@@ -0,0 +1,54 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+ $context['modified_by'] = $serviceRequest->getRecipient()->getDisplayName();
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getOwner()->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/modified_by.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/Email/ServiceRequest/ServiceRequestRefused.php b/src/Mailer/Email/ServiceRequest/ServiceRequestRefused.php
new file mode 100644
index 0000000..85ee151
--- /dev/null
+++ b/src/Mailer/Email/ServiceRequest/ServiceRequestRefused.php
@@ -0,0 +1,55 @@
+ $context
+ */
+ public function getEmail(array $context): TemplatedEmail
+ {
+ /** @var ?ServiceRequest $serviceRequest */
+ $serviceRequest = $context['service_request'] ?? null;
+ Assert::isInstanceOf($serviceRequest, ServiceRequest::class);
+ /** @var ?User $actor */
+ $actor = $context['actor'] ?? null;
+
+ return (new TemplatedEmail())
+ ->to($serviceRequest->getOtherUser($actor)->getEmail())
+ ->priority(Email::PRIORITY_HIGH)
+ ->subject($this->translator->trans($this->getI18nPrefix().'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN))
+ ->htmlTemplate('email/service_request/refused.html.twig')
+ ->context($context);
+ }
+}
diff --git a/src/Mailer/EmailCollection.php b/src/Mailer/EmailCollection.php
new file mode 100644
index 0000000..3f53194
--- /dev/null
+++ b/src/Mailer/EmailCollection.php
@@ -0,0 +1,29 @@
+ $emails
+ */
+ public function __construct(
+ private readonly iterable $emails,
+ ) {
+ }
+
+ /**
+ * @return iterable
+ */
+ public function getEmails(): iterable
+ {
+ return $this->emails;
+ }
+}
diff --git a/src/Message/Command/Admin/AbstractFormCommand.php b/src/Message/Command/Admin/AbstractFormCommand.php
new file mode 100644
index 0000000..19089cb
--- /dev/null
+++ b/src/Message/Command/Admin/AbstractFormCommand.php
@@ -0,0 +1,50 @@
+
+ */
+ abstract protected function getSections(): array;
+
+ /**
+ * Convert the DTO so it can be stored in the database.
+ *
+ * @todo Should be tranform ?
+ *
+ * @return array>
+ */
+ public function toJsonArray(): array
+ {
+ foreach (array_keys(get_class_vars($this::class)) as $classVar) {
+ $array[$this->getSection($classVar)][$classVar] = $this->{$classVar}; // @phpstan-ignore-line
+ }
+
+ return $array ?? [];
+ }
+
+ /**
+ * Extract the section from a property name.
+ */
+ protected function getSection(string $classVar): string
+ {
+ foreach ($this->getSections() as $section) {
+ if (u($classVar)->startsWith($section)) {
+ return $section;
+ }
+ }
+
+ throw new \UnexpectedValueException(sprintf('Invalid property name, it should start with "%s"', implode(', ', $this->getSections())));
+ }
+}
diff --git a/src/Message/Command/Admin/ParametersFormCommand.php b/src/Message/Command/Admin/ParametersFormCommand.php
new file mode 100755
index 0000000..7e836af
--- /dev/null
+++ b/src/Message/Command/Admin/ParametersFormCommand.php
@@ -0,0 +1,80 @@
+
+ */
+ protected function getSections(): array
+ {
+ return [
+ 'notificationsSender',
+ 'contact',
+ 'groups',
+ 'articles',
+ 'confidentiality',
+ ];
+ }
+
+ /**
+ * Hydrate the DTO from the database settings.
+ *
+ * @todo Should be reverse tranform ?
+ */
+ public function hydrate(Configuration $configuration): self
+ {
+ $instanceConfiguration = $configuration->getConfiguration();
+ foreach (array_keys(get_class_vars($this::class)) as $classVar) {
+ $this->{$classVar} = $instanceConfiguration[$this->getSection($classVar)][$classVar]; // @phpstan-ignore-line
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Message/Command/Group/CreateGroupInvitationMessage.php b/src/Message/Command/Group/CreateGroupInvitationMessage.php
new file mode 100755
index 0000000..4683e4a
--- /dev/null
+++ b/src/Message/Command/Group/CreateGroupInvitationMessage.php
@@ -0,0 +1,23 @@
+getEmail();
+ Assert::stringNotEmpty($email, 'The email of a user cannot be null or empty');
+ Assert::email($email, 'The email is invalid');
+ $this->email = $email;
+ }
+}
diff --git a/src/Message/Command/Security/AccountCreateStep2Command.php b/src/Message/Command/Security/AccountCreateStep2Command.php
new file mode 100755
index 0000000..e3fb6dd
--- /dev/null
+++ b/src/Message/Command/Security/AccountCreateStep2Command.php
@@ -0,0 +1,37 @@
+id = $user->getId();
+ Assert::notNull($user->getType());
+ $this->type = $user->getType();
+ $this->firstname = $user->getFirstname();
+ $this->lastname = $user->getLastname();
+ $this->name = $user->getName();
+ Assert::stringNotEmpty($user->getPlainPassword());
+ $this->plainPassword = $user->getPlainPassword();
+ }
+}
diff --git a/src/Message/Command/Security/AccountCreateStep2RefreshCommand.php b/src/Message/Command/Security/AccountCreateStep2RefreshCommand.php
new file mode 100755
index 0000000..e26a01c
--- /dev/null
+++ b/src/Message/Command/Security/AccountCreateStep2RefreshCommand.php
@@ -0,0 +1,17 @@
+id = $id;
+ }
+}
diff --git a/src/Message/Command/Security/LostPasswordCommand.php b/src/Message/Command/Security/LostPasswordCommand.php
new file mode 100644
index 0000000..df7b06e
--- /dev/null
+++ b/src/Message/Command/Security/LostPasswordCommand.php
@@ -0,0 +1,17 @@
+type === self::VACATION;
+ }
+
+ public function hasType(): bool
+ {
+ return !u($this->type)->isEmpty();
+ }
+}
diff --git a/src/Message/Command/User/Product/CreateProductUnavailabilityCommand.php b/src/Message/Command/User/Product/CreateProductUnavailabilityCommand.php
new file mode 100644
index 0000000..560674d
--- /dev/null
+++ b/src/Message/Command/User/Product/CreateProductUnavailabilityCommand.php
@@ -0,0 +1,23 @@
+getAddress());
+ $this->address = $address->getAddress();
+ $this->addressSupplement = $address->getAddressSupplement();
+ Assert::stringNotEmpty($address->getLocality());
+ $this->locality = $address->getLocality();
+ Assert::stringNotEmpty($address->getPostalCode());
+ $this->postalCode = $address->getPostalCode();
+ Assert::stringNotEmpty($address->getCountry());
+ $this->country = $address->getCountry();
+ }
+
+ public string $address;
+ public ?string $addressSupplement = null;
+ public string $locality;
+ public string $postalCode;
+ public string $country;
+
+ /**
+ * Format a full address with the user input. We don't use the supplument or
+ * nothing is found by the provider when using it.
+ */
+ public function getAddressForQuery(): string
+ {
+ return $this->address.', '.$this->postalCode.', '.$this->locality;
+ }
+}
diff --git a/src/Message/Query/Group/GetGroupByIdQuery.php b/src/Message/Query/Group/GetGroupByIdQuery.php
new file mode 100644
index 0000000..1450695
--- /dev/null
+++ b/src/Message/Query/Group/GetGroupByIdQuery.php
@@ -0,0 +1,21 @@
+userId = $user?->getId();
+ }
+}
diff --git a/src/Message/Query/Product/GetProductByIdQuery.php b/src/Message/Query/Product/GetProductByIdQuery.php
new file mode 100644
index 0000000..e010e72
--- /dev/null
+++ b/src/Message/Query/Product/GetProductByIdQuery.php
@@ -0,0 +1,25 @@
+token = $token;
+ }
+}
diff --git a/src/Message/Query/Security/ResetPasswordQuery.php b/src/Message/Query/Security/ResetPasswordQuery.php
new file mode 100644
index 0000000..990633b
--- /dev/null
+++ b/src/Message/Query/Security/ResetPasswordQuery.php
@@ -0,0 +1,21 @@
+token = $token;
+ }
+}
diff --git a/src/Message/Query/User/Account/GetUserQuery.php b/src/Message/Query/User/Account/GetUserQuery.php
new file mode 100644
index 0000000..76d7357
--- /dev/null
+++ b/src/Message/Query/User/Account/GetUserQuery.php
@@ -0,0 +1,20 @@
+|ArrayCollection|null $products */
+ public readonly mixed $products,
+ ) {
+ }
+}
diff --git a/src/Message/Query/User/ServiceRequest/GetLoansQuery.php b/src/Message/Query/User/ServiceRequest/GetLoansQuery.php
new file mode 100644
index 0000000..7ffb4dc
--- /dev/null
+++ b/src/Message/Query/User/ServiceRequest/GetLoansQuery.php
@@ -0,0 +1,25 @@
+|ArrayCollection|null $products */
+ public readonly mixed $products,
+ ) {
+ }
+}
diff --git a/src/Message/Query/User/ServiceRequest/GetServiceRequestByIdQuery.php b/src/Message/Query/User/ServiceRequest/GetServiceRequestByIdQuery.php
new file mode 100644
index 0000000..d6dd9c4
--- /dev/null
+++ b/src/Message/Query/User/ServiceRequest/GetServiceRequestByIdQuery.php
@@ -0,0 +1,19 @@
+messageBus = $commandBus;
+ }
+
+ public function dispatch(object $query): mixed
+ {
+ return $this->handle($query);
+ }
+}
diff --git a/src/MessageBus/CommandBusInterface.php b/src/MessageBus/CommandBusInterface.php
new file mode 100644
index 0000000..a012fd1
--- /dev/null
+++ b/src/MessageBus/CommandBusInterface.php
@@ -0,0 +1,10 @@
+messageBus = $queryBus;
+ }
+
+ public function query(object $query): mixed
+ {
+ return $this->handle($query);
+ }
+}
diff --git a/src/MessageBus/QueryBusInterface.php b/src/MessageBus/QueryBusInterface.php
new file mode 100644
index 0000000..9b9e9bb
--- /dev/null
+++ b/src/MessageBus/QueryBusInterface.php
@@ -0,0 +1,10 @@
+configurationRepository->getInstanceConfigurationOrCreate();
+ $configuration->setConfiguration($message->toJsonArray());
+ $this->configurationRepository->save($configuration, true);
+ }
+}
diff --git a/src/MessageHandler/Command/Payment/DoneCommandHandler.php b/src/MessageHandler/Command/Payment/DoneCommandHandler.php
new file mode 100644
index 0000000..a8ce8d4
--- /dev/null
+++ b/src/MessageHandler/Command/Payment/DoneCommandHandler.php
@@ -0,0 +1,77 @@
+groupOfferRepository->get($message->groupOfferId);
+ $group = $groupOffer->getGroup();
+ $user = $this->userRepository->get($message->userId);
+
+ $gateway = $this->payum->getGateway($message->paymentToken->getGatewayName());
+ $status = new GetHumanStatus($message->paymentToken);
+ $gateway->execute($status);
+
+ // /** @var Payment $payment */
+ // $payment = $status->getFirstModel();
+
+ // Not captured
+ if (!$status->isCaptured()) {
+ return $status;
+ }
+
+ // user has an invitation for this group
+ if ($user->hasLink($group)) {
+ /** @var UserGroup $userGroup */
+ $userGroup = $user->getGroupMembership($group);
+ } else {
+ $userGroup = (new UserGroup())
+ ->setUser($user)
+ ->setGroup($groupOffer->getGroup());
+ }
+
+ // promote to member
+ $userGroup
+ ->setMembership(UserMembership::MEMBER)
+ ->setStartAt(CarbonImmutable::today())
+ ->setPayedAt(CarbonImmutable::now())
+ ;
+
+ // set the end date for recurring offers
+ $offerType = $groupOffer->getType();
+ if ($offerType->isRecurring()) {
+ $userGroup->setEndAt(new CarbonImmutable($offerType->getEndAtInterval()));
+ }
+ $user->addUserGroup($userGroup);
+ $this->userManager->save($user, true);
+
+ // payment was captured and membership is saved so invalidate the token
+ $this->payum->getHttpRequestVerifier()->invalidate($message->paymentToken);
+
+ return $status;
+ }
+}
diff --git a/src/MessageHandler/Command/Product/CreateGroupInvitationMessageHandler.php b/src/MessageHandler/Command/Product/CreateGroupInvitationMessageHandler.php
new file mode 100644
index 0000000..da39fd9
--- /dev/null
+++ b/src/MessageHandler/Command/Product/CreateGroupInvitationMessageHandler.php
@@ -0,0 +1,83 @@
+groupRepository->get($message->groupId);
+ Assert::notEmpty($message->email);
+ Assert::email($message->email);
+ $email = $this->stringHelper->normalizeEmail($message->email);
+ $user = $this->userRepository->findOneByEmail($email);
+ $isNewUser = false;
+
+ // user not found, so we must create a new account (like step1 on the standard workflow)
+ if ($user === null) {
+ $user = $this->userManager->getStep1User($message->email);
+ $this->userManager->save($user, true);
+ $isNewUser = true;
+ }
+
+ // now create the invitation to the group.
+ // check that the user hasn't already have the invitation or doesn't have another role
+ if (!$user->hasLink($group)) {
+ $this->userManager->addInvitation($user, $group);
+ $this->userManager->save($user, true);
+ }
+
+ // We just ignore if something is already found. It's not a critial error.
+ // For ewample an admin has sent twice the invitation to the same user because
+ // he has forgot he has already done it.
+
+ // the notification email is not the same as a new user must confirm its email
+ $email = $isNewUser ? CreateAccountStep1Email::class : GroupInvitationEmail::class;
+ $this->appMailer->send($email, compact('user', 'group'));
+ if (!$isNewUser) {
+ $this->sendSms($user, GroupInvitationEmail::class, [
+ '%group%' => $group->getName(),
+ ]);
+ }
+ }
+}
diff --git a/src/MessageHandler/Command/Product/CreateProductAvailabilityHandler.php b/src/MessageHandler/Command/Product/CreateProductAvailabilityHandler.php
new file mode 100644
index 0000000..e653577
--- /dev/null
+++ b/src/MessageHandler/Command/Product/CreateProductAvailabilityHandler.php
@@ -0,0 +1,34 @@
+productRepository->find($message->productId);
+ Assert::isInstanceOf($product, Product::class);
+
+ $newProductAvailability = ProductAvailability::productAvailabilityCreationByOwner($product, $message->startAt, $message->endAt);
+ $this->productAvailabilityRepository->save($newProductAvailability, true);
+
+ return $newProductAvailability;
+ }
+}
diff --git a/src/MessageHandler/Command/Product/DuplicateProductCommandHandler.php b/src/MessageHandler/Command/Product/DuplicateProductCommandHandler.php
new file mode 100644
index 0000000..99997ef
--- /dev/null
+++ b/src/MessageHandler/Command/Product/DuplicateProductCommandHandler.php
@@ -0,0 +1,39 @@
+productRepository->get($message->productId);
+ if ($message->attribute !== null && !$this->authorizationChecker->isGranted($message->attribute, $product)) {
+ throw new AccessDeniedException();
+ }
+ $duplicated = $this->productManager->duplicate($product);
+ $this->productManager->save($duplicated, true);
+
+ return $duplicated;
+ }
+}
diff --git a/src/MessageHandler/Command/Security/AccountCreateStep1CommandHandler.php b/src/MessageHandler/Command/Security/AccountCreateStep1CommandHandler.php
new file mode 100644
index 0000000..6a91d81
--- /dev/null
+++ b/src/MessageHandler/Command/Security/AccountCreateStep1CommandHandler.php
@@ -0,0 +1,40 @@
+setEmail($message->email);
+ $this->userManager->refreshConfirmationToken($user);
+ $this->userManager->save($user, true);
+ $this->appMailer->send(CreateAccountStep1Email::class, compact('user'));
+ }
+}
diff --git a/src/MessageHandler/Command/Security/AccountCreateStep2CommandHandler.php b/src/MessageHandler/Command/Security/AccountCreateStep2CommandHandler.php
new file mode 100644
index 0000000..564df19
--- /dev/null
+++ b/src/MessageHandler/Command/Security/AccountCreateStep2CommandHandler.php
@@ -0,0 +1,54 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+
+ $user->setType($message->type);
+ switch ($message->type) {
+ case UserType::USER:
+ Assert::stringNotEmpty($message->firstname, 'The firstname is mandatory');
+ $user->setFirstname($message->firstname);
+ Assert::stringNotEmpty($message->lastname, 'The lastname is mandatory');
+ $user->setLastname($message->lastname);
+ $user->setName($message->name);
+ break;
+
+ case UserType::PLACE:
+ $user->setFirstname(null);
+ $user->setLastname(null);
+ Assert::stringNotEmpty($message->name, 'The name is mandatory');
+ $user->setName($message->name);
+ break;
+
+ default:
+ throw new \UnexpectedValueException('This hanlder can only create users or places.');
+ }
+
+ $this->userManager->updatePassword($user->setPlainPassword($message->plainPassword));
+ $this->userManager->finalizeAccountCreateStep2($user);
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/Security/AccountCreateStep2RefreshCommandHandler.php b/src/MessageHandler/Command/Security/AccountCreateStep2RefreshCommandHandler.php
new file mode 100644
index 0000000..5fa3984
--- /dev/null
+++ b/src/MessageHandler/Command/Security/AccountCreateStep2RefreshCommandHandler.php
@@ -0,0 +1,38 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+ $this->userManager->refreshConfirmationToken($user);
+ $this->userManager->save($user, true);
+ $this->appMailer->send(CreateAccountStep1Email::class, compact('user'));
+ }
+}
diff --git a/src/MessageHandler/Command/Security/LostPasswordCommandHandler.php b/src/MessageHandler/Command/Security/LostPasswordCommandHandler.php
new file mode 100644
index 0000000..6400108
--- /dev/null
+++ b/src/MessageHandler/Command/Security/LostPasswordCommandHandler.php
@@ -0,0 +1,47 @@
+email);
+ $email = $this->stringHelper->normalizeEmail($message->email);
+ $user = $this->userRepository->findOneByEmail($email);
+
+ // we don't tell the user the email was not found for security
+ if ($user === null) {
+ return;
+ }
+
+ $this->userManager->refreshLostPasswordToken($user);
+ $this->userManager->save($user, true);
+ $this->appMailer->send(LostPasswordEmail::class, compact('user'));
+ }
+}
diff --git a/src/MessageHandler/Command/Security/ResetPasswordCommandHandler.php b/src/MessageHandler/Command/Security/ResetPasswordCommandHandler.php
new file mode 100644
index 0000000..b7cadc4
--- /dev/null
+++ b/src/MessageHandler/Command/Security/ResetPasswordCommandHandler.php
@@ -0,0 +1,37 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+ $this->userManager->updatePassword($user->setPlainPassword($message->password));
+ $user->resetLostPawword();
+
+ // we consider the reset password also act as an email confirmation
+ // we could also confirm the email as soon the user access the url with the token
+ $user->confirmEmail();
+ $user->resetConfirmation();
+
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/ChangeLoginCommandHandler.php b/src/MessageHandler/Command/User/ChangeLoginCommandHandler.php
new file mode 100644
index 0000000..b83fe91
--- /dev/null
+++ b/src/MessageHandler/Command/User/ChangeLoginCommandHandler.php
@@ -0,0 +1,33 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+ $this->userManager->changeLogin($user, $message->email);
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/ChangeVacationModeCommandHandler.php b/src/MessageHandler/Command/User/ChangeVacationModeCommandHandler.php
new file mode 100644
index 0000000..a69980b
--- /dev/null
+++ b/src/MessageHandler/Command/User/ChangeVacationModeCommandHandler.php
@@ -0,0 +1,31 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+ $user->switchVacationMode($user->getVacationMode());
+
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/Group/AcceptGroupInvitationCommandHandler.php b/src/MessageHandler/Command/User/Group/AcceptGroupInvitationCommandHandler.php
new file mode 100644
index 0000000..4fcb3b3
--- /dev/null
+++ b/src/MessageHandler/Command/User/Group/AcceptGroupInvitationCommandHandler.php
@@ -0,0 +1,37 @@
+groupRepository->get($message->groupId);
+ $user = $this->userRepository->get($message->userId);
+
+ $membership = $user->getGroupMembership($group);
+ if ($membership === null) {
+ throw new UnprocessableEntityHttpException('Membership not found.');
+ }
+
+ $membership->setMember();
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/Group/JoinGroupCommandHandler.php b/src/MessageHandler/Command/User/Group/JoinGroupCommandHandler.php
new file mode 100644
index 0000000..5d97f98
--- /dev/null
+++ b/src/MessageHandler/Command/User/Group/JoinGroupCommandHandler.php
@@ -0,0 +1,48 @@
+groupRepository->get($message->groupId);
+ $user = $this->userRepository->get($message->userId);
+
+ // 1. test if group is public
+ if (!$group->getType()->isPublic()) {
+ throw new AccessDeniedHttpException('Group is not public and can only be joined with an invitation link.');
+ }
+
+ // 2. test if group is NOT free, user must pay even he is invited
+ if ($group->hasActiveOffers()) {
+ throw new AccessDeniedHttpException('Group has paying offers, the user must pay.');
+ }
+
+ // 3. test if the user is not already member of the group
+ if ($user->hasLink($group)) {
+ return;
+ }
+
+ // 4. Save in db with the member status
+ $this->userManager->addToGroup($user, $group);
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/Group/QuitGroupCommandHandler.php b/src/MessageHandler/Command/User/Group/QuitGroupCommandHandler.php
new file mode 100644
index 0000000..10f797f
--- /dev/null
+++ b/src/MessageHandler/Command/User/Group/QuitGroupCommandHandler.php
@@ -0,0 +1,67 @@
+groupRepository->get($message->groupId);
+ $user = $this->userRepository->get($message->userId);
+ $membership = $user->getGroupMembership($group);
+ if ($membership === null) {
+ throw new UnprocessableEntityHttpException('Membership not found.');
+ }
+ $user->removeUserGroup($membership);
+
+ // get all products associated to this group
+ /** @var array $products */
+ $products = $this->productRepository->getUserProductsByType($user, null, null, $group)->execute();
+ foreach ($products as $product) {
+ $product->removeGroup($group);
+
+ // this is a security: we must pause the object if the product is not
+ // associated to other groups, because we don't want the product to
+ // be public without the user consent. He will have to unpause the product
+ // to make it searchable again by other users.
+ if ($product->getGroups()->isEmpty()) {
+ $product->setPublic();
+ $product->setPaused();
+ }
+
+ // user choice for products (popup on quit group)
+ if ($message->hasType()) {
+ if ($message->isVacation()) {
+ $product->setPaused();
+ } else {
+ $product->setPublic();
+ $product->setActive(); // here we can activate the product as it is the user choice
+ }
+ }
+
+ $this->productRepository->save($product, true);
+ }
+
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Command/User/ServiceRequest/CreateMessageCommandHandler.php b/src/MessageHandler/Command/User/ServiceRequest/CreateMessageCommandHandler.php
new file mode 100644
index 0000000..6c67b0d
--- /dev/null
+++ b/src/MessageHandler/Command/User/ServiceRequest/CreateMessageCommandHandler.php
@@ -0,0 +1,50 @@
+serviceRequestRepository->get($message->requestServiceId);
+ $sender = $this->userRepository->get($message->senderId);
+
+ if ($serviceRequest->isOwner($sender)) {
+ $message = $this->messageManager->createFromOwnerMessage($serviceRequest, $message->message);
+ } else {
+ $message = $this->messageManager->createFromRecipientMessage($serviceRequest, $message->message);
+ }
+ $this->messageManager->save($message, true);
+
+ // Send email
+ $this->appMailer->send(NewMessageEmail::class, ['service_request' => $serviceRequest, 'message' => $message]);
+ }
+}
diff --git a/src/MessageHandler/Command/User/ServiceRequest/CreateServiceRequestCommandHandler.php b/src/MessageHandler/Command/User/ServiceRequest/CreateServiceRequestCommandHandler.php
new file mode 100644
index 0000000..f8de69a
--- /dev/null
+++ b/src/MessageHandler/Command/User/ServiceRequest/CreateServiceRequestCommandHandler.php
@@ -0,0 +1,94 @@
+productRepository->get($message->productId);
+ $recipient = $this->userRepository->get($message->recipientId);
+ $serviceRequest = $product->createServiceRequest($recipient, $message->startAt, $message->endAt);
+ $this->serviceRequestManager->save($serviceRequest, true);
+
+ // Initialize the conversation
+ $dateFormat = $this->translator->trans('format.date', [], 'date');
+ $systemMessage = $this->messageManager->createSystemMessage($serviceRequest, self::MESSAGE_SYSTEM_NEW, [
+ '%recipient%' => $recipient->getDisplayName(),
+ '%startAt%' => $serviceRequest->getStartAt()->format($dateFormat),
+ '%endAt%' => $serviceRequest->getEndAt()->format($dateFormat),
+ ]);
+ $this->messageManager->save($systemMessage, true);
+
+ // Optional user message
+ if ($message->message !== null) {
+ $createdAt = $systemMessage->getCreatedAt()->modify('+1 second'); // add 1 second, so the messages order is more natural
+ $userMessage = $this->messageManager->createFromRecipientMessage($serviceRequest, $message->message, $createdAt);
+ $this->messageManager->save($userMessage, true);
+ }
+
+ // modifiy the product availability
+ $pa = $this->productAvailabilityManager->createFromServiceRequest($serviceRequest, $message->startAt, $message->endAt);
+ $this->productAvailabilityManager->save($pa, true);
+
+ // Send email&sms
+ $this->appMailer->send(NewServiceRequest::class, ['service_request' => $serviceRequest]);
+ $this->sendSms($serviceRequest);
+
+ return $serviceRequest;
+ }
+
+ private function sendSms(ServiceRequest $serviceRequest): void
+ {
+ $i18nPrefix = $this->getI18nPrefix(NewServiceRequest::class);
+ $subject = $this->translator->trans($i18nPrefix.'.subject', ['%brand%' => $this->brand], AppMailer::TR_DOMAIN);
+ $this->notifier->notify(
+ $serviceRequest->getOwner(),
+ $subject,
+ );
+ }
+}
diff --git a/src/MessageHandler/Command/User/ServiceRequest/ReadMessagesCommandHandler.php b/src/MessageHandler/Command/User/ServiceRequest/ReadMessagesCommandHandler.php
new file mode 100644
index 0000000..b702251
--- /dev/null
+++ b/src/MessageHandler/Command/User/ServiceRequest/ReadMessagesCommandHandler.php
@@ -0,0 +1,32 @@
+serviceRequestRepository->get($message->requestServiceId);
+ $reader = $this->userRepository->get($message->readerId);
+ $this->serviceRequestManager->readMessages($serviceRequest, $reader);
+ }
+}
diff --git a/src/MessageHandler/Command/User/ServiceRequest/TryAutoFinalizeCommandHandler.php b/src/MessageHandler/Command/User/ServiceRequest/TryAutoFinalizeCommandHandler.php
new file mode 100644
index 0000000..ee32566
--- /dev/null
+++ b/src/MessageHandler/Command/User/ServiceRequest/TryAutoFinalizeCommandHandler.php
@@ -0,0 +1,37 @@
+serviceRequestRepository->get($message->requestServiceId);
+ if ($this->serviceRequestStatusWorkflow->canAutoFinalize($serviceRequest)) {
+ $this->serviceRequestStatusWorkflow->autoFinalize($serviceRequest);
+ $this->serviceRequestManager->save($serviceRequest, true);
+ }
+ }
+}
diff --git a/src/MessageHandler/Command/User/UpdateAddressCommandHandler.php b/src/MessageHandler/Command/User/UpdateAddressCommandHandler.php
new file mode 100644
index 0000000..cd48c34
--- /dev/null
+++ b/src/MessageHandler/Command/User/UpdateAddressCommandHandler.php
@@ -0,0 +1,50 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+
+ // we keep address, addressSupplement, and country from user input
+ $currentUserAddress = $user->getAddress();
+
+ // new address, then take the DTO which is already good
+ if ($currentUserAddress === null) {
+ $currentUserAddress = $message->userAddress;
+ } else {
+ // otherwise override the properties of the old address
+ $currentUserAddress->setFromAddressUpdateStep1($message->userAddress);
+ }
+
+ // we take other properties from the Geocoder address
+ $this->adapter->fill($currentUserAddress, $message->newAddress);
+
+ $user->setAddress($currentUserAddress); // we must set if it's a new address
+ $this->userManager->save($user, true);
+ }
+}
diff --git a/src/MessageHandler/Query/Admin/ParametersFormQueryHandler.php b/src/MessageHandler/Query/Admin/ParametersFormQueryHandler.php
new file mode 100644
index 0000000..880999b
--- /dev/null
+++ b/src/MessageHandler/Query/Admin/ParametersFormQueryHandler.php
@@ -0,0 +1,28 @@
+configurationRepository->getInstanceConfigurationOrCreate();
+ $parametersForm = (new ParametersFormCommand());
+ $parametersForm->hydrate($cfg);
+
+ return $parametersForm;
+ }
+}
diff --git a/src/MessageHandler/Query/Group/GetGroupByIdQueryHandler.php b/src/MessageHandler/Query/Group/GetGroupByIdQueryHandler.php
new file mode 100644
index 0000000..59b54fc
--- /dev/null
+++ b/src/MessageHandler/Query/Group/GetGroupByIdQueryHandler.php
@@ -0,0 +1,30 @@
+groupRepository->find($message->id);
+ if ($group === null) {
+ throw new GroupNotFoundException($message->id);
+ }
+
+ return $group;
+ }
+}
diff --git a/src/MessageHandler/Query/Group/GetGroupMembersHandler.php b/src/MessageHandler/Query/Group/GetGroupMembersHandler.php
new file mode 100644
index 0000000..4db684e
--- /dev/null
+++ b/src/MessageHandler/Query/Group/GetGroupMembersHandler.php
@@ -0,0 +1,28 @@
+groupRepository->get($message->id);
+
+ return $this->userGroupRepository->getGroupMembers($group, $message->memberName);
+ }
+}
diff --git a/src/MessageHandler/Query/Group/GetGroupsQueryHandler.php b/src/MessageHandler/Query/Group/GetGroupsQueryHandler.php
new file mode 100644
index 0000000..63fc722
--- /dev/null
+++ b/src/MessageHandler/Query/Group/GetGroupsQueryHandler.php
@@ -0,0 +1,24 @@
+groupRepository->getGroups($message->groupName);
+ }
+}
diff --git a/src/MessageHandler/Query/Product/GetProductByIdQueryHandler.php b/src/MessageHandler/Query/Product/GetProductByIdQueryHandler.php
new file mode 100644
index 0000000..2b85eea
--- /dev/null
+++ b/src/MessageHandler/Query/Product/GetProductByIdQueryHandler.php
@@ -0,0 +1,40 @@
+productRepository->find($message->id);
+ if ($product === null) {
+ throw new ProductNotFoundException($message->id);
+ }
+
+ if ($message->attribute !== null && !$this->security->isGranted($message->attribute, $product)) {
+ throw new AccessDeniedHttpException("Access to product {$product->getId()} and attribute $message->attribute denied.");
+ }
+
+ return $product;
+ }
+}
diff --git a/src/MessageHandler/Query/Product/GetProductUnavailabilitiesQueryHandler.php b/src/MessageHandler/Query/Product/GetProductUnavailabilitiesQueryHandler.php
new file mode 100644
index 0000000..b98ccf3
--- /dev/null
+++ b/src/MessageHandler/Query/Product/GetProductUnavailabilitiesQueryHandler.php
@@ -0,0 +1,31 @@
+
+ */
+ public function __invoke(GetProductUnavailabilitiesQuery $message): array
+ {
+ $product = $this->productRepository->get($message->id);
+
+ return $this->productAvailabilityRepository->getProductUnavailabilities($product);
+ }
+}
diff --git a/src/MessageHandler/Query/Security/GetUserByTokenQueryHandler.php b/src/MessageHandler/Query/Security/GetUserByTokenQueryHandler.php
new file mode 100644
index 0000000..00b3f73
--- /dev/null
+++ b/src/MessageHandler/Query/Security/GetUserByTokenQueryHandler.php
@@ -0,0 +1,41 @@
+userRepository->findOneByConfirmationToken($message->token);
+
+ if ($user === null) {
+ throw new UserNotFoundException($message->token);
+ }
+
+ if ($user->isConfirmationTokenExpired($this->clock->now())) {
+ throw new UserConfirmationTokenExpiredException($user->getId());
+ }
+
+ return $user;
+ }
+}
diff --git a/src/MessageHandler/Query/Security/ResetPasswordQueryHandler.php b/src/MessageHandler/Query/Security/ResetPasswordQueryHandler.php
new file mode 100644
index 0000000..61fb75d
--- /dev/null
+++ b/src/MessageHandler/Query/Security/ResetPasswordQueryHandler.php
@@ -0,0 +1,41 @@
+userRepository->findOneByLostPasswordToken($message->token);
+
+ if ($user === null) {
+ throw new UserNotFoundException($message->token);
+ }
+
+ if ($user->isLostPasswordTokenExpired($this->clock->now())) {
+ throw new UserLostPasswordTokenExpiredException();
+ }
+
+ return $user;
+ }
+}
diff --git a/src/MessageHandler/Query/ServiceRequest/GetLendingsQueryHandler.php b/src/MessageHandler/Query/ServiceRequest/GetLendingsQueryHandler.php
new file mode 100644
index 0000000..7340eb2
--- /dev/null
+++ b/src/MessageHandler/Query/ServiceRequest/GetLendingsQueryHandler.php
@@ -0,0 +1,28 @@
+userRepository->get($message->userId);
+
+ return $this->serviceRequestRepository->getLendings($user, $message->products);
+ }
+}
diff --git a/src/MessageHandler/Query/ServiceRequest/GetLoansQueryHandler.php b/src/MessageHandler/Query/ServiceRequest/GetLoansQueryHandler.php
new file mode 100644
index 0000000..6000366
--- /dev/null
+++ b/src/MessageHandler/Query/ServiceRequest/GetLoansQueryHandler.php
@@ -0,0 +1,28 @@
+userRepository->get($message->userId);
+
+ return $this->serviceRequestRepository->getLoans($user, $message->products);
+ }
+}
diff --git a/src/MessageHandler/Query/ServiceRequest/GetServiceRequestByIdQueryHandler.php b/src/MessageHandler/Query/ServiceRequest/GetServiceRequestByIdQueryHandler.php
new file mode 100644
index 0000000..386e2a2
--- /dev/null
+++ b/src/MessageHandler/Query/ServiceRequest/GetServiceRequestByIdQueryHandler.php
@@ -0,0 +1,41 @@
+serviceRequestRepository->find($message->id);
+ if ($serviceRequest === null) {
+ throw new ServiceRequestNotFoundException($message->id);
+ }
+
+ if (!$this->security->isGranted(ServiceRequestVoter::VIEW, $serviceRequest)) {
+ throw new AccessDeniedException(sprintf('Access to service request "%s" denied (not owner or recipient).', $message->id));
+ }
+
+ return $serviceRequest;
+ }
+}
diff --git a/src/MessageHandler/Query/User/Account/GetUserQueryHandler.php b/src/MessageHandler/Query/User/Account/GetUserQueryHandler.php
new file mode 100644
index 0000000..4766e22
--- /dev/null
+++ b/src/MessageHandler/Query/User/Account/GetUserQueryHandler.php
@@ -0,0 +1,24 @@
+userRepository->get($message->id);
+ }
+}
diff --git a/src/MessageHandler/Query/User/GetUserObjectsQueryHandler.php b/src/MessageHandler/Query/User/GetUserObjectsQueryHandler.php
new file mode 100644
index 0000000..3666ba1
--- /dev/null
+++ b/src/MessageHandler/Query/User/GetUserObjectsQueryHandler.php
@@ -0,0 +1,32 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+
+ return $this->productRepository->getUserProductsByType($user, ProductType::OBJECT, $message->categoryId, null);
+ }
+}
diff --git a/src/MessageHandler/Query/User/GetUserServicesQueryHandler.php b/src/MessageHandler/Query/User/GetUserServicesQueryHandler.php
new file mode 100644
index 0000000..f5e05f2
--- /dev/null
+++ b/src/MessageHandler/Query/User/GetUserServicesQueryHandler.php
@@ -0,0 +1,32 @@
+userRepository->find($message->id);
+ Assert::isInstanceOf($user, User::class);
+
+ return $this->productRepository->getUserProductsByType($user, ProductType::SERVICE, $message->categoryId, null);
+ }
+}
diff --git a/src/MessageHandler/Query/User/UserAddressQueryHandler.php b/src/MessageHandler/Query/User/UserAddressQueryHandler.php
new file mode 100644
index 0000000..1e5e981
--- /dev/null
+++ b/src/MessageHandler/Query/User/UserAddressQueryHandler.php
@@ -0,0 +1,32 @@
+geoProvider->getAddressCollection(
+ $message->getAddressForQuery(),
+ self::WITH_LIMIT,
+ );
+ }
+}
diff --git a/src/Notifier/SmsNotifier.php b/src/Notifier/SmsNotifier.php
new file mode 100644
index 0000000..6733658
--- /dev/null
+++ b/src/Notifier/SmsNotifier.php
@@ -0,0 +1,58 @@
+canBeNotifiedBySms()) {
+ return null;
+ }
+
+ $phoneNumber = $user->getPhoneNumber();
+ Assert::notEmpty($phoneNumber);
+ Assert::notEmpty($subject);
+
+ // fail silently, it should not happen as the number is validated in the form.
+ // We want to avoid a 500 error from the vendors
+ if (!u($phoneNumber)->startsWith('+')) {
+ $this->logger->warning('Invalid phone number: '.$phoneNumber);
+
+ return null;
+ }
+
+ try {
+ return $this->texter->send(new SmsMessage(
+ phone: $phoneNumber,
+ subject: $subject
+ ));
+ } catch (\Exception $e) {
+ // OK, the sms cannot be delivered, but this is not critical as the an
+ // email is always sent
+ $this->logger->warning('Cannot deliver text message: '.$e->getMessage());
+
+ return null;
+ }
+ }
+}
diff --git a/src/Notifier/SmsNotifierTrait.php b/src/Notifier/SmsNotifierTrait.php
new file mode 100644
index 0000000..5c7d36a
--- /dev/null
+++ b/src/Notifier/SmsNotifierTrait.php
@@ -0,0 +1,30 @@
+ $subjectContext addtional context for the subject
+ */
+ private function sendSms(User $user, string $emailClass, array $subjectContext = []): void
+ {
+ $i18nPrefix = $this->getI18nPrefix($emailClass);
+ $subject = $this->translator->trans($i18nPrefix.'.subject', array_merge(['%brand%' => $this->brand], $subjectContext), AppMailer::TR_DOMAIN);
+ $this->notifier->notify(
+ $user,
+ $subject,
+ );
+ }
+}
diff --git a/src/Payment/PayumManager.php b/src/Payment/PayumManager.php
new file mode 100644
index 0000000..a7b4bbc
--- /dev/null
+++ b/src/Payment/PayumManager.php
@@ -0,0 +1,90 @@
+payum->getStorage(Payment::class);
+
+ /** @var Payment $payment */
+ $payment = $storage->create();
+ $payment->setNumber(uniqid('payum_', true));
+ $payment->setCurrencyCode($groupOffer->getCurrency());
+ $payment->setTotalAmount($groupOffer->getPrice());
+ $payment->setDescription($groupOffer->getGroup()->getName().' / '.$groupOffer->getName());
+ $payment->setClientId((string) $user->getId());
+ $payment->setClientEmail($user->getEmail());
+ $payment->setUser($user);
+ $payment->setDetails($this->getGatewayDetails($groupOffer));
+ $storage->update($payment);
+
+ return $payment;
+ }
+
+ /**
+ * Add specific details to the current gateway. Put here any fields in a gateway format.
+ * For now this function is specific to Mollie.
+ *
+ * For example if you use Paypal ExpressCheckout you can define a description of the first item:
+ * 'L_PAYMENTREQUEST_0_DESC0' => 'A desc'
+ *
+ * @todo Check if the default method can be retrieved from the gateway configuration.
+ *
+ * @see https://github.com/webbaard/payum-mollie/blob/master/Resources/doc/checkout_mollie.md
+ *
+ * @return array
+ */
+ private function getGatewayDetails(GroupOffer $groupOffer): array
+ {
+ return [
+ // method must be set as the default value is not retrieved from the gateway configuration
+ 'method' => PaymentMethod::CREDITCARD->value,
+ 'metadata' => [
+ 'groupId' => (string) $groupOffer->getGroup()->getId(),
+ 'groupOfferId' => (string) $groupOffer->getId(),
+ ],
+ ];
+ }
+
+ /**
+ * @param array $afterParameters
+ *
+ * @see PrepareAction
+ */
+ public function getCaptureToken(Payment $payment, string $afterRoute, array $afterParameters): TokenInterface
+ {
+ return $this->payum->getTokenFactory()->createCaptureToken(
+ $this->payumGateway,
+ $payment,
+ $afterRoute,
+ $afterParameters
+ );
+ }
+}
diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore
new file mode 100644
index 0000000..e69de29
diff --git a/src/Repository/AddressRepository.php b/src/Repository/AddressRepository.php
new file mode 100644
index 0000000..9af156d
--- /dev/null
+++ b/src/Repository/AddressRepository.php
@@ -0,0 +1,27 @@
+
+ *
+ * @method Address|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Address|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Address[] findAll()
+ * @method Address[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class AddressRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Address::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+}
diff --git a/src/Repository/CategoryRepository.php b/src/Repository/CategoryRepository.php
new file mode 100644
index 0000000..98f82c7
--- /dev/null
+++ b/src/Repository/CategoryRepository.php
@@ -0,0 +1,89 @@
+.
+ *
+ * @method Category|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Category|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Category[] findAll()
+ * @method Category[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class CategoryRepository extends NestedTreeRepository implements ServiceEntityRepositoryInterface
+{
+ private const ENTITY_CLASS = Category::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ /** @var EntityManagerInterface $manager */
+ $manager = $registry->getManagerForClass(self::ENTITY_CLASS);
+ parent::__construct($manager, $manager->getClassMetadata(self::ENTITY_CLASS));
+ }
+
+ public function save(Category $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Category $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function addTypeFilter(QueryBuilder $qb, ProductType $productType): QueryBuilder
+ {
+ return $qb
+ ->andWhere('entity.type = :type')
+ ->setParameter(':type', $productType);
+ }
+
+ /**
+ * Get the category hierarchy thanks to the tree behaviour. Ordering by the
+ * left index is the easiest way to do the job.
+ */
+ public function getHierarchy(?ProductType $type = null, ?User $user = null): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('c')
+ ->andWhere('c.enabled = true')
+ ->addOrderBy('c.lft', 'ASC');
+
+ if ($type !== null) {
+ $qb
+ ->andWhere('c.type = :productType')
+ ->setParameter('productType', $type)
+ ;
+ }
+
+ if ($user !== null) {
+ $qb->from(Product::class, 'p')
+ ->andWhere('p.category = c')
+ ->andWhere('p.owner = :user')
+ ->setParameter('user', $user)
+ ;
+ }
+
+ return $qb;
+ }
+}
diff --git a/src/Repository/ConfigurationRepository.php b/src/Repository/ConfigurationRepository.php
new file mode 100644
index 0000000..3826420
--- /dev/null
+++ b/src/Repository/ConfigurationRepository.php
@@ -0,0 +1,61 @@
+
+ *
+ * @method Configuration|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Configuration|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Configuration[] findAll()
+ * @method Configuration[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class ConfigurationRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Configuration::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ public function save(Configuration $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Configuration $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function getInstanceConfiguration(): ?Configuration
+ {
+ return $this->findOneBy(['type' => ConfigurationType::INSTANCE]);
+ }
+
+ public function getInstanceConfigurationOrCreate(): Configuration
+ {
+ $cfg = $this->getInstanceConfiguration();
+ if (!$cfg instanceof Configuration) {
+ $cfg = Configuration::getInstanceConfiguration();
+ }
+
+ return $cfg;
+ }
+}
diff --git a/src/Repository/GroupOfferRepository.php b/src/Repository/GroupOfferRepository.php
new file mode 100644
index 0000000..cda327c
--- /dev/null
+++ b/src/Repository/GroupOfferRepository.php
@@ -0,0 +1,35 @@
+
+ *
+ * @method GroupOffer|null find($id, $lockMode = null, $lockVersion = null)
+ * @method GroupOffer|null findOneBy(array $criteria, array $orderBy = null)
+ * @method GroupOffer[] findAll()
+ * @method GroupOffer[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class GroupOfferRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = GroupOffer::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ /**
+ * Return an object or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): GroupOffer
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Group offer not found.');
+ }
+}
diff --git a/src/Repository/GroupRepository.php b/src/Repository/GroupRepository.php
new file mode 100644
index 0000000..65a01b6
--- /dev/null
+++ b/src/Repository/GroupRepository.php
@@ -0,0 +1,90 @@
+
+ *
+ * @method Group|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Group|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Group[] findAll()
+ * @method Group[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class GroupRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Group::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ /**
+ * Return an object or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): Group
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Group not found.');
+ }
+
+ public function save(Group $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Group $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * Get visible groups for a given user.
+ */
+ public function getGroups(?string $groupName): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('g')
+
+ // public
+ ->andWhere('g.type = :type')
+ ->setParameter(':type', GroupType::PUBLIC);
+
+ // @todo or member of the private group
+
+ // filter list by group name
+ if ($groupName !== null) {
+ $qb->andWhere('LOWER(g.name) LIKE LOWER(:groupName)')->setParameter('groupName', '%'.$groupName.'%');
+ }
+
+ // alpha sort
+ return $qb->orderBy('g.name', 'ASC')->getQuery();
+ }
+
+ public function getUserGroups(User $user): QueryBuilder
+ {
+ return $this->createQueryBuilder('g')
+ ->from(UserGroup::class, 'ug')
+ ->andWhere('g = ug.group')
+ ->andWhere('ug.user = :user')
+ ->setParameter('user', $user);
+ }
+}
diff --git a/src/Repository/MenuItemRepository.php b/src/Repository/MenuItemRepository.php
new file mode 100644
index 0000000..94d9286
--- /dev/null
+++ b/src/Repository/MenuItemRepository.php
@@ -0,0 +1,94 @@
+getClassMetadata(self::ENTITY_CLASS));
+ }
+
+ public function save(MenuItem $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(MenuItem $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * @return MenuItem[]
+ */
+ public function findFirstLevelMenuLinks(string $code): array
+ {
+ /** @var MenuItem[] */
+ return $this
+ ->createQueryBuilder('i')
+ ->andWhere('i.parent is null')
+ ->join('i.menu', 'm', 'WITH', 'm.code = :code')
+ ->setParameter('code', $code)
+ ->orderBy('i.position', 'ASC')
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * @return MenuItem[]
+ */
+ public function getFooterItems(string $linkType): array
+ {
+ /** @var MenuItem[] */
+ return $this
+ ->createQueryBuilder('i')
+ ->andWhere('i.linkType = :linkType')
+ ->join('i.menu', 'm', 'WITH', 'm.code = :code')
+ ->setParameters([
+ 'code' => Menu::FOOTER,
+ 'linkType' => $linkType,
+ ])
+ ->orderBy('i.position', 'ASC')
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function getLinksByCode(QueryBuilder $qb, string $code): QueryBuilder
+ {
+ return $qb
+ ->join('entity.menu', 'm', 'WITH', 'm.code = :code')
+ ->andWhere('entity.linkType = :linkType')
+ ->andWhere('entity.parent is null')
+ ->setParameters([
+ 'code' => $code,
+ 'linkType' => LinkType::LINK->value,
+ ])
+ ;
+ }
+}
diff --git a/src/Repository/MenuRepository.php b/src/Repository/MenuRepository.php
new file mode 100644
index 0000000..15a3c7a
--- /dev/null
+++ b/src/Repository/MenuRepository.php
@@ -0,0 +1,61 @@
+
+ *
+ * @method Menu|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Menu|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Menu[] findAll()
+ * @method Menu[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class MenuRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Menu::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ public function save(Menu $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Menu $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * @throws NonUniqueResultException
+ */
+ public function getByCode(string $code): Menu
+ {
+ /** @var Menu */
+ return $this
+ ->createQueryBuilder('m')
+ ->where('m.code = :code')
+ ->setParameter('code', $code)
+ ->setMaxResults(1)
+ ->getQuery()
+ ->getOneOrNullResult();
+ }
+}
diff --git a/src/Repository/MessageRepository.php b/src/Repository/MessageRepository.php
new file mode 100644
index 0000000..8b9d6a4
--- /dev/null
+++ b/src/Repository/MessageRepository.php
@@ -0,0 +1,69 @@
+
+ *
+ * @method Message|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Message|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Message[] findAll()
+ * @method Message[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class MessageRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Message::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ public function save(Message $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Message $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function userHasNewMessage(User $user, bool $isOwner): bool
+ {
+ $qb = $this
+ ->createQueryBuilder('m')
+ ->join('m.serviceRequest', 'sr');
+
+ if ($isOwner) {
+ $qb->andWhere('m.ownerRead = false')
+ ->andWhere('sr.owner = :user')
+ ->setParameter('user', $user);
+ } else {
+ $qb
+ ->andWhere('m.recipientRead = false')
+ ->andWhere('sr.recipient = :user')
+ ->setParameter('user', $user);
+ }
+
+ /** @var Message[] $unreadMessages */
+ $unreadMessages = $qb->getQuery()->getResult();
+
+ return \count($unreadMessages) !== 0;
+ }
+}
diff --git a/src/Repository/PageRepository.php b/src/Repository/PageRepository.php
new file mode 100644
index 0000000..9f51aff
--- /dev/null
+++ b/src/Repository/PageRepository.php
@@ -0,0 +1,33 @@
+
+ *
+ * @method Page|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Page|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Page|null findOneBySlug(string $slug)
+ * @method Page[] findAll()
+ * @method Page[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class PageRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Page::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ public function getHome(): ?Page
+ {
+ return $this->findOneBy(['home' => true]);
+ }
+}
diff --git a/src/Repository/PaymentRepository.php b/src/Repository/PaymentRepository.php
new file mode 100644
index 0000000..d9f26c7
--- /dev/null
+++ b/src/Repository/PaymentRepository.php
@@ -0,0 +1,32 @@
+
+ *
+ * @method Payment|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Payment|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Payment[] findAll()
+ * @method Payment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class PaymentRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Payment::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): Payment
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Payment not found.');
+ }
+}
diff --git a/src/Repository/ProductAvailabilityRepository.php b/src/Repository/ProductAvailabilityRepository.php
new file mode 100644
index 0000000..8d7af14
--- /dev/null
+++ b/src/Repository/ProductAvailabilityRepository.php
@@ -0,0 +1,76 @@
+
+ *
+ * @method ProductAvailability|null find($id, $lockMode = null, $lockVersion = null)
+ * @method ProductAvailability|null findOneBy(array $criteria, array $orderBy = null)
+ * @method ProductAvailability[] findAll()
+ * @method ProductAvailability[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class ProductAvailabilityRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = ProductAvailability::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ /**
+ * Return product availability or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): ProductAvailability
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('ProductAvailability not found.');
+ }
+
+ public function save(ProductAvailability $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getProductUnavailabilities(Product $product): array
+ {
+ /** @var ProductAvailability[] */
+ return $this
+ ->createQueryBuilder('a')
+ ->andWhere('a.product = :product')
+ ->andWhere('a.mode = :mode')
+ ->andWhere('a.type = :type')
+ ->andWhere('a.endAt > :today')
+ ->orderBy('a.startAt')
+ ->setParameters([
+ 'product' => $product,
+ 'mode' => ProductAvailabilityMode::UNAVAILABLE,
+ 'type' => ProductAvailabilityType::OWNER,
+ 'today' => date('Y-m-d'),
+ ])
+ ->getQuery()
+ ->getResult();
+ }
+
+ public function deleteProductUnavailability(ProductAvailability $productAvailability): void
+ {
+ $this->getEntityManager()->remove($productAvailability);
+ $this->getEntityManager()->flush();
+ }
+}
diff --git a/src/Repository/ProductRepository.php b/src/Repository/ProductRepository.php
new file mode 100644
index 0000000..f61bebd
--- /dev/null
+++ b/src/Repository/ProductRepository.php
@@ -0,0 +1,137 @@
+
+ *
+ * @method Product|null find($id, $lockMode = null, $lockVersion = null)
+ * @method Product|null findOneBy(array $criteria, array $orderBy = null)
+ * @method Product[] findAll()
+ * @method Product[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class ProductRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = Product::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ /**
+ * Return an object or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): Product
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Product not found.');
+ }
+
+ public function save(Product $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(Product $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function getUserProductsByType(User $user, ?ProductType $type, ?Uuid $category, ?Group $group): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('p')
+ ->andWhere('p.owner = :user')
+ ->andWhere('p.status != :status')
+ ->setParameters([
+ 'user' => $user,
+ 'status' => ProductStatus::DELETED,
+ ]);
+
+ if ($type !== null) {
+ $qb->andWhere('p.type = :type')
+ ->setParameter('type', $type)
+ ;
+ }
+
+ if ($category !== null) {
+ $qb->andWhere('p.category = :category')
+ ->setParameter('category', $category)
+ ;
+ }
+
+ if ($group !== null) {
+ $qb->innerJoin('p.groups', 'g')
+ ->andWhere('g.id = :group')
+ ->setParameter('group', $group)
+ ;
+ }
+
+ return $qb->getQuery();
+ }
+
+ /**
+ * Business rules for searchable/indexable products.
+ */
+ public function getIndexable(?ProductType $type = null): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('p')
+ ->innerJoin('p.owner', 'owner')
+
+ // enabled and confirmed accounts
+ ->andWhere('owner.enabled = :enabled')
+ ->setParameter(':enabled', true)
+ ->andWhere('owner.emailConfirmed = :emailConfirmed')
+ ->setParameter(':emailConfirmed', true)
+
+ // vacation mode is not enabled for owner
+ ->andWhere('owner.vacationMode = :vacationMode')
+ ->setParameter(':vacationMode', false)
+
+ // active products
+ ->andWhere('p.status = :status')
+ ->setParameter(':status', ProductStatus::ACTIVE)
+
+ // alpha sort
+ ->orderBy('p.name', 'ASC')
+ ;
+
+ if ($type !== null) {
+ $qb->andWhere('p.type = :type')
+ ->setParameter(':type', $type);
+ }
+
+ return $qb->getQuery();
+ }
+
+ public function getObjects(): Query
+ {
+ return $this->getIndexable(ProductType::OBJECT);
+ }
+
+ public function getServices(): Query
+ {
+ return $this->getIndexable(ProductType::SERVICE);
+ }
+}
diff --git a/src/Repository/ServiceRequestRepository.php b/src/Repository/ServiceRequestRepository.php
new file mode 100644
index 0000000..207c63b
--- /dev/null
+++ b/src/Repository/ServiceRequestRepository.php
@@ -0,0 +1,129 @@
+
+ *
+ * @method ServiceRequest|null find($id, $lockMode = null, $lockVersion = null)
+ * @method ServiceRequest|null findOneBy(array $criteria, array $orderBy = null)
+ * @method ServiceRequest[] findAll()
+ * @method ServiceRequest[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+final class ServiceRequestRepository extends ServiceEntityRepository
+{
+ private const ENTITY_CLASS = ServiceRequest::class;
+
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, self::ENTITY_CLASS);
+ }
+
+ /**
+ * Return an object or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): ServiceRequest
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Service request not found.');
+ }
+
+ public function save(ServiceRequest $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(ServiceRequest $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * @param array|ArrayCollection|null $products
+ */
+ public function getLendings(User $owner, mixed $products): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('sr')
+ ->leftJoin('sr.messages', 'm')
+ ->andWhere('sr.owner = :owner')
+ ->setParameter('owner', $owner)
+ ->orderBy('sr.createdAt', 'DESC');
+
+ if ($products !== null && \count($products) !== 0) {
+ $qb->andWhere('sr.product IN (:products)')->setParameter('products', $products);
+ }
+
+ return $qb->getQuery();
+ }
+
+ /**
+ * @param array|ArrayCollection|null $products
+ */
+ public function getLoans(User $recipient, mixed $products): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('sr')
+ ->leftJoin('sr.messages', 'm')
+ ->andWhere('sr.recipient = :recipient')
+ ->setParameter('recipient', $recipient)
+ ->orderBy('sr.createdAt', 'DESC');
+
+ if ($products !== null && \count($products) !== 0) {
+ $qb->andWhere('sr.product IN (:products)')->setParameter('products', $products);
+ }
+
+ return $qb->getQuery();
+ }
+
+ /**
+ * Get all items having a property set to a given date interval (a day).
+ */
+ public function getActionSoon(string $property, int $days = 1): Query
+ {
+ $from = new \DateTimeImmutable(sprintf('+%d days midnight', $days));
+ $to = $from->modify('+ 1 day'); // just add one day for the end limit
+
+ $qb = $this
+ ->createQueryBuilder('sr')
+ ->innerJoin('sr.owner', 'o')
+ ->innerJoin('sr.recipient', 'g')
+ ->andWhere(sprintf('sr.%s >= :from', $property))
+ ->andWhere(sprintf('sr.%s < :to', $property))
+ ->setParameter('from', $from->format('Y-m-d'))
+ ->setParameter('to', $to->format('Y-m-d'))
+ ->andWhere('sr.status = :status')
+ ->setParameter('status', ServiceRequestStatus::CONFIRMED)
+ ;
+
+ return $qb->getQuery();
+ }
+
+ public function getStartingAtTomorow(): Query
+ {
+ return $this->getActionSoon('startAt');
+ }
+
+ public function getEndingAtTomorow(): Query
+ {
+ return $this->getActionSoon('endAt');
+ }
+}
diff --git a/src/Repository/UserGroupRepository.php b/src/Repository/UserGroupRepository.php
new file mode 100644
index 0000000..1db29dc
--- /dev/null
+++ b/src/Repository/UserGroupRepository.php
@@ -0,0 +1,104 @@
+
+ *
+ * @method UserGroup|null find($id, $lockMode = null, $lockVersion = null)
+ * @method UserGroup|null findOneBy(array $criteria, array $orderBy = null)
+ * @method UserGroup[] findAll()
+ * @method UserGroup[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class UserGroupRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, UserGroup::class);
+ }
+
+ public function save(UserGroup $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function remove(UserGroup $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ public function getGroupMembers(Group $group, ?string $memberName): Query
+ {
+ $qb = $this
+ ->createQueryBuilder('ug')
+ ->andWhere('ug.group = :group')
+ ->join('ug.user', 'u')
+ ->orderBy('u.firstname', 'ASC')
+ ->setParameter('group', $group);
+
+ // filtered list by firstname, lastname or email member.
+ if ($memberName !== null) {
+ $qb
+ ->andWhere(
+ 'LOWER(u.email) LIKE LOWER(:memberName)
+ OR LOWER(u.firstname) LIKE LOWER(:memberName)
+ OR LOWER(u.lastname) LIKE LOWER(:memberName)'
+ )
+ ->setParameter('memberName', '%'.$memberName.'%');
+ }
+
+ return $qb->getQuery();
+ }
+
+ public function getExpired(): Query
+ {
+ $today = Carbon::today();
+ $qb = $this
+ ->createQueryBuilder('ug')
+ ->join('ug.user', 'u')
+ ->join('ug.group', 'g')
+ ->andWhere('ug.endAt < :date')
+ ->setParameter('date', $today->format('Y-m-d'))
+ ;
+
+ return $qb->getQuery();
+ }
+
+ /**
+ * Get all membership expiring in exactly x days.
+ */
+ public function getExpiring(int $days): Query
+ {
+ $from = new \DateTimeImmutable(sprintf('+%d days midnight', $days));
+ $to = $from->modify('+ 1 day'); // just add one day for the end limit
+
+ $qb = $this
+ ->createQueryBuilder('ug')
+ ->join('ug.user', 'u')
+ ->join('ug.group', 'g')
+ ->andWhere('ug.endAt >= :from')
+ ->andWhere('ug.endAt < :to')
+ ->setParameter('from', $from->format('Y-m-d'))
+ ->setParameter('to', $to->format('Y-m-d'))
+ ;
+
+ return $qb->getQuery();
+ }
+}
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
new file mode 100644
index 0000000..cfc22d8
--- /dev/null
+++ b/src/Repository/UserRepository.php
@@ -0,0 +1,108 @@
+
+ *
+ * @method User|null find($id, $lockMode = null, $lockVersion = null)
+ * @method User|null findOneBy(array $criteria, array $orderBy = null)
+ * @method User|null findOneByEmail(string $email)
+ * @method User|null findOneByConfirmationToken(string $token)
+ * @method User|null findOneByLostPasswordToken(string $token)
+ * @method User[] findAll()
+ * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
+ */
+class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, User::class);
+ }
+
+ /**
+ * Return object or throws an exception if not found.
+ */
+ public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): User
+ {
+ return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('User not found.');
+ }
+
+ /**
+ * Use the UserManager instead.
+ */
+ public function save(User $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->persist($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * Use the UserManager instead.
+ */
+ public function remove(User $entity, bool $flush = false): void
+ {
+ $this->getEntityManager()->remove($entity);
+
+ if ($flush) {
+ $this->getEntityManager()->flush();
+ }
+ }
+
+ /**
+ * Used to upgrade (rehash) the user's password automatically over time.
+ */
+ public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
+ {
+ if (!$user instanceof User) {
+ throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user)));
+ }
+
+ $user->setPassword($newHashedPassword);
+ $this->save($user, true);
+ }
+
+ /**
+ * This rules are duplicated with ProductRepository::getIndexable. Check to
+ * factorize this.
+ *
+ * @see ProductRepository::getIndexable
+ */
+ public function getPlacesQueryBuilder(): QueryBuilder
+ {
+ return $this->createQueryBuilder('p')
+ ->andWhere('p.type = :type')
+ ->setParameter('type', UserType::PLACE)
+
+ // enabled and confirmed accounts
+ ->andWhere('p.enabled = :enabled')
+ ->setParameter(':enabled', true)
+ ->andWhere('p.emailConfirmed = :emailConfirmed')
+ ->setParameter(':emailConfirmed', true)
+
+ // vacation mode is not enabled
+ ->andWhere('p.vacationMode = :vacationMode')
+ ->setParameter(':vacationMode', false)
+
+ // only places with a valid address
+ ->andWhere('p.address is not null')
+
+ // sort
+ ->orderBy('p.name', 'ASC')
+ ;
+ }
+}
diff --git a/src/Search/Command/IndexProductsCommand.php b/src/Search/Command/IndexProductsCommand.php
new file mode 100644
index 0000000..fa78fbd
--- /dev/null
+++ b/src/Search/Command/IndexProductsCommand.php
@@ -0,0 +1,111 @@
+configureCommand(self::DESCRIPTION);
+ }
+
+ /**
+ * @throws ExceptionInterface
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $io->title(self::DESCRIPTION.' ('.$this->environment.' env)');
+ $this->memoryReport($io);
+
+ $io->section('Resetting swap index...');
+ $swapIndex = $this->meilisearch->getSwapIndex();
+ $swapIndex->deleteAllDocuments();
+ $io->note(' -> DONE');
+ $io->newLine();
+
+ $io->section('Indexing products in swap...');
+ $count = 0;
+ $toIndex = [];
+ // simple trick for code coverage as we don't have 500 products in the fixtures
+ $batchSize = $this->environment === 'test' ? 10 : self::BACTH_SIZE;
+ $query = $this->productRepository->getIndexable();
+
+ foreach ($query->toIterable() as $product) {
+ /** @var Product $product */
+ $io->comment(sprintf(' > adding product %s to batch', $product->getId()));
+ $toIndex[] = $product;
+ if ((\count($toIndex) % $batchSize) === 0) {
+ $this->meilisearch->indexProducts($toIndex, $swapIndex);
+ $io->note(sprintf(' > indexing %d product(s) from batch', \count($toIndex)));
+ $toIndex = [];
+ }
+ ++$count;
+ }
+
+ $this->meilisearch->indexProducts($toIndex, $swapIndex);
+ $io->note(sprintf(' > indexing %d remaining product(s) from batch', \count($toIndex)));
+
+ $io->note(sprintf(' -> %d product(s) indexed.', $count));
+ $io->note(' -> DONE');
+ $io->newLine();
+
+ $io->section('Swapping indexes...');
+ $this->meilisearch->swapIndexes();
+ $io->note(' -> DONE');
+ $io->newLine();
+
+ $io->section('Applying settings...');
+ $this->meilisearch->setSettings();
+ $io->note(' -> DONE');
+
+ $io->section('Resetting swap index...');
+ $swapIndex = $this->meilisearch->getSwapIndex();
+ $swapIndex->deleteAllDocuments();
+ $io->note(' -> DONE');
+ $io->newLine();
+
+ $this->memoryReport($io);
+ sleep(1);
+ $io->success('DONE');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Search/Document/GeoDocument.php b/src/Search/Document/GeoDocument.php
new file mode 100644
index 0000000..a0568ed
--- /dev/null
+++ b/src/Search/Document/GeoDocument.php
@@ -0,0 +1,29 @@
+getLatitude(),
+ lng: (float) $address->getLongitude(),
+ );
+ }
+}
diff --git a/src/Search/Document/ProductDocument.php b/src/Search/Document/ProductDocument.php
new file mode 100644
index 0000000..de82794
--- /dev/null
+++ b/src/Search/Document/ProductDocument.php
@@ -0,0 +1,136 @@
+
+ */
+ public readonly array $categories,
+
+ /**
+ * Categories' IDs.
+ *
+ * @see Product::$category
+ *
+ * @var array
+ */
+ public readonly array $categoriesIds,
+
+ /**
+ * Groups where the product is visible.
+ *
+ * @see Product::$groups
+ *
+ * @var array
+ */
+ public readonly array $groupsIds,
+
+ /**
+ * Optional description.
+ *
+ * @see Product::$description
+ */
+ public readonly ?string $description,
+
+ /**
+ * Optional lat/long coordinates.
+ *
+ * @@see Product::$owner
+ */
+ public readonly ?GeoDocument $_geo,
+
+ /**
+ * When the document was indexed.
+ */
+ public readonly \DateTimeImmutable $indexedAt,
+ ) {
+ }
+
+ public static function fromProduct(Product $product): self
+ {
+ // categories
+ $category = $product->getCategory();
+ // Assert::isInstanceOf($category, Category::class); // category is not nullable in db
+ $categoriesIds = [];
+ $categories = $categoriesIds;
+ $categories[] = $category->getName();
+ $categoriesIds[] = (string) $category->getId();
+ if ($category->hasParent()) {
+ Assert::notNull($category->getParent());
+ $categories[] = $category->getParent()->getName();
+ $categoriesIds[] = (string) $category->getParent()->getId();
+ }
+
+ // add geoloc
+ $owner = $product->getOwner();
+ if ($owner->hasAddress()) {
+ Assert::notNull($owner->getAddress());
+ $geo = GeoDocument::fromAddress($owner->getAddress());
+ }
+
+ return new self(
+ id: (string) $product->getId(),
+ ownerId: (string) $product->getOwner()->getId(),
+ type: $product->getType()->value,
+ visibility: $product->getVisibility()->value,
+ name: $product->getName(),
+ categories: $categories,
+ categoriesIds: $categoriesIds,
+ groupsIds: $product->getGroupsIds(),
+ description: $product->getDescription(),
+ _geo: $geo ?? null,
+ indexedAt: new \DateTimeImmutable()
+ );
+ }
+}
diff --git a/src/Search/Meilisearch.php b/src/Search/Meilisearch.php
new file mode 100755
index 0000000..ad94dcd
--- /dev/null
+++ b/src/Search/Meilisearch.php
@@ -0,0 +1,320 @@
+client = new Client($this->meilisearchUrl, $this->meilisearchApiKey);
+ }
+
+ /**
+ * For direct access to main Meili client.
+ */
+ public function getClient(): Client
+ {
+ return $this->client;
+ }
+
+ public function getIndex(): Indexes
+ {
+ if ($this->index !== null) {
+ return $this->index;
+ }
+
+ $this->index = $this->client->index(self::PRODUCTS_INDEX);
+
+ return $this->index;
+ }
+
+ public function getSwapIndex(): Indexes
+ {
+ return $this->client->index(self::PRODUCTS_SWAP_INDEX);
+ }
+
+ public function setSearchableAtttributes(): void
+ {
+ $this->getIndex()->updateSearchableAttributes(self::SEARCHABLE_ATTRIBUTES);
+ }
+
+ public function setFiltrableAttributes(): void
+ {
+ $this->getIndex()->updateFilterableAttributes(self::FILTRABLE_ATTRIBUTES);
+ }
+
+ public function setSortableAttributes(): void
+ {
+ $this->getIndex()->updateSortableAttributes(self::SORTABLE_ATTRIBUTES);
+ }
+
+ /**
+ * Aplly all settings at once.
+ */
+ public function setSettings(): void
+ {
+ $this->setSearchableAtttributes();
+ $this->setFiltrableAttributes();
+ $this->setSortableAttributes();
+ }
+
+ /**
+ * Allows to have full control about the normalization. But with tne 1.1 version
+ * of Meilisearch, we should be able to pass the document DTO as it is and let
+ * Meilisearch handle the normalization process.
+ *
+ * @return array
+ *
+ * @throws ExceptionInterface
+ */
+ public function normalizeProduct(Product $product): array
+ {
+ $productDocument = ProductDocument::fromProduct($product);
+ /** @var array $normalized */
+ $normalized = $this->normalizer->normalize($productDocument, 'array');
+
+ return $normalized;
+ }
+
+ public function deleteProduct(Product $product, ?Indexes $index = null): void
+ {
+ $index = $index ?? $this->getIndex();
+ $index->deleteDocument((string) $product->getId());
+ }
+
+ /**
+ * @throws ExceptionInterface
+ */
+ public function indexProduct(Product $product, ?Indexes $index = null): void
+ {
+ $index = $index ?? $this->getIndex();
+ $index->addDocuments([$this->normalizeProduct($product)], self::PRIMARY_KEY);
+ }
+
+ /**
+ * @param array $products
+ *
+ * @throws ExceptionInterface
+ */
+ public function indexProducts(array $products, ?Indexes $index = null): void
+ {
+ $index = $index ?? $this->getIndex();
+ $documents = array_map(fn (Product $product) => $this->normalizeProduct($product), $products);
+ $index->addDocuments($documents, self::PRIMARY_KEY);
+ }
+
+ /**
+ * Swap indexes to avoid downtime.
+ */
+ public function swapIndexes(): void
+ {
+ $this->getClient()->swapIndexes([[self::PRODUCTS_INDEX, self::PRODUCTS_SWAP_INDEX]]);
+ }
+
+ public function searchObjects(Search $searchDto): SearchResult
+ {
+ return $this->search($searchDto, ProductType::OBJECT);
+ }
+
+ public function searchServices(Search $searchDto): SearchResult
+ {
+ return $this->search($searchDto, ProductType::SERVICE);
+ }
+
+ /**
+ * Search with a main query and various filtery.
+ */
+ public function search(Search $searchDto, ProductType $productType = null): SearchResult
+ {
+ $searchParams = [];
+ $searchParams = $this->withFilters($searchParams, $searchDto, $productType);
+ $searchParams = $this->withSort($searchParams, $searchDto);
+
+ // pagination settings
+ $searchParams['hitsPerPage'] = ProductController::MAX_ELEMENT_BY_PAGE;
+ $searchParams['page'] = $searchDto->page;
+
+ // option to transform hits to products while keeping the relevance order
+ $options = ['transformHits' => $this->transformHits(...)];
+
+ return $this->getIndex()->search($searchDto->q, $searchParams, $options);
+ }
+
+ /**
+ * Apply all search filters.
+ *
+ * @param array $searchParams
+ *
+ * @return array
+ */
+ private function withFilters(array $searchParams, Search $searchDto, ?ProductType $productType = null): array
+ {
+ $filters = [];
+
+ // if the user is NOT logged then he can only view public products
+ // if the user is logged he will also view the products belonging to its groups
+ $visibilityFilter = [];
+ $visibilityFilter[] = 'visibility = '.ProductVisibility::PUBLIC->value;
+ if ($searchDto->isLogged()) {
+ Assert::isInstanceOf($searchDto->user, User::class);
+ $userGroupsIds = $searchDto->user->getUserGroupsIds();
+ $visibilityFilter[] = 'groupsIds IN [ '.implode(', ', $userGroupsIds).' ]';
+ }
+ $filters[] = '( '.implode(' OR ', $visibilityFilter).' )';
+
+ // product type as a filter
+ if ($productType !== null) {
+ $filters[] = 'type = '.$productType->value;
+ }
+
+ // category filter
+ if ($searchDto->category !== null) {
+ $filters[] = 'categoriesIds = '.$searchDto->category->getId();
+ }
+
+ // place filter
+ if ($searchDto->place !== null) {
+ $filters[] = sprintf('ownerId = %s', $searchDto->place->getId());
+ }
+
+ // geo filter
+ if ($searchDto->hasProximity()) {
+ Assert::isInstanceOf($searchDto->city, Address::class);
+ $filters[] = sprintf('_geoRadius(%s, %s, %d)',
+ $searchDto->city->getLatitude(),
+ $searchDto->city->getLongitude(),
+ (int) $searchDto->distance * 1000 // the distance is in meters, not kilometers
+ );
+ }
+
+ // Filters are cumulative
+ $searchParams['filter'] = implode(' AND ', $filters);
+
+ return $searchParams;
+ }
+
+ /**
+ * Apply sort by name or proximity.
+ *
+ * @param array $searchParams
+ *
+ * @return array
+ */
+ private function withSort(array $searchParams, Search $searchDto): array
+ {
+ // the proximity search has the priority to sort results
+ if ($searchDto->hasProximity()) {
+ Assert::isInstanceOf($searchDto->city, Address::class);
+ $searchParams['sort'] = [sprintf('_geoPoint(%s, %s):asc',
+ $searchDto->city->getLatitude(),
+ $searchDto->city->getLongitude()),
+ ];
+ }
+
+ // default sort: if no query is specified and not proximity filter then sort by name
+ if (!$searchDto->hasQuery() && !$searchDto->hasProximity()) {
+ $searchParams['sort'] = ['name:asc'];
+ }
+
+ return $searchParams;
+ }
+
+ /**
+ * Transform the hits to an array of product. If a product is not found is it
+ * simply removed from the results.
+ *
+ * @param array> $hits
+ *
+ * @return array
+ */
+ private function transformHits(array $hits): array
+ {
+ $products = array_map($this->getProduct(...), $hits);
+
+ return array_filter($products);
+ }
+
+ /**
+ * @param array $hit
+ */
+ private function getProduct(array $hit): ?Product
+ {
+ $product = $this->productRepository->find($hit['id'] ?? ''); // don't use null as it raises a doctrine exception
+ if ($product === null) {
+ return null;
+ }
+
+ // enrich with the distance to the geoPoint if it is available
+ if (\array_key_exists('_geoDistance', $hit)) {
+ $product->setGeoDistance(\is_int($hit['_geoDistance']) ? $hit['_geoDistance'] : null);
+ }
+
+ return $product;
+ }
+}
diff --git a/src/Search/Subscriber/SearchResultSubscriber.php b/src/Search/Subscriber/SearchResultSubscriber.php
new file mode 100644
index 0000000..5d82f01
--- /dev/null
+++ b/src/Search/Subscriber/SearchResultSubscriber.php
@@ -0,0 +1,34 @@
+target;
+ if (!$searchResult instanceof SearchResult) {
+ return;
+ }
+
+ $event->count = (int) $searchResult->getTotalHits();
+ $event->items = $searchResult->getHits();
+ $event->stopPropagation();
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'knp_pager.items' => ['items', 1/* increased priority to override any internal */],
+ ];
+ }
+}
diff --git a/src/Security/Checker/AuthorizationChecker.php b/src/Security/Checker/AuthorizationChecker.php
new file mode 100644
index 0000000..a64b6e9
--- /dev/null
+++ b/src/Security/Checker/AuthorizationChecker.php
@@ -0,0 +1,45 @@
+authorizationChecker->isGranted(User::ROLE_ADMIN);
+ }
+
+ /**
+ * Check if the current logged user has the ADMIN role and throw an execption
+ * otherwise.
+ */
+ public function checkAdminRole(): void
+ {
+ if (!$this->isAdmin()) {
+ throw new AccessDeniedHttpException('Admin role is required to access this ressource.');
+ }
+ }
+
+ public function hasGroupAdminRole(): bool
+ {
+ return $this->authorizationChecker->isGranted(User::ROLE_GROUP_ADMIN);
+ }
+
+ public function isGroupAdmin(): void
+ {
+ if (!$this->hasGroupAdminRole()) {
+ throw new AccessDeniedHttpException('The group admin role is required to access this resource.');
+ }
+ }
+}
diff --git a/src/Security/Checker/UserEmailConfirmedChecker.php b/src/Security/Checker/UserEmailConfirmedChecker.php
new file mode 100644
index 0000000..ec25828
--- /dev/null
+++ b/src/Security/Checker/UserEmailConfirmedChecker.php
@@ -0,0 +1,30 @@
+ 10])]
+class UserEmailConfirmedChecker implements UserCheckerInterface
+{
+ public function checkPreAuth(UserInterface $user): void
+ {
+ /** @var User $user */
+ if (!$user->isEmailConfirmed()) {
+ throw new AccountEmailNotConfirmedException();
+ }
+ }
+
+ public function checkPostAuth(UserInterface $user): void
+ {
+ }
+}
diff --git a/src/Security/Checker/UserEnabledChecker.php b/src/Security/Checker/UserEnabledChecker.php
new file mode 100644
index 0000000..5c9c78b
--- /dev/null
+++ b/src/Security/Checker/UserEnabledChecker.php
@@ -0,0 +1,30 @@
+ 10])]
+class UserEnabledChecker implements UserCheckerInterface
+{
+ public function checkPreAuth(UserInterface $user): void
+ {
+ /** @var User $user */
+ if (!$user->isEnabled()) {
+ throw new AccountDisabledException();
+ }
+ }
+
+ public function checkPostAuth(UserInterface $user): void
+ {
+ }
+}
diff --git a/src/Security/EntryPoint/AuthenticationEntryPoint.php b/src/Security/EntryPoint/AuthenticationEntryPoint.php
new file mode 100644
index 0000000..644010c
--- /dev/null
+++ b/src/Security/EntryPoint/AuthenticationEntryPoint.php
@@ -0,0 +1,40 @@
+attributes->get('_route');
+ if ($authException instanceof InsufficientAuthenticationException && u($route)->startsWith('_api_')) {
+ throw new UnauthorizedHttpException('', $authException->getMessage(), $authException);
+ }
+
+ return new RedirectResponse($this->urlGenerator->generate('app_login'));
+ }
+}
diff --git a/src/Security/Exception/AccountDisabledException.php b/src/Security/Exception/AccountDisabledException.php
new file mode 100644
index 0000000..7e357a6
--- /dev/null
+++ b/src/Security/Exception/AccountDisabledException.php
@@ -0,0 +1,20 @@
+getUser();
+
+ // the user must be logged in; if not, deny access
+ if (!$user instanceof User) {
+ return false;
+ }
+
+ /** @var Product $subject */
+
+ return match ($attribute) {
+ self::EDIT => $this->canEdit($subject, $user),
+ self::DUPLICATE => $this->canDuplicate($subject, $user),
+ self::BORROW => $this->canBorrow($subject, $user),
+ self::DELETE => $this->canDelete($subject, $user),
+ default => throw new \LogicException('This code should not be reached!')
+ };
+ }
+
+ private function canEdit(Product $product, User $user): bool
+ {
+ return $product->isOwner($user);
+ }
+
+ private function canDuplicate(Product $product, User $user): bool
+ {
+ return $product->isOwner($user);
+ }
+
+ private function canBorrow(Product $product, User $user): bool
+ {
+ // 1. we can't borrow or own products
+ if ($user === $product->getOwner()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function canDelete(Product $product, User $user): bool
+ {
+ return $product->isOwner($user) && !$product->hasOngoingServiceRequests();
+ }
+}
diff --git a/src/Security/Voter/ServiceRequest/ServiceRequestVoter.php b/src/Security/Voter/ServiceRequest/ServiceRequestVoter.php
new file mode 100644
index 0000000..c4709fa
--- /dev/null
+++ b/src/Security/Voter/ServiceRequest/ServiceRequestVoter.php
@@ -0,0 +1,53 @@
+getUser();
+
+ // the user must be logged in; if not, deny access
+ if (!$user instanceof User) {
+ return false;
+ }
+
+ /** @var ServiceRequest $subject */
+
+ return match ($attribute) {
+ self::VIEW => $this->canView($subject, $user),
+ default => throw new \LogicException('This code should not be reached!')
+ };
+ }
+
+ /**
+ * A user can view the conversation of a service request if he is the owner
+ * or the recipient of the service.
+ */
+ private function canView(ServiceRequest $serviceRequest, User $user): bool
+ {
+ return $serviceRequest->isOwnerOrRecipient($user);
+ }
+}
diff --git a/src/Serializer/ProductDocumentNormalizer.php b/src/Serializer/ProductDocumentNormalizer.php
new file mode 100644
index 0000000..e34b23b
--- /dev/null
+++ b/src/Serializer/ProductDocumentNormalizer.php
@@ -0,0 +1,53 @@
+ $context
+ *
+ * @return array
+ *
+ * @throws ExceptionInterface
+ */
+ public function normalize(mixed $object, string $format = null, array $context = []): array
+ {
+ /** @var array $data */
+ $data = $this->normalizer->normalize($object, $format, $context);
+
+ // Meilisearch doesn't support null values for the _geo field for now
+ // @see https://github.com/meilisearch/meilisearch/issues/3497
+ // fixed in 1.1 to test without this fix
+ if (\array_key_exists('_geo', $data) && $data['_geo'] === null) {
+ unset($data['_geo']);
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $context
+ */
+ public function supportsNormalization($data, string $format = null, array $context = []): bool
+ {
+ return $data instanceof ProductDocument;
+ }
+}
diff --git a/src/State/GroupGetStatsProvider.php b/src/State/GroupGetStatsProvider.php
new file mode 100644
index 0000000..3a9a372
--- /dev/null
+++ b/src/State/GroupGetStatsProvider.php
@@ -0,0 +1,29 @@
+
+ */
+final class GroupGetStatsProvider implements ProviderInterface
+{
+ public function __construct(
+ readonly private GroupRepository $groupRepository
+ ) {
+ }
+
+ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null // @phpstan-ignore-line
+ {
+ $groupStats = new GroupResource();
+ $groupStats->count = $this->groupRepository->count([]);
+
+ return $groupStats;
+ }
+}
diff --git a/src/State/Processor/ProductSwitchProcessor.php b/src/State/Processor/ProductSwitchProcessor.php
new file mode 100644
index 0000000..86e66bb
--- /dev/null
+++ b/src/State/Processor/ProductSwitchProcessor.php
@@ -0,0 +1,35 @@
+ $uriVariables
+ * @param array $context
+ */
+ public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Product
+ {
+ Assert::isInstanceOf($data, Product::class);
+ $data->switchStatus();
+ $this->productRepository->save($data, true);
+
+ return $data;
+ }
+}
diff --git a/src/Subscriber/Security/CrudControllerSubscriber.php b/src/Subscriber/Security/CrudControllerSubscriber.php
new file mode 100644
index 0000000..17a2a2e
--- /dev/null
+++ b/src/Subscriber/Security/CrudControllerSubscriber.php
@@ -0,0 +1,43 @@
+ 'onController',
+ ];
+ }
+
+ public function onController(ControllerEvent $event): void
+ {
+ $controller = $event->getController();
+ if (\is_array($controller)) {
+ $ctrl = $controller[0] ?? null;
+ if (is_a($ctrl, AdminSecuredCrudControllerInterface::class)) {
+ $this->authorizationChecker->checkAdminRole();
+ }
+ if (is_a($ctrl, GroupAdminSecuredCrudControllerInterface::class)) {
+ $this->authorizationChecker->isGroupAdmin();
+ }
+ }
+ }
+}
diff --git a/src/Subscriber/SecuritySubscriber.php b/src/Subscriber/SecuritySubscriber.php
new file mode 100644
index 0000000..49d0eb8
--- /dev/null
+++ b/src/Subscriber/SecuritySubscriber.php
@@ -0,0 +1,41 @@
+ 'onLoginSuccess',
+ ];
+ }
+
+ public function onLoginSuccess(LoginSuccessEvent $event): void
+ {
+ /** @var User $user */
+ $user = $event->getUser();
+
+ // redirect admins to the admin space
+ if ($user->isAdmin()) {
+ $event->setResponse(new RedirectResponse($this->router->generate('admin', [], UrlGeneratorInterface::ABSOLUTE_URL)));
+ }
+
+ $this->userManager->updateLoginAt($user);
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestAcceptTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestAcceptTransitionSubscriber.php
new file mode 100644
index 0000000..39e5cf8
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestAcceptTransitionSubscriber.php
@@ -0,0 +1,44 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $sr */
+ $sr = $event->getSubject();
+
+ /** @var ?User $user */
+ $user = $this->security->getUser();
+ if ($user !== null && !$sr->isOwner($user)) {
+ $event->setBlocked(true, 'Only the owner of the object can trigger the "accept" transition.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_ACCEPT_EVENT => ['guardAccept'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestAutoFinalizeTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestAutoFinalizeTransitionSubscriber.php
new file mode 100644
index 0000000..a68892d
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestAutoFinalizeTransitionSubscriber.php
@@ -0,0 +1,41 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $sr */
+ $sr = $event->getSubject();
+ $today = new \DateTimeImmutable('today');
+ if ($today <= $sr->getEndAt()) {
+ $event->setBlocked(true, 'the autoFinalize is blocked if the endAt is before or today.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_AUTO_FINALIZE_EVENT => ['guardAutoFinalize'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestConfirmTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestConfirmTransitionSubscriber.php
new file mode 100644
index 0000000..063dc6a
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestConfirmTransitionSubscriber.php
@@ -0,0 +1,44 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+
+ /** @var ?User $user */
+ $user = $this->security->getUser();
+ if ($user !== null && !$serviceRequest->isRecipient($user)) {
+ $event->setBlocked(true, 'Only the recipient of the object can trigger the "confirm" transition.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_CONFIRM_EVENT => ['guardConfirm'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestFinalizeTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestFinalizeTransitionSubscriber.php
new file mode 100644
index 0000000..851b6b2
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestFinalizeTransitionSubscriber.php
@@ -0,0 +1,49 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $sr */
+ $sr = $event->getSubject();
+
+ /** @var ?User $user */
+ $user = $this->security->getUser();
+ if ($user !== null && !$sr->isOwner($user)) {
+ $event->setBlocked(true, 'Only the owner of the object can trigger the "finalize" transition.');
+ }
+
+ $today = new \DateTimeImmutable('today');
+ if ($sr->getStartAt() > $today) {
+ $event->setBlocked(true, 'We must be in the transaction interval to finalize it.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_FINALIZE_EVENT => ['guardFinalize'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestModifyOwnerTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestModifyOwnerTransitionSubscriber.php
new file mode 100644
index 0000000..c1f7d36
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestModifyOwnerTransitionSubscriber.php
@@ -0,0 +1,44 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $sr */
+ $sr = $event->getSubject();
+
+ /** @var ?User $user */
+ $user = $this->security->getUser();
+ if ($user !== null && !$sr->isOwner($user)) {
+ $event->setBlocked(true, 'Only the owner of the object can trigger the "modifyOwner" transition.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_MODIFY_OWNER_EVENT => ['guardModifyOwner'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/Guard/ServiceRequestModifyRecipientTransitionSubscriber.php b/src/Subscriber/Workflow/Guard/ServiceRequestModifyRecipientTransitionSubscriber.php
new file mode 100644
index 0000000..ff6b076
--- /dev/null
+++ b/src/Subscriber/Workflow/Guard/ServiceRequestModifyRecipientTransitionSubscriber.php
@@ -0,0 +1,44 @@
+setBlocked(false);
+
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+
+ /** @var ?User $user */
+ $user = $this->security->getUser();
+ if ($user !== null && !$serviceRequest->isRecipient($user)) {
+ $event->setBlocked(true, 'Only the recipient of the object can trigger the "modifyRecipient" transition.');
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_STATUS_GUARD_MODIFY_RECIPIENT_EVENT => ['guardModifyRecipient'],
+ ];
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestAcceptedSubscriber.php b/src/Subscriber/Workflow/ServiceRequestAcceptedSubscriber.php
new file mode 100644
index 0000000..2708df5
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestAcceptedSubscriber.php
@@ -0,0 +1,61 @@
+ 'onCompleted',
+ ];
+ }
+
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $this->createSystemMessage($serviceRequest);
+ $this->appMailer->send(ServiceRequestAccepted::class, ['service_request' => $serviceRequest]);
+ $this->sendSms($serviceRequest->getRecipient(), ServiceRequestAccepted::class);
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest): void
+ {
+ $product = $serviceRequest->getProduct();
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_ACCEPTED.'.'.$product->getType()->value,
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestConfirmedSubscriber.php b/src/Subscriber/Workflow/ServiceRequestConfirmedSubscriber.php
new file mode 100644
index 0000000..59bda5d
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestConfirmedSubscriber.php
@@ -0,0 +1,59 @@
+ 'onCompleted',
+ ];
+ }
+
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $this->createSystemMessage($serviceRequest);
+ $this->appMailer->send(ServiceRequestConfirmed::class, ['service_request' => $serviceRequest]);
+ $this->sendSms($serviceRequest->getOwner(), ServiceRequestConfirmed::class);
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest): void
+ {
+ $product = $serviceRequest->getProduct();
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_CONFIRMED.'.'.$product->getType()->value,
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestFinishedSubscriber.php b/src/Subscriber/Workflow/ServiceRequestFinishedSubscriber.php
new file mode 100644
index 0000000..0128775
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestFinishedSubscriber.php
@@ -0,0 +1,66 @@
+ 'onCompleted',
+ ServiceRequestStatusWorkflow::WORKFLOW_SERVICE_REQUEST_COMPLETED_AUTO_FINALIZE_EVENT => 'onCompletedAuto',
+ ];
+ }
+
+ /**
+ * For a manual finalization, we set the end to the current date.
+ */
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $serviceRequest->setEndAt(new \DateTimeImmutable('today'));
+ $this->serviceRequestManager->deleteUnavailabilities($serviceRequest);
+ $this->serviceRequestManager->save($serviceRequest, true);
+ $this->createSystemMessage($serviceRequest);
+ }
+
+ /**
+ * For an auto finalize, the end date doesn't change, but we set the auto message
+ * date to the day after the end date.
+ */
+ public function onCompletedAuto(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $this->serviceRequestManager->deleteUnavailabilities($serviceRequest);
+ $this->createSystemMessage($serviceRequest, $serviceRequest->getFinalizedAt());
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest, \DateTimeImmutable $createdAt = null): void
+ {
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_FINALIZED,
+ createdAt: $createdAt
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestModifiedByOwnerSubscriber.php b/src/Subscriber/Workflow/ServiceRequestModifiedByOwnerSubscriber.php
new file mode 100644
index 0000000..855562c
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestModifiedByOwnerSubscriber.php
@@ -0,0 +1,67 @@
+ 'onCompleted',
+ ];
+ }
+
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $this->createSystemMessage($serviceRequest);
+ $this->appMailer->send(ServiceRequestModifiedByOwner::class, ['service_request' => $serviceRequest]);
+ $this->sendSms($serviceRequest->getRecipient(), ServiceRequestModifiedByOwner::class);
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest): void
+ {
+ $product = $serviceRequest->getProduct();
+ $dateFormat = $this->translator->trans('format.date', [], 'date');
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_MODIFIED_BY_OWNER.'.'.$product->getType()->value,
+ [
+ '%startAt%' => $serviceRequest->getStartAt()->format($dateFormat),
+ '%endAt%' => $serviceRequest->getEndAt()->format($dateFormat),
+ ]
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestModifiedByRecipientSubscriber.php b/src/Subscriber/Workflow/ServiceRequestModifiedByRecipientSubscriber.php
new file mode 100644
index 0000000..c010b7c
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestModifiedByRecipientSubscriber.php
@@ -0,0 +1,66 @@
+ 'onCompleted',
+ ];
+ }
+
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ $this->createSystemMessage($serviceRequest);
+ $this->appMailer->send(ServiceRequestModifiedByRecipient::class, ['service_request' => $serviceRequest]);
+ $this->sendSms($serviceRequest->getOwner(), ServiceRequestModifiedByRecipient::class);
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest): void
+ {
+ $product = $serviceRequest->getProduct();
+ $dateFormat = $this->translator->trans('format.date', [], 'date');
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_MODIFIED_BY_RECIPIENT.'.'.$product->getType()->value,
+ [
+ '%startAt%' => $serviceRequest->getStartAt()->format($dateFormat),
+ '%endAt%' => $serviceRequest->getEndAt()->format($dateFormat),
+ ]
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+}
diff --git a/src/Subscriber/Workflow/ServiceRequestRefusedSubscriber.php b/src/Subscriber/Workflow/ServiceRequestRefusedSubscriber.php
new file mode 100644
index 0000000..0c195ca
--- /dev/null
+++ b/src/Subscriber/Workflow/ServiceRequestRefusedSubscriber.php
@@ -0,0 +1,76 @@
+ 'onCompleted',
+ ];
+ }
+
+ public function onCompleted(Event $event): void
+ {
+ /** @var ServiceRequest $serviceRequest */
+ $serviceRequest = $event->getSubject();
+ /** @var ?User $actor */
+ $actor = $this->security->getUser();
+ $this->createSystemMessage($serviceRequest, $actor);
+ $this->sendEmail($serviceRequest, $actor);
+ $this->sendSms($serviceRequest->getOtherUser($actor), ServiceRequestRefused::class);
+ $this->serviceRequestManager->deleteUnavailabilities($serviceRequest);
+ }
+
+ private function createSystemMessage(ServiceRequest $serviceRequest, ?User $actor): void
+ {
+ $product = $serviceRequest->getProduct();
+ $systemMessage = $this->messageManager->createSystemMessage(
+ $serviceRequest,
+ self::MESSAGE_SYSTEM_REFUSED.'.'.$product->getType()->value,
+ ['%actor%' => $actor?->getDisplayName() ?? ''],
+ );
+ $this->messageManager->save($systemMessage, true);
+ }
+
+ private function sendEmail(ServiceRequest $serviceRequest, ?User $actor): void
+ {
+ $this->appMailer->send(ServiceRequestRefused::class, [
+ 'service_request' => $serviceRequest,
+ 'actor' => $actor,
+ ]);
+ }
+}
diff --git a/src/Test/ContainerRepositoryTrait.php b/src/Test/ContainerRepositoryTrait.php
new file mode 100755
index 0000000..a718071
--- /dev/null
+++ b/src/Test/ContainerRepositoryTrait.php
@@ -0,0 +1,116 @@
+get('doctrine')->getManager()->clear();
+ }
+
+ public function getAddressRepository(): AddressRepository
+ {
+ return self::getContainer()->get(AddressRepository::class);
+ }
+
+ public function getCategoryRepository(): CategoryRepository
+ {
+ return self::getContainer()->get(CategoryRepository::class);
+ }
+
+ public function getGroupRepository(): GroupRepository
+ {
+ return self::getContainer()->get(GroupRepository::class);
+ }
+
+ public function getUserRepository(): UserRepository
+ {
+ return self::getContainer()->get(UserRepository::class);
+ }
+
+ public function getUserGroupRepository(): UserGroupRepository
+ {
+ return self::getContainer()->get(UserGroupRepository::class);
+ }
+
+ public function getGroupOfferRepository(): GroupOfferRepository
+ {
+ return self::getContainer()->get(GroupOfferRepository::class);
+ }
+
+ public function getUserManager(): UserManager
+ {
+ return self::getContainer()->get(UserManager::class);
+ }
+
+ public function getProductManager(): ProductManager
+ {
+ return self::getContainer()->get(ProductManager::class);
+ }
+
+ public function getConfigurationRepository(): ConfigurationRepository
+ {
+ return self::getContainer()->get(ConfigurationRepository::class);
+ }
+
+ public function getProductRepository(): ProductRepository
+ {
+ return self::getContainer()->get(ProductRepository::class);
+ }
+
+ public function getPaymentRepository(): PaymentRepository
+ {
+ return self::getContainer()->get(PaymentRepository::class);
+ }
+
+ public function getProductAvailabilityRepository(): ProductAvailabilityRepository
+ {
+ return self::getContainer()->get(ProductAvailabilityRepository::class);
+ }
+
+ public function getMenuRepository(): MenuRepository
+ {
+ return self::getContainer()->get(MenuRepository::class);
+ }
+
+ public function getMenuItemRepository(): MenuItemRepository
+ {
+ return self::getContainer()->get(MenuItemRepository::class);
+ }
+
+ public function getMessageRepository(): MessageRepository
+ {
+ return self::getContainer()->get(MessageRepository::class);
+ }
+
+ public function getServiceRequestRepository(): ServiceRequestRepository
+ {
+ return self::getContainer()->get(ServiceRequestRepository::class);
+ }
+}
diff --git a/src/Test/ContainerTrait.php b/src/Test/ContainerTrait.php
new file mode 100644
index 0000000..b9898b8
--- /dev/null
+++ b/src/Test/ContainerTrait.php
@@ -0,0 +1,68 @@
+get('doctrine')->getManager()->clear();
+ }
+
+ public function getValidationProcessor(): ValidationProcessor
+ {
+ return self::getContainer()->get(ValidationProcessor::class);
+ }
+
+ public function getCategoryExtension(): CategoryExtension
+ {
+ return self::getContainer()->get(CategoryExtension::class);
+ }
+
+ public function getUserExtension(): UserExtension
+ {
+ return self::getContainer()->get(UserExtension::class);
+ }
+
+ public function getI18nExtension(): i18nExtension
+ {
+ return self::getContainer()->get(i18nExtension::class);
+ }
+
+ public function getTwigExtension(): TwigExtension
+ {
+ return self::getContainer()->get(TwigExtension::class);
+ }
+
+ public function getMenuExtension(): MenuExtension
+ {
+ return self::getContainer()->get(MenuExtension::class);
+ }
+
+ public function getFlysystemExtension(): FlysystemExtension
+ {
+ return self::getContainer()->get(FlysystemExtension::class);
+ }
+
+ public function getEntityExtension(): EntityExtension
+ {
+ return self::getContainer()->get(EntityExtension::class);
+ }
+}
diff --git a/src/Test/KernelTrait.php b/src/Test/KernelTrait.php
new file mode 100644
index 0000000..a6ddf99
--- /dev/null
+++ b/src/Test/KernelTrait.php
@@ -0,0 +1,80 @@
+getContainer();
+ $this->fixDoctrineBug($container);
+
+ /** @var UserRepository $userRepository */
+ $userRepository = self::getContainer()->get(UserRepository::class);
+ $user = $userRepository->get($id);
+ $client->loginUser($user);
+ }
+
+ public function logout(KernelBrowser $client): void
+ {
+ $client->request('GET', '/logout');
+ self::assertResponseRedirects();
+ $client->followRedirect();
+ }
+
+ public function loginAsAdmin(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::ADMIN_LOIC);
+ }
+
+ public function loginAsSarah(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::ADMIN_SARAH);
+ }
+
+ public function loginAsKevin(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::ADMIN_KEVIN);
+ }
+
+ public function loginAsUser(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::USER_17);
+ }
+
+ /**
+ * - No address associated yet.
+ * - Is group admin of Group 1.
+ */
+ public function loginAsUser16(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::USER_16);
+ }
+
+ /**
+ * - Has a pending invitation for the group 1 group.
+ */
+ public function loginAsUser11(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::USER_11);
+ }
+
+ /**
+ * - Has a published product on the Apes place.
+ */
+ public function loginAsPlaceApes(KernelBrowser|Client $client): void
+ {
+ $this->login($client, TestReference::PLACE_APES);
+ }
+}
diff --git a/src/Translator/NoTranslator.php b/src/Translator/NoTranslator.php
new file mode 100644
index 0000000..08a007a
--- /dev/null
+++ b/src/Translator/NoTranslator.php
@@ -0,0 +1,63 @@
+ $parameters
+ */
+ public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null): string
+ {
+ // to find EasyAdmin translations codes, uncomment this
+// if ($domain === 'EasyAdminBundle') {
+// dump($id);
+// }
+
+ return $id;
+ }
+
+ public function getCatalogues(): array
+ {
+ return $this->translator->getCatalogues();
+ }
+
+ public function getCatalogue(string $locale = null): MessageCatalogueInterface
+ {
+ return $this->translator->getCatalogue($locale);
+ }
+
+ public function setLocale(string $locale): void
+ {
+ $this->translator->setLocale($locale);
+ }
+
+ public function getLocale(): string
+ {
+ return $this->translator->getLocale();
+ }
+}
diff --git a/src/Twig/CategoryExtension.php b/src/Twig/CategoryExtension.php
new file mode 100755
index 0000000..f498ad6
--- /dev/null
+++ b/src/Twig/CategoryExtension.php
@@ -0,0 +1,33 @@
+categoryStorage->publicUrl((string) $category->getImage());
+ }
+}
diff --git a/src/Twig/Extension/EntityExtension.php b/src/Twig/Extension/EntityExtension.php
new file mode 100644
index 0000000..951926b
--- /dev/null
+++ b/src/Twig/Extension/EntityExtension.php
@@ -0,0 +1,34 @@
+
+ */
+ public function getFunctions(): array
+ {
+ return [
+ new TwigFunction('is_image_entity', $this->isImageEntity(...)),
+ new TwigFunction('is_images_entity', $this->isImagesEntity(...)),
+ ];
+ }
+
+ public function isImageEntity(object $entity): bool
+ {
+ return $entity instanceof ImageInterface;
+ }
+
+ public function isImagesEntity(object $entity): bool
+ {
+ return $entity instanceof ImagesInterface;
+ }
+}
diff --git a/src/Twig/Extension/ResponseExtension.php b/src/Twig/Extension/ResponseExtension.php
new file mode 100644
index 0000000..33187b8
--- /dev/null
+++ b/src/Twig/Extension/ResponseExtension.php
@@ -0,0 +1,24 @@
+getStatusText(...)),
+ ];
+ }
+
+ public function getStatusText(int $errorCode): string
+ {
+ return Response::$statusTexts[$errorCode] ?? 'Unknown error code';
+ }
+}
diff --git a/src/Twig/Extension/i18nExtension.php b/src/Twig/Extension/i18nExtension.php
new file mode 100644
index 0000000..d5ae355
--- /dev/null
+++ b/src/Twig/Extension/i18nExtension.php
@@ -0,0 +1,36 @@
+getI18Prefix(...)),
+ ];
+ }
+
+ /**
+ * Convert a Twig template name to a i18n prefix to use in XLIFF files.
+ */
+ public function getI18Prefix(string $temlateName): string
+ {
+ $temlateName = u($temlateName)->trimSuffix('.html.twig');
+ $hierarchy = u($temlateName->toString())->split('/');
+
+ // apply snake case on each entry (which also applis lower)
+ $hierarchy = array_map(static fn (UnicodeString $string) => $string->snake()->toString(), $hierarchy);
+
+ // then join the folders with a dot with the templates prefix
+ return 'templates.'.implode('.', $hierarchy);
+ }
+}
diff --git a/src/Twig/FlysystemExtension.php b/src/Twig/FlysystemExtension.php
new file mode 100644
index 0000000..d949f96
--- /dev/null
+++ b/src/Twig/FlysystemExtension.php
@@ -0,0 +1,58 @@
+
+ */
+ public function getFilters(): array
+ {
+ return [
+ new TwigFilter('public_url', $this->getPublicUrl(...)),
+ new TwigFilter('public_url_image', $this->getPublicUrlImage(...)),
+ ];
+ }
+
+ /**
+ * Loop through all extensions implementing the Flysystem publicUrl() function.
+ */
+ public function getPublicUrl(ImageInterface $entity): ?string
+ {
+ foreach ($this->imageExtensionCollection->getExtensions() as $extension) {
+ if ($extension->supports($entity)) {
+ return $extension->getPublicUrl($entity);
+ }
+ }
+
+ throw new \LogicException('This entity is not managed by this function, add the case.');
+ }
+
+ /**
+ * Same as getPublicUrl() but for entities having multiple images associated.
+ */
+ public function getPublicUrlImage(ImagesInterface $entity, string $image): ?string
+ {
+ foreach ($this->imagesExtensionCollection->getExtensions() as $extension) {
+ if ($extension->supports($entity)) {
+ return $extension->getPublicUrl($image);
+ }
+ }
+
+ throw new \LogicException('This entity is not managed by this function, add the case.');
+ }
+}
diff --git a/src/Twig/FlysystemImageInterface.php b/src/Twig/FlysystemImageInterface.php
new file mode 100644
index 0000000..0847202
--- /dev/null
+++ b/src/Twig/FlysystemImageInterface.php
@@ -0,0 +1,14 @@
+ $extensions
+ */
+ public function __construct(
+ private readonly iterable $extensions,
+ ) {
+ }
+
+ /**
+ * @return iterable
+ */
+ public function getExtensions(): iterable
+ {
+ return $this->extensions;
+ }
+}
diff --git a/src/Twig/ImagesExtensionCollection.php b/src/Twig/ImagesExtensionCollection.php
new file mode 100644
index 0000000..e36e787
--- /dev/null
+++ b/src/Twig/ImagesExtensionCollection.php
@@ -0,0 +1,27 @@
+ $extensions
+ */
+ public function __construct(
+ private readonly iterable $extensions,
+ ) {
+ }
+
+ /**
+ * @return iterable
+ */
+ public function getExtensions(): iterable
+ {
+ return $this->extensions;
+ }
+}
diff --git a/src/Twig/MenuExtension.php b/src/Twig/MenuExtension.php
new file mode 100644
index 0000000..5034441
--- /dev/null
+++ b/src/Twig/MenuExtension.php
@@ -0,0 +1,33 @@
+defaultStorage->publicUrl((string) $menu->getImage());
+ }
+}
diff --git a/src/Twig/ProductExtension.php b/src/Twig/ProductExtension.php
new file mode 100755
index 0000000..1117475
--- /dev/null
+++ b/src/Twig/ProductExtension.php
@@ -0,0 +1,28 @@
+productStorage->publicUrl($image);
+ }
+}
diff --git a/src/Twig/TwigExtension.php b/src/Twig/TwigExtension.php
new file mode 100644
index 0000000..e5bca16
--- /dev/null
+++ b/src/Twig/TwigExtension.php
@@ -0,0 +1,34 @@
+
+ */
+ public function getFilters(): array
+ {
+ return [
+ new TwigFilter('snake', $this->snake(...)),
+ ];
+ }
+
+ public function snake(?string $sring): string
+ {
+ return u($sring)->snake()->toString();
+ }
+}
diff --git a/src/Twig/UserExtension.php b/src/Twig/UserExtension.php
new file mode 100644
index 0000000..0cd70b5
--- /dev/null
+++ b/src/Twig/UserExtension.php
@@ -0,0 +1,33 @@
+userStorage->publicUrl((string) $user->getImage());
+ }
+}
diff --git a/src/Validator/Constraints/Category/CategoryParentNotSelf.php b/src/Validator/Constraints/Category/CategoryParentNotSelf.php
new file mode 100644
index 0000000..0c177a2
--- /dev/null
+++ b/src/Validator/Constraints/Category/CategoryParentNotSelf.php
@@ -0,0 +1,18 @@
+hasForbiddenParent($value, $value->getParent())) {
+ $this->context->buildViolation($constraint->message)
+ ->atPath('parent')
+ ->addViolation();
+ }
+ }
+
+ /**
+ * Check recursivly that the parent or grand parent is not equal to the current
+ * category.
+ */
+ private function hasForbiddenParent(Category $group, ?Category $parent): bool
+ {
+ if ($group === $parent) {
+ return true;
+ }
+
+ if ($parent === null) {
+ return false;
+ }
+
+ return $this->hasForbiddenParent($group, $parent->getParent());
+ }
+}
diff --git a/src/Validator/Constraints/File.php b/src/Validator/Constraints/File.php
new file mode 100755
index 0000000..d4a267c
--- /dev/null
+++ b/src/Validator/Constraints/File.php
@@ -0,0 +1,18 @@
+hasForbiddenParent($value, $value->getParent())) {
+ $this->context->buildViolation($constraint->message)
+ ->atPath('parent')
+ ->addViolation();
+ }
+ }
+
+ /**
+ * Check recursivly that the parent or grand parent is not equal to the current
+ * group.
+ */
+ private function hasForbiddenParent(Group $group, ?Group $parent): bool
+ {
+ if ($group === $parent) {
+ return true;
+ }
+
+ if ($parent === null) {
+ return false;
+ }
+
+ return $this->hasForbiddenParent($group, $parent->getParent());
+ }
+}
diff --git a/src/Validator/Constraints/MenuItem/MenuItemParentNotSelf.php b/src/Validator/Constraints/MenuItem/MenuItemParentNotSelf.php
new file mode 100644
index 0000000..c3d0f2b
--- /dev/null
+++ b/src/Validator/Constraints/MenuItem/MenuItemParentNotSelf.php
@@ -0,0 +1,18 @@
+hasForbiddenParent($value, $value->getParent())) {
+ $this->context->buildViolation($constraint->message)
+ ->atPath('parent')
+ ->addViolation();
+ }
+ }
+
+ /**
+ * Check recursivly that the parent or grand parent is not equal to the current
+ * menu item.
+ */
+ private function hasForbiddenParent(MenuItem $menuItem, ?MenuItem $parent): bool
+ {
+ if ($menuItem === $parent) {
+ return true;
+ }
+
+ if ($parent === null) {
+ return false;
+ }
+
+ return $this->hasForbiddenParent($menuItem, $parent->getParent());
+ }
+}
diff --git a/src/Validator/Constraints/ServiceRequest/ProductAvailabilityNoOverlap.php b/src/Validator/Constraints/ServiceRequest/ProductAvailabilityNoOverlap.php
new file mode 100644
index 0000000..68c6ff6
--- /dev/null
+++ b/src/Validator/Constraints/ServiceRequest/ProductAvailabilityNoOverlap.php
@@ -0,0 +1,18 @@
+getProduct()->getUnavailabilities($value);
+
+ // compute effective dates for the new service request
+ $srPeriod = CarbonInterval::days(1)->toPeriod($value->getStartAt(), $value->getEndAt());
+ $srDays = array_map(static fn (CarbonInterface $date) => $date->format('Y-m-d'), $srPeriod->toArray());
+
+ // error if there is at least one day that overlap
+ if (\count(array_intersect($unavailabilities, $srDays)) > 0) {
+ $this->context->buildViolation($constraint->message)
+ ->atPath('startAt')
+ ->addViolation();
+ }
+ }
+}
diff --git a/src/Workflow/ServiceRequestStatusWorkflow.php b/src/Workflow/ServiceRequestStatusWorkflow.php
new file mode 100644
index 0000000..b995aeb
--- /dev/null
+++ b/src/Workflow/ServiceRequestStatusWorkflow.php
@@ -0,0 +1,183 @@
+accept($sr);
+ break;
+ case ServiceRequestStatusTransition::CONFIRM:
+ $this->confirm($sr);
+ break;
+ case ServiceRequestStatusTransition::MODIFY_OWNER:
+ $this->modifyOwner($sr);
+ break;
+ case ServiceRequestStatusTransition::MODIFY_RECIPIENT:
+ $this->modifyRecipient($sr);
+ break;
+ case ServiceRequestStatusTransition::REFUSE:
+ $this->refuse($sr);
+ break;
+ case ServiceRequestStatusTransition::FINALIZE:
+ $this->finalize($sr);
+ break;
+ }
+ }
+
+ private function getException(ServiceRequest $sr, ServiceRequestStatusTransition $transition): \LogicException
+ {
+ return new \LogicException(sprintf(self::EXCEPTION_MESSAGE, $transition->name, $sr->getId(), $sr->getStatus()->value));
+ }
+
+ public function canAccept(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::ACCEPT->value);
+ }
+
+ public function accept(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canAccept($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::ACCEPT);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::ACCEPT->value);
+
+ return $sr;
+ }
+
+ public function canModifyOwner(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::MODIFY_OWNER->value);
+ }
+
+ public function modifyOwner(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canModifyOwner($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::MODIFY_OWNER);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::MODIFY_OWNER->value);
+
+ return $sr;
+ }
+
+ public function canModifyRecipient(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::MODIFY_RECIPIENT->value);
+ }
+
+ public function modifyRecipient(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canModifyRecipient($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::MODIFY_RECIPIENT);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::MODIFY_RECIPIENT->value);
+
+ return $sr;
+ }
+
+ public function canConfirm(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::CONFIRM->value);
+ }
+
+ public function confirm(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canConfirm($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::CONFIRM);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::CONFIRM->value);
+
+ return $sr;
+ }
+
+ public function canRefuse(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::REFUSE->value);
+ }
+
+ public function refuse(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canRefuse($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::REFUSE);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::REFUSE->value);
+
+ return $sr;
+ }
+
+ public function canFinalize(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::FINALIZE->value);
+ }
+
+ public function finalize(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canFinalize($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::FINALIZE);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::FINALIZE->value);
+
+ return $sr;
+ }
+
+ public function canAutoFinalize(ServiceRequest $sr): bool
+ {
+ return $this->serviceRequestStatusStateMachine->can($sr, ServiceRequestStatusTransition::AUTO_FINALIZE->value);
+ }
+
+ public function autoFinalize(ServiceRequest $sr): ServiceRequest
+ {
+ if (!$this->canAutoFinalize($sr)) {
+ throw $this->getException($sr, ServiceRequestStatusTransition::AUTO_FINALIZE);
+ }
+
+ $this->serviceRequestStatusStateMachine->apply($sr, ServiceRequestStatusTransition::AUTO_FINALIZE->value);
+
+ return $sr;
+ }
+}
diff --git a/symfony.lock b/symfony.lock
new file mode 100644
index 0000000..2073928
--- /dev/null
+++ b/symfony.lock
@@ -0,0 +1,589 @@
+{
+ "api-platform/core": {
+ "version": "3.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "0330386d716d3eecc52ee5ac66976e733eb8f961"
+ },
+ "files": [
+ "config/routes/api_platform.yaml",
+ "src/ApiResource/.gitignore"
+ ]
+ },
+ "craue/formflow-bundle": {
+ "version": "3.6.0"
+ },
+ "doctrine/annotations": {
+ "version": "1.13",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.10",
+ "ref": "64d8583af5ea57b7afa4aba4b159907f3a148b05"
+ }
+ },
+ "doctrine/doctrine-bundle": {
+ "version": "2.7",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.4",
+ "ref": "da713d006953b90d1c085c1be480ecdd6c4a95e0"
+ },
+ "files": [
+ "config/packages/doctrine.yaml",
+ "src/Entity/.gitignore",
+ "src/Repository/.gitignore"
+ ]
+ },
+ "doctrine/doctrine-migrations-bundle": {
+ "version": "3.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.1",
+ "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
+ },
+ "files": [
+ "config/packages/doctrine_migrations.yaml",
+ "migrations/.gitignore"
+ ]
+ },
+ "easycorp/easyadmin-bundle": {
+ "version": "4.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "b131e6cbfe1b898a508987851963fff485986285"
+ }
+ },
+ "friendsofphp/php-cs-fixer": {
+ "version": "3.12",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "be2103eb4a20942e28a6dd87736669b757132435"
+ },
+ "files": [
+ ".php-cs-fixer.dist.php"
+ ]
+ },
+ "friendsofsymfony/ckeditor-bundle": {
+ "version": "2.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "2.0",
+ "ref": "f5ad42002183a6881962683e6d84bbb25cdfce5d"
+ },
+ "files": [
+ "config/packages/fos_ckeditor.yaml"
+ ]
+ },
+ "hautelook/alice-bundle": {
+ "version": "2.11",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.2",
+ "ref": "c84e4f2b9d7f436d7d52e8369230b393367607ec"
+ },
+ "files": [
+ "config/packages/hautelook_alice.yaml",
+ "fixtures/.gitignore"
+ ]
+ },
+ "knplabs/knp-paginator-bundle": {
+ "version": "v6.1.1"
+ },
+ "league/flysystem-bundle": {
+ "version": "3.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "913dc3d7a5a1af0d2b044c5ac3a16e2f851d7380"
+ },
+ "files": [
+ "config/packages/flysystem.yaml",
+ "var/storage/.gitignore"
+ ]
+ },
+ "nelmio/alice": {
+ "version": "3.12",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.3",
+ "ref": "42b52d2065dc3fde27912d502c18ca1926e35ae2"
+ },
+ "files": [
+ "config/packages/nelmio_alice.yaml"
+ ]
+ },
+ "nelmio/cors-bundle": {
+ "version": "2.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.5",
+ "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c"
+ },
+ "files": [
+ "config/packages/nelmio_cors.yaml"
+ ]
+ },
+ "nyholm/psr7": {
+ "version": "1.5",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "0cd4d2d0e7f646fda75f9944f747a56e6ed13d4c"
+ },
+ "files": [
+ "config/packages/nyholm_psr7.yaml"
+ ]
+ },
+ "odolbeau/phone-number-bundle": {
+ "version": "3.9",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "4388686329b81291918a948cd42891829fb1de71"
+ },
+ "files": [
+ "config/packages/misd_phone_number.yaml"
+ ]
+ },
+ "payum/payum-bundle": {
+ "version": "2.5",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "2.4",
+ "ref": "518ac22defa04a8a1d82479ed362e2921487adf0"
+ },
+ "files": [
+ "config/packages/payum.yaml"
+ ]
+ },
+ "php-http/discovery": {
+ "version": "1.18",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.18",
+ "ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
+ },
+ "files": [
+ "config/packages/http_discovery.yaml"
+ ]
+ },
+ "phpunit/phpunit": {
+ "version": "9.5",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "9.3",
+ "ref": "a6249a6c4392e9169b87abf93225f7f9f59025e6"
+ },
+ "files": [
+ ".env.test",
+ "phpunit.xml.dist",
+ "tests/bootstrap.php"
+ ]
+ },
+ "sensio/framework-extra-bundle": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.2",
+ "ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b"
+ },
+ "files": [
+ "config/packages/sensio_framework_extra.yaml"
+ ]
+ },
+ "snc/redis-bundle": {
+ "version": "4.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "2.0",
+ "ref": "36b3d9ab65be62de4e085a25e6ca899efa96b1f3"
+ },
+ "files": [
+ "config/packages/snc_redis.yaml"
+ ]
+ },
+ "stof/doctrine-extensions-bundle": {
+ "version": "1.7",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "1.2",
+ "ref": "e805aba9eff5372e2d149a9ff56566769e22819d"
+ },
+ "files": [
+ "config/packages/stof_doctrine_extensions.yaml"
+ ]
+ },
+ "symfony/console": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
+ },
+ "files": [
+ "bin/console"
+ ]
+ },
+ "symfony/debug-bundle": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b"
+ },
+ "files": [
+ "config/packages/debug.yaml"
+ ]
+ },
+ "symfony/fake-sms-notifier": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "70edf4dc13e3082d555575325207f0529ddd3bfe"
+ }
+ },
+ "symfony/flex": {
+ "version": "2.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
+ },
+ "files": [
+ ".env"
+ ]
+ },
+ "symfony/framework-bundle": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.4",
+ "ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb"
+ },
+ "files": [
+ "config/packages/cache.yaml",
+ "config/packages/framework.yaml",
+ "config/preload.php",
+ "config/routes/framework.yaml",
+ "config/services.yaml",
+ "public/index.php",
+ "src/Controller/.gitignore",
+ "src/Kernel.php"
+ ]
+ },
+ "symfony/google-mailer": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "4.4",
+ "ref": "f8fd4ddb9b477510f8f4bce2b9c054ab428c0120"
+ }
+ },
+ "symfony/mailer": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "4.3",
+ "ref": "97a61eabb351d7f6cb7702039bcfe07fe9d7e03c"
+ },
+ "files": [
+ "config/packages/mailer.yaml"
+ ]
+ },
+ "symfony/maker-bundle": {
+ "version": "1.47",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
+ }
+ },
+ "symfony/mercure-bundle": {
+ "version": "0.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "0.3",
+ "ref": "7c7e63c36530052a174f28a6be4e451c4709be83"
+ },
+ "files": [
+ "config/packages/mercure.yaml"
+ ]
+ },
+ "symfony/messenger": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.0",
+ "ref": "2523f7d31488903e247a522e760dc279be7f7aaf"
+ },
+ "files": [
+ "config/packages/messenger.yaml"
+ ]
+ },
+ "symfony/monolog-bundle": {
+ "version": "3.8",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.7",
+ "ref": "213676c4ec929f046dfde5ea8e97625b81bc0578"
+ },
+ "files": [
+ "config/packages/monolog.yaml"
+ ]
+ },
+ "symfony/notifier": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.0",
+ "ref": "c31585e252b32fe0e1f30b1f256af553f4a06eb9"
+ },
+ "files": [
+ "config/packages/notifier.yaml"
+ ]
+ },
+ "symfony/ovh-cloud-notifier": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.1",
+ "ref": "71809b3735a012a6359e9f48aa373b2680c3da54"
+ }
+ },
+ "symfony/panther": {
+ "version": "2.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "877d853a05c6713665a2f4b954734592680abff6"
+ }
+ },
+ "symfony/phpunit-bridge": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "97cb3dc7b0f39c7cfc4b7553504c9d7b7795de96"
+ },
+ "files": [
+ ".env.test",
+ "bin/phpunit",
+ "phpunit.xml.dist",
+ "tests/bootstrap.php"
+ ]
+ },
+ "symfony/requirements-checker": {
+ "version": "2.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.0",
+ "ref": "02c6e4b9b117c39e8a23eab7f3840ef6e62293b9"
+ }
+ },
+ "symfony/routing": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.1",
+ "ref": "a44010c0d06989bd4f154aa07d2542d47caf5b83"
+ },
+ "files": [
+ "config/packages/routing.yaml",
+ "config/routes.yaml"
+ ]
+ },
+ "symfony/security-bundle": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.0",
+ "ref": "8a5b112826f7d3d5b07027f93786ae11a1c7de48"
+ },
+ "files": [
+ "config/packages/security.yaml"
+ ]
+ },
+ "symfony/translation": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "da64f5a2b6d96f5dc24914517c0350a5f91dee43"
+ },
+ "files": [
+ "config/packages/translation.yaml",
+ "translations/.gitignore"
+ ]
+ },
+ "symfony/twig-bundle": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.4",
+ "ref": "bb2178c57eee79e6be0b297aa96fc0c0def81387"
+ },
+ "files": [
+ "config/packages/twig.yaml",
+ "templates/base.html.twig"
+ ]
+ },
+ "symfony/twilio-notifier": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.0",
+ "ref": "eb697fe6a90b4972a4a544b4f7ea56520d29d9e9"
+ }
+ },
+ "symfony/uid": {
+ "version": "6.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.2",
+ "ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
+ },
+ "files": [
+ "config/packages/uid.yaml"
+ ]
+ },
+ "symfony/ux-autocomplete": {
+ "version": "2.7",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.6",
+ "ref": "07d9602b7231ba355f484305d6cea58310c01741"
+ },
+ "files": [
+ "config/routes/ux_autocomplete.yaml"
+ ]
+ },
+ "symfony/validator": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "c32cfd98f714894c4f128bb99aa2530c1227603c"
+ },
+ "files": [
+ "config/packages/validator.yaml"
+ ]
+ },
+ "symfony/web-profiler-bundle": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.1",
+ "ref": "e42b3f0177df239add25373083a564e5ead4e13a"
+ },
+ "files": [
+ "config/packages/web_profiler.yaml",
+ "config/routes/web_profiler.yaml"
+ ]
+ },
+ "symfony/webpack-encore-bundle": {
+ "version": "1.16",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.10",
+ "ref": "5878c7c28468ca0fdce2497a04cfc66bab0dc3ef"
+ },
+ "files": [
+ "assets/app.js",
+ "assets/stimulus.js",
+ "assets/controllers.json",
+ "assets/controllers/hello_controller.js",
+ "assets/styles/app.css",
+ "config/packages/webpack_encore.yaml",
+ "package.json",
+ "webpack.config.js"
+ ]
+ },
+ "symfony/workflow": {
+ "version": "6.1",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.3",
+ "ref": "3b2f8ca32a07fcb00f899649053943fa3d8bbfb6"
+ },
+ "files": [
+ "config/packages/workflow.yaml"
+ ]
+ },
+ "theofidry/alice-data-fixtures": {
+ "version": "1.6",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "fe5a50faf580eb58f08ada2abe8afbd2d4941e05"
+ }
+ },
+ "twig/extra-bundle": {
+ "version": "v3.4.0"
+ },
+ "willdurand/geocoder-bundle": {
+ "version": "5.18",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "5.0",
+ "ref": "b272ad4fbfcd45a20e7cbfdc4ad1b0e27a62fd3b"
+ },
+ "files": [
+ "config/packages/bazinga_geocoder.yaml"
+ ]
+ },
+ "zenstruck/messenger-test": {
+ "version": "v1.5.1"
+ }
+}
diff --git a/templates/admin/dashboard.html.twig b/templates/admin/dashboard.html.twig
new file mode 100644
index 0000000..0e66af8
--- /dev/null
+++ b/templates/admin/dashboard.html.twig
@@ -0,0 +1,29 @@
+{# https://symfony.com/bundles/EasyAdminBundle/current/dashboards.html#content-page-template #}
+{% extends '@EasyAdmin/page/content.html.twig' %}
+
+{% trans_default_domain 'admin' %}
+
+{% block content_title %}Dashboard{% endblock %}
+
+{% block page_actions %}
+ {#
+ Some Action
+ #}
+{% endblock %}
+
+{% block main %}
+
+ {% if form is defined and not null %}
+ {{ form_label(form) }}
+ {% else %}
+
+ {% endif %}
+
+ {% if form is defined and not null %}
+ {{ form_widget(form) }}
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% if form is defined and not null %}
+ {{ form_errors(form) }}
+ {% endif %}
+
+ {% if form is defined and not null %}
+ {{ form_help(form) }}
+ {% endif %}
+
diff --git a/templates/components/form/_photo_preview.html.twig b/templates/components/form/_photo_preview.html.twig
new file mode 100644
index 0000000..8e6de15
--- /dev/null
+++ b/templates/components/form/_photo_preview.html.twig
@@ -0,0 +1,11 @@
+{% if isUser is defined and isUser == true %}
+
diff --git a/templates/components/layout/_footer.html.twig b/templates/components/layout/_footer.html.twig
new file mode 100644
index 0000000..4bda24e
--- /dev/null
+++ b/templates/components/layout/_footer.html.twig
@@ -0,0 +1,5 @@
+
+ {% include 'components/footer/_logo.html.twig' with {menu} %}
+ {% include 'components/footer/_links.html.twig' with links %}
+ {% include 'components/footer/_social_network.html.twig' with icons %}
+
diff --git a/templates/components/layout/_multiple_searchbar.html.twig b/templates/components/layout/_multiple_searchbar.html.twig
new file mode 100644
index 0000000..584419e
--- /dev/null
+++ b/templates/components/layout/_multiple_searchbar.html.twig
@@ -0,0 +1,5 @@
+
+{{ form_start(form) }}
+ {{ form_widget(form.product, {'attr': {'onChange': 'submit()'}}) }}
+ {{ form_widget(form.submit, {'attr': {'class': 'hidden'}}) }}
+{{ form_end(form) }}
diff --git a/templates/components/layout/_navbar.html.twig b/templates/components/layout/_navbar.html.twig
new file mode 100644
index 0000000..6d45702
--- /dev/null
+++ b/templates/components/layout/_navbar.html.twig
@@ -0,0 +1,127 @@
+{% set logged = is_granted('IS_AUTHENTICATED_REMEMBERED') %}
+{% set path = path('app_user_my_account') %}
+
+
diff --git a/templates/components/layout/_pagination.html.twig b/templates/components/layout/_pagination.html.twig
new file mode 100644
index 0000000..efc3cf5
--- /dev/null
+++ b/templates/components/layout/_pagination.html.twig
@@ -0,0 +1,5 @@
+{% if pagination is defined %}
+
+
+ {# CAROUSEL #}
+ {% include 'components/product/_carousel.html.twig' with {
+ product,
+ category,
+ } %}
+
+ {# CITY #}
+ {% set city = product.owner.address ? product.owner.address.subAndlocality : '' %}
+ {% if city is not empty %}
+
+
+ {{ city }}
+
+
+ {% endif %}
+
+ {# DESCRIPTION #}
+ {% if product.description is not empty %}
+
+
+ {% if product.description|length > 150 %}
+
+
{{ product.description|nl2br }}
+
+ {{ 'product.info_more'|trans }}
+ {% else %}
+
{{ product.description|nl2br }}
+ {% endif %}
+
+
+ {% endif %}
+
+ {# AGE #}
+ {% if product.age is not null %}
+
+
+ {# TODO: Remove this translation form 'security' group #}
+
+{% endif %}
+{# ## Custom preview END ############################################# #}
diff --git a/templates/easy_admin/crud/_js_upload_size_validation.html.twig b/templates/easy_admin/crud/_js_upload_size_validation.html.twig
new file mode 100644
index 0000000..9c6cc77
--- /dev/null
+++ b/templates/easy_admin/crud/_js_upload_size_validation.html.twig
@@ -0,0 +1,21 @@
+{# ## Custom JS validation ################################################### #}
+{# This is a specific error div for the client side validation #}
+{# It works for botth single and multiple uploads #}
+{# @todo create a stimulus controller #}
+
+
diff --git a/templates/easy_admin/crud/form_theme.html.twig b/templates/easy_admin/crud/form_theme.html.twig
new file mode 100755
index 0000000..9466cc2
--- /dev/null
+++ b/templates/easy_admin/crud/form_theme.html.twig
@@ -0,0 +1,77 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @see src/Resources/views/crud/form_theme.html.twig #}
+{# @see vendor/easycorp/easyadmin-bundle/src/Resources/views/crud/form_theme.html.twig #}
+
+{# ## Modifictions are only in the include of /easy_admin/* custom templates #}
+{# ## All modifictions should be in partial only #}
+
+{% block ea_fileupload_widget %}
+
+ {# We don't use the existing EA currentFiles variable as it doesn't contains correct values with the cloud storage (prod env) #}
+ {# because EA tries to create UploadedFile instances from the local path, and it doesn't find anything. #}
+ {# Instead, we can just get the images path from our entities and use the custom flysystem path helpers #}
+
+ {% set entity = ea.entity.instance %}
+ {% set current_files = is_images_entity(entity) ? entity.images : [entity.image] %}
+ {% set current_files = (current_files ?? [])|filter(v => v is not null) %} {# no error with new image #}
+
+
+
+ {% set placeholder = t('action.choose_file', {}, 'EasyAdminBundle') %}
+ {% set title = '' %}
+ {% set files_label = 'files'|trans({}, 'EasyAdminBundle') %}
+ {% set placeholder = (current_files|length) > 0 ? current_files|length ~ ' ' ~ files_label : placeholder %}
+
+ {% if current_files is not empty and form.vars.valid %}
+
+
+
+ {# If multiple file is not acticated we just have one file #}
+ {% for file in current_files %}
+ {# get public URL of resource depending on if it has multiple images associated or not #}
+ {% set public_url = is_images_entity(entity) ? entity|public_url_image(file) : entity|public_url %}
+
+ {{ form_errors(form.file) }}
+
+ {% include 'easy_admin/crud/_js_upload_size_validation.html.twig' %}
+{% endblock %}
diff --git a/templates/easy_admin/field/boolean.html.twig b/templates/easy_admin/field/boolean.html.twig
new file mode 100644
index 0000000..e724e0f
--- /dev/null
+++ b/templates/easy_admin/field/boolean.html.twig
@@ -0,0 +1,7 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
+{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
+{# @see vendor/easycorp/easyadmin-bundle/src/Resources/views/crud/field/boolean.html.twig #}
+{% trans_default_domain 'EasyAdminBundle' %}
+
+ {{ field.value == true ? '✅' : '❌' }}
diff --git a/templates/easy_admin/field/boolean_check_only.html.twig b/templates/easy_admin/field/boolean_check_only.html.twig
new file mode 100644
index 0000000..f054377
--- /dev/null
+++ b/templates/easy_admin/field/boolean_check_only.html.twig
@@ -0,0 +1,7 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
+{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
+{# @see vendor/easycorp/easyadmin-bundle/src/Resources/views/crud/field/boolean.html.twig #}
+{% trans_default_domain 'EasyAdminBundle' %}
+
+ {{ field.value == true ? '✅' : '' }}
diff --git a/templates/easy_admin/field/flysystem_image.html.twig b/templates/easy_admin/field/flysystem_image.html.twig
new file mode 100644
index 0000000..fb29771
--- /dev/null
+++ b/templates/easy_admin/field/flysystem_image.html.twig
@@ -0,0 +1,20 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
+{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
+{% set images = field.formattedValue %}
+{% if images is not iterable %}
+ {% set images = [images] %}
+{% endif %}
+
+{% for image in images %}
+ {% if image is not empty %}
+ {% set html_id = 'ea-lightbox-' ~ field.uniqueId ~ '-' ~ loop.index %}
+
+
+
+
+
+
+
+ {% endif %}
+{% endfor %}
diff --git a/templates/easy_admin/field/flysystem_images.html.twig b/templates/easy_admin/field/flysystem_images.html.twig
new file mode 100644
index 0000000..89708ae
--- /dev/null
+++ b/templates/easy_admin/field/flysystem_images.html.twig
@@ -0,0 +1,27 @@
+{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
+{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
+{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
+
+{# We need the raw value here (an array because the extensions handle the public_url() prefix #}
+{% set images = field.value %}
+{% if images is not iterable %}
+ {% set images = [images] %}
+{% endif %}
+
+{# Check if we want to display one image or multiple ones #}
+{% if field.customOption('first_image_only') == true %}
+ {% set images = images|slice(0, 1) %}
+{% endif %}
+
+{% for image in images %}
+ {% if image is not empty %}
+ {% set html_id = 'ea-lightbox-' ~ field.uniqueId ~ '-' ~ loop.index %}
+
+
+
+
+
+ Un nouveau message concernant la demande de service du
+ {{ service_request.endAt|date('format.date'|trans({}, 'date')) }} ({{ service_request.product.name }})
+ a été envoyé par {{ message.sender.displayName }}.
+
diff --git a/templates/email/service_request/new.html.twig b/templates/email/service_request/new.html.twig
new file mode 100644
index 0000000..90df3da
--- /dev/null
+++ b/templates/email/service_request/new.html.twig
@@ -0,0 +1,17 @@
+{% trans_default_domain 'email' %}
+
+{# @todo : use translations #}
+
+{% set url = url('app_user_my_lendings') %}
+
+
Bonjour !
+
+
+ Une nouvelle demande d'emprunt du {{ service_request.startAt|date('d/m/Y') }} au
+ {{ service_request.endAt|date('d/m/Y') }} concernant votre objet
+ {{ service_request.product.name }} a été créé par {{ service_request.recipient.displayName }}.
+
+{% endblock %}
+
+{% block link %}{% endblock %}
diff --git a/templates/pages/group/show.html.twig b/templates/pages/group/show.html.twig
new file mode 100644
index 0000000..024e604
--- /dev/null
+++ b/templates/pages/group/show.html.twig
@@ -0,0 +1,61 @@
+{% extends 'layout/base.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{# User logged? #}
+{% set logged = is_granted('IS_AUTHENTICATED_REMEMBERED') %}
+
+{# Is the group public? #}
+{% set is_public = group.type.isPublic %}
+
+{# Get the current status of the user for the current group #}
+{% set group_membership = logged ? app.user.getGroupMembership(group) : null %}
+{% set has_group_membership = group_membership != null %}
+
+{# Can we display the show members button? #}
+{% set display_show_members_button = is_public or (has_group_membership and group_membership.membership.isConfirmed) %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: group.name
+ } %}
+
+
+
+ {% include 'components/layout/_text.html.twig' with {
+ text: group.description
+ } %}
+
+ {# 1. logged but not member and no pending invitaton #}
+ {% if logged and not has_group_membership %}
+ {% include 'pages/group/show/_logged_without_link.html.twig' %}
+
+ {# 2. logged and has a link with the group #}
+ {% elseif logged and has_group_membership %}
+ {% include 'pages/group/show/_logged_with_link.html.twig' %}
+
+ {# 3. not logged, display the link to login and come back to this page #}
+ {% else %}
+
+ {# 1.1 group is free and public, we can join it #}
+ {% if is_public and group.membership.isFree %}
+
+
+ {# 1.2 group is not free, we have to pay, display the offers #}
+ {% elseif group.hasActiveOffers %}
+
+ {% include 'components/group/_first_offer.html.twig' with {group} %}
+ {% include 'components/group/_modal_offers.html.twig' with {
+ offers: group.activeOffers
+ } %}
+
+ {# 1.3 group is not correclty configured, it doesn't have any offer configured yet. #}
+ {% else %}
+
+ {{ form_end(form) }}
+{% endblock %}
+
+{% block link %}{% endblock %}
diff --git a/templates/pages/product/edit_object.html.twig b/templates/pages/product/edit_object.html.twig
new file mode 100644
index 0000000..27d54fa
--- /dev/null
+++ b/templates/pages/product/edit_object.html.twig
@@ -0,0 +1,37 @@
+{% extends 'layout/base.html.twig' %}
+
+{% form_theme form 'bootstrap_5_layout.html.twig' %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: 'edit_object.form.title'|trans
+ } %}
+ {% if info is defined and not null %}
+ {% include 'components/layout/_text.html.twig' with {
+ text: info
+ } %}
+ {% endif %}
+
+
+ {{ form_start(form) }}
+ {% include 'components/product/_object_fields.html.twig' %}
+ {% if product.images is not empty %}
+
+ {% for image in product.images %}
+ {% include 'components/form/_photo_preview.html.twig' with {
+ image,
+ entity: product,
+ } %}
+ {% endfor %}
+
+ {% endif %}
+ {% include 'components/product/_address_form.html.twig' %}
+
+ {{ form_widget(form.submit) }}
+
+ {{ form_end(form) }}
+
+
+
+{% endblock %}
diff --git a/templates/pages/product/edit_service.html.twig b/templates/pages/product/edit_service.html.twig
new file mode 100644
index 0000000..377e247
--- /dev/null
+++ b/templates/pages/product/edit_service.html.twig
@@ -0,0 +1,41 @@
+{% extends 'layout/base.html.twig' %}
+
+{% form_theme form 'bootstrap_5_layout.html.twig' %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: 'edit_service.form.title'|trans
+ } %}
+ {% if info is defined and not null %}
+ {% include 'components/layout/_text.html.twig' with {
+ text: info
+ } %}
+ {% endif %}
+
+
+ {{ form_start(form, {
+ attr: {
+ novalidate: true,
+ }
+ }) }}
+ {% include 'components/product/_service_fields.html.twig' %}
+ {% if product.images is not empty %}
+
+ {% for image in product.images %}
+ {% include 'components/form/_photo_preview.html.twig' with {
+ image,
+ entity: product,
+ } %}
+ {% endfor %}
+
+ {% endif %}
+ {% include 'components/product/_address_form.html.twig' %}
+ {% include 'components/product/service_request/_submit.html.twig' with {
+ submit: form.submit
+ } %}
+ {{ form_end(form) }}
+
+ {% include 'components/layout/_title_3.html.twig' with {name: 'product.list_name'|trans} %}
+ {% include 'components/product/_search.html.twig' with {form: search_form} %}
+ {% include 'components/product/_section.html.twig'with {objects_pagination, services_pagination} %}
+ {% include 'components/product/_tab_content.html.twig' with {objects_pagination, services_pagination} %}
+
+{% endblock %}
+
+{% block link %}{% endblock %}
diff --git a/templates/pages/product/new_object.html.twig b/templates/pages/product/new_object.html.twig
new file mode 100644
index 0000000..b93b6bd
--- /dev/null
+++ b/templates/pages/product/new_object.html.twig
@@ -0,0 +1,37 @@
+{% extends 'layout/base.html.twig' %}
+
+{% form_theme form 'bootstrap_5_layout.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: (i18n_prefix ~ '.title')|trans
+ } %}
+ {% if info is defined and not null %}
+ {% include 'components/layout/_text.html.twig' with {
+ text: info
+ } %}
+ {% endif %}
+
+ {% include 'components/layout/_title_5.html.twig' with {
+ title: 'new_product.available'|trans,
+ centerRow: true,
+ colClass: 'col-md-8 col-lg-6 col-xl-5'
+ } %}
+
+
+
+ {{ form_start(form) }}
+ {% include 'components/product/_object_fields.html.twig' %}
+ {% include 'components/product/_address_form.html.twig' %}
+ {% include 'components/product/service_request/_submit.html.twig' with {
+ submit: form.submit
+ } %}
+ {{ form_end(form) }}
+
+
+
+{% endblock %}
+{% block link %}{% endblock %}
diff --git a/templates/pages/product/new_service.html.twig b/templates/pages/product/new_service.html.twig
new file mode 100644
index 0000000..58f5f23
--- /dev/null
+++ b/templates/pages/product/new_service.html.twig
@@ -0,0 +1,42 @@
+{% extends 'layout/base.html.twig' %}
+
+{% form_theme form 'bootstrap_5_layout.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: (i18n_prefix ~ '.title')|trans
+ } %}
+ {% if info is defined and not null %}
+ {% include 'components/layout/_text.html.twig' with {
+ text: info
+ } %}
+ {% endif %}
+
+ {% include 'components/layout/_title_5.html.twig' with {
+ title: 'new_product.available'|trans,
+ centerRow: true,
+ colClass: 'col-md-8 col-lg-6 col-xl-5'
+ } %}
+
+
+{% endblock %}
+
+{% block link %}{% endblock %}
diff --git a/templates/pages/user/account/edit_profile.html.twig b/templates/pages/user/account/edit_profile.html.twig
new file mode 100644
index 0000000..2ada201
--- /dev/null
+++ b/templates/pages/user/account/edit_profile.html.twig
@@ -0,0 +1,70 @@
+{% extends 'layout/base.html.twig' %}
+
+{% form_theme form 'bootstrap_5_layout.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{% block error %}{% endblock %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: (i18n_prefix ~ '.title')|trans
+ } %}
+
+
+ {{ form_start(form, {
+ attr: {
+ novalidate: true,
+ }
+ }) }}
+ {% if form.firstname is defined %}
+ {{ form_row(form.firstname) }}
+ {% endif %}
+
+ {% if form.lastname is defined %}
+ {{ form_row(form.lastname) }}
+ {% endif %}
+
+ {# place field #}
+ {% if form.name is defined %}
+ {{ form_row(form.name) }}
+ {% endif %}
+
+ {{ form_row(form.avatar) }}
+ {% if app.user.avatar is not null %}
+ {% include 'components/form/_photo_preview.html.twig' with {
+ image: app.user.avatar,
+ entity: app.user,
+ isUser: true,
+ } %}
+ {% endif %}
+
+ {# place field #}
+ {% if form.schedule is defined %}
+ {{ form_row(form.schedule) }}
+ {% endif %}
+
+ {% if form.category is defined %}
+ {{ form_row(form.category) }}
+ {% endif %}
+
+ {% if form.description is defined %}
+ {{ form_row(form.description) }}
+ {% endif %}
+
+ {{ form_row(form.phone) }}
+
+
+ {{ form_widget(form.smsNotifications) }}
+
+
+
+ {{ form_widget(form.submit) }}
+
+ {{ form_end(form) }}
+
+
+
+{% endblock %}
+{% block link %}{% endblock %}
diff --git a/templates/pages/user/account/profile.html.twig b/templates/pages/user/account/profile.html.twig
new file mode 100644
index 0000000..b814169
--- /dev/null
+++ b/templates/pages/user/account/profile.html.twig
@@ -0,0 +1,64 @@
+{% extends 'layout/base.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{% block body %}
+
+
+ {% if user.avatar is null %}
+
+ {% else %}
+
+
+ {% endif %}
+
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: user.getDisplayName,
+ rowClass: 'mt-3'
+ } %}
+
+ {% if user.category is not null %}
+ {% if user.category.parent is not null %}
+ {% set category = user.category.parent ~ ' / ' ~ user.category %}
+ {% else %}
+ {% set category = user.category %}
+ {% endif %}
+ {% include 'components/layout/_text.html.twig' with {
+ text: category,
+ textAlign: 'text-center'
+ } %}
+ {% endif %}
+ {% if user.address is not null %}
+
+
+ {{ user.address.subAndlocality }}
+
+
+ {% endif %}
+ {% if user.description is not null %}
+ {% include 'components/layout/_text.html.twig' with {
+ margin: 'mt-3',
+ text: user.description,
+ } %}
+ {% endif %}
+
+ {% if objects_pagination is empty and services_pagination is empty %}
+
+ {{ (i18n_prefix ~ '.no_result')|trans }}
+
+ {% else %}
+ {% include 'components/product/_section.html.twig' %}
+ {% include 'components/product/_tab_content.html.twig' with {objects_pagination, services_pagination} %}
+ {% endif %}
+
+{% endblock %}
+{% block link %}{% endblock %}
diff --git a/templates/pages/user/group/list.html.twig b/templates/pages/user/group/list.html.twig
new file mode 100644
index 0000000..9cb646f
--- /dev/null
+++ b/templates/pages/user/group/list.html.twig
@@ -0,0 +1,79 @@
+{% extends 'layout/base.html.twig' %}
+
+{% set i18n_prefix = _self|i18n_prefix %}
+
+{% block body %}
+
+ {% include 'components/layout/_title_3.html.twig' with {
+ name: (i18n_prefix ~ '.title')|trans
+ } %}
+
+ {# include 'components/layout/_searchbar.html.twig' with {
+ col: 'col col-lg-5',
+ } #}
+
+
+ {% if app.user.userGroups is not empty %}
+ {% if not app.user.getMyUserGroupsAsInvited.empty %}
+
+ {# user invitations to groups #}
+ {% include 'components/layout/_title_5.html.twig' with {
+ title: (i18n_prefix ~ '.user_invitations')|trans,
+ rowClass: 'justify-content-center mt-4'
+ } %}
+
+
+ {% for item in app.user.getMyUserGroupsAsInvited %}
+
+ {% include 'components/group/_list-content.html.twig' with {
+ item,
+ needAction: true,
+ isAdmin: item.membership.isAdmin()
+ } %}
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+ {% if not app.user.getMyUserGroupsAsConfirmed.empty %}
+
+ {# user groups #}
+ {% include 'components/layout/_title_5.html.twig' with {
+ title: (i18n_prefix ~ '.user_groups')|trans,
+ rowClass: 'justify-content-center'
+ } %}
+
+
+ {% for item in app.user.getMyUserGroupsAsConfirmed %}
+