Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 25f3ac7316 | |||
| 42b02a8148 | |||
| fe317f37b4 | |||
| d66288d061 | |||
| d723c7631a | |||
| 029a09dc09 |
@@ -56,3 +56,10 @@ JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
###< lexik/jwt-authentication-bundle ###
|
||||
|
||||
###> sentry/sentry-symfony ###
|
||||
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
|
||||
# À définir dans l'env_file du serveur, PAS ici. Format :
|
||||
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
|
||||
# SENTRY_DSN=
|
||||
###< sentry/sentry-symfony ###
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
||||
- No-op saves preserve existing validations
|
||||
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
|
||||
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`.
|
||||
- **Garde backend : suppression sur intention explicite (`delete: true`)** — la protection front ci-dessus **ne couvre que les sessions chargeant le nouveau bundle**. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS (sans dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (onglet ouvert le matin → ~10 lignes saisies dans la journée supprimées). Donc `WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si l'entrée porte `delete: true`** (`$deleteRequested = true === ($entry['delete'] ?? false)`). Sinon : **no-op** (ligne préservée) — aucune grille périmée, quel que soit le client, ne peut plus détruire une saisie concurrente. Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** via `isEntryEmpty(...)` appliqué après le filtre dirty-tracking (les deux composables). Flag **optionnel** (`WorkHourEntryPayload` front + DTO `WorkHourBulkUpsert` back, défaut `false`). Branche de **création de ligne technique de validation** (toggle quand aucune ligne) inchangée : `null === $existing && (absence || contrat 4h)`. Le processor dépend des **interfaces** `EmployeeScopedRepositoryInterface`/`WorkHourReadRepositoryInterface` (repos concrets `final` non mockables) → testé dans `tests/State/WorkHourBulkUpsertProcessorTest.php`. **Limite résiduelle** : pas de verrou optimiste — l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (seule la **suppression** est protégée). Doc : `doc/hours-save-dirty-tracking.md`.
|
||||
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
@@ -216,6 +217,17 @@
|
||||
- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `employee` (LIKE nom/prénom de l'employé affecté, via join), `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (10/25/50/100, défaut 10). Filtres du drawer = champs texte (recherche libre), période en `MalioDateRange`, type/action en cases à cocher. Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
|
||||
- Documentation: `doc/audit-logging.md`
|
||||
|
||||
## Notifications
|
||||
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`, via `getHolidaysDayByYears` → applique `EXCLUDED_PUBLIC_HOLIDAYS`, donc **Lundi de Pentecôte traité comme jour ouvré**, cohérent avec le reste de l'app). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||
|
||||
## Error tracking (GlitchTip)
|
||||
- Backend Symfony → GlitchTip (SDK Sentry), org `malio`, projet `sirh-api`. **Prod only**, **inerte sans `SENTRY_DSN`**.
|
||||
- Config : `config/packages/sentry.yaml` (DSN runtime `%env(SENTRY_DSN)%`, release `%app.version%`, 4xx ignorés, `traces_sample_rate: 0`, handler Monolog ERROR+) ; `SentryBundle` enregistré `['prod' => true]` (`config/bundles.php`) ; handler `sentry` en `when@prod` (`config/packages/monolog.yaml`). DSN runtime via l'env_file serveur, jamais committé/baké.
|
||||
- **Transport réseau** : GlitchTip est interne (bloqué Sophos), servi HTTPS sur `logs.malio-dev.fr` (cert auto-signé) ; SIRH sur VPS OVH → tunnel **Tailscale** entre les deux hôtes (GlitchTip `100.111.223.34`, OVH `100.93.52.45`). Topologie retenue (Option A) : DSN **inchangé** (`https://…@logs.malio-dev.fr/3`), hostname résolu vers l'IP tailnet via `extra_hosts` dans le `docker-compose` serveur, et **CA racine MALIO bakée** dans l'image (`deploy/docker/Dockerfile.prod` + `deploy/docker/malio-dev-root-ca.crt`). Frontend hors périmètre (ajout futur via proxy nginx `/ingest`).
|
||||
- Test d'envoi : `php bin/console sentry:test` (prod-only, `SENTRY_DSN` requis) → Issue dans `sirh-api`.
|
||||
- Doc : `doc/error-tracking.md`.
|
||||
|
||||
## Backend Conventions
|
||||
- Prefer explicit DTOs over associative arrays
|
||||
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"sentry/sentry-symfony": "^5.10",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
|
||||
Generated
+685
-72
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "bdc04f5145303388bac52809ea3f4b05",
|
||||
"content-hash": "5fa560dba1bae2997c8f71afbbbfb4ab",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2515,6 +2515,185 @@
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "2.12.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2.5 || ^8.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"ralouphie/getallheaders": "^3.0",
|
||||
"symfony/deprecation-contracts": "^2.5 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.25"
|
||||
},
|
||||
"provide": {
|
||||
"psr/http-factory-implementation": "1.0",
|
||||
"psr/http-message-implementation": "1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"bamarni/composer-bin-plugin": "^1.8.2",
|
||||
"http-interop/http-factory-tests": "1.1.0",
|
||||
"jshttp/mime-db": "1.54.0.1",
|
||||
"phpunit/phpunit": "^8.5.52 || ^9.6.34"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"bamarni-bin": {
|
||||
"bin-links": true,
|
||||
"forward-command": false
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GuzzleHttp\\Psr7\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Graham Campbell",
|
||||
"email": "hello@gjcampbell.co.uk",
|
||||
"homepage": "https://github.com/GrahamCampbell"
|
||||
},
|
||||
{
|
||||
"name": "Michael Dowling",
|
||||
"email": "mtdowling@gmail.com",
|
||||
"homepage": "https://github.com/mtdowling"
|
||||
},
|
||||
{
|
||||
"name": "George Mponos",
|
||||
"email": "gmponos@gmail.com",
|
||||
"homepage": "https://github.com/gmponos"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com",
|
||||
"homepage": "https://github.com/Nyholm"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://github.com/sagikazarmark"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Schultze",
|
||||
"email": "webmaster@tubo-world.de",
|
||||
"homepage": "https://github.com/Tobion"
|
||||
},
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com",
|
||||
"homepage": "https://sagikazarmark.hu"
|
||||
}
|
||||
],
|
||||
"description": "PSR-7 message implementation that also provides common utility methods",
|
||||
"keywords": [
|
||||
"http",
|
||||
"message",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response",
|
||||
"stream",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/2.12.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/GrahamCampbell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Nyholm",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-23T15:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jean85/pretty-package-versions",
|
||||
"version": "2.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Jean85/pretty-package-versions.git",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2.1.0",
|
||||
"php": "^7.4|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"jean85/composer-provided-replaced-stub-package": "^1.0",
|
||||
"phpstan/phpstan": "^2.0",
|
||||
"phpunit/phpunit": "^7.5|^8.5|^9.6",
|
||||
"rector/rector": "^2.0",
|
||||
"vimeo/psalm": "^4.3 || ^5.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Jean85\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Alessandro Lai",
|
||||
"email": "alessandro.lai85@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A library to get pretty versions strings of installed dependencies",
|
||||
"keywords": [
|
||||
"composer",
|
||||
"package",
|
||||
"release",
|
||||
"versions"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
|
||||
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lcobucci/jwt",
|
||||
"version": "5.6.0",
|
||||
@@ -3361,6 +3540,114 @@
|
||||
},
|
||||
"time": "2019-01-08T18:20:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-factory",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-factory.git",
|
||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Message\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
|
||||
"keywords": [
|
||||
"factory",
|
||||
"http",
|
||||
"message",
|
||||
"psr",
|
||||
"psr-17",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-factory"
|
||||
},
|
||||
"time": "2024-04-15T12:06:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-message",
|
||||
"version": "2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-message.git",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Message\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP messages",
|
||||
"homepage": "https://github.com/php-fig/http-message",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-message/tree/2.0"
|
||||
},
|
||||
"time": "2023-04-04T09:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/link",
|
||||
"version": "2.0.1",
|
||||
@@ -3467,6 +3754,50 @@
|
||||
},
|
||||
"time": "2024-09-11T13:17:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "ralouphie/getallheaders",
|
||||
"version": "3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ralouphie/getallheaders.git",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
|
||||
"reference": "120b605dfeb996808c31b6477290a714d356e822",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-coveralls/php-coveralls": "^2.1",
|
||||
"phpunit/phpunit": "^5 || ^6.5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/getallheaders.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ralph Khattar",
|
||||
"email": "ralph.khattar@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A polyfill for getallheaders.",
|
||||
"support": {
|
||||
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
||||
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
||||
},
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.1.0",
|
||||
@@ -3541,6 +3872,201 @@
|
||||
},
|
||||
"time": "2025-09-14T07:37:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.28.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-php.git",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"reference": "662cb7a01a342a7f33780fc955ff4a028d8b785a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-curl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5|^2.0.4",
|
||||
"php": "^7.2|^8.0",
|
||||
"psr/log": "^1.0|^2.0|^3.0",
|
||||
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"raven/raven": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"carthage-software/mago": "1.30.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.4",
|
||||
"guzzlehttp/promises": "^2.0.3",
|
||||
"monolog/monolog": "^1.6|^2.0|^3.0",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"open-telemetry/api": "^1.0",
|
||||
"open-telemetry/exporter-otlp": "^1.0",
|
||||
"open-telemetry/sdk": "^1.0",
|
||||
"phpstan/phpstan": "^1.3",
|
||||
"phpunit/phpunit": "^8.5.52|^9.6.34",
|
||||
"spiral/roadrunner-http": "^3.6",
|
||||
"spiral/roadrunner-worker": "^3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP SDK for Sentry (http://sentry.io)",
|
||||
"homepage": "http://sentry.io",
|
||||
"keywords": [
|
||||
"crash-reporting",
|
||||
"crash-reports",
|
||||
"error-handler",
|
||||
"error-monitoring",
|
||||
"log",
|
||||
"logging",
|
||||
"profiling",
|
||||
"sentry",
|
||||
"tracing"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-php/issues",
|
||||
"source": "https://github.com/getsentry/sentry-php/tree/4.28.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-11T12:22:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry-symfony",
|
||||
"version": "5.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getsentry/sentry-symfony.git",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/psr7": "^2.1.1",
|
||||
"jean85/pretty-package-versions": "^1.5||^2.0",
|
||||
"php": "^7.2||^8.0",
|
||||
"sentry/sentry": "^4.23.0",
|
||||
"symfony/cache-contracts": "^1.1||^2.4||^3.0",
|
||||
"symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/polyfill-php80": "^1.22",
|
||||
"symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0",
|
||||
"symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^2.13||^3.3||^4.0",
|
||||
"doctrine/doctrine-bundle": "^2.6||^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^2.19||^3.40",
|
||||
"masterminds/html5": "^2.8",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "1.12.5",
|
||||
"phpstan/phpstan-phpunit": "1.4.0",
|
||||
"phpstan/phpstan-symfony": "1.4.10",
|
||||
"phpunit/phpunit": "^8.5.40||^9.6.21",
|
||||
"symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/monolog-bundle": "^3.4||^4.0",
|
||||
"symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0",
|
||||
"symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0",
|
||||
"vimeo/psalm": "^4.3||^5.16.0"
|
||||
},
|
||||
"suggest": {
|
||||
"doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.",
|
||||
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.",
|
||||
"symfony/cache": "Allow distributed tracing of cache pools using Sentry.",
|
||||
"symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry."
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/aliases.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sentry\\SentryBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sentry",
|
||||
"email": "accounts@sentry.io"
|
||||
}
|
||||
],
|
||||
"description": "Symfony integration for Sentry (http://getsentry.com)",
|
||||
"homepage": "http://getsentry.com",
|
||||
"keywords": [
|
||||
"errors",
|
||||
"logging",
|
||||
"sentry",
|
||||
"symfony"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/getsentry/sentry-symfony/issues",
|
||||
"source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://sentry.io/pricing/",
|
||||
"type": "custom"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-01T14:50:32+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/asset",
|
||||
"version": "v8.0.4",
|
||||
@@ -5616,6 +6142,77 @@
|
||||
],
|
||||
"time": "2025-12-08T08:00:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/options-resolver.git",
|
||||
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
|
||||
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\OptionsResolver\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"config",
|
||||
"configuration",
|
||||
"options"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-12T15:55:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/password-hasher",
|
||||
"version": "v8.0.4",
|
||||
@@ -6358,6 +6955,93 @@
|
||||
],
|
||||
"time": "2026-01-27T16:18:07+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v8.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19",
|
||||
"reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/runtime": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-30T15:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v8.0.4",
|
||||
@@ -11220,77 +11904,6 @@
|
||||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/options-resolver",
|
||||
"version": "v8.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/options-resolver.git",
|
||||
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
|
||||
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"symfony/deprecation-contracts": "^2.5|^3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\OptionsResolver\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Provides an improved replacement for the array_replace PHP function",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"config",
|
||||
"configuration",
|
||||
"options"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-12T15:55:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/process",
|
||||
"version": "v8.0.5",
|
||||
|
||||
@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
use Symfony\Bundle\MonologBundle\MonologBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
@@ -24,4 +25,5 @@ return [
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
MonologBundle::class => ['all' => true],
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
SentryBundle::class => ['prod' => true],
|
||||
];
|
||||
|
||||
@@ -36,3 +36,9 @@ when@prod:
|
||||
type: stream
|
||||
channels: [deprecation]
|
||||
path: "%kernel.logs_dir%/deprecations.log"
|
||||
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini
|
||||
# dans sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
|
||||
sentry:
|
||||
type: service
|
||||
id: Sentry\Monolog\Handler
|
||||
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
|
||||
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
|
||||
when@prod:
|
||||
parameters:
|
||||
env(SENTRY_DSN): ''
|
||||
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
|
||||
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du
|
||||
# kernel sont déjà logguées par Symfony → remontées via Monolog).
|
||||
register_error_listener: false
|
||||
register_error_handler: true
|
||||
options:
|
||||
environment: '%env(APP_ENV)%'
|
||||
release: '%app.version%'
|
||||
traces_sample_rate: 0.0
|
||||
ignore_exceptions:
|
||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||
|
||||
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
|
||||
services:
|
||||
Sentry\Monolog\Handler:
|
||||
arguments:
|
||||
$hub: '@Sentry\State\HubInterface'
|
||||
$level: !php/const Monolog\Level::Error
|
||||
$bubble: true
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.125'
|
||||
app.version: '0.1.128'
|
||||
|
||||
@@ -39,10 +39,17 @@ FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor \
|
||||
nginx supervisor ca-certificates \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# CA racine interne MALIO (auto-signée) — permet au SDK Sentry/HttpClient de joindre
|
||||
# GlitchTip en HTTPS sur logs.malio-dev.fr (cert *.malio-dev.fr). Le host est résolu vers
|
||||
# l'IP tailnet via `extra_hosts` dans le docker-compose du serveur (cf. doc/error-tracking.md).
|
||||
# Sans cette CA approuvée, le SDK logue « Message not sent » et rien ne remonte.
|
||||
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
|
||||
RUN update-ca-certificates
|
||||
|
||||
# PHP production config
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL
|
||||
BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX
|
||||
TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy
|
||||
MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD
|
||||
VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD
|
||||
ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02
|
||||
QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM
|
||||
3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr
|
||||
R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT
|
||||
lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ
|
||||
NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5
|
||||
0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj
|
||||
89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy
|
||||
tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo
|
||||
saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo
|
||||
FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB
|
||||
AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW
|
||||
gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
|
||||
DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m
|
||||
AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81
|
||||
Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr
|
||||
W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ
|
||||
3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/
|
||||
gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt
|
||||
KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE
|
||||
2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI
|
||||
iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f
|
||||
atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe
|
||||
zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg==
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,42 @@
|
||||
# Notification de fin de contrat (veille du dernier jour)
|
||||
|
||||
## Objectif
|
||||
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||
salarié arrive au terme de son emploi.
|
||||
|
||||
## Déclenchement
|
||||
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||
|
||||
## Règle métier
|
||||
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||
samedi, dimanche et lundi (mardi si lundi férié).
|
||||
- **Jour de solidarité (Lundi de Pentecôte)** : traité comme un **jour ouvré** (choix
|
||||
délibéré). Le calcul s'appuie sur `getHolidaysDayByYears`, qui applique
|
||||
`EXCLUDED_PUBLIC_HOLIDAYS` (défaut = `"Lundi de Pentecôte"`) — la même liste de fériés que
|
||||
le reste de l'app (heures, congés, RTT). On évite ainsi une définition de « férié »
|
||||
divergente pour ce seul calcul ; et le jour de solidarité est, par nature, un jour travaillé
|
||||
(admins présents → la cloche est vue). Une fin de contrat le mardi après Pentecôte est donc
|
||||
notifiée le Lundi de Pentecôte, pas le vendredi précédent.
|
||||
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||
|
||||
## Idempotence
|
||||
Avant création, on vérifie l'absence d'une notif identique
|
||||
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||
|
||||
## Implémentation
|
||||
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||
- Pas de migration : réutilise la table `notifications`.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Error tracking (GlitchTip)
|
||||
|
||||
Les erreurs **backend** (Symfony) sont remontées vers **GlitchTip** (instance interne MALIO,
|
||||
compatible SDK Sentry), org `malio`, projet `sirh-api`. **Prod uniquement**, **inerte sans DSN**.
|
||||
|
||||
> Frontend hors périmètre (les erreurs front partent du navigateur RH ; ajout futur possible via
|
||||
> un proxy nginx `/ingest`).
|
||||
|
||||
## Contrainte réseau & transport
|
||||
|
||||
GlitchTip est sur le réseau interne (bloqué par Sophos), servi en **HTTPS sur
|
||||
`logs.malio-dev.fr`** (cert auto-signé par la CA interne « MALIO-DEV Local Root CA »). SIRH tourne
|
||||
sur un **VPS OVH** public. Le lien passe par un **tunnel Tailscale** entre les deux hôtes.
|
||||
|
||||
**Topologie retenue (Option A — HTTPS + hostname mappé sur le tailnet) :**
|
||||
- Tailscale est installé **sur l'hôte GlitchTip** (IP tailnet `100.111.223.34`) **et sur le VPS
|
||||
OVH** (IP tailnet `100.93.52.45`).
|
||||
- Le **DSN reste inchangé** : `https://<clé>@logs.malio-dev.fr/<id>` (même endpoint que le
|
||||
navigateur → pas de souci `ALLOWED_HOSTS`, Host header et cert cohérents).
|
||||
- Côté SIRH, le nom `logs.malio-dev.fr` est résolu vers l'**IP tailnet de GlitchTip** via
|
||||
`extra_hosts` dans le `docker-compose` du serveur.
|
||||
- La **CA racine MALIO** est bakée dans l'image SIRH (`deploy/docker/Dockerfile.prod`) pour que le
|
||||
SDK accepte le TLS auto-signé.
|
||||
|
||||
> Pré-requis : le nginx qui sert `logs.malio-dev.fr` en 443 doit écouter sur une interface
|
||||
> joignable via le tailnet (typiquement `0.0.0.0:443` → joignable sur `100.111.223.34:443`).
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `config/packages/sentry.yaml` | conf backend (prod-only, DSN runtime, 4xx ignorés, release = `app.version`, handler Monolog ERROR+) |
|
||||
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
|
||||
| `config/packages/monolog.yaml` | handler `sentry` (service) en `when@prod` |
|
||||
| `.env` | bloc documenté `SENTRY_DSN` (vide → inerte) |
|
||||
| `deploy/docker/Dockerfile.prod` | bake la CA racine MALIO (`update-ca-certificates`) pour le TLS interne |
|
||||
| `deploy/docker/malio-dev-root-ca.crt` | certificat **public** de la CA interne (aucune clé privée) |
|
||||
|
||||
## Activation (runbook)
|
||||
|
||||
1. **Tailscale sur les deux hôtes** (GlitchTip **et** VPS OVH) :
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up # ou --authkey tskey-auth-XXXX (headless)
|
||||
tailscale ip -4 # GlitchTip → 100.111.223.34 ; OVH → 100.93.52.45
|
||||
```
|
||||
2. **Vérifier l'accès** depuis le VPS OVH (tunnel + nginx 443 de GlitchTip) :
|
||||
```bash
|
||||
tailscale ping 100.111.223.34
|
||||
curl -sSk -o /dev/null -w "%{http_code}\n" https://100.111.223.34/ # réponse HTTP = tunnel OK
|
||||
```
|
||||
3. **Mapper le hostname vers l'IP tailnet** dans le `docker-compose` du serveur OVH (service `php`),
|
||||
pour que le container résolve `logs.malio-dev.fr` :
|
||||
```yaml
|
||||
extra_hosts:
|
||||
- "logs.malio-dev.fr:100.111.223.34"
|
||||
```
|
||||
4. **Projet GlitchTip** : déjà créé (org `malio`, projet `sirh-api`, id `3`). DSN de base affiché
|
||||
dans *Settings → Client Keys* : `https://<clé>@logs.malio-dev.fr/3`.
|
||||
5. **Injecter le DSN tel quel** (hostname conservé) dans l'env_file serveur (pas dans l'image),
|
||||
puis rebuild/redéployer l'image (la CA est bakée au build) :
|
||||
```env
|
||||
SENTRY_DSN=https://<clé>@logs.malio-dev.fr/3
|
||||
```
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose exec php php bin/console cache:clear --env=prod
|
||||
```
|
||||
|
||||
## Tester l'envoi
|
||||
|
||||
Le bundle `sentry/sentry-symfony` fournit une commande qui envoie un événement de test et
|
||||
confirme s'il est bien parti vers GlitchTip. Elle n'existe qu'en **prod** (bundle prod-only) et
|
||||
nécessite `SENTRY_DSN` défini.
|
||||
|
||||
```bash
|
||||
# Sur le serveur, dans le container PHP (SENTRY_DSN doit être dans l'env) :
|
||||
docker compose exec php sh -lc "APP_ENV=prod php bin/console sentry:test"
|
||||
```
|
||||
|
||||
Sortie attendue : `Sending test message... done.` → une **Issue de test** apparaît dans le projet
|
||||
`sirh-api` côté GlitchTip. Si l'envoi échoue (`Message not sent`), le problème est réseau
|
||||
(Tailscale/route/port) ou DSN, pas applicatif.
|
||||
|
||||
Pré-check connectivité depuis le VPS OVH (`-k` ignore le cert juste pour ce test) :
|
||||
|
||||
```bash
|
||||
tailscale ping 100.111.223.34
|
||||
curl -sSk -o /dev/null -w "%{http_code}\n" https://100.111.223.34/ # réponse HTTP = tunnel OK
|
||||
# Avec résolution du hostname (comme le container) + validation par la CA :
|
||||
curl --resolve logs.malio-dev.fr:443:100.111.223.34 \
|
||||
--cacert deploy/docker/malio-dev-root-ca.crt \
|
||||
-sS -o /dev/null -w "%{http_code}\n" https://logs.malio-dev.fr/
|
||||
```
|
||||
|
||||
Alternative sans commande dédiée : déclencher un `throw new \RuntimeException('glitchtip test')`
|
||||
temporaire dans un endpoint, ou un `$logger->error('glitchtip test')` (niveau ERROR+ → Issue).
|
||||
|
||||
## CA HTTPS interne (bakée dans l'image)
|
||||
|
||||
GlitchTip est en HTTPS avec un cert auto-signé par la **CA interne MALIO**. Le SDK refuse un TLS
|
||||
non approuvé (« Message not sent »). La CA publique (`deploy/docker/malio-dev-root-ca.crt`, aucune
|
||||
clé privée) est donc installée dans le trust store de l'image au build (`deploy/docker/Dockerfile.prod`,
|
||||
stage production) :
|
||||
|
||||
```dockerfile
|
||||
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
|
||||
RUN update-ca-certificates
|
||||
```
|
||||
|
||||
Combinée à l'`extra_hosts` (hostname → IP tailnet), le container fait confiance à
|
||||
`logs.malio-dev.fr` et l'atteint via le tunnel.
|
||||
|
||||
Design détaillé : `docs/superpowers/specs/2026-06-28-glitchtip-backend-error-tracking-design.md`.
|
||||
@@ -497,6 +497,13 @@ Seuls les employés dont au moins une période de contrat intersecte la période
|
||||
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||
- destinataires: utilisateurs `ROLE_ADMIN`
|
||||
|
||||
- **Fin de contrat (J-1 ouvré)** : une commande quotidienne (`app:contract:end-notifications`)
|
||||
notifie tous les admins, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||
salarié arrive au terme de son emploi. Cible = **dernière** période de l'employé (un
|
||||
changement de contrat enchaîné ne notifie pas). Week-ends et fériés sautés. Message
|
||||
« Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien vers la fiche employé,
|
||||
sans acteur. Idempotente. Détail : `doc/contract-end-notifications.md`.
|
||||
|
||||
## 16) Export PDF des heures annuelles
|
||||
|
||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||
|
||||
@@ -45,10 +45,51 @@ Conséquences :
|
||||
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
|
||||
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
|
||||
|
||||
## Limite connue (hors périmètre de ce correctif)
|
||||
## Garde backend : suppression sur intention explicite (`delete: true`)
|
||||
|
||||
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
|
||||
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
|
||||
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
|
||||
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
|
||||
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
|
||||
Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil
|
||||
onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans
|
||||
dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet
|
||||
ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres
|
||||
utilisateurs). La protection front est donc **insuffisante** : il faut une garde **côté
|
||||
backend**, indépendante de la version du client.
|
||||
|
||||
`WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si
|
||||
la suppression est explicitement demandée** par le flag `delete: true` sur l'entrée :
|
||||
|
||||
```php
|
||||
$deleteRequested = true === ($entry['delete'] ?? false);
|
||||
if ($existing && $deleteRequested) {
|
||||
// suppression (audit 'delete' + remove)
|
||||
} elseif (null === $existing && ($absence || $is4hContract)) {
|
||||
// création d'une ligne technique (validation d'une journée d'absence / contrat 4h)
|
||||
}
|
||||
// existing && !deleteRequested → NO-OP : la ligne existante est préservée
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- Une entrée vide **sans flag** sur une ligne existante est un **no-op** → une grille périmée
|
||||
(n'importe quel client, vieil onglet inclus) **ne peut plus détruire** une saisie concurrente.
|
||||
- Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (entrée vide qui
|
||||
diffère de l'instantané chargé, donc transmise) → la suppression métier reste possible.
|
||||
Helper `isEntryEmpty(...)` dans les deux composables, appliqué après le filtre dirty-tracking.
|
||||
|
||||
Le `delete` est **optionnel** (`WorkHourEntryPayload` front, DTO `WorkHourBulkUpsert` back) et
|
||||
vaut `false` par défaut. Les appels de **création de ligne technique de validation** (toggles de
|
||||
validation quand aucune ligne n'existe) envoient une entrée vide sans flag → inchangés (branche
|
||||
`null === $existing`).
|
||||
|
||||
Tests : `tests/State/WorkHourBulkUpsertProcessorTest.php` (no-op sans flag / suppression avec
|
||||
flag / mise à jour d'une entrée non vide). Le processor dépend des interfaces
|
||||
`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface` (repos concrets `final`,
|
||||
non mockables) pour permettre ces tests unitaires.
|
||||
|
||||
## Limite connue résiduelle (hors périmètre)
|
||||
|
||||
Reste le cas où l'admin **édite explicitement** une ligne sur des données périmées (il voit la
|
||||
ligne vide, tape une valeur, écrasant une saisie concurrente sur cette même ligne). Ce cas
|
||||
relèverait d'un **verrou optimiste** (comparaison d'`updatedAt`/version côté backend), non
|
||||
implémenté ici — choix métier (option « suppression explicite » retenue plutôt que verrou
|
||||
optimiste). Le backend n'a aucune détection de conflit concurrent sur l'**édition** (seule la
|
||||
**suppression** est désormais protégée par l'intention explicite).
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
# Notification de fin de contrat (veille du dernier jour) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Prévenir automatiquement les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un salarié arrive au terme de son emploi.
|
||||
|
||||
**Architecture:** Une commande console quotidienne (`app:contract:end-notifications`, déclenchée par le crontab prod) délègue à un service. La logique « dure » (saut des week-ends/fériés, fenêtre de détection, libellé du message) vit dans deux collaborateurs purs et testés en isolation (`WorkingDayCalculator`, `ContractEndNotificationPlanner`). Le service oriente le résultat vers la création de `Notification` (une par admin), avec déduplication par message exact. Aucune migration : on réutilise la table `notifications` existante.
|
||||
|
||||
**Tech Stack:** Symfony 7 + API Platform + Doctrine ORM (backend), PHPUnit (tests), Nuxt 4 / Vue 3 (front). Conteneur de test Docker `php-sirh-fpm`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **PHP** : `declare(strict_types=1);` en tête de chaque fichier ; classes services en `final readonly` quand sans état mutable (suivre `RttRolloverCommand`, `HolidayVirtualHoursResolver`).
|
||||
- **Commit message** : format `<type> : <message>` — **espace obligatoire avant les deux-points** (hook pre-commit), types autorisés : `feat, fix, docs, refactor, test, chore`, etc. Exemple : `feat : add working day calculator`.
|
||||
- **Pre-commit hook** : lance php-cs-fixer + **toute** la suite PHPUnit. Tout commit échoue si un test casse → garder la suite verte à chaque commit.
|
||||
- **Lancer les tests** : `make test` (suite complète) ou ciblé `make test FILES="--filter NomDuTest"` (= `docker exec -u www-data php-sirh-fpm php vendor/bin/phpunit ...`).
|
||||
- **Fériés** : zone `'metropole'`, via `PublicHolidayServiceInterface::getHolidaysDayByYears('metropole', $year)` → tableau `['Y-m-d' => 'libellé']` (suivre `HolidayVirtualHoursResolver::isPublicHoliday`).
|
||||
- **Catégorie** notif = `'Contrat'` ; **target** = `'/employees/{id}'` ; **acteur** = `null` ; destinataires = `UserRepository::findAllAdmins()`.
|
||||
- **Règles projet (CLAUDE.md)** : toute évolution fonctionnelle MET À JOUR `doc/` ET `frontend/data/documentation-content.ts` dans la même intervention ; mettre à jour `CLAUDE.md` à la fin.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Backend — nouveaux**
|
||||
- `src/Service/Notification/WorkingDayCalculator.php` — jour ouvré (week-end + férié), prochain jour ouvré. Pur (dépend de `PublicHolidayServiceInterface`).
|
||||
- `src/Service/Notification/ContractEndNotice.php` — DTO immuable `{ ?int employeeId, string message }`.
|
||||
- `src/Service/Notification/ContractEndNotificationPlanner.php` — sélection des candidats + construction du message. Pur (dépend de `WorkingDayCalculator`).
|
||||
- `src/Service/Notification/ContractEndNotificationResult.php` — DTO résultat `{ int notificationsCreated, int contractsMatched }`.
|
||||
- `src/Service/Notification/ContractEndNotificationService.php` — orchestration (repos + EntityManager).
|
||||
- `src/Command/ContractEndNotificationCommand.php` — commande `app:contract:end-notifications`.
|
||||
|
||||
**Backend — modifiés**
|
||||
- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()`.
|
||||
- `src/Repository/NotificationRepository.php` — `existsForRecipientCategoryTargetMessage()`.
|
||||
|
||||
**Tests — nouveaux**
|
||||
- `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||
- `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||
|
||||
**Frontend — modifié**
|
||||
- `frontend/components/AppTopNav.vue` — gérer `actorName` vide (ligne 65).
|
||||
|
||||
**Docs — modifiés/nouveaux**
|
||||
- `doc/functional-rules.md` (section 15), `doc/contract-end-notifications.md` (nouveau), `frontend/data/documentation-content.ts`, `CLAUDE.md`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : WorkingDayCalculator (jour ouvré : week-end + férié)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Service/Notification/WorkingDayCalculator.php`
|
||||
- Test: `tests/Service/Notification/WorkingDayCalculatorTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `App\Service\PublicHolidayServiceInterface::getHolidaysDayByYears(string $zone, string $year): array`
|
||||
- Produces:
|
||||
- `WorkingDayCalculator::__construct(PublicHolidayServiceInterface $holidays)`
|
||||
- `WorkingDayCalculator::isWorkingDay(DateTimeImmutable $date): bool`
|
||||
- `WorkingDayCalculator::nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable` — premier jour ouvré **strictement après** `$date` (heure remise à 00:00:00).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`tests/Service/Notification/WorkingDayCalculatorTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Notification;
|
||||
|
||||
use App\Service\Notification\WorkingDayCalculator;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class WorkingDayCalculatorTest extends TestCase
|
||||
{
|
||||
private function calculator(): WorkingDayCalculator
|
||||
{
|
||||
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||
// Lundi 14/07/2025 férié
|
||||
'2025-07-14' => 'Fête nationale',
|
||||
]);
|
||||
|
||||
return new WorkingDayCalculator($holidays);
|
||||
}
|
||||
|
||||
public function testWeekdayIsWorkingDay(): void
|
||||
{
|
||||
// Mardi 08/07/2025
|
||||
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||
}
|
||||
|
||||
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||
{
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||
}
|
||||
|
||||
public function testPublicHolidayIsNotWorkingDay(): void
|
||||
{
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||
}
|
||||
|
||||
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||
{
|
||||
// Mardi 08/07 -> Mercredi 09/07
|
||||
self::assertSame(
|
||||
'2025-07-09',
|
||||
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||
);
|
||||
}
|
||||
|
||||
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||
{
|
||||
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||
self::assertSame(
|
||||
'2025-07-15',
|
||||
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||
Expected: FAIL — `Class "App\Service\Notification\WorkingDayCalculator" not found`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`src/Service/Notification/WorkingDayCalculator.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
final readonly class WorkingDayCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private PublicHolidayServiceInterface $holidays,
|
||||
) {}
|
||||
|
||||
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||
{
|
||||
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||
if ($dayOfWeek >= 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->isPublicHoliday($date);
|
||||
}
|
||||
|
||||
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||
{
|
||||
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||
while (!$this->isWorkingDay($candidate)) {
|
||||
$candidate = $candidate->modify('+1 day');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||
{
|
||||
try {
|
||||
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `make test FILES="--filter WorkingDayCalculatorTest"`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/Notification/WorkingDayCalculator.php tests/Service/Notification/WorkingDayCalculatorTest.php
|
||||
git commit -m "feat : add working day calculator (weekend + holiday aware)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : ContractEndNotice DTO
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Service/Notification/ContractEndNotice.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ContractEndNotice::__construct(public ?int $employeeId, public string $message)` (lecture seule).
|
||||
|
||||
Pas de test dédié (DTO sans logique) — sera couvert par le test du planner (Task 3).
|
||||
|
||||
- [ ] **Step 1: Create the DTO**
|
||||
|
||||
`src/Service/Notification/ContractEndNotice.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
final readonly class ContractEndNotice
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $employeeId,
|
||||
public string $message,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/Notification/ContractEndNotice.php
|
||||
git commit -m "feat : add contract end notice DTO"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : ContractEndNotificationPlanner (fenêtre + message)
|
||||
|
||||
Sélectionne, parmi les **dernières périodes** de chaque employé, celles dont la fin tombe dans la fenêtre `]today, nextWorkingDay(today)]`, et construit le message FR.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Service/Notification/ContractEndNotificationPlanner.php`
|
||||
- Test: `tests/Service/Notification/ContractEndNotificationPlannerTest.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:
|
||||
- `WorkingDayCalculator::isWorkingDay(...)`, `::nextWorkingDay(...)` (Task 1)
|
||||
- `App\Entity\EmployeeContractPeriod::getEndDate(): ?DateTimeImmutable`, `::getEmployee(): ?Employee`, `::getContractNatureEnum(): App\Enum\ContractNature`
|
||||
- `App\Entity\Employee::getId(): ?int`, `::getFirstName(): string`, `::getLastName(): string`
|
||||
- `App\Enum\ContractNature` (cases `CDI`, `CDD`, `INTERIM`)
|
||||
- Produces:
|
||||
- `ContractEndNotificationPlanner::__construct(WorkingDayCalculator $calculator)`
|
||||
- `ContractEndNotificationPlanner::plan(array $latestPeriods, DateTimeImmutable $today): array` — `@param EmployeeContractPeriod[] $latestPeriods` → `@return ContractEndNotice[]`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
`tests/Service/Notification/ContractEndNotificationPlannerTest.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Notification;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||
use App\Service\Notification\WorkingDayCalculator;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ContractEndNotificationPlannerTest extends TestCase
|
||||
{
|
||||
private function planner(): ContractEndNotificationPlanner
|
||||
{
|
||||
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||
]);
|
||||
|
||||
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||
}
|
||||
|
||||
private function period(
|
||||
string $firstName,
|
||||
string $lastName,
|
||||
?string $endDate,
|
||||
ContractNature $nature = ContractNature::CDD,
|
||||
): EmployeeContractPeriod {
|
||||
$employee = new Employee();
|
||||
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||
|
||||
$period = new EmployeeContractPeriod();
|
||||
$period->setEmployee($employee)
|
||||
->setContractNature($nature)
|
||||
->setEndDate($endDate === null ? null : new DateTimeImmutable($endDate))
|
||||
;
|
||||
|
||||
return $period;
|
||||
}
|
||||
|
||||
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||
{
|
||||
// Mardi 08/07 -> fin mercredi 09/07
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertCount(1, $notices);
|
||||
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||
}
|
||||
|
||||
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||
{
|
||||
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||
$notices = $this->planner()->plan(
|
||||
[
|
||||
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||
],
|
||||
new DateTimeImmutable('2025-07-11'),
|
||||
);
|
||||
|
||||
self::assertCount(3, $notices);
|
||||
}
|
||||
|
||||
public function testIgnoresOpenEndedContract(): void
|
||||
{
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testIgnoresContractEndingToday(): void
|
||||
{
|
||||
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||
{
|
||||
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||
new DateTimeImmutable('2025-07-12'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testInterimNatureLabel(): void
|
||||
{
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||
Expected: FAIL — `Class "App\Service\Notification\ContractEndNotificationPlanner" not found`.
|
||||
|
||||
- [ ] **Step 3: Write minimal implementation**
|
||||
|
||||
`src/Service/Notification/ContractEndNotificationPlanner.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ContractEndNotificationPlanner
|
||||
{
|
||||
public function __construct(
|
||||
private WorkingDayCalculator $calculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param EmployeeContractPeriod[] $latestPeriods
|
||||
*
|
||||
* @return ContractEndNotice[]
|
||||
*/
|
||||
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||
{
|
||||
$today = $today->setTime(0, 0, 0);
|
||||
if (!$this->calculator->isWorkingDay($today)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||
|
||||
$notices = [];
|
||||
foreach ($latestPeriods as $period) {
|
||||
$endDate = $period->getEndDate();
|
||||
if (null === $endDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$endDate = $endDate->setTime(0, 0, 0);
|
||||
if ($endDate <= $today || $endDate > $upperBound) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$employee = $period->getEmployee();
|
||||
if (null === $employee) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Fin de %s de %s %s le %s',
|
||||
$this->natureLabel($period->getContractNatureEnum()),
|
||||
$employee->getFirstName(),
|
||||
$employee->getLastName(),
|
||||
$endDate->format('d/m/Y'),
|
||||
);
|
||||
|
||||
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
private function natureLabel(ContractNature $nature): string
|
||||
{
|
||||
return match ($nature) {
|
||||
ContractNature::CDI => 'CDI',
|
||||
ContractNature::CDD => 'CDD',
|
||||
ContractNature::INTERIM => 'Intérim',
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `make test FILES="--filter ContractEndNotificationPlannerTest"`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/Notification/ContractEndNotificationPlanner.php tests/Service/Notification/ContractEndNotificationPlannerTest.php
|
||||
git commit -m "feat : add contract end notification planner"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Méthodes de repository
|
||||
|
||||
Deux requêtes : la dernière période par employé, et le test d'existence anti-doublon.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Repository/EmployeeContractPeriodRepository.php`
|
||||
- Modify: `src/Repository/NotificationRepository.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees(): array` (`@return EmployeeContractPeriod[]` — une période par employé, celle de `startDate` max).
|
||||
- `NotificationRepository::existsForRecipientCategoryTargetMessage(User $recipient, string $category, string $target, string $message): bool`.
|
||||
|
||||
> Pas de test unitaire (accès Doctrine, pas de tests d'intégration DB dans ce projet) — vérifié manuellement en Task 6.
|
||||
|
||||
- [ ] **Step 1: Add `findLatestPeriodsForAllEmployees` to EmployeeContractPeriodRepository**
|
||||
|
||||
Ajouter cette méthode dans `src/Repository/EmployeeContractPeriodRepository.php` (après `findLatestPeriod`) :
|
||||
|
||||
```php
|
||||
/**
|
||||
* Latest contract period (max startDate) for every employee that has at least one.
|
||||
*
|
||||
* @return EmployeeContractPeriod[]
|
||||
*/
|
||||
public function findLatestPeriodsForAllEmployees(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->andWhere('p.startDate = (
|
||||
SELECT MAX(p2.startDate)
|
||||
FROM App\Entity\EmployeeContractPeriod p2
|
||||
WHERE p2.employee = p.employee
|
||||
)')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `existsForRecipientCategoryTargetMessage` to NotificationRepository**
|
||||
|
||||
Ajouter dans `src/Repository/NotificationRepository.php` (après `markAllReadByRecipient`) :
|
||||
|
||||
```php
|
||||
public function existsForRecipientCategoryTargetMessage(
|
||||
User $recipient,
|
||||
string $category,
|
||||
string $target,
|
||||
string $message,
|
||||
): bool {
|
||||
$id = $this->createQueryBuilder('n')
|
||||
->select('n.id')
|
||||
->andWhere('n.recipient = :recipient')
|
||||
->andWhere('n.category = :category')
|
||||
->andWhere('n.target = :target')
|
||||
->andWhere('n.message = :message')
|
||||
->setParameter('recipient', $recipient)
|
||||
->setParameter('category', $category)
|
||||
->setParameter('target', $target)
|
||||
->setParameter('message', $message)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
|
||||
return null !== $id;
|
||||
}
|
||||
```
|
||||
|
||||
> `User` est déjà importé dans `NotificationRepository` (`use App\Entity\User;`). Si l'import manquait, l'ajouter.
|
||||
|
||||
- [ ] **Step 3: Verify the suite still passes**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (suite complète, aucun test cassé).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Repository/EmployeeContractPeriodRepository.php src/Repository/NotificationRepository.php
|
||||
git commit -m "feat : add repository queries for contract end notifications"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Service + Result DTO + Command
|
||||
|
||||
Assemble la détection (planner) et la persistance (Notification par admin, dédupliquée), exposée par une commande console.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Service/Notification/ContractEndNotificationResult.php`
|
||||
- Create: `src/Service/Notification/ContractEndNotificationService.php`
|
||||
- Create: `src/Command/ContractEndNotificationCommand.php`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes:
|
||||
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()` (Task 4)
|
||||
- `NotificationRepository::existsForRecipientCategoryTargetMessage(...)` (Task 4)
|
||||
- `App\Repository\UserRepository::findAllAdmins(): array` (existant)
|
||||
- `ContractEndNotificationPlanner::plan(...)` (Task 3) renvoyant `ContractEndNotice[]`
|
||||
- `App\Entity\Notification` setters `setRecipient/setMessage/setCategory/setTarget`
|
||||
- `Doctrine\ORM\EntityManagerInterface`
|
||||
- Produces:
|
||||
- `ContractEndNotificationResult::__construct(public int $notificationsCreated, public int $contractsMatched)`
|
||||
- `ContractEndNotificationService::run(DateTimeImmutable $today): ContractEndNotificationResult`
|
||||
- Commande `app:contract:end-notifications` avec option `--date=YYYY-MM-DD`.
|
||||
|
||||
- [ ] **Step 1: Create the Result DTO**
|
||||
|
||||
`src/Service/Notification/ContractEndNotificationResult.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
final readonly class ContractEndNotificationResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $notificationsCreated,
|
||||
public int $contractsMatched,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the service**
|
||||
|
||||
`src/Service/Notification/ContractEndNotificationService.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class ContractEndNotificationService
|
||||
{
|
||||
private const CATEGORY = 'Contrat';
|
||||
|
||||
public function __construct(
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private NotificationRepository $notificationRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ContractEndNotificationPlanner $planner,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||
{
|
||||
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||
$notices = $this->planner->plan($latestPeriods, $today);
|
||||
|
||||
if ([] === $notices) {
|
||||
return new ContractEndNotificationResult(0, 0);
|
||||
}
|
||||
|
||||
$admins = $this->userRepository->findAllAdmins();
|
||||
$created = 0;
|
||||
|
||||
foreach ($notices as $notice) {
|
||||
if (null === $notice->employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = '/employees/'.$notice->employeeId;
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||
$admin,
|
||||
self::CATEGORY,
|
||||
$target,
|
||||
$notice->message,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->setRecipient($admin)
|
||||
->setMessage($notice->message)
|
||||
->setCategory(self::CATEGORY)
|
||||
->setTarget($target)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($notification);
|
||||
++$created;
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new ContractEndNotificationResult($created, \count($notices));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create the command**
|
||||
|
||||
`src/Command/ContractEndNotificationCommand.php` :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\Notification\ContractEndNotificationService;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Throwable;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:contract:end-notifications',
|
||||
description: 'Notify admins on the last working day before a contract ends.'
|
||||
)]
|
||||
final class ContractEndNotificationCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContractEndNotificationService $service,
|
||||
#[Autowire(service: 'monolog.logger.cron')]
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'date',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$dateOption = $input->getOption('date');
|
||||
|
||||
try {
|
||||
$today = \is_string($dateOption) && '' !== $dateOption
|
||||
? new DateTimeImmutable($dateOption)
|
||||
: new DateTimeImmutable('today');
|
||||
} catch (Throwable $exception) {
|
||||
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$result = $this->service->run($today);
|
||||
|
||||
$this->logger->info('Contract end notifications generated.', [
|
||||
'date' => $today->format('Y-m-d'),
|
||||
'contractsMatched' => $result->contractsMatched,
|
||||
'notificationsCreated' => $result->notificationsCreated,
|
||||
]);
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||
$result->notificationsCreated,
|
||||
$result->contractsMatched,
|
||||
$today->format('Y-m-d'),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the suite still passes and the command is registered**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS (suite complète).
|
||||
|
||||
Run: `docker exec -t -u www-data php-sirh-fpm php bin/console list app:contract`
|
||||
Expected: la commande `app:contract:end-notifications` apparaît dans la liste.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Service/Notification/ContractEndNotificationResult.php src/Service/Notification/ContractEndNotificationService.php src/Command/ContractEndNotificationCommand.php
|
||||
git commit -m "feat : add contract end notification service and command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Vérification manuelle de bout en bout (commande)
|
||||
|
||||
Confirme que la commande crée bien des notifications sur des données réelles, et qu'elle est idempotente.
|
||||
|
||||
**Files:** aucun (vérification).
|
||||
|
||||
- [ ] **Step 1: Repérer un employé dont la dernière période finit bientôt**
|
||||
|
||||
Run (adapter la date au besoin) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||
"SELECT employee_id, MAX(start_date) AS s, end_date FROM employee_contract_periods GROUP BY employee_id HAVING end_date IS NOT NULL ORDER BY end_date DESC LIMIT 10"
|
||||
```
|
||||
Expected: liste d'employés avec leur dernière `end_date`. Choisir une `end_date` E pour viser un jour ouvré juste avant.
|
||||
|
||||
- [ ] **Step 2: Lancer la commande sur la veille ouvrée de E**
|
||||
|
||||
Run (remplacer `YYYY-MM-DD` par le dernier jour ouvré avant E) :
|
||||
```bash
|
||||
docker exec -t -u www-data php-sirh-fpm php bin/console app:contract:end-notifications --date=YYYY-MM-DD
|
||||
```
|
||||
Expected: `N notification(s) créée(s) pour M fin(s) de contrat...` avec M ≥ 1.
|
||||
|
||||
- [ ] **Step 3: Vérifier l'idempotence (relancer la même commande)**
|
||||
|
||||
Run: même commande qu'au Step 2.
|
||||
Expected: `0 notification(s) créée(s) pour M fin(s) de contrat...` (aucun doublon).
|
||||
|
||||
- [ ] **Step 4: Vérifier le contenu en base**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec -t -u www-data php-sirh-fpm php bin/console dbal:run-sql \
|
||||
"SELECT message, category, target, actor_id, is_read FROM notifications WHERE category='Contrat' ORDER BY id DESC LIMIT 5"
|
||||
```
|
||||
Expected: lignes `Fin de … de … le dd/mm/yyyy`, `category=Contrat`, `target=/employees/{id}`, `actor_id=NULL`, `is_read=0`.
|
||||
|
||||
> Aucune commande de commit ici — étape de vérification uniquement. Si un comportement diffère, revenir aux tasks concernées avant de continuer.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Front — afficher le message sans acteur
|
||||
|
||||
La notif fin de contrat a `actorName` vide ; supprimer le span gras vide.
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/AppTopNav.vue` (ligne 65)
|
||||
|
||||
- [ ] **Step 1: Remplacer la ligne de rendu du message**
|
||||
|
||||
Remplacer exactement (ligne 65) :
|
||||
|
||||
```html
|
||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```html
|
||||
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||
```
|
||||
|
||||
> Avec acteur : `**Jean** a validé les heures` (l'espace est dans le span). Sans acteur : `Fin de CDD de … le …` (pas de span, pas d'espace en tête).
|
||||
|
||||
- [ ] **Step 2: Vérifier le typecheck front**
|
||||
|
||||
Run: `cd frontend && npx vue-tsc --noEmit -p tsconfig.json 2>&1 | head -20`
|
||||
Expected: aucune nouvelle erreur liée à `AppTopNav.vue`. (Ne PAS lancer `npm run build`.)
|
||||
|
||||
> Si `vue-tsc` n'est pas disponible / trop lent, vérification visuelle suffisante : la modification est un simple `v-if` sur un span existant.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/AppTopNav.vue
|
||||
git commit -m "feat : render actorless notifications without empty bold span"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8 : Documentation
|
||||
|
||||
Mise à jour obligatoire (règles CLAUDE.md) : `doc/`, doc in-app, `CLAUDE.md`.
|
||||
|
||||
**Files:**
|
||||
- Create: `doc/contract-end-notifications.md`
|
||||
- Modify: `doc/functional-rules.md` (section 15) Notifications)
|
||||
- Modify: `frontend/data/documentation-content.ts`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Créer `doc/contract-end-notifications.md`**
|
||||
|
||||
```markdown
|
||||
# Notification de fin de contrat (veille du dernier jour)
|
||||
|
||||
## Objectif
|
||||
Prévenir les administrateurs, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||
salarié arrive au terme de son emploi.
|
||||
|
||||
## Déclenchement
|
||||
Commande `app:contract:end-notifications`, lancée chaque jour par le crontab de production
|
||||
(ex. `0 6 * * *`). Option `--date=YYYY-MM-DD` pour test/rattrapage. Logger `cron`.
|
||||
|
||||
## Règle métier
|
||||
- **Cible** : la **dernière** période de contrat d'un employé (aucune période ne lui succède).
|
||||
Un changement de contrat enchaîné (ex. CDD → CDI) ne notifie pas.
|
||||
- **Quand** : sur le **dernier jour ouvré strictement avant** `endDate` (`endDate` est inclusif
|
||||
= dernier jour travaillé). Les week-ends ET jours fériés (`PublicHolidayService`, zone
|
||||
`metropole`) sont sautés. Concrètement, le jour J ouvré couvre les fins de contrat dans
|
||||
l'intervalle `]J ; prochain_jour_ouvré(J)]` — un vendredi notifie ainsi les fins du
|
||||
samedi, dimanche et lundi (mardi si lundi férié).
|
||||
- **Destinataires** : tous les `ROLE_ADMIN`.
|
||||
- **Message** : `Fin de {CDI|CDD|Intérim} de {Prénom Nom} le {dd/mm/yyyy}`, catégorie
|
||||
`Contrat`, cible `/employees/{id}`, sans acteur.
|
||||
|
||||
## Idempotence
|
||||
Avant création, on vérifie l'absence d'une notif identique
|
||||
`(recipient, category='Contrat', target, message)`. Le message étant unique par
|
||||
(employé + date + nature), relancer la commande le même jour ne crée aucun doublon.
|
||||
|
||||
## Implémentation
|
||||
- `App\Service\Notification\WorkingDayCalculator` — jour ouvré / prochain jour ouvré.
|
||||
- `App\Service\Notification\ContractEndNotificationPlanner` — sélection + message (pur, testé).
|
||||
- `App\Service\Notification\ContractEndNotificationService` — persistance (1 notif/admin).
|
||||
- `App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||
- `EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees`,
|
||||
`NotificationRepository::existsForRecipientCategoryTargetMessage`.
|
||||
- Pas de migration : réutilise la table `notifications`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Compléter `doc/functional-rules.md` section 15) Notifications**
|
||||
|
||||
Repérer la section `15) Notifications` (vers ligne 475). Ajouter, à la fin de la section, ce paragraphe :
|
||||
|
||||
```markdown
|
||||
- **Fin de contrat (J-1 ouvré)** : une commande quotidienne (`app:contract:end-notifications`)
|
||||
notifie tous les admins, sur le dernier jour ouvré précédant la fin d'un contrat, qu'un
|
||||
salarié arrive au terme de son emploi. Cible = **dernière** période de l'employé (un
|
||||
changement de contrat enchaîné ne notifie pas). Week-ends et fériés sautés. Message
|
||||
« Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, lien vers la fiche employé,
|
||||
sans acteur. Idempotente. Détail : `doc/contract-end-notifications.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Ajouter une entrée dans la doc in-app `frontend/data/documentation-content.ts`**
|
||||
|
||||
Localiser la section/article traitant des notifications (rechercher `Notification` dans le
|
||||
fichier) au niveau d'accès `admin`. Y ajouter un bloc décrivant la notif fin de contrat. Si
|
||||
aucun article notifications n'existe au niveau admin, ajouter un article dans la section la
|
||||
plus proche (gestion employés / administration) avec `requiredLevel: 'admin'`. Exemple de bloc
|
||||
à insérer dans le tableau `blocks` de l'article :
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'paragraph',
|
||||
text: "Chaque jour ouvré, l'application prévient les administrateurs (cloche en haut à droite) lorsqu'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.",
|
||||
},
|
||||
```
|
||||
|
||||
> Respecter les types `DocBlock` de `frontend/types/documentation.ts` (vérifier le champ exact :
|
||||
> `text` vs `content`) en s'alignant sur les blocs voisins existants du fichier.
|
||||
|
||||
- [ ] **Step 4: Mettre à jour `CLAUDE.md`**
|
||||
|
||||
Sous la section `## Audit Logging` ou à la suite des sections « Notifications » existantes (il
|
||||
n'y a pas encore de section Notifications dédiée dans CLAUDE.md — l'ajouter), insérer :
|
||||
|
||||
```markdown
|
||||
## Notifications
|
||||
- Système : entité `Notification` (table `notifications`, `recipient`/`actor`/`message`/`category`/`target`/`isRead`), cloche **admin-only** dans `AppTopNav.vue`, providers `/notifications/{unread,today,history}` + `POST /notifications/mark-all-read`. Création historique : `WorkHourSiteValidationProcessor` (1 notif/admin via `UserRepository::findAllAdmins`).
|
||||
- **Fin de contrat (J-1 ouvré)** : commande cron quotidienne `app:contract:end-notifications` (crontab prod, ~6h ; option `--date`). Notifie les admins sur le **dernier jour ouvré avant** `endDate` (inclusif) de la **dernière** période d'un employé (changement de contrat enchaîné exclu). Week-ends + fériés sautés (`WorkingDayCalculator`). Fenêtre couverte un jour J = `]J ; prochain_jour_ouvré(J)]`. Message « Fin de {nature} de {Nom} le {date} », catégorie `Contrat`, target `/employees/{id}`, acteur null. Idempotent (`NotificationRepository::existsForRecipientCategoryTargetMessage`). Logique pure testée : `ContractEndNotificationPlanner` + `WorkingDayCalculator`. Front : `AppTopNav.vue` masque le span acteur si `actorName` vide. Doc : `doc/contract-end-notifications.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/contract-end-notifications.md doc/functional-rules.md frontend/data/documentation-content.ts CLAUDE.md
|
||||
git commit -m "docs : document contract end notification feature"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (effectuée à la rédaction)
|
||||
|
||||
- **Couverture du spec** : détection (Task 1+3), idempotence (Task 4+5), création/destinataires (Task 5), commande cron (Task 5), front acteur vide (Task 7), tests (Task 1, 3), docs 4 fichiers (Task 8), vérif e2e (Task 6). ✅
|
||||
- **Pas de placeholder** : tout le code est fourni ; les seules zones « à adapter » sont des valeurs runtime (dates réelles en Task 6) et l'emplacement exact de l'article doc in-app (Task 8 Step 3), explicitement cadrées. ✅
|
||||
- **Cohérence des types** : `WorkingDayCalculator::{isWorkingDay,nextWorkingDay}`, `ContractEndNotificationPlanner::plan(array, DateTimeImmutable): ContractEndNotice[]`, `ContractEndNotice{employeeId,message}`, `ContractEndNotificationResult{notificationsCreated,contractsMatched}`, `findLatestPeriodsForAllEmployees()`, `existsForRecipientCategoryTargetMessage()` — noms identiques entre définition et usage. ✅
|
||||
- **Note** : `findLatestPeriodsForAllEmployees` renvoie la période de `startDate` max par employé ; en cas d'égalité exacte de `startDate` (anomalie de données) plusieurs lignes peuvent remonter pour un même employé — sans impact fonctionnel (la dédup par message évite les doublons de notif).
|
||||
@@ -0,0 +1,255 @@
|
||||
# GlitchTip Backend Error Tracking Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remonter les erreurs backend Symfony de SIRH vers GlitchTip (SDK Sentry), en prod uniquement, inerte sans DSN, le transport réseau étant assuré par Tailscale (infra, hors repo).
|
||||
|
||||
**Architecture:** On installe `sentry/sentry-symfony`, enregistré **prod-only** dans `bundles.php`. Un fichier `config/packages/sentry.yaml` (bloc `when@prod`) configure le SDK (DSN runtime `%env(SENTRY_DSN)%`, release = `%app.version%`, 4xx ignorés, pas de tracing) et déclare un handler Monolog qui remonte les logs `ERROR+` comme Issues. Sans `SENTRY_DSN`, le SDK est un no-op. Aucun changement frontend ni CI.
|
||||
|
||||
**Tech Stack:** PHP 8.4, Symfony 8, API Platform, `sentry/sentry-symfony` ^5.10, Monolog, Docker (prod), Tailscale (transport infra).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Prod only** : `SentryBundle` enregistré `['prod' => true]`, config sous `when@prod`. Dev/test : zéro impact.
|
||||
- **Inerte sans DSN** : `env(SENTRY_DSN)` défaut `''` → SDK no-op si vide.
|
||||
- **DSN runtime** : lu depuis l'env_file serveur, jamais baké dans l'image, jamais committé.
|
||||
- **Version dépendance** : `sentry/sentry-symfony:^5.10` (identique au projet Lesstime, même stack).
|
||||
- **Release** : `%app.version%` (déjà fourni par `config/version.yaml`, ex. `0.1.127`).
|
||||
- **Pas de tracing/APM** : `traces_sample_rate: 0.0`.
|
||||
- **Format commit** (hook SIRH) : Conventional Commits FR, ex. `feat : …`, `docs : …`. Terminer par la ligne `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||
- **Pas de modif frontend** (`frontend/**`), **pas de modif CI** (`.gitea/workflows/**`), **pas de modif Dockerfile** (DSN runtime ; CA HTTPS hors périmètre).
|
||||
- **Branche** : `feat/glitchtip-backend-error-tracking` (déjà créée).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Câblage backend Sentry/GlitchTip (inerte sans DSN)
|
||||
|
||||
**Files:**
|
||||
- Modify: `composer.json`, `composer.lock` (via `composer require`)
|
||||
- Modify: `config/bundles.php`
|
||||
- Create: `config/packages/sentry.yaml`
|
||||
- Modify: `config/packages/monolog.yaml`
|
||||
- Modify: `.env`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: paramètre `%app.version%` (`config/version.yaml`), variable d'env `SENTRY_DSN`, `%env(APP_ENV)%`.
|
||||
- Produces: service `Sentry\Monolog\Handler` (handler Monolog niveau `Error`), variable d'env attendue `SENTRY_DSN` (runtime). Aucune API PHP consommée par d'autres tâches.
|
||||
|
||||
- [ ] **Step 1: Installer la dépendance**
|
||||
|
||||
Depuis le container PHP (`make shell` ou `docker compose exec php sh`) :
|
||||
```bash
|
||||
composer require sentry/sentry-symfony:^5.10
|
||||
```
|
||||
Expected : `composer.json` + `composer.lock` modifiés, paquet `sentry/sentry-symfony` 5.x installé.
|
||||
|
||||
- [ ] **Step 2: Forcer l'enregistrement prod-only du bundle**
|
||||
|
||||
Le recipe Flex peut écrire `['all' => true]` dans `config/bundles.php`. Corriger en prod-only. Contenu attendu de la ligne ajoutée :
|
||||
```php
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
// ... dans le tableau de retour :
|
||||
SentryBundle::class => ['prod' => true],
|
||||
```
|
||||
Si Flex a aussi généré un `config/packages/sentry.yaml`, il sera remplacé à l'étape suivante.
|
||||
|
||||
- [ ] **Step 3: Écrire `config/packages/sentry.yaml`**
|
||||
|
||||
Remplacer intégralement le contenu du fichier par :
|
||||
```yaml
|
||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
|
||||
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
|
||||
when@prod:
|
||||
parameters:
|
||||
env(SENTRY_DSN): ''
|
||||
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
# Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener
|
||||
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du
|
||||
# kernel sont déjà logguées par Symfony → remontées via Monolog).
|
||||
register_error_listener: false
|
||||
register_error_handler: true
|
||||
options:
|
||||
environment: '%env(APP_ENV)%'
|
||||
release: '%app.version%'
|
||||
traces_sample_rate: 0.0
|
||||
ignore_exceptions:
|
||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||
|
||||
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
|
||||
services:
|
||||
Sentry\Monolog\Handler:
|
||||
arguments:
|
||||
$hub: '@Sentry\State\HubInterface'
|
||||
$level: !php/const Monolog\Level::Error
|
||||
$bubble: true
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Brancher le handler Monolog en prod**
|
||||
|
||||
Dans `config/packages/monolog.yaml`, sous `when@prod:` → `monolog:` → `handlers:`, ajouter le handler `sentry` (à côté des handlers `cron`, `main`, `deprecation` existants, sans les modifier) :
|
||||
```yaml
|
||||
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini
|
||||
# dans sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
|
||||
sentry:
|
||||
type: service
|
||||
id: Sentry\Monolog\Handler
|
||||
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Documenter la variable dans `.env`**
|
||||
|
||||
Ajouter à la fin de `.env` le bloc commenté (valeur réelle injectée côté serveur uniquement) :
|
||||
```env
|
||||
###> sentry/sentry-symfony ###
|
||||
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
|
||||
# À définir dans l'env_file du serveur, PAS ici. Format :
|
||||
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
|
||||
# SENTRY_DSN=
|
||||
###< sentry/sentry-symfony ###
|
||||
```
|
||||
> Si Flex a déjà inséré un bloc `###> sentry/sentry-symfony ###`, le remplacer par celui-ci (ne pas dupliquer).
|
||||
|
||||
- [ ] **Step 6: Vérifier que la conf prod compile (DSN vide → inerte)**
|
||||
|
||||
Depuis le container :
|
||||
```bash
|
||||
APP_ENV=prod php bin/console cache:clear --no-warmup
|
||||
APP_ENV=prod php bin/console debug:container --env=prod 2>/dev/null | grep -i sentry | head
|
||||
```
|
||||
Expected :
|
||||
- `cache:clear` : `[OK] Cleared the cache.` (aucune erreur de config/DI).
|
||||
- `debug:container` : au moins une ligne `Sentry\...` (services chargés). DSN vide → SDK inerte, aucun envoi.
|
||||
|
||||
- [ ] **Step 7: Vérifier que le dev n'est pas impacté**
|
||||
|
||||
```bash
|
||||
php bin/console cache:clear # APP_ENV=dev par défaut
|
||||
make test
|
||||
```
|
||||
Expected : cache dev OK ; suite de tests verte (le bundle n'est pas chargé hors prod).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add composer.json composer.lock config/bundles.php config/packages/sentry.yaml config/packages/monolog.yaml .env
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat : error tracking backend vers GlitchTip (prod-only, inerte sans DSN)
|
||||
|
||||
Ajout du SDK sentry/sentry-symfony enregistré prod-only, config sentry.yaml
|
||||
(DSN runtime, release app.version, 4xx ignorés, pas de tracing) et handler
|
||||
Monolog ERROR+. Sans SENTRY_DSN le SDK est no-op. Transport réseau via
|
||||
Tailscale (infra, hors repo).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Documentation (doc/ + CLAUDE.md)
|
||||
|
||||
**Files:**
|
||||
- Create: `doc/error-tracking.md`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: néant (documentation).
|
||||
- Produces: néant.
|
||||
|
||||
- [ ] **Step 1: Créer `doc/error-tracking.md`**
|
||||
|
||||
```markdown
|
||||
# Error tracking (GlitchTip)
|
||||
|
||||
Les erreurs **backend** (Symfony) sont remontées vers **GlitchTip** (instance interne MALIO,
|
||||
compatible SDK Sentry), org `malio`, projet `sirh-api`. **Prod uniquement**, **inerte sans DSN**.
|
||||
|
||||
> Frontend hors périmètre (les erreurs front partent du navigateur RH ; ajout futur possible via
|
||||
> un proxy nginx `/ingest`).
|
||||
|
||||
## Contrainte réseau & transport
|
||||
|
||||
GlitchTip est sur le réseau interne (bloqué par Sophos). SIRH tourne sur un VPS OVH public. Le
|
||||
container PHP joint GlitchTip via un **tunnel Tailscale** monté sur le host de prod.
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `config/packages/sentry.yaml` | conf backend (prod-only, DSN runtime, 4xx ignorés, release = `app.version`, handler Monolog ERROR+) |
|
||||
| `config/bundles.php` | `SentryBundle` enregistré `['prod' => true]` |
|
||||
| `config/packages/monolog.yaml` | handler `sentry` (service) en `when@prod` |
|
||||
| `.env` | bloc documenté `SENTRY_DSN` (vide → inerte) |
|
||||
|
||||
## Activation (runbook)
|
||||
|
||||
1. **Tailscale sur le host prod OVH** :
|
||||
\`\`\`bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up # ou --authkey tskey-auth-XXXX (headless)
|
||||
tailscale status && tailscale ip -4
|
||||
\`\`\`
|
||||
2. **Vérifier l'accès à GlitchTip** depuis le host :
|
||||
\`\`\`bash
|
||||
tailscale ping <glitchtip-tailnet>
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" http://<glitchtip-IP-tailnet>:<port>/_health/
|
||||
\`\`\`
|
||||
3. **Routage container → tailnet** : pointer `SENTRY_DSN` sur l'**IP tailnet** de GlitchTip
|
||||
(le container ne résout pas MagicDNS). Repli si non routé : sidecar `tailscale/tailscale`
|
||||
+ `network_mode: service:tailscale`.
|
||||
4. **Créer le projet GlitchTip** `sirh-api` (plateforme `php-symfony`) dans l'org `malio`,
|
||||
récupérer le DSN (Settings → Client Keys).
|
||||
5. **Injecter le DSN** dans l'env_file serveur (pas dans l'image), puis redéployer :
|
||||
\`\`\`env
|
||||
SENTRY_DSN=http://<clé>@100.x.y.z:<port>/<id-sirh-api>
|
||||
\`\`\`
|
||||
\`\`\`bash
|
||||
docker compose up -d
|
||||
docker compose exec php php bin/console cache:clear --env=prod
|
||||
\`\`\`
|
||||
|
||||
## CA HTTPS (conditionnel)
|
||||
|
||||
Uniquement si le DSN cible l'HTTPS interne `logs.malio-dev.fr` (cert auto-signé) : baker la CA
|
||||
racine MALIO dans `deploy/docker/Dockerfile.prod` (stage production). Recommandé : préférer
|
||||
l'endpoint **HTTP** via le tailnet (déjà chiffré par WireGuard) → pas de CA.
|
||||
|
||||
Design détaillé : `docs/superpowers/specs/2026-06-28-glitchtip-backend-error-tracking-design.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Ajouter une section à `CLAUDE.md`**
|
||||
|
||||
Insérer une section (après « ## Notifications ») :
|
||||
```markdown
|
||||
## Error tracking (GlitchTip)
|
||||
- Backend Symfony → GlitchTip (SDK Sentry), org `malio`, projet `sirh-api`. **Prod only**, **inerte sans `SENTRY_DSN`**.
|
||||
- Config : `config/packages/sentry.yaml` (DSN runtime `%env(SENTRY_DSN)%`, release `%app.version%`, 4xx ignorés, `traces_sample_rate: 0`, handler Monolog ERROR+) ; `SentryBundle` enregistré `['prod' => true]` (`config/bundles.php`) ; handler `sentry` en `when@prod` (`config/packages/monolog.yaml`). DSN runtime via l'env_file serveur, jamais committé/baké.
|
||||
- **Transport réseau** : GlitchTip est interne (bloqué Sophos), SIRH sur VPS OVH → tunnel **Tailscale** sur le host prod. Frontend hors périmètre (ajout futur via proxy nginx `/ingest`).
|
||||
- Doc : `doc/error-tracking.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add doc/error-tracking.md CLAUDE.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs : documentation error tracking GlitchTip (doc/ + CLAUDE.md)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes d'exécution
|
||||
|
||||
- **TDD non applicable** ici : ce sont des changements de **configuration** (pas de logique métier unitairement testable). La vérification se fait par commandes console (`cache:clear` prod, `debug:container`) + non-régression de la suite existante (`make test`). Ne pas ajouter de test PHPUnit factice.
|
||||
- Les valeurs `<…>` du runbook (IP tailnet, port GlitchTip, clé DSN) sont renseignées au **déploiement**, hors repo.
|
||||
- Après les deux tasks : pousser la branche et ouvrir la MR (`git push -u origin feat/glitchtip-backend-error-tracking`).
|
||||
@@ -0,0 +1,142 @@
|
||||
# Notification de fin de contrat (veille du dernier jour) — Design
|
||||
|
||||
**Date :** 2026-06-24
|
||||
**Branche :** feature/SIRH-43-ajouter-une-notif-la-veille-d-un-contrat-qui-se-te
|
||||
**Statut :** Validé (brainstorming)
|
||||
|
||||
## Objectif
|
||||
|
||||
Prévenir automatiquement **les administrateurs**, sur le **dernier jour ouvré précédant la fin
|
||||
d'un contrat**, qu'un salarié arrive au terme de son emploi — afin qu'ils puissent anticiper
|
||||
(solde de tout compte, désactivation des accès, etc.).
|
||||
|
||||
La notification réutilise le **système de notification existant** (entité `Notification`, cloche
|
||||
admin dans `AppTopNav.vue`). Aucune migration de base de données.
|
||||
|
||||
## Décisions de cadrage
|
||||
|
||||
| Sujet | Décision |
|
||||
|---|---|
|
||||
| **Déclencheur** | Vraie fin d'emploi uniquement : la période qui se termine est la **dernière** période de contrat du salarié (aucune période ne lui succède). Un changement de contrat enchaîné (ex. CDD 35h → CDI 39h) ne notifie pas. |
|
||||
| **Timing** | Le **dernier jour ouvré strictement avant** `endDate`, en sautant **week-ends ET jours fériés**. |
|
||||
| **Message** | « Fin de {nature} de {Prénom Nom} le {dd/mm/yyyy} » — nature = libellé FR (CDI / CDD / Intérim). |
|
||||
| **Catégorie** | `Contrat` |
|
||||
| **Cible du clic** | `/employees/{id}` (fiche employé). |
|
||||
| **Destinataires** | Tous les `ROLE_ADMIN` (`UserRepository::findAllAdmins()`). |
|
||||
| **Acteur** | `null` (notif générée par un job automatique, pas par un utilisateur). |
|
||||
| **Déclenchement** | Commande console quotidienne via crontab prod (~6h du matin). |
|
||||
|
||||
## Rappels sur l'existant
|
||||
|
||||
- `Notification` (table `notifications`) : `recipient` (NOT NULL), `actor` (nullable),
|
||||
`message`, `category`, `target`, `isRead`, `createdAt`. Exposé via `getActorName()`.
|
||||
- `endDate` d'une `EmployeeContractPeriod` est **inclusif** : c'est le dernier jour couvert
|
||||
par le contrat (`findOneCoveringDate` : `endDate >= :date`).
|
||||
- Pattern de notif existant : `WorkHourSiteValidationProcessor` crée une `Notification` par
|
||||
admin (`findAllAdmins`) avec `new Notification()` + persist + flush. Pas de service factory.
|
||||
- Pattern cron existant : `RttRolloverCommand` / `LeaveRolloverCommand` (`#[AsCommand]`,
|
||||
logger `monolog.logger.cron`, options `--force`/`--recompute`). Déclenchées par le crontab
|
||||
système (pas de Symfony Scheduler dans le projet).
|
||||
- `PublicHolidayService` : source des fériés (cache 30j), déjà en place.
|
||||
|
||||
## Approche retenue
|
||||
|
||||
**Commande cron quotidienne + service métier dédié et testable.**
|
||||
|
||||
Alternatives écartées :
|
||||
- **Symfony Scheduler (Messenger)** : brique non utilisée dans le projet, inutile ici.
|
||||
- **Calcul à la volée dans le provider** : casse `isRead`/historique, recalcul à chaque
|
||||
ouverture de la cloche, mélange notifs persistées et virtuelles.
|
||||
|
||||
## Conception détaillée
|
||||
|
||||
### 1. Détection (cœur métier)
|
||||
|
||||
Nouveau service `App\Service\Notification\ContractEndNotificationService`.
|
||||
|
||||
Algorithme (date du jour `T` injectable pour les tests) :
|
||||
|
||||
1. Si `T` est un week-end ou un férié → **sortie** (aucun jour chômé ne génère de notif).
|
||||
2. Calculer `N` = **prochain jour ouvré strictement après `T`** (saute week-ends + fériés via
|
||||
`PublicHolidayService`).
|
||||
3. Charger en **une seule requête** la dernière période de chaque employé
|
||||
(`EmployeeContractPeriodRepository::findLatestPeriodsForAllEmployees()`).
|
||||
4. Candidat = dernière période dont `endDate` est non nul et vérifie `T < endDate <= N`.
|
||||
- Le test « dernière période » assure nativement la règle « vraie fin d'emploi » : un
|
||||
changement de contrat enchaîné a une période suivante, donc n'est jamais la dernière.
|
||||
- `endDate = null` (CDI ouvert) → jamais candidat.
|
||||
|
||||
**Exemples :**
|
||||
- Mardi (`T`), `N` = mercredi → notifie les contrats finissant mercredi (J-1 classique).
|
||||
- Vendredi (`T`), `N` = lundi → notifie les contrats finissant samedi, dimanche **ou** lundi.
|
||||
- Vendredi (`T`), lundi férié → `N` = mardi → notifie samedi…mardi.
|
||||
- Week-end (`T`) → rien.
|
||||
|
||||
### 2. Création des notifications & idempotence
|
||||
|
||||
Pour chaque candidat :
|
||||
- Message : `Fin de {nature} de {Prénom Nom} le {endDate->format('d/m/Y')}`.
|
||||
- Une `Notification` par admin : `recipient=admin`, `actor=null`, `category='Contrat'`,
|
||||
`target='/employees/{id}'`. Persist groupé, un seul `flush()` final.
|
||||
|
||||
**Idempotence** (le job peut être relancé le même jour) : avant création, vérifier qu'il
|
||||
n'existe pas déjà une notif identique pour ce destinataire via
|
||||
`(recipient, category='Contrat', target='/employees/{id}', message)` **exact**. Le message
|
||||
étant unique par (employé + date + nature), cela empêche tout doublon — y compris après que
|
||||
l'admin a lu la notif (`isRead=true`). Nouvelle méthode
|
||||
`NotificationRepository::existsForRecipientTargetMessage(...)` (ou `findOneBy`). Pas de
|
||||
migration : on ne stocke pas de FK « période » sur `Notification`.
|
||||
|
||||
### 3. Affichage front (cloche)
|
||||
|
||||
`AppTopNav.vue` rend aujourd'hui `**{actorName}** {message}`. Pour `actorName` vide :
|
||||
- N'afficher que `{message}` (pas de span gras vide ni `capitalize` orphelin).
|
||||
- Le reste est inchangé : avatar/pastille, `formatTimeAgo` + catégorie « Contrat », point
|
||||
non-lu, lien `target`.
|
||||
|
||||
Aucune route ni service front nouveau : `category` et `target` passent par le DTO existant.
|
||||
Cloche déjà admin-only → rien d'autre côté visibilité.
|
||||
|
||||
### 4. Commande console
|
||||
|
||||
`App\Command\ContractEndNotificationCommand` — `app:contract:end-notifications`.
|
||||
- Délègue tout au service.
|
||||
- Option `--date=YYYY-MM-DD` : forcer la date du jour (tests / rattrapage manuel).
|
||||
- Logger `monolog.logger.cron`. Sortie `SymfonyStyle` (nb de notifs créées / employés concernés).
|
||||
- **Idempotente** par construction (cf. §2) → relançable sans risque.
|
||||
- Crontab prod (infra) : `0 6 * * *` (tous les jours, 6h). Pas de restriction jour de semaine
|
||||
(la commande s'auto-neutralise week-ends/fériés).
|
||||
|
||||
## Fichiers
|
||||
|
||||
**Backend — nouveaux**
|
||||
- `src/Service/Notification/ContractEndNotificationService.php`
|
||||
- `src/Command/ContractEndNotificationCommand.php`
|
||||
- `tests/...` (tests du service)
|
||||
|
||||
**Backend — modifiés**
|
||||
- `src/Repository/EmployeeContractPeriodRepository.php` — `findLatestPeriodsForAllEmployees()`
|
||||
- `src/Repository/NotificationRepository.php` — existence anti-doublon
|
||||
- Helper « jour ouvré » (week-end + férié) — dans le service ou petit util réutilisable
|
||||
|
||||
**Frontend — modifié**
|
||||
- `frontend/components/AppTopNav.vue` — gérer `actorName` vide
|
||||
|
||||
**Docs**
|
||||
- `doc/functional-rules.md` — compléter la section 15) Notifications
|
||||
- `doc/contract-end-notifications.md` — nouveau (règle complète)
|
||||
- `frontend/data/documentation-content.ts` — entrée admin
|
||||
- `CLAUDE.md` — note du nouveau pattern (commande cron de notification)
|
||||
|
||||
Pas de migration DB.
|
||||
|
||||
## Tests (PHPUnit)
|
||||
|
||||
Cœur isolé dans `ContractEndNotificationService` (date `T` fixe + `PublicHolidayService` mocké) :
|
||||
- Fin mercredi, `T`=mardi → 1 notif/admin.
|
||||
- Fin lundi, `T`=vendredi → notifié vendredi ; rien samedi/dimanche.
|
||||
- Lundi férié + fin mardi, `T`=vendredi → notifié vendredi (`N` saute le lundi férié).
|
||||
- Période suivante existante (changement enchaîné) → pas de notif.
|
||||
- `endDate=null` → pas de notif.
|
||||
- Idempotence : 2ᵉ exécution même jour → aucun doublon.
|
||||
- `T` = week-end → aucune création.
|
||||
@@ -0,0 +1,223 @@
|
||||
# Error tracking backend SIRH → GlitchTip (via Tailscale)
|
||||
|
||||
> Date : 2026-06-28
|
||||
> Périmètre : **backend Symfony uniquement**, **prod only**, transport **Tailscale**.
|
||||
> Référence pattern : projet **Lesstime** (`config/packages/sentry.yaml`, `README.md` § Error tracking).
|
||||
|
||||
## 1. Contexte & contrainte
|
||||
|
||||
GlitchTip (instance auto-hébergée MALIO, compatible SDK Sentry) vit sur le **réseau interne**,
|
||||
bloqué par **Sophos**, sur le domaine interne `logs.malio-dev.fr` (DNS local, CA auto-signée).
|
||||
SIRH tourne sur un **VPS OVH** (Internet public) → le container PHP ne peut pas joindre l'interne.
|
||||
|
||||
**Décision** : on monte un **tunnel Tailscale** sur le host de prod OVH. Le container PHP atteint
|
||||
GlitchTip par le tailnet. **Backend seulement** pour l'instant (les erreurs front partent du
|
||||
navigateur RH, hors périmètre — pourra être ajouté plus tard via un proxy nginx `/ingest`).
|
||||
|
||||
Flux retenu :
|
||||
|
||||
| Flux | Source | Chemin vers GlitchTip |
|
||||
|---|---|---|
|
||||
| **Backend** Symfony | container PHP sur le VPS OVH | → host Tailscale → tailnet → GlitchTip ✅ |
|
||||
| Frontend SPA | navigateur RH | **hors périmètre** (pas de SDK front) |
|
||||
|
||||
## 2. Principes
|
||||
|
||||
- **Prod only** : le bundle n'est enregistré que pour `prod`. En dev/test : zéro impact.
|
||||
- **Inerte sans DSN** : si `SENTRY_DSN` est vide/absent, le SDK ne fait rien (no-op).
|
||||
- **Runtime DSN** : le DSN est lu à l'exécution depuis l'`env_file` du serveur, jamais baké dans
|
||||
l'image (pas de secret dans le repo ni dans l'image Docker).
|
||||
- **Pas d'APM/tracing** (`traces_sample_rate: 0`) : on ne remonte que les erreurs.
|
||||
- **Bruit filtré** : 4xx HTTP (404/405/AccessDenied) ignorés ; channels `event/doctrine/
|
||||
deprecation/cron` exclus du handler Monolog.
|
||||
|
||||
## 3. Changements de code (repo SIRH)
|
||||
|
||||
### 3.1 Dépendance
|
||||
```bash
|
||||
make shell # ou docker exec dans le container php
|
||||
composer require sentry/sentry-symfony:^5.10
|
||||
```
|
||||
Met à jour `composer.json` + `composer.lock`. Version identique à Lesstime (stack PHP 8.4 /
|
||||
Symfony 8 commune).
|
||||
|
||||
### 3.2 `config/bundles.php`
|
||||
Ajouter l'enregistrement **prod-only** :
|
||||
```php
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
// ...
|
||||
SentryBundle::class => ['prod' => true],
|
||||
```
|
||||
> `composer require` ajoute généralement la ligne `['all' => true]` via Flex — la corriger en
|
||||
> `['prod' => true]`.
|
||||
|
||||
### 3.3 `config/packages/sentry.yaml` *(nouveau fichier)*
|
||||
```yaml
|
||||
# Error tracking → GlitchTip (compatible SDK Sentry).
|
||||
# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php).
|
||||
# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé).
|
||||
when@prod:
|
||||
parameters:
|
||||
env(SENTRY_DSN): ''
|
||||
|
||||
sentry:
|
||||
dsn: '%env(SENTRY_DSN)%'
|
||||
# On capture les erreurs fatales PHP via le handler, mais on DÉSACTIVE le listener
|
||||
# kernel pour éviter les doublons avec le handler Monolog (les exceptions du kernel
|
||||
# sont déjà logguées par Symfony → remontées via Monolog).
|
||||
register_error_listener: false
|
||||
register_error_handler: true
|
||||
options:
|
||||
environment: '%env(APP_ENV)%'
|
||||
release: '%app.version%'
|
||||
traces_sample_rate: 0.0
|
||||
ignore_exceptions:
|
||||
- Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
||||
- Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException
|
||||
- Symfony\Component\Security\Core\Exception\AccessDeniedException
|
||||
|
||||
# Handler Monolog → Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip.
|
||||
services:
|
||||
Sentry\Monolog\Handler:
|
||||
arguments:
|
||||
$hub: '@Sentry\State\HubInterface'
|
||||
$level: !php/const Monolog\Level::Error
|
||||
$bubble: true
|
||||
```
|
||||
> `release: '%app.version%'` réutilise `config/version.yaml` (`app.version`, ex. `0.1.127`).
|
||||
|
||||
### 3.4 `config/packages/monolog.yaml`
|
||||
Dans le bloc `when@prod.monolog.handlers`, ajouter :
|
||||
```yaml
|
||||
# Remonte les logs ERROR+ vers GlitchTip en tant qu'Issues (service défini dans
|
||||
# sentry.yaml). Envoi immédiat, indépendamment des handlers fichier.
|
||||
sentry:
|
||||
type: service
|
||||
id: Sentry\Monolog\Handler
|
||||
channels: ["!event", "!doctrine", "!deprecation", "!cron"]
|
||||
```
|
||||
> Les autres handlers (`main`, `cron`, `deprecation`) restent inchangés.
|
||||
|
||||
### 3.5 `.env` (+ `.env.example` si présent)
|
||||
Bloc documenté (valeur réelle injectée côté serveur uniquement) :
|
||||
```env
|
||||
###> sentry/sentry-symfony ###
|
||||
# Error tracking backend → GlitchTip (projet "sirh-api"). Prod only, vide => inerte.
|
||||
# À définir dans l'env_file du serveur, PAS ici. Format :
|
||||
# SENTRY_DSN=http://<clé>@<host-ou-IP-tailnet>:<port>/<id-projet>
|
||||
# SENTRY_DSN=
|
||||
###< sentry/sentry-symfony ###
|
||||
```
|
||||
|
||||
### 3.6 CI / Dockerfile
|
||||
**Aucun changement requis** pour le backend : le DSN est runtime (env_file). La CI
|
||||
(`.gitea/workflows/build-docker.yml`) ne build/push que l'image — rien à toucher.
|
||||
|
||||
**CA TLS (conditionnel)** — voir §4.4 : nécessaire **uniquement** si le DSN cible l'HTTPS interne
|
||||
`logs.malio-dev.fr`. Si on tape l'endpoint **HTTP** GlitchTip via le tailnet (recommandé), pas de
|
||||
modif Dockerfile.
|
||||
|
||||
## 4. Runbook infra (hors repo) — toutes les étapes & commandes
|
||||
|
||||
### 4.1 Installer Tailscale sur le host de prod OVH
|
||||
```bash
|
||||
# Sur le serveur OVH (Debian/Ubuntu), en root/sudo :
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
|
||||
# Jointure du tailnet (ouvre une URL d'auth, ou utiliser une auth key headless) :
|
||||
sudo tailscale up
|
||||
# --- headless (CI/scripté) :
|
||||
# sudo tailscale up --authkey tskey-auth-XXXXXXXXXXXX
|
||||
|
||||
# Vérifier l'état et récupérer l'IP tailnet du serveur :
|
||||
tailscale status
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
> **Si GlitchTip est sur une autre machine du tailnet** : noter son IP tailnet (`100.x.y.z`) ou son
|
||||
> nom MagicDNS. **Si GlitchTip est derrière un subnet router** (LAN interne non tailnet) : ajouter
|
||||
> `--accept-routes` au `tailscale up`, et s'assurer qu'un subnet router annonce le sous-réseau.
|
||||
|
||||
### 4.2 Vérifier la connectivité host → GlitchTip via le tailnet
|
||||
```bash
|
||||
# Depuis le host OVH :
|
||||
tailscale ping <glitchtip-tailnet-name-ou-IP>
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" http://<glitchtip-IP-tailnet>:<port>/_health/ # → 200 attendu
|
||||
```
|
||||
|
||||
### 4.3 Rendre le tailnet joignable depuis le container PHP
|
||||
|
||||
Le container PHP est sur le réseau bridge Docker, pas directement sur le tailnet. Deux options :
|
||||
|
||||
**Option A — Host Tailscale + IP tailnet dans le DSN (recommandé, simple).**
|
||||
L'egress du container est masqueradé par le host, qui route `100.x.y.z` via `tailscale0`.
|
||||
→ Pointer `SENTRY_DSN` directement sur l'**IP tailnet** de GlitchTip (pas MagicDNS, que le
|
||||
container ne résout pas). Optionnellement figer le nom via `extra_hosts` dans le compose :
|
||||
```yaml
|
||||
# docker-compose.yml (serveur)
|
||||
extra_hosts:
|
||||
- "glitchtip.tailnet:100.x.y.z"
|
||||
```
|
||||
Prérequis : IP forwarding actif sur le host (`net.ipv4.ip_forward=1`, déjà posé par l'install
|
||||
Tailscale).
|
||||
|
||||
**Option B — Sidecar Tailscale (robuste, si A ne route pas).**
|
||||
Service `tailscale/tailscale` dans le compose, et le container app en
|
||||
`network_mode: service:tailscale` → l'app partage l'interface tailnet (MagicDNS dispo).
|
||||
À retenir seulement si l'option A ne fonctionne pas.
|
||||
|
||||
### 4.4 (Conditionnel) CA racine MALIO — uniquement si DSN = HTTPS interne
|
||||
Si le DSN cible `https://logs.malio-dev.fr` (cert auto-signé), baker la CA dans l'image
|
||||
(`deploy/docker/Dockerfile.prod`, stage `production`) — `ca-certificates` est déjà installé :
|
||||
```dockerfile
|
||||
COPY deploy/docker/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt
|
||||
RUN update-ca-certificates
|
||||
```
|
||||
(Le `.crt` public est récupérable depuis le repo Lesstime : `infra/prod/malio-dev-root-ca.crt`.)
|
||||
Vérification :
|
||||
```bash
|
||||
curl --cacert deploy/docker/malio-dev-root-ca.crt https://logs.malio-dev.fr/api/<id>/store/
|
||||
```
|
||||
> **Recommandation** : préférer l'endpoint **HTTP** via le tailnet (déjà chiffré par WireGuard) →
|
||||
> on évite complètement la CA et cette modif Dockerfile.
|
||||
|
||||
### 4.5 Créer le projet GlitchTip `sirh-api`
|
||||
Dans l'UI GlitchTip (org `malio`) : **New Project** → plateforme `php-symfony` → nom `sirh-api`.
|
||||
Récupérer le **DSN** dans *Settings → Client Keys (DSN)*. Adapter le host du DSN à l'IP/nom tailnet
|
||||
si nécessaire.
|
||||
> Le MCP GlitchTip est en lecture seule (pas de `create_project`) → création manuelle UI.
|
||||
|
||||
### 4.6 Injecter le DSN sur le serveur
|
||||
Ajouter à l'`env_file` du docker-compose serveur (PAS dans l'image), puis redéployer :
|
||||
```env
|
||||
SENTRY_DSN=http://<clé>@100.x.y.z:<port>/<id-sirh-api>
|
||||
```
|
||||
```bash
|
||||
docker compose up -d # recharge l'env_file
|
||||
docker compose exec php php bin/console cache:clear --env=prod
|
||||
```
|
||||
|
||||
## 5. Documentation (règles SIRH)
|
||||
|
||||
- **`doc/error-tracking.md`** *(nouveau)* : pattern back, activation, runbook Tailscale, CA, lien
|
||||
vers ce spec.
|
||||
- **`CLAUDE.md`** : nouvelle section « Error tracking (GlitchTip) » résumant le pattern + le fait
|
||||
que c'est prod-only / inerte sans DSN / transport Tailscale.
|
||||
- **In-app documentation** (`frontend/data/documentation-content.ts`) : **non concernée** —
|
||||
infra invisible pour les utilisateurs RH (employé/chef de site/admin), aucun changement
|
||||
fonctionnel UI.
|
||||
|
||||
## 6. Vérification
|
||||
|
||||
| Niveau | Test | Attendu |
|
||||
|---|---|---|
|
||||
| Dev (sans DSN) | `make test`, boot dev | aucune régression, SDK absent en dev |
|
||||
| Prod config | build image + `APP_ENV=prod cache:clear` (DSN bidon) | bundle chargé, pas d'erreur de conf |
|
||||
| Inerte | prod sans `SENTRY_DSN` | aucun envoi, no-op |
|
||||
| End-to-end | une fois Tailscale + projet OK : déclencher une erreur ERROR+ | Issue visible dans GlitchTip `sirh-api` |
|
||||
|
||||
## 7. Hors périmètre (explicite)
|
||||
|
||||
- Frontend (SDK Nuxt, source maps, build-args CI) — ajout futur via proxy nginx `/ingest`.
|
||||
- APM / tracing / performance (DuckDB-like) — non.
|
||||
- Exposition publique de GlitchTip — non (tout passe par Tailscale).
|
||||
@@ -62,7 +62,7 @@
|
||||
>
|
||||
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
|
||||
<div class="flex flex-col min-w-0 text-[16px]">
|
||||
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
|
||||
<p class="text-black"><span v-if="notification.actorName" class="font-semibold capitalize">{{ notification.actorName }} </span>{{ notification.message }}</p>
|
||||
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
|
||||
</div>
|
||||
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
|
||||
|
||||
@@ -959,6 +959,12 @@ export const useDriverHoursPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Une entrée conducteur est vide quand aucune minute (jour/nuit/atelier) ni repas/nuitée
|
||||
// n'est renseignée. Sert à marquer une suppression explicite (`delete: true`).
|
||||
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
|
||||
!entry.dayHoursMinutes && !entry.nightHoursMinutes && !entry.workshopHoursMinutes
|
||||
&& !entry.hasBreakfast && !entry.hasLunch && !entry.hasDinner && !entry.hasOvernight
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -977,7 +983,9 @@ export const useDriverHoursPage = () => {
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
// Une ligne vidée par l'utilisateur porte le flag `delete` : le backend n'autorise la
|
||||
// suppression d'une ligne existante que sur intention explicite (anti-grille périmée).
|
||||
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
|
||||
|
||||
if (entries.length === 0) return
|
||||
|
||||
|
||||
@@ -1175,6 +1175,14 @@ export const useHoursPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Une entrée est vide quand aucune plage horaire ni présence n'est renseignée.
|
||||
// Sert à marquer une suppression explicite (`delete: true`) côté bulk-upsert.
|
||||
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
|
||||
!entry.morningFrom && !entry.morningTo
|
||||
&& !entry.afternoonFrom && !entry.afternoonTo
|
||||
&& !entry.eveningFrom && !entry.eveningTo
|
||||
&& !entry.isPresentMorning && !entry.isPresentAfternoon
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -1190,7 +1198,10 @@ export const useHoursPage = () => {
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
// Une ligne vidée par l'utilisateur (donc différente de l'instantané chargé) porte le
|
||||
// flag `delete` : le backend n'autorise la suppression d'une ligne existante que sur
|
||||
// intention explicite. Sans ce flag, une grille périmée ne peut rien détruire.
|
||||
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
|
||||
@@ -268,6 +268,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
|
||||
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
|
||||
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
|
||||
{ type: 'paragraph', content: 'Notification fin de contrat : chaque jour ouvré, les administrateurs sont prévenus (cloche en haut à droite) lorsqu\'un salarié atteint le dernier jour ouvré avant la fin de son contrat. Le message indique la nature du contrat, le nom du salarié et la date de fin, et renvoie vers sa fiche. Les week-ends et jours fériés sont pris en compte : une fin de contrat le lundi est signalée dès le vendredi.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -362,6 +363,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
|
||||
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
|
||||
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
|
||||
{ type: 'note', content: 'Sécurité anti-écrasement : « Enregistrer » ne touche que les lignes que vous avez réellement modifiées ; les lignes auxquelles vous n\'avez pas touché ne sont jamais envoyées, donc jamais écrasées même si un autre utilisateur les a saisies pendant que votre écran était ouvert. Pour supprimer les heures d\'un salarié, videz explicitement sa ligne puis Enregistrer. Conseil : si votre écran est resté ouvert longtemps, rechargez la page avant de saisir pour repartir des données à jour.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -42,6 +42,10 @@ export type WorkHourEntryPayload = {
|
||||
hasLunch?: boolean
|
||||
hasDinner?: boolean
|
||||
hasOvernight?: boolean
|
||||
// Autorise la suppression d'une ligne existante quand l'entrée est vide.
|
||||
// Sans ce flag, le backend ignore une entrée vide sur une ligne existante
|
||||
// (garde anti-perte de données contre les grilles périmées).
|
||||
delete?: boolean
|
||||
}
|
||||
|
||||
export type WeeklyWorkHourDailySummary = {
|
||||
|
||||
@@ -37,8 +37,13 @@ final class WorkHourBulkUpsert
|
||||
* nightHoursMinutes?:?int,
|
||||
* hasBreakfast?:bool,
|
||||
* hasLunch?:bool,
|
||||
* hasOvernight?:bool
|
||||
* hasOvernight?:bool,
|
||||
* delete?:bool
|
||||
* }>
|
||||
*
|
||||
* Le flag `delete` (défaut false) autorise la suppression d'une ligne existante
|
||||
* quand l'entrée est vide. Sans lui, une entrée vide sur une ligne existante est
|
||||
* un no-op (garde anti-perte de données contre les grilles périmées).
|
||||
*/
|
||||
public array $entries = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Service\Notification\ContractEndNotificationService;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Throwable;
|
||||
|
||||
use function is_string;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:contract:end-notifications',
|
||||
description: 'Notify admins on the last working day before a contract ends.'
|
||||
)]
|
||||
final class ContractEndNotificationCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContractEndNotificationService $service,
|
||||
#[Autowire(service: 'monolog.logger.cron')]
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption(
|
||||
'date',
|
||||
null,
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'Override the reference day (YYYY-MM-DD) for testing or manual catch-up.'
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$dateOption = $input->getOption('date');
|
||||
|
||||
try {
|
||||
$today = is_string($dateOption) && '' !== $dateOption
|
||||
? new DateTimeImmutable($dateOption)
|
||||
: new DateTimeImmutable('today');
|
||||
} catch (Throwable $exception) {
|
||||
$io->error(sprintf('Invalid --date value: %s', $exception->getMessage()));
|
||||
|
||||
return Command::INVALID;
|
||||
}
|
||||
|
||||
$result = $this->service->run($today);
|
||||
|
||||
$this->logger->info('Contract end notifications generated.', [
|
||||
'date' => $today->format('Y-m-d'),
|
||||
'contractsMatched' => $result->contractsMatched,
|
||||
'notificationsCreated' => $result->notificationsCreated,
|
||||
]);
|
||||
|
||||
$io->success(sprintf(
|
||||
'%d notification(s) créée(s) pour %d fin(s) de contrat (%s).',
|
||||
$result->notificationsCreated,
|
||||
$result->contractsMatched,
|
||||
$today->format('Y-m-d'),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,11 @@ interface EmployeeScopedRepositoryInterface
|
||||
* @return list<Employee>
|
||||
*/
|
||||
public function findScoped(User $user): array;
|
||||
|
||||
/**
|
||||
* @param list<int> $employeeIds
|
||||
*
|
||||
* @return array<int, Employee>
|
||||
*/
|
||||
public function findAccessibleByIds(array $employeeIds, User $user): array;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ interface WorkHourReadRepositoryInterface
|
||||
*/
|
||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return array<int, WorkHour>
|
||||
*/
|
||||
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array;
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
|
||||
@@ -72,6 +72,24 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository imp
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest contract period (max startDate) for every employee that has at least one.
|
||||
*
|
||||
* @return EmployeeContractPeriod[]
|
||||
*/
|
||||
public function findLatestPeriodsForAllEmployees(): array
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->andWhere('p.startDate = (
|
||||
SELECT MAX(p2.startDate)
|
||||
FROM App\Entity\EmployeeContractPeriod p2
|
||||
WHERE p2.employee = p.employee
|
||||
)')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
|
||||
@@ -84,4 +84,28 @@ final class NotificationRepository extends ServiceEntityRepository
|
||||
->execute()
|
||||
;
|
||||
}
|
||||
|
||||
public function existsForRecipientCategoryTargetMessage(
|
||||
User $recipient,
|
||||
string $category,
|
||||
string $target,
|
||||
string $message,
|
||||
): bool {
|
||||
$id = $this->createQueryBuilder('n')
|
||||
->select('n.id')
|
||||
->andWhere('n.recipient = :recipient')
|
||||
->andWhere('n.category = :category')
|
||||
->andWhere('n.target = :target')
|
||||
->andWhere('n.message = :message')
|
||||
->setParameter('recipient', $recipient)
|
||||
->setParameter('category', $category)
|
||||
->setParameter('target', $target)
|
||||
->setParameter('message', $message)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
|
||||
return null !== $id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
final readonly class ContractEndNotice
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $employeeId,
|
||||
public string $message,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class ContractEndNotificationPlanner
|
||||
{
|
||||
public function __construct(
|
||||
private WorkingDayCalculator $calculator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param EmployeeContractPeriod[] $latestPeriods
|
||||
*
|
||||
* @return ContractEndNotice[]
|
||||
*/
|
||||
public function plan(array $latestPeriods, DateTimeImmutable $today): array
|
||||
{
|
||||
$today = $today->setTime(0, 0, 0);
|
||||
if (!$this->calculator->isWorkingDay($today)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$upperBound = $this->calculator->nextWorkingDay($today);
|
||||
|
||||
$notices = [];
|
||||
foreach ($latestPeriods as $period) {
|
||||
$endDate = $period->getEndDate();
|
||||
if (null === $endDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$endDate = $endDate->setTime(0, 0, 0);
|
||||
if ($endDate <= $today || $endDate > $upperBound) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$employee = $period->getEmployee();
|
||||
if (null === $employee) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
'Fin de %s de %s %s le %s',
|
||||
$this->natureLabel($period->getContractNatureEnum()),
|
||||
$employee->getFirstName(),
|
||||
$employee->getLastName(),
|
||||
$endDate->format('d/m/Y'),
|
||||
);
|
||||
|
||||
$notices[] = new ContractEndNotice($employee->getId(), $message);
|
||||
}
|
||||
|
||||
return $notices;
|
||||
}
|
||||
|
||||
private function natureLabel(ContractNature $nature): string
|
||||
{
|
||||
return match ($nature) {
|
||||
ContractNature::CDI => 'CDI',
|
||||
ContractNature::CDD => 'CDD',
|
||||
ContractNature::INTERIM => 'Intérim',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
final readonly class ContractEndNotificationResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $notificationsCreated,
|
||||
public int $contractsMatched,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\EmployeeContractPeriodRepository;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
use function count;
|
||||
|
||||
final readonly class ContractEndNotificationService
|
||||
{
|
||||
private const CATEGORY = 'Contrat';
|
||||
|
||||
public function __construct(
|
||||
private EmployeeContractPeriodRepository $periodRepository,
|
||||
private NotificationRepository $notificationRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ContractEndNotificationPlanner $planner,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function run(DateTimeImmutable $today): ContractEndNotificationResult
|
||||
{
|
||||
$latestPeriods = $this->periodRepository->findLatestPeriodsForAllEmployees();
|
||||
$notices = $this->planner->plan($latestPeriods, $today);
|
||||
|
||||
if ([] === $notices) {
|
||||
return new ContractEndNotificationResult(0, 0);
|
||||
}
|
||||
|
||||
$admins = $this->userRepository->findAllAdmins();
|
||||
$created = 0;
|
||||
|
||||
foreach ($notices as $notice) {
|
||||
if (null === $notice->employeeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = '/employees/'.$notice->employeeId;
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
if ($this->notificationRepository->existsForRecipientCategoryTargetMessage(
|
||||
$admin,
|
||||
self::CATEGORY,
|
||||
$target,
|
||||
$notice->message,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$notification = new Notification();
|
||||
$notification->setRecipient($admin)
|
||||
->setMessage($notice->message)
|
||||
->setCategory(self::CATEGORY)
|
||||
->setTarget($target)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($notification);
|
||||
++$created;
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new ContractEndNotificationResult($created, count($notices));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Notification;
|
||||
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use Throwable;
|
||||
|
||||
final readonly class WorkingDayCalculator
|
||||
{
|
||||
public function __construct(
|
||||
private PublicHolidayServiceInterface $holidays,
|
||||
) {}
|
||||
|
||||
public function isWorkingDay(DateTimeImmutable $date): bool
|
||||
{
|
||||
$dayOfWeek = (int) $date->format('N'); // 1 (lundi) .. 7 (dimanche)
|
||||
if ($dayOfWeek >= 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !$this->isPublicHoliday($date);
|
||||
}
|
||||
|
||||
public function nextWorkingDay(DateTimeImmutable $date): DateTimeImmutable
|
||||
{
|
||||
$candidate = $date->modify('+1 day')->setTime(0, 0, 0);
|
||||
while (!$this->isWorkingDay($candidate)) {
|
||||
$candidate = $candidate->modify('+1 day');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function isPublicHoliday(DateTimeImmutable $date): bool
|
||||
{
|
||||
try {
|
||||
$holidays = $this->holidays->getHolidaysDayByYears('metropole', $date->format('Y'));
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isset($holidays[$date->format('Y-m-d')]);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
@@ -28,8 +28,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private AuditLogger $auditLogger,
|
||||
@@ -142,8 +142,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
if ($this->isEntryEmpty($normalized)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
// Garde anti-perte de données : une ligne vide ne supprime l'enregistrement
|
||||
// existant QUE si la suppression est explicitement demandée (`delete: true`).
|
||||
// Sans ce flag, une grille périmée (ex. vieil onglet sans dirty-tracking front)
|
||||
// ne peut plus détruire une ligne saisie entre-temps par un autre utilisateur :
|
||||
// l'entrée vide est traitée comme un no-op.
|
||||
$deleteRequested = true === ($entry['delete'] ?? false);
|
||||
|
||||
if ($existing && $deleteRequested) {
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'delete',
|
||||
@@ -155,7 +161,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
);
|
||||
$this->entityManager->remove($existing);
|
||||
++$result->deleted;
|
||||
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
|
||||
} elseif (null === $existing && (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract)) {
|
||||
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
|
||||
$workHour = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
|
||||
@@ -112,6 +112,15 @@
|
||||
"bin/phpunit"
|
||||
]
|
||||
},
|
||||
"sentry/sentry-symfony": {
|
||||
"version": "5.10",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes-contrib",
|
||||
"branch": "main",
|
||||
"version": "5.0",
|
||||
"ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148"
|
||||
}
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Notification;
|
||||
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Service\Notification\ContractEndNotificationPlanner;
|
||||
use App\Service\Notification\WorkingDayCalculator;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ContractEndNotificationPlannerTest extends TestCase
|
||||
{
|
||||
public function testNotifiesContractEndingTomorrowOnAWeekday(): void
|
||||
{
|
||||
// Mardi 08/07 -> fin mercredi 09/07
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-09')],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertCount(1, $notices);
|
||||
self::assertSame('Fin de CDD de Jean Dupont le 09/07/2025', $notices[0]->message);
|
||||
}
|
||||
|
||||
public function testFridayNotifiesContractsEndingOverTheWeekendAndMonday(): void
|
||||
{
|
||||
// Vendredi 11/07 ; lundi 14/07 férié -> prochain ouvré = mardi 15/07.
|
||||
// Fenêtre ]11/07 ; 15/07] -> samedi 12, dimanche 13, lundi 14, mardi 15.
|
||||
$notices = $this->planner()->plan(
|
||||
[
|
||||
$this->period('A', 'Sat', '2025-07-12'), // samedi -> inclus
|
||||
$this->period('B', 'Mon', '2025-07-14'), // lundi férié -> inclus
|
||||
$this->period('C', 'Tue', '2025-07-15'), // mardi (= borne haute) -> inclus
|
||||
$this->period('D', 'Wed', '2025-07-16'), // mercredi -> hors fenêtre
|
||||
],
|
||||
new DateTimeImmutable('2025-07-11'),
|
||||
);
|
||||
|
||||
self::assertCount(3, $notices);
|
||||
}
|
||||
|
||||
public function testIgnoresOpenEndedContract(): void
|
||||
{
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', null, ContractNature::CDI)],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testIgnoresContractEndingToday(): void
|
||||
{
|
||||
// fin = today -> trop tard, pas de notif (on notifie la veille)
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-08')],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testReturnsNothingWhenTodayIsNotAWorkingDay(): void
|
||||
{
|
||||
// Samedi 12/07 -> aucun jour chômé ne génère de notif
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Jean', 'Dupont', '2025-07-14')],
|
||||
new DateTimeImmutable('2025-07-12'),
|
||||
);
|
||||
|
||||
self::assertSame([], $notices);
|
||||
}
|
||||
|
||||
public function testInterimNatureLabel(): void
|
||||
{
|
||||
$notices = $this->planner()->plan(
|
||||
[$this->period('Marie', 'Martin', '2025-07-09', ContractNature::INTERIM)],
|
||||
new DateTimeImmutable('2025-07-08'),
|
||||
);
|
||||
|
||||
self::assertSame('Fin de Intérim de Marie Martin le 09/07/2025', $notices[0]->message);
|
||||
}
|
||||
|
||||
private function planner(): ContractEndNotificationPlanner
|
||||
{
|
||||
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||
'2025-07-14' => 'Fête nationale', // lundi 14/07 férié
|
||||
]);
|
||||
|
||||
return new ContractEndNotificationPlanner(new WorkingDayCalculator($holidays));
|
||||
}
|
||||
|
||||
private function period(
|
||||
string $firstName,
|
||||
string $lastName,
|
||||
?string $endDate,
|
||||
ContractNature $nature = ContractNature::CDD,
|
||||
): EmployeeContractPeriod {
|
||||
$employee = new Employee();
|
||||
$employee->setFirstName($firstName)->setLastName($lastName);
|
||||
|
||||
$period = new EmployeeContractPeriod();
|
||||
$period->setEmployee($employee)
|
||||
->setContractNature($nature)
|
||||
->setEndDate(null === $endDate ? null : new DateTimeImmutable($endDate))
|
||||
;
|
||||
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Notification;
|
||||
|
||||
use App\Service\Notification\WorkingDayCalculator;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class WorkingDayCalculatorTest extends TestCase
|
||||
{
|
||||
public function testWeekdayIsWorkingDay(): void
|
||||
{
|
||||
// Mardi 08/07/2025
|
||||
self::assertTrue($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-08')));
|
||||
}
|
||||
|
||||
public function testSaturdayAndSundayAreNotWorkingDays(): void
|
||||
{
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-12'))); // samedi
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-13'))); // dimanche
|
||||
}
|
||||
|
||||
public function testPublicHolidayIsNotWorkingDay(): void
|
||||
{
|
||||
self::assertFalse($this->calculator()->isWorkingDay(new DateTimeImmutable('2025-07-14'))); // lundi férié
|
||||
}
|
||||
|
||||
public function testNextWorkingDayFromWeekdayIsTomorrow(): void
|
||||
{
|
||||
// Mardi 08/07 -> Mercredi 09/07
|
||||
self::assertSame(
|
||||
'2025-07-09',
|
||||
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-08'))->format('Y-m-d')
|
||||
);
|
||||
}
|
||||
|
||||
public function testNextWorkingDayFromFridaySkipsWeekend(): void
|
||||
{
|
||||
// Vendredi 11/07 -> lundi 14/07 est férié -> mardi 15/07
|
||||
self::assertSame(
|
||||
'2025-07-15',
|
||||
$this->calculator()->nextWorkingDay(new DateTimeImmutable('2025-07-11'))->format('Y-m-d')
|
||||
);
|
||||
}
|
||||
|
||||
private function calculator(): WorkingDayCalculator
|
||||
{
|
||||
$holidays = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$holidays->method('getHolidaysDayByYears')->willReturn([
|
||||
// Lundi 14/07/2025 férié
|
||||
'2025-07-14' => 'Fête nationale',
|
||||
]);
|
||||
|
||||
return new WorkingDayCalculator($holidays);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\ApiResource\WorkHourBulkUpsert;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\State\WorkHourBulkUpsertProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Garde anti-perte de données : une entrée vide ne supprime une ligne existante
|
||||
* QUE si la suppression est explicitement demandée (flag `delete: true`).
|
||||
* Sans ce flag, une grille périmée (vieil onglet) ne peut plus rien détruire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WorkHourBulkUpsertProcessorTest extends TestCase
|
||||
{
|
||||
public function testEmptyEntryWithoutDeleteFlagPreservesExistingRow(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::never())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload(['employeeId' => 7]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(0, $result->deleted);
|
||||
self::assertSame(1, $result->processed);
|
||||
}
|
||||
|
||||
public function testEmptyEntryWithDeleteFlagRemovesExistingRow(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::once())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload(['employeeId' => 7, 'delete' => true]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(1, $result->deleted);
|
||||
}
|
||||
|
||||
public function testNonEmptyEntryStillUpdatesRegardlessOfFlag(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::never())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload([
|
||||
'employeeId' => 7,
|
||||
'morningFrom' => '08:00',
|
||||
'morningTo' => '12:30',
|
||||
'afternoonFrom' => '14:00',
|
||||
'afternoonTo' => '18:00',
|
||||
]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(1, $result->updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function payload(array $entry): WorkHourBulkUpsert
|
||||
{
|
||||
$payload = new WorkHourBulkUpsert();
|
||||
$payload->workDate = '2026-06-24';
|
||||
$payload->entries = [$entry];
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildProcessor(EntityManagerInterface $em): WorkHourBulkUpsertProcessor
|
||||
{
|
||||
$user = new User()->setUsername('Elodie')->setRoles(['ROLE_ADMIN']);
|
||||
|
||||
$employee = new Employee()->setFirstName('Delphine')->setLastName('BACHELIER');
|
||||
|
||||
// Ligne existante NON vide (journée complète saisie entre-temps par un autre utilisateur).
|
||||
$existing = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
->setWorkDate(new DateTimeImmutable('2026-06-24'))
|
||||
->setMorningFrom('08:00')
|
||||
->setMorningTo('12:00')
|
||||
->setAfternoonFrom('14:00')
|
||||
->setAfternoonTo('18:00')
|
||||
;
|
||||
|
||||
$contract = new Contract()->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
|
||||
$employeeRepository->method('findAccessibleByIds')->willReturn([7 => $employee]);
|
||||
|
||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$workHourRepository->method('findByDateAndEmployeesIndexedByEmployeeId')->willReturn([7 => $existing]);
|
||||
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||
$absenceRepository->method('findByDateAndEmployees')->willReturn([]);
|
||||
|
||||
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$contractResolver->method('resolveForEmployeeAndDate')->willReturn($contract);
|
||||
$contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false);
|
||||
|
||||
return new WorkHourBulkUpsertProcessor(
|
||||
$em,
|
||||
$security,
|
||||
$employeeRepository,
|
||||
$workHourRepository,
|
||||
$absenceRepository,
|
||||
$contractResolver,
|
||||
$this->createStub(AuditLogger::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user