mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-11-04 03:08:25 +00:00 
			
		
		
		
	Compare commits
	
		
			23 Commits
		
	
	
		
			migrate_to
			...
			451-activi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0f03413383 | |||
| 9c2abb2dfa | |||
| 94744b9542 | |||
| f42bb498e4 | |||
| 01889ac671 | |||
| 62e5842311 | |||
| 
						
						
							
						
						8ad6f397a8
	
				 | 
					
					
						|||
| d713704633 | |||
| b1fa9242a0 | |||
| 6ac554f93a | |||
| 372d8e5825 | |||
| 10f05e5559 | |||
| ddb2a65419 | |||
| 8d40a8089f | |||
| e1bf4a24d2 | |||
| 208a378185 | |||
| 9089c8959b | |||
| 
						
						
							
						
						1b9b581c31
	
				 | 
					
					
						|||
| aa1abe4c88 | |||
| d82c9cc9a7 | |||
| a7e3b1c5d2 | |||
| 84cf11933d | |||
| bc2fbee5c6 | 
							
								
								
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
kind: DX
 | 
			
		||||
body: |
 | 
			
		||||
    Send notifications log to dedicated channel, if it exists
 | 
			
		||||
time: 2025-10-27T15:00:53.309372316+01:00
 | 
			
		||||
custom:
 | 
			
		||||
    Issue: ""
 | 
			
		||||
    SchemaChange: No schema change
 | 
			
		||||
@@ -1,6 +0,0 @@
 | 
			
		||||
kind: Fixed
 | 
			
		||||
body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
 | 
			
		||||
time: 2025-10-03T22:40:44.685474863+02:00
 | 
			
		||||
custom:
 | 
			
		||||
    Issue: ""
 | 
			
		||||
    SchemaChange: No schema change
 | 
			
		||||
							
								
								
									
										6
									
								
								.changes/unreleased/Fixed-20251029-124355.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/Fixed-20251029-124355.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
			
		||||
kind: Fixed
 | 
			
		||||
body: 'Fix: display also social actions linked to parents of the selected social issue'
 | 
			
		||||
time: 2025-10-29T12:43:55.008647232+01:00
 | 
			
		||||
custom:
 | 
			
		||||
    Issue: "451"
 | 
			
		||||
    SchemaChange: No schema change
 | 
			
		||||
							
								
								
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
			
		||||
## v4.6.0 - 2025-10-15
 | 
			
		||||
### Feature
 | 
			
		||||
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed   
 | 
			
		||||
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"   
 | 
			
		||||
### Fixed
 | 
			
		||||
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present   
 | 
			
		||||
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted   
 | 
			
		||||
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists   
 | 
			
		||||
* Fix loading of social issues and social actions within vue component   
 | 
			
		||||
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames   
 | 
			
		||||
 | 
			
		||||
  **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
 | 
			
		||||
* [workflow] take permissions into account to delete the workflow attachment   
 | 
			
		||||
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid   
 | 
			
		||||
							
								
								
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
## v4.6.1 - 2025-10-27
 | 
			
		||||
### Fixed
 | 
			
		||||
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php   
 | 
			
		||||
@@ -240,9 +240,6 @@ The tests are run from the project's root (not from the bundle's root).
 | 
			
		||||
# Run all tests
 | 
			
		||||
vendor/bin/phpunit
 | 
			
		||||
 | 
			
		||||
# Run tests for a specific bundle
 | 
			
		||||
vendor/bin/phpunit --testsuite NameBundle
 | 
			
		||||
 | 
			
		||||
# Run a specific test file
 | 
			
		||||
vendor/bin/phpunit path/to/TestFile.php
 | 
			
		||||
 | 
			
		||||
@@ -250,6 +247,9 @@ vendor/bin/phpunit path/to/TestFile.php
 | 
			
		||||
vendor/bin/phpunit --filter methodName path/to/TestFile.php
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
When writing tests, only test specific files. Do not run all tests or the full
 | 
			
		||||
test suite.
 | 
			
		||||
 | 
			
		||||
#### Test Structure
 | 
			
		||||
 | 
			
		||||
Tests are organized by bundle and follow the same structure as the bundle itself:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							@@ -6,6 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
 | 
			
		||||
and is generated by [Changie](https://github.com/miniscruff/changie).
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## v4.6.1 - 2025-10-27
 | 
			
		||||
### Fixed
 | 
			
		||||
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php   
 | 
			
		||||
 | 
			
		||||
## v4.6.0 - 2025-10-15
 | 
			
		||||
### Feature
 | 
			
		||||
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed   
 | 
			
		||||
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"   
 | 
			
		||||
### Fixed
 | 
			
		||||
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present   
 | 
			
		||||
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted   
 | 
			
		||||
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists   
 | 
			
		||||
* Fix loading of social issues and social actions within vue component   
 | 
			
		||||
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames   
 | 
			
		||||
 | 
			
		||||
  **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
 | 
			
		||||
* [workflow] take permissions into account to delete the workflow attachment   
 | 
			
		||||
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid   
 | 
			
		||||
 | 
			
		||||
## v4.5.1 - 2025-10-03
 | 
			
		||||
### Fixed
 | 
			
		||||
* Add missing javascript dependency   
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,13 @@
 | 
			
		||||
chill_main:
 | 
			
		||||
    available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
 | 
			
		||||
    available_countries: ['BE', 'FR']
 | 
			
		||||
    top_banner:
 | 
			
		||||
        visible: false
 | 
			
		||||
        text:
 | 
			
		||||
            fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
 | 
			
		||||
            nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
 | 
			
		||||
        color: '#353535'
 | 
			
		||||
        background_color: '#d8bb48'
 | 
			
		||||
    notifications:
 | 
			
		||||
        from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
 | 
			
		||||
        from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
 | 
			
		||||
 
 | 
			
		||||
@@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt
 | 
			
		||||
 | 
			
		||||
    public function getFormDefaultData(): array
 | 
			
		||||
    {
 | 
			
		||||
        return [];
 | 
			
		||||
        return [
 | 
			
		||||
            'reasons' => [],
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array
 | 
			
		||||
 
 | 
			
		||||
@@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
 | 
			
		||||
    public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
 | 
			
		||||
    {
 | 
			
		||||
        error_log('alterQuery called with data: '.json_encode(array_keys($data)));
 | 
			
		||||
 | 
			
		||||
        // create a subquery for activity
 | 
			
		||||
        $sqb = $qb->getEntityManager()->createQueryBuilder();
 | 
			
		||||
        $sqb->select('1')
 | 
			
		||||
@@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
        if (\in_array('activity', $qb->getAllAliases(), true)) {
 | 
			
		||||
            $sqb->andWhere('activity_person_having_activity.id = activity.id');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (isset($data['reasons']) && [] !== $data['reasons']) {
 | 
			
		||||
            // add clause activity reason
 | 
			
		||||
            $sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
 | 
			
		||||
@@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
 | 
			
		||||
    public function normalizeFormData(array $formData): array
 | 
			
		||||
    {
 | 
			
		||||
        return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()];
 | 
			
		||||
        $normalized = [
 | 
			
		||||
            'date_from_rolling' => $formData['date_from_rolling']->normalize(),
 | 
			
		||||
            'date_to_rolling' => $formData['date_to_rolling']->normalize(),
 | 
			
		||||
            'reasons' => [],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if (isset($formData['reasons']) && [] !== $formData['reasons']) {
 | 
			
		||||
            $normalized['reasons'] = array_map(
 | 
			
		||||
                fn (ActivityReason $reason) => $reason->getId(),
 | 
			
		||||
                $formData['reasons']
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $normalized;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function denormalizeFormData(array $formData, int $fromVersion): array
 | 
			
		||||
    {
 | 
			
		||||
        return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])];
 | 
			
		||||
        $denormalized = [
 | 
			
		||||
            'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']),
 | 
			
		||||
            'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']),
 | 
			
		||||
            'reasons' => [],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if (isset($formData['reasons']) && [] !== $formData['reasons']) {
 | 
			
		||||
            $denormalized['reasons'] = array_map(
 | 
			
		||||
                fn ($id) => $this->activityReasonRepository->find($id),
 | 
			
		||||
                $formData['reasons']
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $denormalized;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function getFormDefaultData(): array
 | 
			
		||||
@@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
 | 
			
		||||
    public function describeAction($data, ExportGenerationContext $context): array
 | 
			
		||||
    {
 | 
			
		||||
        $reasons = $data['reasons'] ?? [];
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            [] === $data['reasons'] ?
 | 
			
		||||
                'export.filter.person_between_dates.describe_action_with_no_subject'
 | 
			
		||||
                : 'export.filter.person_between_dates.describe_action_with_subject',
 | 
			
		||||
            [] === $reasons ?
 | 
			
		||||
                'export.filter.activity.describe_action_with_no_subject'
 | 
			
		||||
                : 'export.filter.activity.describe_action_with_subject',
 | 
			
		||||
            [
 | 
			
		||||
                'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
 | 
			
		||||
                'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
 | 
			
		||||
@@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
                    ', ',
 | 
			
		||||
                    array_map(
 | 
			
		||||
                        fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
 | 
			
		||||
                        $data['reasons']
 | 
			
		||||
                        $reasons
 | 
			
		||||
                    )
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
@@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
 | 
			
		||||
 | 
			
		||||
    public function validateForm($data, ExecutionContextInterface $context): void
 | 
			
		||||
    {
 | 
			
		||||
        error_log('validateForm called with data: '.json_encode(array_keys($data)));
 | 
			
		||||
        if ($this->rollingDateConverter->convert($data['date_from_rolling'])
 | 
			
		||||
            >= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
 | 
			
		||||
            $context->buildViolation('export.filter.activity.person_between_dates.date mismatch')
 | 
			
		||||
 
 | 
			
		||||
@@ -136,8 +136,14 @@ export default {
 | 
			
		||||
            issueIsLoading: false,
 | 
			
		||||
            actionIsLoading: false,
 | 
			
		||||
            actionAreLoaded: false,
 | 
			
		||||
            socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
 | 
			
		||||
            socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
 | 
			
		||||
            socialIssuesClassList: {
 | 
			
		||||
                "col-form-label": true,
 | 
			
		||||
                required: false,
 | 
			
		||||
            },
 | 
			
		||||
            socialActionsClassList: {
 | 
			
		||||
                "col-form-label": true,
 | 
			
		||||
                required: false,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    },
 | 
			
		||||
    computed: {
 | 
			
		||||
@@ -158,6 +164,21 @@ export default {
 | 
			
		||||
        },
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
        /* Load classNames after element is present */
 | 
			
		||||
        const socialActionsEl = document.querySelector(
 | 
			
		||||
            "input#chill_activitybundle_activity_socialActions",
 | 
			
		||||
        );
 | 
			
		||||
        if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
 | 
			
		||||
            this.socialActionsClassList.required = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const socialIssuesEl = document.querySelector(
 | 
			
		||||
            "input#chill_activitybundle_activity_socialIssues",
 | 
			
		||||
        );
 | 
			
		||||
        if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
 | 
			
		||||
            this.socialIssuesClassList.required = true;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        /* Load other issues in multiselect */
 | 
			
		||||
        this.issueIsLoading = true;
 | 
			
		||||
        this.actionAreLoaded = false;
 | 
			
		||||
 
 | 
			
		||||
@@ -23,10 +23,14 @@ use Random\RandomException;
 | 
			
		||||
 * Store each version of StoredObject's.
 | 
			
		||||
 *
 | 
			
		||||
 * A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
 | 
			
		||||
 *
 | 
			
		||||
 * Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
 | 
			
		||||
 * newly created versions when this new index is applied.
 | 
			
		||||
 */
 | 
			
		||||
#[ORM\Entity]
 | 
			
		||||
#[ORM\Table('chill_doc.stored_object_version')]
 | 
			
		||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
 | 
			
		||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
 | 
			
		||||
class StoredObjectVersion implements TrackCreationInterface
 | 
			
		||||
{
 | 
			
		||||
    use TrackCreationTrait;
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc {
 | 
			
		||||
    context: "accompanying-period";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isGenericDocForAccompanyingPeriod(
 | 
			
		||||
    doc: GenericDoc,
 | 
			
		||||
): doc is GenericDocForAccompanyingPeriod {
 | 
			
		||||
    return doc.context === "accompanying-period";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isGenericDocWithStoredObject(
 | 
			
		||||
    doc: GenericDoc,
 | 
			
		||||
): doc is GenericDoc & { storedObject: StoredObject } {
 | 
			
		||||
    return doc.storedObject !== null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface BaseMetadataWithHtml extends BaseMetadata {
 | 
			
		||||
    html: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument
 | 
			
		||||
    extends GenericDocForAccompanyingPeriod {
 | 
			
		||||
    key: "accompanying_course_document";
 | 
			
		||||
    metadata: BaseMetadataWithHtml;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GenericDocForAccompanyingCourseActivityDocument
 | 
			
		||||
    extends GenericDocForAccompanyingPeriod {
 | 
			
		||||
    key: "accompanying_course_activity_document";
 | 
			
		||||
    metadata: BaseMetadataWithHtml;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GenericDocForAccompanyingCourseCalendarDocument
 | 
			
		||||
    extends GenericDocForAccompanyingPeriod {
 | 
			
		||||
    key: "accompanying_course_calendar_document";
 | 
			
		||||
    metadata: BaseMetadataWithHtml;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GenericDocForAccompanyingCoursePersonDocument
 | 
			
		||||
    extends GenericDocForAccompanyingPeriod {
 | 
			
		||||
    key: "person_document";
 | 
			
		||||
    metadata: BaseMetadataWithHtml;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
 | 
			
		||||
    extends GenericDocForAccompanyingPeriod {
 | 
			
		||||
    key: "accompanying_period_work_evaluation_document";
 | 
			
		||||
    metadata: BaseMetadataWithHtml;
 | 
			
		||||
    storedObject: StoredObject;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,63 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\Migrations\DocStore;
 | 
			
		||||
 | 
			
		||||
use Doctrine\DBAL\Schema\Schema;
 | 
			
		||||
use Doctrine\Migrations\AbstractMigration;
 | 
			
		||||
 | 
			
		||||
final class Version20251013094414 extends AbstractMigration
 | 
			
		||||
{
 | 
			
		||||
    public function getDescription(): string
 | 
			
		||||
    {
 | 
			
		||||
        return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function up(Schema $schema): void
 | 
			
		||||
    {
 | 
			
		||||
        // 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row
 | 
			
		||||
        //    and delete all others. Use ROW_NUMBER over id DESC to define the last one.
 | 
			
		||||
        $this->addSql(<<<'SQL'
 | 
			
		||||
            WITH ranked AS (
 | 
			
		||||
                SELECT id,
 | 
			
		||||
                       rank() OVER (
 | 
			
		||||
                           PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb
 | 
			
		||||
                           ORDER BY id DESC
 | 
			
		||||
                       ) AS rn
 | 
			
		||||
                FROM chill_doc.stored_object_version
 | 
			
		||||
            )
 | 
			
		||||
            DELETE FROM chill_doc.stored_object_version sov
 | 
			
		||||
            USING ranked r
 | 
			
		||||
            WHERE sov.id = r.id
 | 
			
		||||
              AND r.rn > 1
 | 
			
		||||
        SQL);
 | 
			
		||||
 | 
			
		||||
        // 2) Create a partial unique index on filename that applies only to subsequently inserted rows.
 | 
			
		||||
        //    Per user's instruction, compute the cutoff using the stored_object_id sequence value.
 | 
			
		||||
        $nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')");
 | 
			
		||||
 | 
			
		||||
        // Safety: if somehow sequence is not available, fallback to current max id from the table
 | 
			
		||||
        if ($nextVal <= 0) {
 | 
			
		||||
            $nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        $this->addSql(sprintf(
 | 
			
		||||
            'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d',
 | 
			
		||||
            $nextVal
 | 
			
		||||
        ));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function down(Schema $schema): void
 | 
			
		||||
    {
 | 
			
		||||
        // Drop the partial unique index; data cleanup is irreversible.
 | 
			
		||||
        $this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command
 | 
			
		||||
 | 
			
		||||
    protected function loadUsers()
 | 
			
		||||
    {
 | 
			
		||||
        $reader = Reader::createFromPath($this->tempInput->getArgument('csvfile'));
 | 
			
		||||
        $reader = Reader::from($this->tempInput->getArgument('csvfile'));
 | 
			
		||||
        $reader->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
        foreach ($reader->getRecords() as $line => $r) {
 | 
			
		||||
@@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command
 | 
			
		||||
 | 
			
		||||
    protected function prepareGroupingCenters()
 | 
			
		||||
    {
 | 
			
		||||
        $reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers'));
 | 
			
		||||
        $reader = Reader::from($this->tempInput->getOption('grouping-centers'));
 | 
			
		||||
        $reader->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
        foreach ($reader->getRecords() as $r) {
 | 
			
		||||
@@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command
 | 
			
		||||
 | 
			
		||||
    protected function prepareWriter()
 | 
			
		||||
    {
 | 
			
		||||
        $this->output = $output = Writer::createFromPath($this->tempInput
 | 
			
		||||
        $this->output = $output = Writer::from($this->tempInput
 | 
			
		||||
            ->getOption('csv-dump'), 'a+');
 | 
			
		||||
 | 
			
		||||
        $output->insertOne([
 | 
			
		||||
 
 | 
			
		||||
@@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command
 | 
			
		||||
    protected function getReader()
 | 
			
		||||
    {
 | 
			
		||||
        try {
 | 
			
		||||
            $reader = Reader::createFromPath($this->input->getArgument('csvfile'));
 | 
			
		||||
            $reader = Reader::from($this->input->getArgument('csvfile'));
 | 
			
		||||
        } catch (\Exception $e) {
 | 
			
		||||
            $this->logger->error('The csv file could not be read', [
 | 
			
		||||
                'path' => $this->input->getArgument('csvfile'),
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,7 @@ final readonly class UserExportController
 | 
			
		||||
 | 
			
		||||
        $users = $this->userRepository->findAllAsArray($request->getLocale());
 | 
			
		||||
 | 
			
		||||
        $csv = Writer::createFromPath('php://temp', 'r+');
 | 
			
		||||
        $csv = Writer::from('php://temp', 'r+');
 | 
			
		||||
        $csv->insertOne(
 | 
			
		||||
            array_map(
 | 
			
		||||
                fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
 | 
			
		||||
@@ -104,7 +104,7 @@ final readonly class UserExportController
 | 
			
		||||
 | 
			
		||||
        $userPermissions = $this->userRepository->findAllUserACLAsArray();
 | 
			
		||||
 | 
			
		||||
        $csv = Writer::createFromPath('php://temp', 'r+');
 | 
			
		||||
        $csv = Writer::from('php://temp', 'r+');
 | 
			
		||||
        $csv->insertOne(
 | 
			
		||||
            array_map(
 | 
			
		||||
                fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
 | 
			
		||||
 
 | 
			
		||||
@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
 | 
			
		||||
    {
 | 
			
		||||
        $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
 | 
			
		||||
 | 
			
		||||
        $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
 | 
			
		||||
        $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
 | 
			
		||||
        $paginator = $this->paginatorFactory->create($total);
 | 
			
		||||
 | 
			
		||||
        $workflows = $this->entityWorkflowRepository->findBySubscriber(
 | 
			
		||||
            $this->security->getUser(),
 | 
			
		||||
            false,
 | 
			
		||||
            ['createdAt' => 'DESC'],
 | 
			
		||||
            $paginator->getItemsPerPage(),
 | 
			
		||||
            $paginator->getCurrentPageFirstItemNumber()
 | 
			
		||||
 
 | 
			
		||||
@@ -205,6 +205,11 @@ class ChillMainExtension extends Extension implements
 | 
			
		||||
                []
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        $container->setParameter(
 | 
			
		||||
            'chill_main.top_banner',
 | 
			
		||||
            $config['top_banner'] ?? []
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
 | 
			
		||||
        $loader->load('services.yaml');
 | 
			
		||||
        $loader->load('services/doctrine.yaml');
 | 
			
		||||
@@ -250,6 +255,7 @@ class ChillMainExtension extends Extension implements
 | 
			
		||||
                    'name' => $config['installation_name'], ],
 | 
			
		||||
                'available_languages' => $config['available_languages'],
 | 
			
		||||
                'add_address' => $config['add_address'],
 | 
			
		||||
                'chill_main_config' => $config,
 | 
			
		||||
            ],
 | 
			
		||||
            'form_themes' => ['@ChillMain/Form/fields.html.twig'],
 | 
			
		||||
        ];
 | 
			
		||||
 
 | 
			
		||||
@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
 | 
			
		||||
            ->end()
 | 
			
		||||
            ->end()
 | 
			
		||||
            ->end()
 | 
			
		||||
            ->arrayNode('top_banner')
 | 
			
		||||
            ->canBeUnset()
 | 
			
		||||
            ->children()
 | 
			
		||||
            ->booleanNode('visible')
 | 
			
		||||
            ->defaultFalse()
 | 
			
		||||
            ->end()
 | 
			
		||||
            ->arrayNode('text')
 | 
			
		||||
            ->useAttributeAsKey('lang')
 | 
			
		||||
            ->scalarPrototype()->end()
 | 
			
		||||
            ->end() // end of text
 | 
			
		||||
            ->scalarNode('color')->defaultNull()->end()
 | 
			
		||||
            ->scalarNode('background_color')->defaultNull()->end()
 | 
			
		||||
            ->end() // end of top_banner children
 | 
			
		||||
            ->end() // end of top_banner
 | 
			
		||||
            ->arrayNode('widgets')
 | 
			
		||||
            ->canBeEnabled()
 | 
			
		||||
            ->canBeUnset()
 | 
			
		||||
 
 | 
			
		||||
@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
 | 
			
		||||
    public function run(array $lastExecutionData): ?array
 | 
			
		||||
    {
 | 
			
		||||
        $now = $this->clock->now();
 | 
			
		||||
 | 
			
		||||
        if (isset($lastExecutionData['last_execution'])) {
 | 
			
		||||
            $lastExecution = \DateTimeImmutable::createFromFormat(
 | 
			
		||||
                \DateTimeImmutable::ATOM,
 | 
			
		||||
                $lastExecutionData['last_execution']
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
            if (false === $lastExecution) {
 | 
			
		||||
                $lastExecution = $now->sub(new \DateInterval('P1D'));
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            $lastExecution = $now->sub(new \DateInterval('P1D'));
 | 
			
		||||
        }
 | 
			
		||||
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        return [
 | 
			
		||||
            'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
 | 
			
		||||
            'last_execution' => $now->format(\DateTimeInterface::ATOM),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
 | 
			
		||||
        return (int) $qb->getQuery()->getSingleScalarResult();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function countBySubscriber(User $user): int
 | 
			
		||||
    /**
 | 
			
		||||
     * @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
 | 
			
		||||
     *
 | 
			
		||||
     * @throws \Doctrine\ORM\NoResultException
 | 
			
		||||
     * @throws \Doctrine\ORM\NonUniqueResultException
 | 
			
		||||
     */
 | 
			
		||||
    public function countBySubscriber(User $user, ?bool $isFinal = null): int
 | 
			
		||||
    {
 | 
			
		||||
        $qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
 | 
			
		||||
        $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
 | 
			
		||||
 | 
			
		||||
        return (int) $qb->getQuery()->getSingleScalarResult();
 | 
			
		||||
    }
 | 
			
		||||
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
 | 
			
		||||
        return $qb->getQuery()->getResult();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
 | 
			
		||||
    /**
 | 
			
		||||
     * @param bool|null  $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
 | 
			
		||||
     * @param mixed|null $limit
 | 
			
		||||
     * @param mixed|null $offset
 | 
			
		||||
     */
 | 
			
		||||
    public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
 | 
			
		||||
    {
 | 
			
		||||
        $qb = $this->buildQueryBySubscriber($user)->select('ew');
 | 
			
		||||
        $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
 | 
			
		||||
 | 
			
		||||
        foreach ($orderBy as $key => $sort) {
 | 
			
		||||
            $qb->addOrderBy('ew.'.$key, $sort);
 | 
			
		||||
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
 | 
			
		||||
        return $qb;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private function buildQueryBySubscriber(User $user): QueryBuilder
 | 
			
		||||
    private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
 | 
			
		||||
    {
 | 
			
		||||
        $qb = $this->repository->createQueryBuilder('ew');
 | 
			
		||||
 | 
			
		||||
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
 | 
			
		||||
 | 
			
		||||
        $qb->setParameter('user', $user);
 | 
			
		||||
 | 
			
		||||
        if (null !== $isFinal) {
 | 
			
		||||
            if ($isFinal) {
 | 
			
		||||
                $qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
 | 
			
		||||
            } else {
 | 
			
		||||
                $qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return $qb;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,7 @@
 | 
			
		||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
 | 
			
		||||
import {
 | 
			
		||||
    GenericDoc,
 | 
			
		||||
    isGenericDocWithStoredObject,
 | 
			
		||||
} from "ChillDocStoreAssets/types/generic_doc";
 | 
			
		||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
 | 
			
		||||
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
 | 
			
		||||
 | 
			
		||||
@@ -203,6 +206,25 @@ export interface WorkflowAttachment {
 | 
			
		||||
    genericDoc: null | GenericDoc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type AttachmentWithDocAndStored = WorkflowAttachment & {
 | 
			
		||||
    genericDoc: GenericDoc & { storedObject: StoredObject };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function isAttachmentWithDocAndStored(
 | 
			
		||||
    a: WorkflowAttachment,
 | 
			
		||||
): a is AttachmentWithDocAndStored {
 | 
			
		||||
    return (
 | 
			
		||||
        isWorkflowAttachmentWithGenericDoc(a) &&
 | 
			
		||||
        isGenericDocWithStoredObject(a.genericDoc)
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isWorkflowAttachmentWithGenericDoc(
 | 
			
		||||
    attachment: WorkflowAttachment,
 | 
			
		||||
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
 | 
			
		||||
    return attachment.genericDoc !== null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface Workflow {
 | 
			
		||||
    name: string;
 | 
			
		||||
    text: string;
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/gener
 | 
			
		||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
 | 
			
		||||
import { GenericDoc } from "ChillDocStoreAssets/types";
 | 
			
		||||
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
 | 
			
		||||
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
 | 
			
		||||
 | 
			
		||||
interface AppConfig {
 | 
			
		||||
    workflowId: number;
 | 
			
		||||
@@ -83,7 +84,7 @@ const canEditAttachement = computed<boolean>(() => {
 | 
			
		||||
    <ul v-if="canEditAttachement" class="record_actions">
 | 
			
		||||
        <li>
 | 
			
		||||
            <button type="button" class="btn btn-create" @click="openModal">
 | 
			
		||||
                Ajouter une pièce jointe
 | 
			
		||||
                {{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
 | 
			
		||||
            </button>
 | 
			
		||||
        </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,14 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
 | 
			
		||||
import {
 | 
			
		||||
    AttachmentWithDocAndStored,
 | 
			
		||||
    EntityWorkflow,
 | 
			
		||||
    isAttachmentWithDocAndStored,
 | 
			
		||||
    WorkflowAttachment,
 | 
			
		||||
} from "ChillMainAssets/types";
 | 
			
		||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
 | 
			
		||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
 | 
			
		||||
 | 
			
		||||
interface AttachmentListProps {
 | 
			
		||||
    attachments: WorkflowAttachment[];
 | 
			
		||||
@@ -14,35 +21,43 @@ const emit = defineEmits<{
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const props = defineProps<AttachmentListProps>();
 | 
			
		||||
 | 
			
		||||
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
 | 
			
		||||
    props.attachments.filter(
 | 
			
		||||
        (a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
 | 
			
		||||
            isAttachmentWithDocAndStored(a),
 | 
			
		||||
    ),
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const canRemove = computed<boolean>((): boolean => {
 | 
			
		||||
    if (null === props.workflow) {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
    <p
 | 
			
		||||
        v-if="props.attachments.length === 0"
 | 
			
		||||
        v-if="notNullAttachments.length === 0"
 | 
			
		||||
        class="chill-no-data-statement text-center"
 | 
			
		||||
    >
 | 
			
		||||
        Aucune pièce jointe
 | 
			
		||||
        {{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
 | 
			
		||||
    </p>
 | 
			
		||||
    <!-- TODO translate -->
 | 
			
		||||
    <div else class="flex-table">
 | 
			
		||||
        <div v-for="a in props.attachments" :key="a.id" class="item-bloc">
 | 
			
		||||
    <div v-else class="flex-table">
 | 
			
		||||
        <div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
 | 
			
		||||
            <generic-doc-item-box
 | 
			
		||||
                v-if="a.genericDoc !== null"
 | 
			
		||||
                :generic-doc="a.genericDoc"
 | 
			
		||||
            ></generic-doc-item-box>
 | 
			
		||||
            <div class="item-row separator">
 | 
			
		||||
                <ul class="record_actions">
 | 
			
		||||
                    <li v-if="a.genericDoc?.storedObject !== null">
 | 
			
		||||
                    <li>
 | 
			
		||||
                        <document-action-buttons-group
 | 
			
		||||
                            :stored-object="a.genericDoc.storedObject"
 | 
			
		||||
                        ></document-action-buttons-group>
 | 
			
		||||
                    </li>
 | 
			
		||||
                    <li
 | 
			
		||||
                        v-if="
 | 
			
		||||
                            !workflow?._permissions
 | 
			
		||||
                                .CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT
 | 
			
		||||
                        "
 | 
			
		||||
                    >
 | 
			
		||||
                    <li v-if="canRemove">
 | 
			
		||||
                        <button
 | 
			
		||||
                            type="button"
 | 
			
		||||
                            class="btn btn-delete"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,8 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
 | 
			
		||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
 | 
			
		||||
 | 
			
		||||
interface GenericDocItemBoxProps {
 | 
			
		||||
    genericDoc: GenericDocForAccompanyingPeriod;
 | 
			
		||||
    genericDoc: GenericDoc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const props = defineProps<GenericDocItemBoxProps>();
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,17 @@
 | 
			
		||||
{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %}
 | 
			
		||||
    {% set banner_text = '' %}
 | 
			
		||||
    {% set current_locale = app.request.locale %}
 | 
			
		||||
 | 
			
		||||
    {% if chill_main_config.top_banner.text[current_locale] is defined %}
 | 
			
		||||
        {% set banner_text = chill_main_config.top_banner.text[current_locale] %}
 | 
			
		||||
    {% else %}
 | 
			
		||||
        {% set banner_text = chill_main_config.top_banner.text|first %}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% if banner_text %}
 | 
			
		||||
        <div class="top-banner w-100 text-center py-2"
 | 
			
		||||
             style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}">
 | 
			
		||||
            {{ banner_text }}
 | 
			
		||||
        </div>
 | 
			
		||||
    {% endif %}
 | 
			
		||||
{% endif %}
 | 
			
		||||
@@ -21,8 +21,6 @@
 | 
			
		||||
    {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }}
 | 
			
		||||
    {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }}
 | 
			
		||||
 | 
			
		||||
    {{ form_row(form.addressesEmails) }}
 | 
			
		||||
 | 
			
		||||
    {% include handler.template(notification) with handler.templateData(notification) %}
 | 
			
		||||
 | 
			
		||||
    <div class="mb-3 row">
 | 
			
		||||
 
 | 
			
		||||
@@ -26,6 +26,10 @@
 | 
			
		||||
</head>
 | 
			
		||||
 | 
			
		||||
<body>
 | 
			
		||||
    {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %}
 | 
			
		||||
        {{ include('@ChillMain/Layout/_top_banner.html.twig') }}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 | 
			
		||||
    {% if responsive_debug is defined and responsive_debug == 1 %}
 | 
			
		||||
        {{ include('@ChillMain/Layout/_debug.html.twig') }}
 | 
			
		||||
    {% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress
 | 
			
		||||
 | 
			
		||||
        $uncompressedStream = gzopen($tmpname, 'r');
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($uncompressedStream);
 | 
			
		||||
        $csv = Reader::from($uncompressedStream);
 | 
			
		||||
        $csv->setDelimiter(',');
 | 
			
		||||
        $csv->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -287,7 +287,7 @@ final class AddressReferenceBaseImporter
 | 
			
		||||
 | 
			
		||||
            $filename = sprintf('%s-%s.csv', (new \DateTimeImmutable())->format('Ymd-His'), uniqid());
 | 
			
		||||
            $path = Path::normalize(sprintf('%s%s%s', sys_get_temp_dir(), DIRECTORY_SEPARATOR, $filename));
 | 
			
		||||
            $writer = Writer::createFromPath($path, 'w+');
 | 
			
		||||
            $writer = Writer::from($path, 'w+');
 | 
			
		||||
            // insert headers
 | 
			
		||||
            $writer->insertOne([
 | 
			
		||||
                'postalcode',
 | 
			
		||||
 
 | 
			
		||||
@@ -53,7 +53,7 @@ class AddressReferenceFromBAN
 | 
			
		||||
        // re-open it to read it
 | 
			
		||||
        $csvDecompressed = gzopen($path, 'r');
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($csvDecompressed);
 | 
			
		||||
        $csv = Reader::from($csvDecompressed);
 | 
			
		||||
        $csv->setDelimiter(';')->setHeaderOffset(0);
 | 
			
		||||
        $stmt = new Statement();
 | 
			
		||||
        $stmt = $stmt->process($csv, [
 | 
			
		||||
 
 | 
			
		||||
@@ -41,7 +41,7 @@ class AddressReferenceFromBano
 | 
			
		||||
 | 
			
		||||
        fseek($file, 0);
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($file);
 | 
			
		||||
        $csv = Reader::from($file);
 | 
			
		||||
        $csv->setDelimiter(',');
 | 
			
		||||
        $stmt = new Statement();
 | 
			
		||||
        $stmt = $stmt->process($csv, [
 | 
			
		||||
 
 | 
			
		||||
@@ -39,7 +39,7 @@ class AddressReferenceLU
 | 
			
		||||
 | 
			
		||||
        fseek($file, 0);
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($file);
 | 
			
		||||
        $csv = Reader::from($file);
 | 
			
		||||
        $csv->setDelimiter(';');
 | 
			
		||||
        $csv->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress
 | 
			
		||||
 | 
			
		||||
        $uncompressedStream = gzopen($tmpname, 'r');
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($uncompressedStream);
 | 
			
		||||
        $csv = Reader::from($uncompressedStream);
 | 
			
		||||
        $csv->setDelimiter(',');
 | 
			
		||||
        $csv->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData
 | 
			
		||||
 | 
			
		||||
        fseek($tmpfile, 0);
 | 
			
		||||
 | 
			
		||||
        $csv = Reader::createFromStream($tmpfile);
 | 
			
		||||
        $csv = Reader::from($tmpfile);
 | 
			
		||||
        $csv->setDelimiter(',');
 | 
			
		||||
        $csv->setHeaderOffset(0);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent;
 | 
			
		||||
final readonly class SentMessageEventSubscriber implements EventSubscriberInterface
 | 
			
		||||
{
 | 
			
		||||
    public function __construct(
 | 
			
		||||
        private LoggerInterface $logger,
 | 
			
		||||
        private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists
 | 
			
		||||
    ) {}
 | 
			
		||||
 | 
			
		||||
    public static function getSubscribedEvents()
 | 
			
		||||
@@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf
 | 
			
		||||
        $message = $event->getMessage();
 | 
			
		||||
 | 
			
		||||
        if (null === $message->getMessageId()) {
 | 
			
		||||
            $this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
 | 
			
		||||
            $this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]);
 | 
			
		||||
        } else {
 | 
			
		||||
            $this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
 | 
			
		||||
            $this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,98 @@
 | 
			
		||||
<?php
 | 
			
		||||
 | 
			
		||||
declare(strict_types=1);
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
 * Chill is a software for social workers
 | 
			
		||||
 *
 | 
			
		||||
 * For the full copyright and license information, please view
 | 
			
		||||
 * the LICENSE file that was distributed with this source code.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
namespace Chill\MainBundle\Tests\DependencyInjection;
 | 
			
		||||
 | 
			
		||||
use Chill\MainBundle\DependencyInjection\Configuration;
 | 
			
		||||
use PHPUnit\Framework\TestCase;
 | 
			
		||||
use Symfony\Component\Config\Definition\Processor;
 | 
			
		||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @internal
 | 
			
		||||
 *
 | 
			
		||||
 * @coversNothing
 | 
			
		||||
 */
 | 
			
		||||
class ConfigurationTest extends TestCase
 | 
			
		||||
{
 | 
			
		||||
    public function testTopBannerConfiguration(): void
 | 
			
		||||
    {
 | 
			
		||||
        $containerBuilder = new ContainerBuilder();
 | 
			
		||||
        $configuration = new Configuration([], $containerBuilder);
 | 
			
		||||
        $processor = new Processor();
 | 
			
		||||
 | 
			
		||||
        // Test with top_banner configuration
 | 
			
		||||
        $config = [
 | 
			
		||||
            'chill_main' => [
 | 
			
		||||
                'top_banner' => [
 | 
			
		||||
                    'text' => [
 | 
			
		||||
                        'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.',
 | 
			
		||||
                        'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.',
 | 
			
		||||
                    ],
 | 
			
		||||
                    'color' => 'white',
 | 
			
		||||
                    'background-color' => 'red',
 | 
			
		||||
                ],
 | 
			
		||||
            ],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        $processedConfig = $processor->processConfiguration($configuration, $config);
 | 
			
		||||
 | 
			
		||||
        self::assertArrayHasKey('top_banner', $processedConfig);
 | 
			
		||||
        self::assertArrayHasKey('text', $processedConfig['top_banner']);
 | 
			
		||||
        self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']);
 | 
			
		||||
        self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']);
 | 
			
		||||
        self::assertSame('white', $processedConfig['top_banner']['color']);
 | 
			
		||||
        self::assertSame('red', $processedConfig['top_banner']['background_color']);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function testTopBannerConfigurationOptional(): void
 | 
			
		||||
    {
 | 
			
		||||
        $containerBuilder = new ContainerBuilder();
 | 
			
		||||
        $configuration = new Configuration([], $containerBuilder);
 | 
			
		||||
        $processor = new Processor();
 | 
			
		||||
 | 
			
		||||
        // Test without top_banner configuration
 | 
			
		||||
        $config = [
 | 
			
		||||
            'chill_main' => [],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        $processedConfig = $processor->processConfiguration($configuration, $config);
 | 
			
		||||
 | 
			
		||||
        // top_banner should not be present when not configured
 | 
			
		||||
        self::assertArrayNotHasKey('top_banner', $processedConfig);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function testTopBannerWithMinimalConfiguration(): void
 | 
			
		||||
    {
 | 
			
		||||
        $containerBuilder = new ContainerBuilder();
 | 
			
		||||
        $configuration = new Configuration([], $containerBuilder);
 | 
			
		||||
        $processor = new Processor();
 | 
			
		||||
 | 
			
		||||
        // Test with minimal top_banner configuration (only text)
 | 
			
		||||
        $config = [
 | 
			
		||||
            'chill_main' => [
 | 
			
		||||
                'top_banner' => [
 | 
			
		||||
                    'text' => [
 | 
			
		||||
                        'fr' => 'Test message',
 | 
			
		||||
                    ],
 | 
			
		||||
                ],
 | 
			
		||||
            ],
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        $processedConfig = $processor->processConfiguration($configuration, $config);
 | 
			
		||||
 | 
			
		||||
        self::assertArrayHasKey('top_banner', $processedConfig);
 | 
			
		||||
        self::assertArrayHasKey('text', $processedConfig['top_banner']);
 | 
			
		||||
        self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']);
 | 
			
		||||
        self::assertNull($processedConfig['top_banner']['color']);
 | 
			
		||||
        self::assertNull($processedConfig['top_banner']['background_color']);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase
 | 
			
		||||
        $actual = $this->dailyNotificationDigestCronjob->run([]);
 | 
			
		||||
 | 
			
		||||
        self::assertArrayHasKey('last_execution', $actual);
 | 
			
		||||
        self::assertInstanceOf(
 | 
			
		||||
            \DateTimeImmutable::class,
 | 
			
		||||
            \DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']),
 | 
			
		||||
            'test that the string can be converted to a date'
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -12,16 +12,21 @@ declare(strict_types=1);
 | 
			
		||||
namespace Chill\MainBundle\Tests\Notification\Email;
 | 
			
		||||
 | 
			
		||||
use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob;
 | 
			
		||||
use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage;
 | 
			
		||||
use Doctrine\DBAL\Connection;
 | 
			
		||||
use Doctrine\DBAL\Result;
 | 
			
		||||
use Doctrine\DBAL\Statement;
 | 
			
		||||
use PHPUnit\Framework\TestCase;
 | 
			
		||||
use Psr\Log\LoggerInterface;
 | 
			
		||||
use Symfony\Component\Clock\ClockInterface;
 | 
			
		||||
use Symfony\Component\Clock\MockClock;
 | 
			
		||||
use Symfony\Component\Messenger\Envelope;
 | 
			
		||||
use Symfony\Component\Messenger\MessageBusInterface;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @internal
 | 
			
		||||
 *
 | 
			
		||||
 * @coversNothing
 | 
			
		||||
 * @covers \DailyNotificationDigestCronjob
 | 
			
		||||
 */
 | 
			
		||||
class DailyNotificationDigestCronJobTest extends TestCase
 | 
			
		||||
{
 | 
			
		||||
@@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase
 | 
			
		||||
    private MessageBusInterface $messageBus;
 | 
			
		||||
    private LoggerInterface $logger;
 | 
			
		||||
    private DailyNotificationDigestCronjob $cronjob;
 | 
			
		||||
    private \DateTimeImmutable $firstNow;
 | 
			
		||||
 | 
			
		||||
    protected function setUp(): void
 | 
			
		||||
    {
 | 
			
		||||
@@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase
 | 
			
		||||
        $this->messageBus = $this->createMock(MessageBusInterface::class);
 | 
			
		||||
        $this->logger = $this->createMock(LoggerInterface::class);
 | 
			
		||||
 | 
			
		||||
        $this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00');
 | 
			
		||||
 | 
			
		||||
        $this->cronjob = new DailyNotificationDigestCronjob(
 | 
			
		||||
            $this->clock,
 | 
			
		||||
            $this->connection,
 | 
			
		||||
@@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase
 | 
			
		||||
            'hour 23 - should not run' => [23, false],
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function testRunFirstExecutionReturnsStateAndDispatches(): array
 | 
			
		||||
    {
 | 
			
		||||
        // Use MockClock for deterministic time
 | 
			
		||||
        $firstNow = $this->firstNow;
 | 
			
		||||
        $clock = new MockClock($firstNow);
 | 
			
		||||
 | 
			
		||||
        // Mock DBAL statement/result
 | 
			
		||||
        $statement = $this->createMock(Statement::class);
 | 
			
		||||
        $result = $this->createMock(Result::class);
 | 
			
		||||
 | 
			
		||||
        $this->connection->method('prepare')->willReturn($statement);
 | 
			
		||||
        $statement->method('bindValue')->willReturnSelf();
 | 
			
		||||
        $statement->method('executeQuery')->willReturn($result);
 | 
			
		||||
 | 
			
		||||
        $rows = [
 | 
			
		||||
            ['user_id' => 10],
 | 
			
		||||
            ['user_id' => 42],
 | 
			
		||||
        ];
 | 
			
		||||
        $result->method('fetchAllAssociative')->willReturn($rows);
 | 
			
		||||
 | 
			
		||||
        $dispatched = [];
 | 
			
		||||
        $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) {
 | 
			
		||||
            $dispatched[] = $message;
 | 
			
		||||
 | 
			
		||||
            return new Envelope($message);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
 | 
			
		||||
        $state = $cron->run([]);
 | 
			
		||||
 | 
			
		||||
        // Assert dispatch count and message contents
 | 
			
		||||
        self::assertCount(2, $dispatched);
 | 
			
		||||
        $expectedLast = $firstNow->sub(new \DateInterval('P1D'));
 | 
			
		||||
        foreach ($dispatched as $i => $msg) {
 | 
			
		||||
            self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
 | 
			
		||||
            self::assertTrue(in_array($msg->getUserId(), [10, 42], true));
 | 
			
		||||
            self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
 | 
			
		||||
            self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Assert returned state
 | 
			
		||||
        self::assertIsArray($state);
 | 
			
		||||
        self::assertArrayHasKey('last_execution', $state);
 | 
			
		||||
        self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']);
 | 
			
		||||
 | 
			
		||||
        return $state;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @depends testRunFirstExecutionReturnsStateAndDispatches
 | 
			
		||||
     */
 | 
			
		||||
    public function testRunSecondExecutionUsesPreviousState(array $previousState): void
 | 
			
		||||
    {
 | 
			
		||||
        $firstNow = $this->firstNow;
 | 
			
		||||
        $secondNow = $firstNow->add(new \DateInterval('P1D'));
 | 
			
		||||
        $clock = new MockClock($secondNow);
 | 
			
		||||
 | 
			
		||||
        // Mock DBAL for a single user this time
 | 
			
		||||
        $statement = $this->createMock(Statement::class);
 | 
			
		||||
        $result = $this->createMock(Result::class);
 | 
			
		||||
 | 
			
		||||
        $this->connection->method('prepare')->willReturn($statement);
 | 
			
		||||
        $statement->method('bindValue')->willReturnSelf();
 | 
			
		||||
        $statement->method('executeQuery')->willReturn($result);
 | 
			
		||||
 | 
			
		||||
        $rows = [
 | 
			
		||||
            ['user_id' => 7],
 | 
			
		||||
        ];
 | 
			
		||||
        $result->method('fetchAllAssociative')->willReturn($rows);
 | 
			
		||||
 | 
			
		||||
        $captured = [];
 | 
			
		||||
        $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
 | 
			
		||||
            $captured[] = $message;
 | 
			
		||||
 | 
			
		||||
            return new Envelope($message);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
 | 
			
		||||
        $cron->run($previousState);
 | 
			
		||||
 | 
			
		||||
        self::assertCount(1, $captured);
 | 
			
		||||
        $msg = $captured[0];
 | 
			
		||||
        self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
 | 
			
		||||
        self::assertEquals(7, $msg->getUserId());
 | 
			
		||||
        self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date');
 | 
			
		||||
        self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public function testRunWithInvalidExecutionState(): void
 | 
			
		||||
    {
 | 
			
		||||
        $firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels');
 | 
			
		||||
        $previousExpected = $firstNow->sub(new \DateInterval('P1D'));
 | 
			
		||||
        $clock = new MockClock($firstNow);
 | 
			
		||||
 | 
			
		||||
        // Mock DBAL for a single user this time
 | 
			
		||||
        $statement = $this->createMock(Statement::class);
 | 
			
		||||
        $result = $this->createMock(Result::class);
 | 
			
		||||
 | 
			
		||||
        $this->connection->method('prepare')->willReturn($statement);
 | 
			
		||||
        $statement->method('bindValue')->willReturnSelf();
 | 
			
		||||
        $statement->method('executeQuery')->willReturn($result);
 | 
			
		||||
 | 
			
		||||
        $rows = [
 | 
			
		||||
            ['user_id' => 7],
 | 
			
		||||
        ];
 | 
			
		||||
        $result->method('fetchAllAssociative')->willReturn($rows);
 | 
			
		||||
 | 
			
		||||
        $captured = [];
 | 
			
		||||
        $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) {
 | 
			
		||||
            $captured[] = $message;
 | 
			
		||||
 | 
			
		||||
            return new Envelope($message);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger);
 | 
			
		||||
        $cron->run(['last_execution' => 'invalid data']);
 | 
			
		||||
 | 
			
		||||
        self::assertCount(1, $captured);
 | 
			
		||||
        $msg = $captured[0];
 | 
			
		||||
        self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg);
 | 
			
		||||
        self::assertEquals(7, $msg->getUserId());
 | 
			
		||||
        self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date');
 | 
			
		||||
        self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -670,6 +670,8 @@ workflow:
 | 
			
		||||
 | 
			
		||||
    attachments:
 | 
			
		||||
        title: Pièces jointes
 | 
			
		||||
        no_attachment: Aucune pièce jointe
 | 
			
		||||
        Add_an_attachment: Ajouter une pièce jointe
 | 
			
		||||
 | 
			
		||||
    wait:
 | 
			
		||||
        title: En attente de traitement
 | 
			
		||||
 
 | 
			
		||||
@@ -49,7 +49,7 @@ final class ImportSocialWorkMetadata extends Command
 | 
			
		||||
        $filepath = $input->getOption('filepath');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            $csv = Reader::createFromPath($filepath);
 | 
			
		||||
            $csv = Reader::from($filepath);
 | 
			
		||||
        } catch (\Throwable $e) {
 | 
			
		||||
            throw new \Exception('Error while loading CSV.', 0, $e);
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -29,7 +29,7 @@ class LoadSocialWorkMetadata extends Fixture implements OrderedFixtureInterface
 | 
			
		||||
    public function load(ObjectManager $manager): void
 | 
			
		||||
    {
 | 
			
		||||
        try {
 | 
			
		||||
            $csv = Reader::createFromPath(__DIR__.'/data/social_work_metadata.csv');
 | 
			
		||||
            $csv = Reader::from(__DIR__.'/data/social_work_metadata.csv');
 | 
			
		||||
        } catch (\Throwable $e) {
 | 
			
		||||
            throw new \Exception('Error while loading CSV.', 0, $e);
 | 
			
		||||
        }
 | 
			
		||||
 
 | 
			
		||||
@@ -239,13 +239,22 @@ class SocialIssue
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @return Collection<SocialAction> All the descendant social actions of all
 | 
			
		||||
     *                                  the descendants of the entity
 | 
			
		||||
     * @return Collection<SocialAction> All the social actions of the entity, it's
 | 
			
		||||
     *                                  the descendants and it's parents
 | 
			
		||||
     */
 | 
			
		||||
    public function getRecursiveSocialActions(): Collection
 | 
			
		||||
    {
 | 
			
		||||
        $recursiveSocialActions = new ArrayCollection();
 | 
			
		||||
 | 
			
		||||
        // Get social actions from parent issues
 | 
			
		||||
        foreach ($this->getAncestors(false) as $ancestor) {
 | 
			
		||||
            foreach ($ancestor->getDescendantsSocialActions() as $descendant) {
 | 
			
		||||
                if (!$recursiveSocialActions->contains($descendant)) {
 | 
			
		||||
                    $recursiveSocialActions->add($descendant);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        foreach ($this->getDescendantsWithThis() as $socialIssue) {
 | 
			
		||||
            foreach ($socialIssue->getDescendantsSocialActions() as $descendant) {
 | 
			
		||||
                if (!$recursiveSocialActions->contains($descendant)) {
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ final readonly class SocialActionCSVExportService
 | 
			
		||||
            array_keys($this->formatRow(new SocialAction()))
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        $csv = Writer::createFromPath('php://temp', 'w+');
 | 
			
		||||
        $csv = Writer::from('php://temp', 'w+');
 | 
			
		||||
        $csv->insertOne($headers);
 | 
			
		||||
 | 
			
		||||
        foreach ($actions as $action) {
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ readonly class SocialIssueCSVExportService
 | 
			
		||||
    public function generateCsv(array $issues): Writer
 | 
			
		||||
    {
 | 
			
		||||
        // CSV headers
 | 
			
		||||
        $csv = Writer::createFromPath('php://temp', 'r+');
 | 
			
		||||
        $csv = Writer::from('php://temp', 'r+');
 | 
			
		||||
        $csv->insertOne(
 | 
			
		||||
            array_map(
 | 
			
		||||
                fn (string $e) => $this->translator->trans($e),
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,7 @@ class ThirdpartyCSVExportController extends AbstractController
 | 
			
		||||
            fwrite($output, "\xEF\xBB\xBF");
 | 
			
		||||
 | 
			
		||||
            // Create CSV writer
 | 
			
		||||
            $csv = Writer::createFromStream($output);
 | 
			
		||||
            $csv = Writer::from($output);
 | 
			
		||||
 | 
			
		||||
            // Write header row
 | 
			
		||||
            $header = array_map(
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user