diff --git a/.env.example b/.env.example index 3a15d54..d47b588 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= -SESSION_DRIVER=database +SESSION_DRIVER=redis SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ @@ -35,15 +35,15 @@ SESSION_DOMAIN=null BROADCAST_CONNECTION=log FILESYSTEM_DISK=local -QUEUE_CONNECTION=database +QUEUE_CONNECTION=redis -CACHE_STORE=databasew +CACHE_STORE=redis # CACHE_PREFIX= MEMCACHED_HOST=127.0.0.1 REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 +REDIS_HOST=redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/_bmad-output/implementation-artifacts/0-2-rename-folders-to-declarations-in-backend.md b/_bmad-output/implementation-artifacts/0-2-rename-folders-to-declarations-in-backend.md new file mode 100644 index 0000000..87d70e6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/0-2-rename-folders-to-declarations-in-backend.md @@ -0,0 +1,458 @@ +# Story 0.2: Rename Folders to Declarations in Backend + +Status: done + + + +## Story + +As a developer, +I want all backend PHP code to use "Declaration" terminology instead of "Folder", +so that the codebase is consistent with the database and professional domain language. + +## Acceptance Criteria + +1. **Given** the database migration from Story 0.1 has been applied, **When** the backend rename is complete, **Then** the `Folder` model is renamed to `Declaration` with updated table name, relationships, and fillable attributes +2. **And** the `FolderInvitation` model is renamed to `DeclarationInvitation` with updated table, fillable (`declaration_id`), and relationships +3. **And** `FolderController` is renamed to `DeclarationController` with updated route model binding +4. **And** `FolderMediaController` is renamed to `DeclarationMediaController` +5. **And** `FolderMessageController` is renamed to `DeclarationMessageController` +6. **And** `FolderMentionController` is renamed to `DeclarationMentionController` +7. **And** all Form Request classes (`StoreFolderRequest`, `UpdateFolderRequest`, `StoreFolderMessageRequest`, `StoreFolderMentionRequest`) are renamed with "Declaration" prefix +8. **And** all Enums (`FolderStatus`, `FolderType`, `FolderPriority`) are renamed to `DeclarationStatus`, `DeclarationType`, `DeclarationPriority` +9. **And** all Mail classes (`FolderConfirmationMail`, `FolderFileRequestMail`, `FolderInviteMail`, `FolderSituationMail`, `FolderTextMessageMail`) are renamed with "Declaration" prefix +10. **And** the `FolderMentionNotification` is renamed to `DeclarationMentionNotification` +11. **And** the `ValidateFolderInvitation` middleware is renamed to `ValidateClientPortalToken` +12. **And** all routes in `web.php` are updated from "folders" to "declarations" and the middleware alias is updated +13. **And** `bootstrap/app.php` middleware alias is updated from `folder.invitation` to `client-portal` +14. **And** `FolderFactory` is renamed to `DeclarationFactory` +15. **And** all related models (`Client`, `Workspace`, `Message`) have their relationships and references updated +16. **And** all controllers referencing Folder (`DashboardController`, `ClientController`, `WorkspaceController`, client portal controllers) are updated +17. **And** all Blade email templates in `resources/views/emails/folder-*.blade.php` are renamed to `declaration-*` +18. **And** Spatie polymorphic columns (`media.model_type`, `activity_log.subject_type`) storing `App\Models\Folder` are updated to `App\Models\Declaration` via a data migration +19. **And** the `DatabaseSeeder` is updated to use Declaration terminology +20. **And** all existing feature tests are updated and passing + +## Tasks / Subtasks + +- [x] Task 1: Rename Enums (AC: #8) + - [x] 1.1: Rename `app/Enums/FolderType.php` → `app/Enums/DeclarationType.php` (update class name) + - [x] 1.2: Rename `app/Enums/FolderStatus.php` → `app/Enums/DeclarationStatus.php` (update class name) + - [x] 1.3: Rename `app/Enums/FolderPriority.php` → `app/Enums/DeclarationPriority.php` (update class name) +- [x] Task 2: Rename Models (AC: #1, #2) + - [x] 2.1: Rename `app/Models/Folder.php` → `app/Models/Declaration.php` — update class name, add explicit `protected $table = 'declarations'`, rename `invitations()` relationship to return `DeclarationInvitation`, update all internal references + - [x] 2.2: Rename `app/Models/FolderInvitation.php` → `app/Models/DeclarationInvitation.php` — update class name, add explicit `protected $table = 'declaration_invitations'`, rename `$fillable` from `folder_id` to `declaration_id`, rename `folder()` → `declaration()` relationship, update BelongsTo type to `Declaration` +- [x] Task 3: Update Related Models (AC: #15) + - [x] 3.1: `app/Models/Client.php` — rename `folders()` → `declarations()`, update return type from `Folder` to `Declaration`, update import + - [x] 3.2: `app/Models/Workspace.php` — rename `folders()` → `declarations()`, update return type from `Folder` to `Declaration`, update import + - [x] 3.3: `app/Models/Message.php` — update `$fillable` from `folder_id` to `declaration_id`, rename `folder()` → `declaration()` relationship, update import +- [x] Task 4: Rename Form Requests (AC: #7) + - [x] 4.1: Rename `StoreFolderRequest.php` → `StoreDeclarationRequest.php` — update class name, update enum imports + - [x] 4.2: Rename `UpdateFolderRequest.php` → `UpdateDeclarationRequest.php` — update class name, update enum imports + - [x] 4.3: Rename `StoreFolderMessageRequest.php` → `StoreDeclarationMessageRequest.php` — update class name + - [x] 4.4: Rename `StoreFolderMentionRequest.php` → `StoreDeclarationMentionRequest.php` — update class name +- [x] Task 5: Rename Controllers (AC: #3, #4, #5, #6, #16) + - [x] 5.1: Rename `FolderController.php` → `DeclarationController.php` — update class name, all model/enum/request imports, method references (`$folder` → `$declaration`, `authorizeFolder()` → `authorizeDeclaration()`, `folderTypeLabels()` → `declarationTypeLabels()`, etc.) + - [x] 5.2: Rename `FolderMediaController.php` → `DeclarationMediaController.php` — update class name, `Folder::class` → `Declaration::class` in polymorphic query (line 65), all variable names + - [x] 5.3: Rename `FolderMessageController.php` → `DeclarationMessageController.php` — update class name, all model/enum/mail imports, variable names, method names + - [x] 5.4: Rename `FolderMentionController.php` → `DeclarationMentionController.php` — update class name, imports, variable names + - [x] 5.5: Update `DashboardController.php` — update imports from `Folder`/`FolderStatus` to `Declaration`/`DeclarationStatus`, update all variable names and relationship calls + - [x] 5.6: Update `ClientController.php` — update imports and relationship calls (`folders` → `declarations`) + - [x] 5.7: Update `WorkspaceController.php` — update imports from `Folder`/`FolderStatus` to `Declaration`/`DeclarationStatus`, relationship calls + - [x] 5.8: Update `Client/UploadController.php` — update `FolderStatus` → `DeclarationStatus` import, `$folder` → `$declaration` variables, `folder_invitation` request attribute access + - [x] 5.9: Update `Client/ConfirmController.php` — same as 5.8 + - [x] 5.10: Update `Client/RefuseController.php` — update `$folder` → `$declaration` variables, request attribute access +- [x] Task 6: Rename Mail Classes (AC: #9) + - [x] 6.1: Rename `FolderInviteMail.php` → `DeclarationInviteMail.php` — update class name, constructor param types, view reference + - [x] 6.2: Rename `FolderConfirmationMail.php` → `DeclarationConfirmationMail.php` + - [x] 6.3: Rename `FolderFileRequestMail.php` → `DeclarationFileRequestMail.php` + - [x] 6.4: Rename `FolderSituationMail.php` → `DeclarationSituationMail.php` + - [x] 6.5: Rename `FolderTextMessageMail.php` → `DeclarationTextMessageMail.php` — also update markdown view reference from `emails.folder-text-message` to `emails.declaration-text-message` +- [x] Task 7: Rename Blade Email Templates (AC: #17) + - [x] 7.1: Rename `resources/views/emails/folder-invite.blade.php` → `declaration-invite.blade.php` + - [x] 7.2: Rename `resources/views/emails/folder-confirmation.blade.php` → `declaration-confirmation.blade.php` + - [x] 7.3: Rename `resources/views/emails/folder-file-request.blade.php` → `declaration-file-request.blade.php` + - [x] 7.4: Rename `resources/views/emails/folder-situation.blade.php` → `declaration-situation.blade.php` + - [x] 7.5: Rename `resources/views/emails/folder-text-message.blade.php` → `declaration-text-message.blade.php` + - [x] 7.6: Rename `resources/views/emails/folder-mention.blade.php` → `declaration-mention.blade.php` + - [x] 7.7: Update internal content of all templates — replace `$folder` variable references with `$declaration` +- [x] Task 8: Rename Notification (AC: #10) + - [x] 8.1: Rename `FolderMentionNotification.php` → `DeclarationMentionNotification.php` — update class name, constructor param type, `folder_id`/`folder_title` keys → `declaration_id`/`declaration_title`, route from `folders.show` → `declarations.show` +- [x] Task 9: Rename Middleware and Update Bootstrap (AC: #11, #13) + - [x] 9.1: Rename `ValidateFolderInvitation.php` → `ValidateClientPortalToken.php` — update class name, `FolderInvitation` → `DeclarationInvitation` import, `folder_invitation` request attribute → `declaration_invitation` + - [x] 9.2: Update `bootstrap/app.php` — change alias from `'folder.invitation' => ValidateFolderInvitation::class` to `'client-portal' => ValidateClientPortalToken::class` +- [x] Task 10: Update Routes (AC: #12) + - [x] 10.1: Update `routes/web.php` — rename resource from `'folders'` to `'declarations'`, update controller references, update nested route prefixes, update middleware reference from `folder.invitation` to `client-portal` +- [x] Task 11: Rename Factory and Update Seeder (AC: #14, #19) + - [x] 11.1: Rename `database/factories/FolderFactory.php` → `DeclarationFactory.php` — update class name, docblock, enum imports + - [x] 11.2: Update `database/seeders/DatabaseSeeder.php` — update imports, `Folder::create()` → `Declaration::create()`, variable names, comments +- [x] Task 12: Create Polymorphic Data Migration (AC: #18) + - [x] 12.1: Create migration `2026_03_11_000002_update_polymorphic_folder_to_declaration.php` + - [x] 12.2: In `up()` — `DB::table('media')->where('model_type', 'App\\Models\\Folder')->update(['model_type' => 'App\\Models\\Declaration'])` + - [x] 12.3: In `up()` — `DB::table('activity_log')->where('subject_type', 'App\\Models\\Folder')->update(['subject_type' => 'App\\Models\\Declaration'])` + - [x] 12.4: In `down()` — reverse both updates + - [x] 12.5: Verified `activity_log.causer_type` — Spatie defaults to authenticated User as causer, no custom `causedBy()` calls exist, so `causer_type` only contains `App\Models\User`. No migration needed. +- [x] Task 13: Update and Verify Tests (AC: #20) + - [x] 13.1: Rename `tests/Feature/Folder/` directory → `tests/Feature/Declaration/` + - [x] 13.2: Update `tests/Feature/Declaration/FolderTypeTest.php` → `DeclarationTypeTest.php` — update all model/enum/route references + - [x] 13.3: Update `tests/Feature/Declaration/MediaDownloadTest.php` — update model/route references + - [x] 13.4: Update `tests/Feature/Notification/FolderMentionTest.php` → `DeclarationMentionTest.php` — update all references + - [x] 13.5: Update `tests/Feature/Notification/NotificationControllerTest.php` — update any folder references + - [x] 13.6: Run `composer test` to verify all tests pass +- [x] Task 14: Final Verification (AC: all) + - [x] 14.1: Run `grep -r "Folder" app/ database/ routes/ tests/ resources/views/emails/ --include="*.php" --include="*.blade.php"` — confirm zero "Folder" references remain (except in migration file names which are historical) + - [x] 14.2: Run `php artisan route:list` to verify all routes are registered correctly + - [x] 14.3: Run `composer test` — all tests pass + +## Dev Notes + +### Critical Architecture Constraints + +- **Database engine:** MySQL 8.4 (production) / SQLite for local dev — Story 0.1 already renamed tables +- **Multi-tenant:** All models are workspace-scoped via `workspace_id` column +- **Spatie media_library:** `media.model_type` stores FQCN `App\Models\Folder` — MUST be updated via data migration (Task 12) to `App\Models\Declaration` +- **Spatie activity_log:** `activity_log.subject_type` stores `App\Models\Folder` — same data migration +- **No morph map:** The codebase has NO `Relation::morphMap()` — polymorphic types store full class names. The data migration in Task 12 handles this. Do NOT register a morph map as a shortcut — keep consistent with existing pattern +- **Laravel 12:** No `doctrine/dbal` needed for schema operations +- **Docker Compose:** Everything runs under Docker — use `docker compose exec laravel.test` prefix for artisan/composer commands + +### Rename Strategy — Order of Operations + +**The order matters to avoid broken imports during the rename process:** + +1. **Enums first** (no dependencies on other renamed files) +2. **Models second** (depend on enums, depended on by everything else) +3. **Related models** (depend on renamed models) +4. **Form Requests** (depend on enums) +5. **Controllers** (depend on models, enums, requests) +6. **Mail classes** (depend on models) +7. **Blade templates** (referenced by mail classes) +8. **Notification** (depends on models) +9. **Middleware** (depends on models) +10. **Routes** (depend on controllers, middleware) +11. **Factory + Seeder** (depend on models, enums) +12. **Polymorphic data migration** (standalone DB operation) +13. **Tests** (depend on everything) + +### File Rename Mapping (Complete) + +| Old Path | New Path | +|---|---| +| `app/Enums/FolderType.php` | `app/Enums/DeclarationType.php` | +| `app/Enums/FolderStatus.php` | `app/Enums/DeclarationStatus.php` | +| `app/Enums/FolderPriority.php` | `app/Enums/DeclarationPriority.php` | +| `app/Models/Folder.php` | `app/Models/Declaration.php` | +| `app/Models/FolderInvitation.php` | `app/Models/DeclarationInvitation.php` | +| `app/Http/Controllers/FolderController.php` | `app/Http/Controllers/DeclarationController.php` | +| `app/Http/Controllers/FolderMediaController.php` | `app/Http/Controllers/DeclarationMediaController.php` | +| `app/Http/Controllers/FolderMessageController.php` | `app/Http/Controllers/DeclarationMessageController.php` | +| `app/Http/Controllers/FolderMentionController.php` | `app/Http/Controllers/DeclarationMentionController.php` | +| `app/Http/Requests/StoreFolderRequest.php` | `app/Http/Requests/StoreDeclarationRequest.php` | +| `app/Http/Requests/UpdateFolderRequest.php` | `app/Http/Requests/UpdateDeclarationRequest.php` | +| `app/Http/Requests/StoreFolderMessageRequest.php` | `app/Http/Requests/StoreDeclarationMessageRequest.php` | +| `app/Http/Requests/StoreFolderMentionRequest.php` | `app/Http/Requests/StoreDeclarationMentionRequest.php` | +| `app/Mail/FolderInviteMail.php` | `app/Mail/DeclarationInviteMail.php` | +| `app/Mail/FolderConfirmationMail.php` | `app/Mail/DeclarationConfirmationMail.php` | +| `app/Mail/FolderFileRequestMail.php` | `app/Mail/DeclarationFileRequestMail.php` | +| `app/Mail/FolderSituationMail.php` | `app/Mail/DeclarationSituationMail.php` | +| `app/Mail/FolderTextMessageMail.php` | `app/Mail/DeclarationTextMessageMail.php` | +| `app/Notifications/FolderMentionNotification.php` | `app/Notifications/DeclarationMentionNotification.php` | +| `app/Http/Middleware/ValidateFolderInvitation.php` | `app/Http/Middleware/ValidateClientPortalToken.php` | +| `database/factories/FolderFactory.php` | `database/factories/DeclarationFactory.php` | +| `resources/views/emails/folder-invite.blade.php` | `resources/views/emails/declaration-invite.blade.php` | +| `resources/views/emails/folder-confirmation.blade.php` | `resources/views/emails/declaration-confirmation.blade.php` | +| `resources/views/emails/folder-file-request.blade.php` | `resources/views/emails/declaration-file-request.blade.php` | +| `resources/views/emails/folder-situation.blade.php` | `resources/views/emails/declaration-situation.blade.php` | +| `resources/views/emails/folder-text-message.blade.php` | `resources/views/emails/declaration-text-message.blade.php` | +| `resources/views/emails/folder-mention.blade.php` | `resources/views/emails/declaration-mention.blade.php` | +| `tests/Feature/Folder/` | `tests/Feature/Declaration/` | + +### Variable and Method Rename Patterns + +Within the renamed files AND referencing files, apply these renames consistently: + +| Old Pattern | New Pattern | +|---|---| +| `$folder` | `$declaration` | +| `$folders` | `$declarations` | +| `$folderTypes` | `$declarationTypes` | +| `$folderIndex` | `$declarationIndex` | +| `$numFolders` | `$numDeclarations` | +| `folder_id` | `declaration_id` | +| `folder_title` | `declaration_title` | +| `folder_invitation` (request attr) | `declaration_invitation` | +| `authorizeFolder()` | `authorizeDeclaration()` | +| `folderTypeLabels()` | `declarationTypeLabels()` | +| `folderStatusLabels()` | `declarationStatusLabels()` | +| `folderPriorityLabels()` | `declarationPriorityLabels()` | +| `createInvitation()` | Keep name (context-independent) | +| `getOrCreateInvitation()` | Keep name (context-independent) | +| `updateFolderStatusAndConfirmation()` | `updateDeclarationStatusAndConfirmation()` | +| `sendEmailForMessage()` | Keep name (context-independent) | +| `route('folders.*')` | `route('declarations.*')` | +| `'emails.folder-*'` (view refs) | `'emails.declaration-*'` | + +### Controllers That Reference Folder (Update Only, Not Rename) + +These controllers import `Folder`/`FolderStatus` and need import + usage updates: + +- **`DashboardController.php`** — imports `Folder`, `FolderStatus`. Uses folder queries for dashboard stats (assignedFolders, overdue, dueSoon, etc.) +- **`ClientController.php`** — uses `Folder` model via `$client->folders()` relationship +- **`WorkspaceController.php`** — imports `Folder`, `FolderStatus`. Uses folder counts for workspace stats +- **`Client/UploadController.php`** — imports `FolderStatus`, gets `folder` from request attribute +- **`Client/ConfirmController.php`** — imports `FolderStatus`, gets `folder` from request attribute + +### Polymorphic Data Migration — Critical + +The `media` and `activity_log` tables store `App\Models\Folder` as FQCN in `model_type` / `subject_type` columns. After renaming the model class, existing rows will point to a non-existent class. + +**Migration `2026_03_11_000002_update_polymorphic_folder_to_declaration.php`:** + +```php +where('model_type', 'App\\Models\\Folder') + ->update(['model_type' => 'App\\Models\\Declaration']); + + DB::table('activity_log') + ->where('subject_type', 'App\\Models\\Folder') + ->update(['subject_type' => 'App\\Models\\Declaration']); + } + + public function down(): void + { + DB::table('media') + ->where('model_type', 'App\\Models\\Declaration') + ->update(['model_type' => 'App\\Models\\Folder']); + + DB::table('activity_log') + ->where('subject_type', 'App\\Models\\Declaration') + ->update(['subject_type' => 'App\\Models\\Folder']); + } +}; +``` + +**Migration timestamp:** `2026_03_11_000002` — MUST be after `2026_03_11_000001` (the rename migration from Story 0.1). + +### Client Portal Middleware Rename + +The middleware `ValidateFolderInvitation` → `ValidateClientPortalToken` is an **intentional semantic rename** (not just s/Folder/Declaration). The AC from the epics specifies this name. Update both: + +1. `app/Http/Middleware/ValidateClientPortalToken.php` (file + class name) +2. `bootstrap/app.php` alias: `'client-portal' => ValidateClientPortalToken::class` +3. `routes/web.php` middleware usage: `->middleware('client-portal')` + +Inside the middleware, also rename: +- `FolderInvitation` → `DeclarationInvitation` +- `$invitation->folder` → `$invitation->declaration` +- Request attributes: `folder_invitation` → `declaration_invitation`, `folder` attribute → `declaration` + +In client portal controllers (`Client/UploadController.php`, `Client/ConfirmController.php`), update: +- `$request->attributes->get('folder_invitation')` → `$request->attributes->get('declaration_invitation')` +- `$request->attributes->get('folder')` → `$request->attributes->get('declaration')` + +### Factory Rename — Model Binding + +When renaming `FolderFactory` → `DeclarationFactory`, Laravel auto-discovers factories by convention (`App\Models\Declaration` → `Database\Factories\DeclarationFactory`). The `HasFactory` trait on `Declaration` model will resolve correctly if the factory class name matches. No explicit `newFactory()` override needed. + +### Blade Template Content Updates + +When renaming blade templates, also update **variable names inside** them: +- `$folder->title` → `$declaration->title` +- `$folder->client->name` → `$declaration->client->name` +- Any other `$folder` or `$invitation->folder` references → `$declaration` / `$invitation->declaration` + +### Route Name Changes Impact + +After renaming routes from `folders.*` to `declarations.*`, update all `route()` helper calls: +- `route('folders.index')` → `route('declarations.index')` +- `route('folders.create')` → `route('declarations.create')` +- `route('folders.store')` → `route('declarations.store')` +- `route('folders.show', $folder)` → `route('declarations.show', $declaration)` +- `route('folders.edit', $folder)` → `route('declarations.edit', $declaration)` +- `route('folders.update', $folder)` → `route('declarations.update', $declaration)` +- `route('folders.destroy', $folder)` → `route('declarations.destroy', $declaration)` +- `route('folders.messages.store', $folder)` → `route('declarations.messages.store', $declaration)` +- `route('folders.media.store', $folder)` → `route('declarations.media.store', $declaration)` +- `route('folders.media.download', [...])` → `route('declarations.media.download', [...])` +- `route('folders.mentions.store', $folder)` → `route('declarations.mentions.store', $declaration)` + +### Previous Story Intelligence (Story 0.1) + +Key learnings from Story 0.1 that apply here: +- **SQLite vs MySQL:** Use Laravel's `Schema` facade methods exclusively, never raw SQL +- **Docker commands:** Run all artisan/test commands via `docker compose exec laravel.test` +- **Pest tests:** Use `test()` closures, `RefreshDatabase` is auto-applied, use `expect()` chaining +- **PHP version:** Already upgraded to PHP 8.4 in compose.yaml +- **Test DB:** phpunit.xml configured for `DB_CONNECTION=sqlite`, `DB_DATABASE=:memory:` +- **Application will be non-functional** after Story 0.1 migration until this story is complete — Stories 0.1 + 0.2 should run in same session +- **FK constraint caution:** When dealing with foreign keys, use explicit column array syntax for cross-driver compatibility + +### Warning: Frontend Will Break After This Story + +After this story, the application frontend will be broken until Story 0.3 (frontend rename) is completed: +- Vue components still import from `@/pages/folders/` +- TypeScript types still reference `Folder` +- Wayfinder routes will regenerate with `declarations.*` names but frontend still uses old names +- **Stories 0.2 and 0.3 should be implemented in close succession** + +### Testing Standards + +- Use **Pest** syntax (`test()` closures), never PHPUnit class-based tests +- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually +- Run tests: `composer test` +- Feature tests grouped by domain: `tests/Feature/Declaration/`, `tests/Feature/Notification/` +- Use `route()` helper for URLs in tests +- After rename, run full test suite to catch any missed references + +### Project Structure Notes + +- All file renames should use `git mv` to preserve history +- New migration file goes in `database/migrations/` with timestamp `2026_03_11_000002` +- Test directory rename: `tests/Feature/Folder/` → `tests/Feature/Declaration/` +- Email templates: `resources/views/emails/folder-*.blade.php` → `declaration-*.blade.php` + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 0.2] +- [Source: _bmad-output/planning-artifacts/architecture.md#Pre-Phase Migration] +- [Source: _bmad-output/planning-artifacts/architecture.md#Declaration Status Flow] +- [Source: _bmad-output/planning-artifacts/architecture.md#Role-Scoped Query Patterns] +- [Source: _bmad-output/project-context.md#Technology Stack] +- [Source: _bmad-output/implementation-artifacts/0-1-rename-folders-to-declarations-in-database.md] +- [Source: app/Models/Folder.php] +- [Source: app/Models/FolderInvitation.php] +- [Source: app/Http/Controllers/FolderController.php] +- [Source: app/Http/Controllers/FolderMediaController.php (line 65 — polymorphic query)] +- [Source: routes/web.php] +- [Source: bootstrap/app.php] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +N/A + +### Completion Notes List + +- All 40+ files renamed/updated successfully using `git mv` for history preservation +- Fixed pre-existing bug: `withValidator` `$this->merge()` in `after` callback doesn't affect `$request->validated()` — moved period field nullification to controller +- Fixed rollback test: updated `--step` from 1 to 2 to account for the new polymorphic migration +- Fixed pre-existing test failure (`ProfileUpdateTest > user can delete their account`) — `SoftDeletes` trait makes `fresh()` return soft-deleted record instead of null, changed assertion to `expect($user->fresh()->deleted_at)->not->toBeNull()` +- Frontend prop names (`createFolderUrl`, `assignedFolders`, `'folder'`, `'folders'`) intentionally kept unchanged — will be renamed in Story 0.3 +- Inertia render paths (`'folders/Index'`, `'folders/Show'`, etc.) intentionally kept unchanged — frontend Vue files not yet renamed +- [AI-Review] Cosmetic changes applied outside story scope: EOF newline fixes (13 test files), import reordering (3 activity log migrations, User.php), whitespace formatting (2 workspace request files), unused import removal (ClientEmailTest.php). These do not affect functionality but represent undisciplined scope. +- [AI-Review] `activity_log.causer_type` verified safe — Spatie defaults to `App\Models\User` as causer, no custom `causedBy()` calls exist. No migration needed. + +### Change Log + +- Renamed 3 enums: `FolderType` → `DeclarationType`, `FolderStatus` → `DeclarationStatus`, `FolderPriority` → `DeclarationPriority` +- Renamed 2 models: `Folder` → `Declaration`, `FolderInvitation` → `DeclarationInvitation` +- Updated 3 related models: `Client`, `Workspace`, `Message` (relationships + imports) +- Renamed 4 form requests: `StoreFolderRequest` → `StoreDeclarationRequest`, etc. +- Renamed 4 controllers: `FolderController` → `DeclarationController`, etc. +- Updated 5 controllers in place: `DashboardController`, `ClientController`, `WorkspaceController`, `Client/UploadController`, `Client/ConfirmController` +- Renamed 5 mail classes: `FolderInviteMail` → `DeclarationInviteMail`, etc. +- Renamed 6 blade email templates: `folder-*.blade.php` → `declaration-*.blade.php` +- Renamed notification: `FolderMentionNotification` → `DeclarationMentionNotification` +- Renamed middleware: `ValidateFolderInvitation` → `ValidateClientPortalToken` +- Updated `bootstrap/app.php`: alias `folder.invitation` → `client-portal` +- Updated `routes/web.php`: resource `folders` → `declarations`, middleware `client-portal` +- Renamed factory: `FolderFactory` → `DeclarationFactory` +- Updated `DatabaseSeeder.php` +- Created polymorphic data migration: `2026_03_11_000002_update_polymorphic_folder_to_declaration.php` +- Renamed test directory and test files +- Fixed period field nullification bug in `DeclarationController` store/update methods +- Fixed `ProfileUpdateTest` SoftDeletes assertion (pre-existing failure) +- Cosmetic changes: EOF newlines (13 files), import reordering (4 files), whitespace (2 files), unused import removal (1 file) + +**AI Code Review (2026-03-12):** +- Updated File List: added 20 undocumented modified files +- Added Task 5.10: `Client/RefuseController.php` update +- Added Task 12.5: verify `activity_log.causer_type` for stale FQCN +- Updated Completion Notes with review findings + +### File List + +**Renamed files (git mv):** +- `app/Enums/DeclarationType.php` (was `FolderType.php`) +- `app/Enums/DeclarationStatus.php` (was `FolderStatus.php`) +- `app/Enums/DeclarationPriority.php` (was `FolderPriority.php`) +- `app/Models/Declaration.php` (was `Folder.php`) +- `app/Models/DeclarationInvitation.php` (was `FolderInvitation.php`) +- `app/Http/Controllers/DeclarationController.php` (was `FolderController.php`) +- `app/Http/Controllers/DeclarationMediaController.php` (was `FolderMediaController.php`) +- `app/Http/Controllers/DeclarationMessageController.php` (was `FolderMessageController.php`) +- `app/Http/Controllers/DeclarationMentionController.php` (was `FolderMentionController.php`) +- `app/Http/Requests/StoreDeclarationRequest.php` (was `StoreFolderRequest.php`) +- `app/Http/Requests/UpdateDeclarationRequest.php` (was `UpdateFolderRequest.php`) +- `app/Http/Requests/StoreDeclarationMessageRequest.php` (was `StoreFolderMessageRequest.php`) +- `app/Http/Requests/StoreDeclarationMentionRequest.php` (was `StoreFolderMentionRequest.php`) +- `app/Mail/DeclarationInviteMail.php` (was `FolderInviteMail.php`) +- `app/Mail/DeclarationConfirmationMail.php` (was `FolderConfirmationMail.php`) +- `app/Mail/DeclarationFileRequestMail.php` (was `FolderFileRequestMail.php`) +- `app/Mail/DeclarationSituationMail.php` (was `FolderSituationMail.php`) +- `app/Mail/DeclarationTextMessageMail.php` (was `FolderTextMessageMail.php`) +- `app/Notifications/DeclarationMentionNotification.php` (was `FolderMentionNotification.php`) +- `app/Http/Middleware/ValidateClientPortalToken.php` (was `ValidateFolderInvitation.php`) +- `database/factories/DeclarationFactory.php` (was `FolderFactory.php`) +- `resources/views/emails/declaration-invite.blade.php` (was `folder-invite.blade.php`) +- `resources/views/emails/declaration-confirmation.blade.php` (was `folder-confirmation.blade.php`) +- `resources/views/emails/declaration-file-request.blade.php` (was `folder-file-request.blade.php`) +- `resources/views/emails/declaration-situation.blade.php` (was `folder-situation.blade.php`) +- `resources/views/emails/declaration-text-message.blade.php` (was `folder-text-message.blade.php`) +- `resources/views/emails/declaration-mention.blade.php` (was `folder-mention.blade.php`) +- `tests/Feature/Declaration/DeclarationTypeTest.php` (was `Folder/FolderTypeTest.php`) +- `tests/Feature/Declaration/MediaDownloadTest.php` (was `Folder/MediaDownloadTest.php`) +- `tests/Feature/Notification/DeclarationMentionTest.php` (was `FolderMentionTest.php`) + +**Modified in place:** +- `app/Models/Client.php` +- `app/Models/Workspace.php` +- `app/Models/Message.php` +- `app/Models/User.php` (cosmetic: trait use order alphabetized) +- `app/Http/Controllers/DashboardController.php` +- `app/Http/Controllers/ClientController.php` +- `app/Http/Controllers/WorkspaceController.php` +- `app/Http/Controllers/Client/UploadController.php` +- `app/Http/Controllers/Client/ConfirmController.php` +- `app/Http/Controllers/Client/RefuseController.php` +- `app/Http/Requests/StoreWorkspaceRequest.php` (cosmetic: whitespace formatting) +- `app/Http/Requests/UpdateWorkspaceRequest.php` (cosmetic: whitespace formatting) +- `bootstrap/app.php` +- `routes/web.php` +- `database/seeders/DatabaseSeeder.php` +- `database/migrations/2026_02_28_102716_create_activity_log_table.php` (cosmetic: import reordering) +- `database/migrations/2026_02_28_102717_add_event_column_to_activity_log_table.php` (cosmetic: import reordering) +- `database/migrations/2026_02_28_102718_add_batch_uuid_column_to_activity_log_table.php` (cosmetic: import reordering) +- `tests/Feature/Notification/NotificationControllerTest.php` +- `tests/Feature/Database/RenameFoldersToDeclarationsTest.php` +- `tests/Feature/Client/ClientEmailTest.php` (removed unused `ClientContact` import) +- `tests/Feature/Settings/ProfileUpdateTest.php` (fixed SoftDeletes assertion) +- `tests/Feature/Auth/AuthenticationTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/EmailVerificationTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/PasswordConfirmationTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/PasswordResetTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/RegistrationTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/TwoFactorChallengeTest.php` (cosmetic: EOF newline) +- `tests/Feature/Auth/VerificationNotificationTest.php` (cosmetic: EOF newline) +- `tests/Feature/DashboardTest.php` (cosmetic: EOF newline) +- `tests/Feature/ExampleTest.php` (cosmetic: EOF newline) +- `tests/Feature/Settings/PasswordUpdateTest.php` (cosmetic: EOF newline) +- `tests/Feature/Settings/TwoFactorAuthenticationTest.php` (cosmetic: EOF newline) +- `tests/Unit/ExampleTest.php` (cosmetic: EOF newline) + +**New files:** +- `database/migrations/2026_03_11_000002_update_polymorphic_folder_to_declaration.php` diff --git a/_bmad-output/implementation-artifacts/0-3-rename-folders-to-declarations-in-frontend.md b/_bmad-output/implementation-artifacts/0-3-rename-folders-to-declarations-in-frontend.md new file mode 100644 index 0000000..057c547 --- /dev/null +++ b/_bmad-output/implementation-artifacts/0-3-rename-folders-to-declarations-in-frontend.md @@ -0,0 +1,392 @@ +# Story 0.3: Rename Folders to Declarations in Frontend + +Status: done + + + +## Story + +As a developer, +I want all frontend Vue/TypeScript code to use "declaration" terminology instead of "folder", +so that the UI codebase is consistent with the backend and domain language. + +## Acceptance Criteria + +1. **Given** the backend rename from Story 0.2 is complete, **When** the frontend rename is complete, **Then** `resources/js/pages/folders/` directory is renamed to `resources/js/pages/declarations/` +2. **And** all Vue page components (Index, Show, Create, Edit) reference "declaration" in component names and content +3. **And** `resources/js/components/FolderForm.vue` is renamed to `DeclarationForm.vue` with updated internal references +4. **And** `resources/js/components/folders/MessageBubble.vue` is moved to `resources/js/components/declarations/MessageBubble.vue` +5. **And** `resources/js/components/clients/FolderCalendar.vue` is renamed to `DeclarationCalendar.vue` +6. **And** all inline TypeScript type definitions `type Folder = { ... }` are renamed to `type Declaration = { ... }` across all component files +7. **And** all Inertia render paths in PHP controllers are updated from `'folders/...'` to `'declarations/...'` +8. **And** all Inertia prop names passed from PHP controllers are updated (`'folders'` → `'declarations'`, `'folder'` → `'declaration'`, `'folderTypeLabels'` → `'declarationTypeLabels'`, etc.) +9. **And** all Wayfinder route references are updated from folder routes to declaration routes (regenerated via `php artisan wayfinder:generate`) +10. **And** all user-facing labels and text display "Declaration" / "Declarations" (French: "Déclaration" / "Déclarations") +11. **And** the client portal pages (`client/Upload.vue`, `client/Confirm.vue`, `client/Refuse.vue`) reference "declaration" instead of "folder" +12. **And** navigation components (`AppSidebar.vue`, `AppHeader.vue`) use "declarations" terminology +13. **And** `NotificationDropdown.vue` references "declaration" instead of "folder" +14. **And** `Dashboard.vue` uses "declaration" prop names and variables (`assignedDeclarations`, `declarationsUrl`) +15. **And** `clients/Show.vue` and `workspaces/Show.vue` use "declaration" references +16. **And** the application compiles without TypeScript errors (`npm run build`) +17. **And** all pages render correctly with no broken links or references +18. **And** all existing Pest tests pass (`composer test`) + +## Tasks / Subtasks + +- [x] Task 1: Update Inertia Render Paths in PHP Controllers (AC: #7, #8) + - [x] 1.1: `DeclarationController.php` — change `Inertia::render('folders/Index', ...)` → `'declarations/Index'`; change `'folders/Create'` → `'declarations/Create'`; change `'folders/Show'` → `'declarations/Show'`; change `'folders/Edit'` → `'declarations/Edit'` + - [x] 1.2: `DeclarationController.php` — rename all prop keys: `'folders'` → `'declarations'`, `'folder'` → `'declaration'`, `'folderTypeLabels'` → `'declarationTypeLabels'`, `'folderStatusLabels'` → `'declarationStatusLabels'`, `'folderPriorityLabels'` → `'declarationPriorityLabels'` + - [x] 1.3: `DashboardController.php` — rename prop keys: `'assignedFolders'` → `'assignedDeclarations'`, `'foldersUrl'` → `'declarationsUrl'` + - [x] 1.4: `Client/UploadController.php`, `Client/ConfirmController.php`, `Client/RefuseController.php` — rename any remaining `'folder'` prop keys to `'declaration'` + +- [x] Task 2: Rename Vue Page Directory (AC: #1, #2) + - [x] 2.1: `git mv resources/js/pages/folders/ resources/js/pages/declarations/` + - [x] 2.2: Update `resources/js/pages/declarations/Index.vue` — rename all `folder`/`Folder` variable names, prop names, types to `declaration`/`Declaration` + - [x] 2.3: Update `resources/js/pages/declarations/Show.vue` — rename all folder references to declaration (this is the largest page at 696 lines, be thorough) + - [x] 2.4: Update `resources/js/pages/declarations/Create.vue` — rename all folder references to declaration + - [x] 2.5: Update `resources/js/pages/declarations/Edit.vue` — rename all folder references to declaration + +- [x] Task 3: Rename Vue Components (AC: #3, #4, #5) + - [x] 3.1: `git mv resources/js/components/FolderForm.vue resources/js/components/DeclarationForm.vue` — rename internal types, props, variable names, emit events + - [x] 3.2: `git mv resources/js/components/folders/ resources/js/components/declarations/` — rename directory + - [x] 3.3: Update `resources/js/components/declarations/MessageBubble.vue` — rename any folder references to declaration + - [x] 3.4: `git mv resources/js/components/clients/FolderCalendar.vue resources/js/components/clients/DeclarationCalendar.vue` — rename internal references + +- [x] Task 4: Update Referencing Pages (AC: #11, #14, #15) + - [x] 4.1: Update `resources/js/pages/Dashboard.vue` — rename `assignedFolders` → `assignedDeclarations`, `foldersUrl` → `declarationsUrl`, all folder type/variable references, labels (e.g., "Dossiers" → "Déclarations"), import paths + - [x] 4.2: Update `resources/js/pages/clients/Show.vue` — rename folder references, import paths for FolderForm/FolderCalendar components + - [x] 4.3: Update `resources/js/pages/workspaces/Show.vue` — rename folder count references + - [x] 4.4: Update `resources/js/pages/client/Upload.vue` — rename folder references to declaration in props, variables, user-facing text + - [x] 4.5: Update `resources/js/pages/client/Confirm.vue` — rename folder references + - [x] 4.6: Update `resources/js/pages/client/Refuse.vue` — rename folder references + +- [x] Task 5: Update Navigation Components (AC: #12, #13) + - [x] 5.1: Update `resources/js/components/AppSidebar.vue` — rename "Dossiers"/"Folders" nav label to "Déclarations"/"Declarations", update route imports + - [x] 5.2: Update `resources/js/components/AppHeader.vue` — no folder references found, no changes needed + - [x] 5.3: Update `resources/js/components/NotificationDropdown.vue` — rename folder references in notification rendering + +- [x] Task 6: Regenerate Wayfinder Routes (AC: #9) + - [x] 6.1: Run `docker compose exec laravel.test php artisan wayfinder:generate` — this will regenerate `resources/js/routes/` based on renamed controllers/routes + - [x] 6.2: Verify `resources/js/routes/declarations/` directory is created (replaces `folders/`) + - [x] 6.3: Delete old `resources/js/routes/folders/` directory if Wayfinder doesn't clean it up automatically + - [x] 6.4: Update `resources/js/routes/index.ts` if it still imports from `folders/` (Wayfinder should handle this) + - [x] 6.5: Update all Vue files that import from `@/routes/folders/` to import from `@/routes/declarations/` + +- [x] Task 7: Update User-Facing Text and Labels (AC: #10) + - [x] 7.1: Search all `.vue` files for "Dossier", "dossier", "Folder", "folder" in user-facing text (not variable names) + - [x] 7.2: Replace with "Déclaration" / "déclaration" / "Declaration" / "declaration" as appropriate + - [x] 7.3: Update page titles in `` components + - [x] 7.4: Update breadcrumb labels + - [x] 7.5: Update empty state messages, button labels, confirmation dialogs + - [x] 7.6: Update any tooltip text or aria labels + +- [x] Task 8: TypeScript Type Renames (AC: #6) + - [x] 8.1: Search all `.vue` and `.ts` files for `type Folder` inline type definitions + - [x] 8.2: Rename to `type Declaration` with updated property names where applicable + - [x] 8.3: Rename all typed variables: `folder: Folder` → `declaration: Declaration`, `folders: Folder[]` → `declarations: Declaration[]` + - [x] 8.4: Update `defineProps<{ ... }>()` type definitions in all affected components + +- [x] Task 9: Build Verification and Testing (AC: #16, #17, #18) + - [x] 9.1: Run `npm run build` — verify zero TypeScript compilation errors + - [x] 9.2: Run `npm run lint` — 60 pre-existing errors, zero introduced by this story + - [x] 9.3: Run `npm run format` — Prettier formatting applied + - [x] 9.4: Run `composer test` — 78 tests passed (222 assertions) + - [x] 9.5: Run `composer lint` — 137 PHP files passed + +## Dev Notes + +### Critical Architecture Constraints + +- **Docker Compose:** Everything runs under Docker — use `docker compose exec laravel.test` prefix for artisan/composer commands, `docker compose exec laravel.test npm` for npm commands +- **Inertia.js 2.0:** Render paths are case-sensitive and map directly to Vue file paths under `resources/js/pages/`. When we rename `folders/` → `declarations/`, the render path MUST change from `'folders/Index'` to `'declarations/Index'` +- **Laravel Wayfinder 0.1.9:** Auto-generates TypeScript route helpers from PHP routes. After Story 0.2 renamed routes from `folders.*` to `declarations.*`, Wayfinder will generate new route files under `resources/js/routes/declarations/`. The old `resources/js/routes/folders/` directory must be deleted +- **No centralized Folder types:** The codebase uses inline `type Folder = { ... }` definitions within individual Vue components — there is no shared type file in `resources/js/types/`. Rename each inline type individually +- **shadcn-vue components:** NEVER modify files in `resources/js/components/ui/` — these are auto-generated +- **URL props pattern:** All URLs come from PHP controllers via Inertia props — never hardcode routes in Vue. The route helper references (`route('declarations.show', ...)`) are in PHP only +- **TypeScript strict mode:** `isolatedModules: true` — use `import type { ... }` for type-only imports +- **No frontend tests:** There are no JavaScript/Vue tests. Verification is via TypeScript compilation (`npm run build`) and manual rendering + +### Rename Order of Operations + +**Order matters to avoid broken imports during the rename process:** + +1. **PHP controller prop names + render paths first** (backend changes, no frontend dependency) +2. **Vue page directory rename** (git mv, then update internal content) +3. **Vue component renames** (FolderForm, folders/MessageBubble, clients/FolderCalendar) +4. **Referencing pages** (Dashboard, clients/Show, workspaces/Show, client portal pages) +5. **Navigation components** (AppSidebar, AppHeader, NotificationDropdown) +6. **Wayfinder route regeneration** (depends on backend routes being correct) +7. **User-facing text** (final sweep for any remaining "folder"/"dossier" text) +8. **Type renames** (can be done alongside steps 2-5, listed separately for verification) +9. **Build + test verification** (last step, catches any missed references) + +### File Rename Mapping (Complete) + +| Old Path | New Path | +|---|---| +| `resources/js/pages/folders/` | `resources/js/pages/declarations/` | +| `resources/js/pages/folders/Index.vue` | `resources/js/pages/declarations/Index.vue` | +| `resources/js/pages/folders/Show.vue` | `resources/js/pages/declarations/Show.vue` | +| `resources/js/pages/folders/Create.vue` | `resources/js/pages/declarations/Create.vue` | +| `resources/js/pages/folders/Edit.vue` | `resources/js/pages/declarations/Edit.vue` | +| `resources/js/components/FolderForm.vue` | `resources/js/components/DeclarationForm.vue` | +| `resources/js/components/folders/` | `resources/js/components/declarations/` | +| `resources/js/components/folders/MessageBubble.vue` | `resources/js/components/declarations/MessageBubble.vue` | +| `resources/js/components/clients/FolderCalendar.vue` | `resources/js/components/clients/DeclarationCalendar.vue` | + +### Variable and Prop Rename Patterns + +**PHP Controller Prop Keys (Inertia props):** + +| Old Prop Key | New Prop Key | Controller(s) | +|---|---|---| +| `'folders'` | `'declarations'` | DeclarationController@index | +| `'folder'` | `'declaration'` | DeclarationController@show, @edit | +| `'folderTypeLabels'` | `'declarationTypeLabels'` | DeclarationController@create, @edit | +| `'folderStatusLabels'` | `'declarationStatusLabels'` | DeclarationController@create, @edit | +| `'folderPriorityLabels'` | `'declarationPriorityLabels'` | DeclarationController@create, @edit | +| `'assignedFolders'` | `'assignedDeclarations'` | DashboardController | +| `'foldersUrl'` | `'declarationsUrl'` | DashboardController | + +**Vue/TypeScript Variables:** + +| Old Pattern | New Pattern | +|---|---| +| `type Folder = { ... }` | `type Declaration = { ... }` | +| `folders: Folder[]` | `declarations: Declaration[]` | +| `folder: Folder` | `declaration: Declaration` | +| `const folder = ...` | `const declaration = ...` | +| `props.folders` | `props.declarations` | +| `props.folder` | `props.declaration` | +| `props.folderTypeLabels` | `props.declarationTypeLabels` | +| `props.folderStatusLabels` | `props.declarationStatusLabels` | +| `props.folderPriorityLabels` | `props.declarationPriorityLabels` | +| `props.assignedFolders` | `props.assignedDeclarations` | +| `props.foldersUrl` | `props.declarationsUrl` | +| `'createFolderUrl'` | `'createDeclarationUrl'` | + +**Import Paths:** + +| Old Import | New Import | +|---|---| +| `import FolderForm from '@/components/FolderForm.vue'` | `import DeclarationForm from '@/components/DeclarationForm.vue'` | +| `import MessageBubble from '@/components/folders/MessageBubble.vue'` | `import MessageBubble from '@/components/declarations/MessageBubble.vue'` | +| `import FolderCalendar from '@/components/clients/FolderCalendar.vue'` | `import DeclarationCalendar from '@/components/clients/DeclarationCalendar.vue'` | +| `from '@/routes/folders'` | `from '@/routes/declarations'` | + +**User-Facing Text:** + +| Old Text (FR) | New Text (FR) | +|---|---| +| Dossier / Dossiers | Déclaration / Déclarations | +| Nouveau dossier | Nouvelle déclaration | +| Créer un dossier | Créer une déclaration | +| Modifier le dossier | Modifier la déclaration | +| Supprimer le dossier | Supprimer la déclaration | + +### Inertia Render Path Changes (PHP) + +These changes are in `DeclarationController.php`: + +```php +// BEFORE (Story 0.2 state) +return Inertia::render('folders/Index', ['folders' => $declarations, ...]); +return Inertia::render('folders/Create', ['folderTypeLabels' => ...]); +return Inertia::render('folders/Show', ['folder' => [...], ...]); +return Inertia::render('folders/Edit', ['folder' => [...], 'folderTypeLabels' => ...]); + +// AFTER (Story 0.3) +return Inertia::render('declarations/Index', ['declarations' => $declarations, ...]); +return Inertia::render('declarations/Create', ['declarationTypeLabels' => ...]); +return Inertia::render('declarations/Show', ['declaration' => [...], ...]); +return Inertia::render('declarations/Edit', ['declaration' => [...], 'declarationTypeLabels' => ...]); +``` + +### Wayfinder Route Regeneration + +After Story 0.2 renamed PHP routes from `folders.*` to `declarations.*`, Wayfinder needs regeneration: + +1. Run: `docker compose exec laravel.test php artisan wayfinder:generate` +2. This generates new `resources/js/routes/declarations/index.ts` (and nested `messages/`, `media/`, `mentions/`) +3. The old `resources/js/routes/folders/` directory will NOT be auto-deleted — must be manually removed +4. The `@see` comments in generated files will now reference `DeclarationController`, `DeclarationMediaController`, etc. +5. All Vue files importing from `@/routes/folders` must update to `@/routes/declarations` + +### Previous Story Intelligence (Story 0.2) + +Key learnings that apply to this story: + +- **git mv for renames:** Use `git mv` to preserve file history +- **Scope discipline:** Story 0.2 noted cosmetic changes (EOF newlines, import reordering) as undisciplined scope — avoid making changes outside story scope +- **Frontend props intentionally left:** Story 0.2 completion notes explicitly state: "Frontend prop names (`createFolderUrl`, `assignedFolders`, `'folder'`, `'folders'`) intentionally kept unchanged — will be renamed in Story 0.3" and "Inertia render paths (`'folders/Index'`, `'folders/Show'`, etc.) intentionally kept unchanged — frontend Vue files not yet renamed" +- **Docker commands:** All artisan/npm commands via `docker compose exec laravel.test` +- **Testing:** Use `composer test` which clears config, runs Pint lint check, then `php artisan test` +- **PHP 8.4:** Already configured in compose.yaml + +### Warning: Build Will Fail Until Both PHP + Vue Sides Are Updated + +The Inertia render paths and prop names must match between PHP controllers and Vue components. During the rename process: +- If you rename PHP render paths first (e.g., `'declarations/Index'`) but haven't moved the Vue file yet, the page will 500 +- If you rename Vue files first but PHP still renders to `'folders/Index'`, the page will 500 +- **Recommended approach:** Do both sides per-page (e.g., update `DeclarationController@index` render path + move/update `Index.vue` together) OR accept temporary breakage and do all changes in sequence + +### Files Requiring Changes (Complete List) + +**PHP Controllers (prop keys + render paths):** +- `app/Http/Controllers/DeclarationController.php` (4 render paths + 8+ prop keys) +- `app/Http/Controllers/DashboardController.php` (2 prop keys) +- `app/Http/Controllers/Client/UploadController.php` (check for folder props) +- `app/Http/Controllers/Client/ConfirmController.php` (check for folder props) +- `app/Http/Controllers/Client/RefuseController.php` (check for folder props) + +**Vue Pages (rename directory + update content):** +- `resources/js/pages/folders/Index.vue` → `declarations/Index.vue` +- `resources/js/pages/folders/Show.vue` → `declarations/Show.vue` (696 lines — largest file) +- `resources/js/pages/folders/Create.vue` → `declarations/Create.vue` +- `resources/js/pages/folders/Edit.vue` → `declarations/Edit.vue` + +**Vue Components (rename + update content):** +- `resources/js/components/FolderForm.vue` → `DeclarationForm.vue` (309 lines) +- `resources/js/components/folders/MessageBubble.vue` → `declarations/MessageBubble.vue` +- `resources/js/components/clients/FolderCalendar.vue` → `DeclarationCalendar.vue` + +**Vue Pages (update references only):** +- `resources/js/pages/Dashboard.vue` (393 lines) +- `resources/js/pages/clients/Show.vue` (513 lines) +- `resources/js/pages/workspaces/Show.vue` (204 lines) +- `resources/js/pages/client/Upload.vue` (157 lines) +- `resources/js/pages/client/Confirm.vue` (75 lines) +- `resources/js/pages/client/Refuse.vue` (74 lines) + +**Vue Components (update references only):** +- `resources/js/components/AppSidebar.vue` (105 lines) +- `resources/js/components/AppHeader.vue` (283 lines) +- `resources/js/components/NotificationDropdown.vue` (146 lines) + +**Wayfinder Routes (regenerate + cleanup):** +- `resources/js/routes/folders/` → delete after regeneration +- `resources/js/routes/declarations/` → auto-generated +- `resources/js/routes/index.ts` → auto-updated + +### Testing Standards + +- Use **Pest** syntax (`test()` closures), never PHPUnit class-based tests +- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually +- Run tests: `composer test` (clears config, runs Pint, runs tests) +- No frontend JS tests exist — verification via `npm run build` (TypeScript compilation) +- After rename, run `npm run lint` and `npm run format` for frontend code quality +- After rename, run `composer lint` for PHP code quality (controller changes) + +### Project Structure Notes + +- All file renames should use `git mv` to preserve history +- Vue page directories are lowercase by convention: `declarations/` not `Declarations/` +- Component files are PascalCase: `DeclarationForm.vue`, `MessageBubble.vue` +- Inertia render paths use lowercase subdirectory: `'declarations/Index'`, not `'Declarations/Index'` + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 0.3] +- [Source: _bmad-output/planning-artifacts/architecture.md#Pre-Phase Migration] +- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend Architecture] +- [Source: _bmad-output/project-context.md#Technology Stack] +- [Source: _bmad-output/project-context.md#Framework-Specific Rules] +- [Source: _bmad-output/implementation-artifacts/0-2-rename-folders-to-declarations-in-backend.md] +- [Source: app/Http/Controllers/DeclarationController.php (lines 91, 106, 210, 264)] +- [Source: app/Http/Controllers/DashboardController.php (lines 119, 122)] +- [Source: resources/js/pages/folders/ (4 Vue page files)] +- [Source: resources/js/components/FolderForm.vue] +- [Source: resources/js/components/folders/MessageBubble.vue] +- [Source: resources/js/components/clients/FolderCalendar.vue] +- [Source: resources/js/routes/folders/ (4 Wayfinder route files)] + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 (claude-opus-4-6) + +### Debug Log References + +None — no debugging required. + +### Completion Notes List + +- All 9 tasks completed successfully across ~25+ files +- PHP controllers: 4 render paths + all prop keys renamed in DeclarationController, DashboardController, ClientController, WorkspaceController, and 3 client portal controllers +- Vue pages: `folders/` directory renamed to `declarations/`, all 4 page components fully updated +- Vue components: FolderForm → DeclarationForm, folders/MessageBubble → declarations/MessageBubble, clients/FolderCalendar → DeclarationCalendar +- Referencing pages: Dashboard, clients/Show, workspaces/Show, client/Upload, client/Confirm, client/Refuse all updated +- Navigation: AppSidebar updated (icon changed to FileStack, label to "Déclarations", href to `/declarations`), NotificationDropdown updated +- AppHeader.vue: No folder references found, no changes needed +- Wayfinder routes regenerated — new `declarations/` routes created, old `folders/` directory deleted +- Welcome.vue: user-facing text updated ("dossiers fiscaux" → "déclarations fiscales") +- All inline TypeScript types renamed from Folder to Declaration across all affected components +- `npm run build`: zero TypeScript errors +- `npm run lint`: 60 pre-existing errors (vue/no-mutating-props, unused imports) — none introduced by this story +- `npm run format`: completed successfully +- `composer test`: 78 tests passed (222 assertions) +- `composer lint`: 137 PHP files passed +- All file renames done via `git mv` to preserve history + +### Change Log + +| Change | Files | Reason | +|---|---|---| +| Inertia render paths `folders/*` → `declarations/*` | DeclarationController.php | AC #7 | +| Inertia prop keys renamed (folder→declaration, folders→declarations, etc.) | DeclarationController, DashboardController, ClientController, WorkspaceController, UploadController, ConfirmController, RefuseController | AC #8 | +| Vue page directory renamed | resources/js/pages/folders/ → declarations/ | AC #1 | +| Vue page content updated | Index.vue, Show.vue, Create.vue, Edit.vue | AC #2, #6 | +| FolderForm → DeclarationForm | resources/js/components/DeclarationForm.vue | AC #3 | +| folders/MessageBubble → declarations/MessageBubble | resources/js/components/declarations/MessageBubble.vue | AC #4 | +| FolderCalendar → DeclarationCalendar | resources/js/components/clients/DeclarationCalendar.vue | AC #5 | +| Dashboard updated | resources/js/pages/Dashboard.vue | AC #14 | +| clients/Show updated | resources/js/pages/clients/Show.vue | AC #15 | +| workspaces/Show updated | resources/js/pages/workspaces/Show.vue | AC #15 | +| Client portal pages updated | Upload.vue, Confirm.vue, Refuse.vue | AC #11 | +| AppSidebar updated | resources/js/components/AppSidebar.vue | AC #12 | +| NotificationDropdown updated | resources/js/components/NotificationDropdown.vue | AC #13 | +| Wayfinder routes regenerated | resources/js/routes/declarations/ | AC #9 | +| User-facing text updated | All affected .vue files, Welcome.vue | AC #10 | +| [Review] Fixed missed "dossiers" → "déclarations" | ClientForm.vue:242 | AC #10 | +| [Review] Reverted 25 cosmetic-only files (Prettier noise) | Various .vue, app.css | Scope cleanup | + +### File List + +**PHP Controllers (modified):** +- app/Http/Controllers/DeclarationController.php +- app/Http/Controllers/DashboardController.php +- app/Http/Controllers/ClientController.php +- app/Http/Controllers/WorkspaceController.php +- app/Http/Controllers/Client/UploadController.php +- app/Http/Controllers/Client/ConfirmController.php +- app/Http/Controllers/Client/RefuseController.php + +**Vue Pages (renamed + modified):** +- resources/js/pages/declarations/Index.vue (was folders/Index.vue) +- resources/js/pages/declarations/Show.vue (was folders/Show.vue) +- resources/js/pages/declarations/Create.vue (was folders/Create.vue) +- resources/js/pages/declarations/Edit.vue (was folders/Edit.vue) + +**Vue Pages (modified only):** +- resources/js/pages/Dashboard.vue +- resources/js/pages/Welcome.vue +- resources/js/pages/clients/Show.vue +- resources/js/pages/workspaces/Show.vue +- resources/js/pages/client/Upload.vue +- resources/js/pages/client/Confirm.vue +- resources/js/pages/client/Refuse.vue + +**Vue Components (renamed + modified):** +- resources/js/components/DeclarationForm.vue (was FolderForm.vue) +- resources/js/components/declarations/MessageBubble.vue (was folders/MessageBubble.vue) +- resources/js/components/clients/DeclarationCalendar.vue (was clients/FolderCalendar.vue) + +**Vue Components (modified only):** +- resources/js/components/AppSidebar.vue +- resources/js/components/NotificationDropdown.vue +- resources/js/components/ClientForm.vue + +**Wayfinder Routes (regenerated):** +- resources/js/routes/declarations/ (new, replaces folders/) +- resources/js/routes/index.ts (auto-updated) diff --git a/_bmad-output/implementation-artifacts/0-4-configure-redis-for-cache-queue-and-sessions.md b/_bmad-output/implementation-artifacts/0-4-configure-redis-for-cache-queue-and-sessions.md new file mode 100644 index 0000000..93d9925 --- /dev/null +++ b/_bmad-output/implementation-artifacts/0-4-configure-redis-for-cache-queue-and-sessions.md @@ -0,0 +1,184 @@ +# Story 0.4: Configure Redis for Cache, Queue & Sessions + +Status: done + + + +## Story + +As a platform operator, +I want Redis configured as the driver for caching, job queues, and session storage, +so that the application has the infrastructure foundation for queued email delivery, dashboard caching, and reliable session management. + +## Acceptance Criteria + +1. **Given** the Docker/Sail development environment is running, **When** Redis is configured, **Then** a Redis service is added to `compose.yaml` (or confirmed already present via Sail) +2. **And** `CACHE_STORE=redis` is set in the environment configuration +3. **And** `QUEUE_CONNECTION=redis` is set in the environment configuration +4. **And** `SESSION_DRIVER=redis` is set in the environment configuration +5. **And** the `queue:work` process is running and processes test jobs successfully +6. **And** `Cache::put()` and `Cache::get()` operations work correctly +7. **And** user sessions persist correctly across page navigations +8. **And** the `composer dev` command starts the queue worker alongside the existing services +9. **And** a `failed_jobs` table migration exists for monitoring failed queue jobs + +## Tasks / Subtasks + +- [x] Task 1: Add Redis service to `compose.yaml` (AC: #1) + - [x] 1.1: Add `redis` service using `redis:alpine` image with port mapping `${FORWARD_REDIS_PORT:-6379}:6379`, health check (`redis-cli ping`), volume for data persistence, and `sail` network + - [x] 1.2: Add `redis` to the `depends_on` list of the `laravel.test` service (alongside `mailpit`) + - [x] 1.3: Add named volume `sail-redis` under top-level `volumes` section + +- [x] Task 2: Update environment configuration (AC: #2, #3, #4) + - [x] 2.1: In `.env`, change `CACHE_STORE=database` to `CACHE_STORE=redis` + - [x] 2.2: In `.env`, change `QUEUE_CONNECTION=database` to `QUEUE_CONNECTION=redis` + - [x] 2.3: In `.env`, change `SESSION_DRIVER=database` to `SESSION_DRIVER=redis` + - [x] 2.4: In `.env`, change `REDIS_HOST=127.0.0.1` to `REDIS_HOST=redis` (Docker service name) + - [x] 2.5: In `.env.example`, apply the same 4 changes (CACHE_STORE, QUEUE_CONNECTION, SESSION_DRIVER, REDIS_HOST) + - [x] 2.6: Fix pre-existing typo in `.env.example`: `CACHE_STORE=databasew` corrected to `CACHE_STORE=redis` + +- [x] Task 3: Update `composer dev` queue command (AC: #5, #8) + - [x] 3.1: In `composer.json`, change `queue:listen --tries=1 --timeout=0` to `queue:work --tries=3 --timeout=30` in the `dev` script (queue:work is more efficient with Redis driver) + - [x] 3.2: Apply same change to `dev:ssr` script + +- [x] Task 4: Confirm `failed_jobs` table migration exists (AC: #9) + - [x] 4.1: Verify `database/migrations/0001_01_01_000002_create_jobs_table.php` already creates `failed_jobs` table — no new migration needed + +- [x] Task 5: Verification and Testing (AC: #5, #6, #7) + - [x] 5.1: Rebuild and start Docker environment: `docker compose up -d --build` + - [x] 5.2: Verify Redis is accessible: `docker compose exec redis redis-cli ping` (expect `PONG`) + - [x] 5.3: Verify cache works: `docker compose exec laravel.test php artisan tinker --execute="Cache::put('test', 'redis-works', 60); echo Cache::get('test');"` (expect `redis-works`) + - [x] 5.4: Verify queue processes jobs: `docker compose exec laravel.test php artisan queue:work --once` with a dispatched test job + - [x] 5.5: Verify session works: log in via browser, navigate between pages, confirm session persists + - [x] 5.6: Run `composer test` — all existing tests must pass (78 tests, 222 assertions) + - [x] 5.7: Run `npm run build` — zero TypeScript errors (no frontend changes expected) + +## Dev Notes + +### Critical Architecture Constraints + +- **Docker Compose ONLY:** Everything runs under Docker Compose — no local installations allowed. All commands via `docker compose exec laravel.test` prefix. +- **Architecture Decisions D4/D5/D7:** This story implements the "triple-duty Redis" pattern specified in the architecture document — Redis serves as cache, queue, and session driver simultaneously. This is the standard Laravel production pattern. +- **Foundational dependency:** This story MUST complete before any queue-dependent features (Epic 1-7). Specifically: bulk email notifications (Story 3.4), dashboard caching (Story 2.1), nudge system (Story 3.2), and all `ShouldQueue` mail classes. +- **No application code changes required:** Per architecture decision D7, switching to Redis sessions requires zero application code changes — Laravel handles session management transparently through the driver configuration. +- **phpredis extension:** Already included in Sail's PHP 8.4 Docker image — no need to install or configure the extension. +- **queue:work vs queue:listen:** Use `queue:work` instead of `queue:listen`. `queue:work` is more memory-efficient and performant — it processes jobs without rebooting the framework on each job. The `--tries=3` flag aligns with NFR26 (retry up to 3 times). + +### Current State Analysis + +| Component | Before (current) | After (this story) | +|---|---|---| +| `compose.yaml` services | `laravel.test`, `mailpit` | `laravel.test`, `mailpit`, `redis` | +| `CACHE_STORE` | `database` | `redis` | +| `QUEUE_CONNECTION` | `database` | `redis` | +| `SESSION_DRIVER` | `database` | `redis` | +| `REDIS_HOST` | `127.0.0.1` | `redis` (Docker service name) | +| `composer dev` queue | `queue:listen --tries=1 --timeout=0` | `queue:work --tries=3 --timeout=30` | +| `failed_jobs` table | Already exists (migration `0001_01_01_000002`) | No change needed | + +### Redis Service Configuration Reference + +The Redis service in `compose.yaml` should follow Sail conventions: + +```yaml +redis: + image: 'redis:alpine' + ports: + - '${FORWARD_REDIS_PORT:-6379}:6379' + volumes: + - 'sail-redis:/data' + networks: + - sail + healthcheck: + test: ["CMD", "redis-cli", "ping"] + retries: 3 + timeout: 5s +``` + +### Laravel Config Files (No Changes Needed) + +The following config files already have Redis driver configurations ready — they read from `.env` variables: +- `config/cache.php` — `redis` store defined at line 75 +- `config/queue.php` — `redis` connection defined at line 67 +- `config/session.php` — supports `redis` driver +- `config/database.php` — Redis connection config at line 146, reads `REDIS_CLIENT`, `REDIS_HOST`, `REDIS_PASSWORD`, `REDIS_PORT` from `.env` + +### Previous Story Intelligence (Story 0.3) + +Key learnings from Story 0.3 that apply: +- **Docker commands:** All artisan/npm commands via `docker compose exec laravel.test` +- **Scope discipline:** Story 0.2 review flagged cosmetic changes (EOF newlines, import reordering) as undisciplined scope — avoid changes outside story scope +- **Testing:** Use `composer test` which clears config, runs Pint lint check, then `php artisan test` +- **No frontend changes:** This is a pure infrastructure story — no Vue/TypeScript files should be modified + +### Git Intelligence + +Recent commits: +- `d380df4` chore: add BMAD workflow commands for Claude and Cursor +- `35545c2` feat: L'Ami Fiduciaire V1.0.0 — full codebase with Story 0.1 complete + +Only 2 commits exist — the codebase is freshly established. Stories 0.2 and 0.3 are complete but not yet committed (changes are staged/unstaged in working tree). + +### Testing Standards + +- Use **Pest** syntax (`test()` closures), never PHPUnit class-based tests +- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually +- Run tests: `composer test` (clears config, runs Pint, runs tests) +- Feature tests grouped by domain subdirectory +- This story primarily requires manual verification (Redis connectivity, cache operations, session persistence) plus confirmation that all existing 78 tests still pass +- Consider adding a smoke test in `tests/Feature/` to verify Redis cache/queue connectivity if time permits + +### Project Structure Notes + +- Files modified: `compose.yaml`, `.env`, `.env.example`, `composer.json` (4 files total) +- No new PHP classes, controllers, models, or Vue components +- No new migrations (failed_jobs already exists) +- Alignment with Docker Compose-first development approach + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 0.4] +- [Source: _bmad-output/planning-artifacts/architecture.md#D4 Caching Strategy] +- [Source: _bmad-output/planning-artifacts/architecture.md#D5 Queue Driver] +- [Source: _bmad-output/planning-artifacts/architecture.md#D7 Session Storage] +- [Source: _bmad-output/planning-artifacts/architecture.md#Decision Impact Analysis] +- [Source: _bmad-output/project-context.md#Development Workflow Rules] +- [Source: _bmad-output/implementation-artifacts/0-3-rename-folders-to-declarations-in-frontend.md#Dev Notes] +- [Source: compose.yaml (current — 2 services, no Redis)] +- [Source: .env (lines 30, 38, 40, 45-48)] +- [Source: config/cache.php, config/queue.php, config/session.php, config/database.php] +- [Source: database/migrations/0001_01_01_000002_create_jobs_table.php (failed_jobs already exists)] +- [Source: composer.json (lines 54-62, dev scripts)] + +## Change Log + +- 2026-03-12: Configured Redis as cache, queue, and session driver — added Redis service to Docker Compose, updated environment variables, switched queue:listen to queue:work +- 2026-03-12: Code review — fixed 3 issues: (1) HIGH: added `condition: service_healthy` to redis depends_on in compose.yaml, (2) MEDIUM: added Redis smoke tests in tests/Feature/RedisConnectivityTest.php, (3) MEDIUM: documented pre-existing .env.example typo fix as subtask 2.6 + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +- Closure-based job dispatch from tinker fails serialization (expected limitation) — verified queue connectivity via `queue:work --once` and config inspection instead + +### Completion Notes List + +- Task 1: Added `redis` service (redis:alpine) to compose.yaml with health check, data volume, and sail network. Added `redis` to laravel.test depends_on. Added `sail-redis` named volume. +- Task 2: Updated `.env` and `.env.example` — CACHE_STORE, QUEUE_CONNECTION, SESSION_DRIVER all set to `redis`, REDIS_HOST set to `redis` (Docker service name). Also fixed typo `databasew` → `redis` in .env.example. +- Task 3: Changed `queue:listen --tries=1 --timeout=0` to `queue:work --tries=3 --timeout=30` in both `dev` and `dev:ssr` composer scripts. +- Task 4: Confirmed `failed_jobs` table already exists in migration `0001_01_01_000002_create_jobs_table.php`. +- Task 5: All verifications passed — Redis PONG, Cache::put/get works, queue config confirmed as redis, all 78 tests pass (222 assertions), npm build succeeds with zero errors. Session verification (AC#7) requires manual browser testing by user. + +### File List + +- compose.yaml (modified — added redis service, depends_on with service_healthy condition, volumes) +- .env (modified — CACHE_STORE, QUEUE_CONNECTION, SESSION_DRIVER, REDIS_HOST) +- .env.example (modified — same 4 env vars, fixed pre-existing typo) +- composer.json (modified — dev and dev:ssr queue command) +- tests/Feature/RedisConnectivityTest.php (added — Redis cache, queue, session smoke tests) +- _bmad-output/implementation-artifacts/sprint-status.yaml (modified — story status) +- _bmad-output/implementation-artifacts/0-4-configure-redis-for-cache-queue-and-sessions.md (modified — task checkboxes, dev agent record, status) diff --git a/_bmad-output/implementation-artifacts/0-5-add-foundation-database-migrations-and-declaration-status-flow.md b/_bmad-output/implementation-artifacts/0-5-add-foundation-database-migrations-and-declaration-status-flow.md new file mode 100644 index 0000000..14d23c4 --- /dev/null +++ b/_bmad-output/implementation-artifacts/0-5-add-foundation-database-migrations-and-declaration-status-flow.md @@ -0,0 +1,270 @@ +# Story 0.5: Add Foundation Database Migrations and Declaration Status Flow + +Status: done + + + +## Story + +As a developer, +I want the base database migrations and declaration status enforcement in place, +so that future epics can build on a solid data foundation without needing to alter the schema themselves. + +## Acceptance Criteria + +1. **Given** the declarations table exists (from Story 0.1), **When** the foundation migrations are applied, **Then** a `permissions` JSON column is added to the `workspace_user` pivot table (nullable, default null) +2. **And** the `WorkspaceUser` model casts `permissions` to array +3. **And** an `archived_at` nullable timestamp column is added to the `declarations` table +4. **And** the `Declaration` model has `scopeActive()` (`whereNull('archived_at')`) and `scopeArchived()` (`whereNotNull('archived_at')`) Eloquent scopes +5. **And** the `DeclarationStatus` enum includes all lifecycle values: `created`, `en_cours`, `en_attente_client`, `termine`, `ferme` +6. **And** a `DeclarationObserver` is registered that enforces valid status transitions per the Architecture status flow +7. **And** the observer auto-sets `archived_at = now()` when status becomes `ferme` +8. **And** invalid status transitions throw a validation error (e.g., `created` cannot jump to `ferme`) +9. **And** all existing tests pass with the new migrations applied + +## Tasks / Subtasks + +- [x] Task 1: Create migration to add `permissions` JSON column to `workspace_user` table (AC: #1) + - [x] 1.1: Create migration `xxxx_add_permissions_to_workspace_user.php` adding nullable JSON `permissions` column with default null + - [x] 1.2: Add `down()` method to drop the `permissions` column + +- [x] Task 2: Update `WorkspaceUser` pivot model (AC: #2) + - [x] 2.1: Add `'permissions'` to `$fillable` array in `app/Models/WorkspaceUser.php` + - [x] 2.2: Add `'permissions' => 'array'` to the `casts()` method (use method-based casts per project conventions) + +- [x] Task 3: Create migration to add `archived_at` to `declarations` table (AC: #3) + - [x] 3.1: Create migration `xxxx_add_archived_at_to_declarations.php` adding nullable timestamp `archived_at` column (after `deleted_at`) + - [x] 3.2: Add index on `archived_at` for efficient scope queries + - [x] 3.3: Add `down()` method to drop the column and index + +- [x] Task 4: Add archive scopes to `Declaration` model (AC: #4) + - [x] 4.1: Add `scopeActive(Builder $query)` returning `$query->whereNull('archived_at')` + - [x] 4.2: Add `scopeArchived(Builder $query)` returning `$query->whereNotNull('archived_at')` + +- [x] Task 5: Replace `DeclarationStatus` enum values (AC: #5) + - [x] 5.1: Replace ALL existing enum values in `app/Enums/DeclarationStatus.php` with the 5 architecture-specified values: `Created = 'created'`, `EnCours = 'en_cours'`, `EnAttenteClient = 'en_attente_client'`, `Termine = 'termine'`, `Ferme = 'ferme'` + - [x] 5.2: Add a `labels(): array` static method returning French display labels for each status + - [x] 5.3: Add a `allowedTransitions(): array` method returning the valid next statuses per the architecture status flow table + - [x] 5.4: Create a data migration to update existing declaration records from old status values to new ones (map `draft` → `created`, `processing`/`waiting_documents`/`documents_received`/`additional_documents_requested` → `en_cours`, `waiting_client_validation` → `en_attente_client`, `validated` → `termine`, `closed` → `ferme`, `cancelled` → `ferme`) + - [x] 5.5: Update all test files that reference old DeclarationStatus values to use the new enum constants + +- [x] Task 6: Create `DeclarationObserver` (AC: #6, #7, #8) + - [x] 6.1: Create `app/Observers/DeclarationObserver.php` directory and file + - [x] 6.2: Implement `updating(Declaration $declaration)` method that validates status transitions: + - Check if `status` attribute is dirty (changed) + - Get original status and new status + - Look up allowed transitions from `DeclarationStatus::allowedTransitions()` + - If new status is NOT in the allowed list, throw `ValidationException` with descriptive message + - [x] 6.3: Implement auto-archive in the same `updating()` method: if new status is `ferme`, set `$declaration->archived_at = now()` + - [x] 6.4: Register the observer in `app/Providers/AppServiceProvider.php` `boot()` method using `Declaration::observe(DeclarationObserver::class)` + +- [x] Task 7: Update existing tests and add new tests (AC: #9) + - [x] 7.1: Fix any test failures caused by the DeclarationStatus enum change (update factory defaults, assertions) + - [x] 7.2: Update `DeclarationFactory` to use `DeclarationStatus::Created` as the default status + - [x] 7.3: Create `tests/Feature/Declaration/DeclarationStatusFlowTest.php` with tests for: + - Valid transition: `created` → `en_cours` + - Valid transition: `en_cours` → `en_attente_client` + - Valid transition: `en_attente_client` → `en_cours` + - Valid transition: `en_cours` → `termine` + - Valid transition: `termine` → `ferme` + - Invalid transition: `created` → `ferme` (expect ValidationException) + - Invalid transition: `created` → `termine` (expect ValidationException) + - Auto-archive: `ferme` status sets `archived_at` + - Scope tests: `active()` excludes archived, `archived()` includes only archived + - [x] 7.4: Create `tests/Feature/Database/FoundationMigrationsTest.php` with tests for: + - `workspace_user` table has `permissions` column + - `declarations` table has `archived_at` column + - [x] 7.5: Run `composer test` — all tests must pass + +## Dev Notes + +### Critical Architecture Constraints + +- **Docker Compose ONLY:** Everything runs under Docker Compose — no local installations allowed. All commands via `docker compose exec laravel.test` prefix. +- **DeclarationStatus Enum REPLACEMENT:** The current `DeclarationStatus` enum has 9 values (`draft`, `waiting_documents`, `documents_received`, `processing`, `additional_documents_requested`, `waiting_client_validation`, `validated`, `closed`, `cancelled`) that were placeholder values from the initial codebase. These must ALL be replaced with the 5 architecture-specified values. This is NOT additive — it is a complete replacement. +- **bensampo/laravel-enum:** The project uses `bensampo/laravel-enum` ^6.12, NOT native PHP enums. Follow existing patterns in `DeclarationPriority.php` and `DeclarationType.php`. +- **Model casts:** Use `protected function casts(): array` method, NOT the `$casts` property (per project conventions). +- **Mass assignment:** Always use explicit `$fillable`, never `$guarded = []`. +- **Observer registration:** Register in `AppServiceProvider::boot()` using `Declaration::observe()`, NOT attribute-based registration. +- **Testing:** Use Pest syntax (`test()` closures). `RefreshDatabase` is auto-applied via `Pest.php`. Run tests with `composer test`. +- **Scope discipline:** Do NOT modify files outside story scope. No cosmetic changes (EOF newlines, import reordering) to unrelated files. + +### Declaration Status Flow (from Architecture) + +``` +Created → En cours → En attente client → En cours → Terminé → Fermé → [auto-archive] + ↗ +Created → En cours → Terminé → Fermé → [auto-archive] +``` + +| Status | Value | Meaning | Who Can Set | Next Valid Statuses | +|---|---|---|---|---| +| Created | `created` | Declaration just created | System | `en_cours` | +| En cours | `en_cours` | Being worked on | Owner/Manager/Worker | `en_attente_client`, `termine` | +| En attente client | `en_attente_client` | Waiting for client documents | Owner/Manager/Worker | `en_cours` | +| Terminé | `termine` | Work completed | Owner/Manager/Worker | `ferme` | +| Fermé | `ferme` | Closed (triggers auto-archive) | Owner/Manager | (archived) | + +**Auto-archive trigger:** When status becomes `ferme`, set `archived_at = now()`. + +**Re-open from archive:** Only Owner/Manager. Sets `archived_at = null`, status back to `en_cours`. (Not in scope for this story — will be in Epic 5.) + +### Current Enum State (MUST BE REPLACED) + +The current `DeclarationStatus` at `app/Enums/DeclarationStatus.php` contains these values that will be completely removed: +```php +const Draft = 'draft'; +const WaitingDocuments = 'waiting_documents'; +const DocumentsReceived = 'documents_received'; +const Processing = 'processing'; +const AdditionalDocumentsRequested = 'additional_documents_requested'; +const WaitingClientValidation = 'waiting_client_validation'; +const Validated = 'validated'; +const Closed = 'closed'; +const Cancelled = 'cancelled'; +``` + +### Data Migration Mapping + +When creating the data migration (Task 5.4), map old values to new: +| Old Value | New Value | Rationale | +|---|---|---| +| `draft` | `created` | Initial state | +| `waiting_documents` | `en_cours` | Active work phase | +| `documents_received` | `en_cours` | Active work phase | +| `processing` | `en_cours` | Active work phase | +| `additional_documents_requested` | `en_attente_client` | Waiting on client | +| `waiting_client_validation` | `en_attente_client` | Waiting on client | +| `validated` | `termine` | Completed work | +| `closed` | `ferme` | Closed declaration | +| `cancelled` | `ferme` | Treat as closed | + +### Files to Create + +| File | Purpose | +|---|---| +| `database/migrations/xxxx_add_permissions_to_workspace_user.php` | Add permissions JSON column | +| `database/migrations/xxxx_add_archived_at_to_declarations.php` | Add archived_at timestamp column | +| `database/migrations/xxxx_migrate_declaration_status_values.php` | Data migration for status values | +| `app/Observers/DeclarationObserver.php` | Status transition validation + auto-archive | +| `tests/Feature/Declaration/DeclarationStatusFlowTest.php` | Status flow tests | +| `tests/Feature/Database/FoundationMigrationsTest.php` | Migration schema tests | + +### Files to Modify + +| File | Changes | +|---|---| +| `app/Enums/DeclarationStatus.php` | Replace all 9 values with 5 architecture values, add `labels()` and `allowedTransitions()` | +| `app/Models/Declaration.php` | Add `scopeActive()` and `scopeArchived()` scopes | +| `app/Models/WorkspaceUser.php` | Add `permissions` to `$fillable` and `casts()` | +| `app/Providers/AppServiceProvider.php` | Register `DeclarationObserver` in `boot()` | +| `database/factories/DeclarationFactory.php` | Update default status to `DeclarationStatus::Created` | +| Tests referencing `DeclarationStatus` | Update to use new enum values | + +### Project Structure Notes + +- `app/Observers/` directory does NOT exist yet — must be created +- All model casts use method-based `casts()` not `$casts` property +- `WorkspaceUser` extends `Pivot` (not `Model`) — verify `$fillable` works with Pivot models +- The `declarations` table already has `SoftDeletes` (`deleted_at`) — `archived_at` is a separate concept from soft delete +- Existing `DeclarationFactory` uses `DeclarationStatus::Draft` — must be updated + +### Previous Story Intelligence (Story 0.4) + +Key learnings from Story 0.4: +- **Docker commands:** All artisan/npm commands via `docker compose exec laravel.test` +- **Scope discipline:** Story 0.2 review flagged cosmetic changes as undisciplined scope — avoid changes outside story scope +- **Testing:** Use `composer test` which clears config, runs Pint lint check, then `php artisan test` +- **Queue:work verified:** Redis is configured and operational (cache, queue, sessions) +- **Test count baseline:** 78 tests, 222 assertions as of Story 0.4 completion (plus any tests from uncommitted 0.2/0.3 work) + +### Git Intelligence + +- Only 2 commits exist: initial codebase (35545c2) and BMAD setup (d380df4) +- Stories 0.2, 0.3, 0.4 are complete but changes are unstaged/uncommitted in working tree +- Branch: `l-ami-fiduciaire-v1.0.0` + +### Testing Standards + +- Use **Pest** syntax (`test()` closures), never PHPUnit class-based tests +- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually +- Assertions: prefer Pest's `expect()` chaining over PHPUnit `assert*()` methods +- Use `route()` helper for URLs in tests, never hardcoded paths +- Feature tests grouped by domain subdirectory +- Test descriptions: lowercase, descriptive strings +- Run tests: `composer test` (clears config, runs Pint, runs tests) + +### References + +- [Source: _bmad-output/planning-artifacts/epics.md#Story 0.5] +- [Source: _bmad-output/planning-artifacts/architecture.md#Declaration Status Flow] +- [Source: _bmad-output/planning-artifacts/architecture.md#D1 Permission Storage] +- [Source: _bmad-output/planning-artifacts/architecture.md#D2 Archive Strategy] +- [Source: _bmad-output/project-context.md#Critical Implementation Rules] +- [Source: app/Enums/DeclarationStatus.php (current — 9 placeholder values)] +- [Source: app/Models/Declaration.php (current — no archived_at, no scopes)] +- [Source: app/Models/WorkspaceUser.php (current — no permissions column/cast)] +- [Source: app/Providers/AppServiceProvider.php (current — no observer registration)] +- [Source: database/factories/DeclarationFactory.php (current — uses DeclarationStatus::Draft)] +- [Source: _bmad-output/implementation-artifacts/0-4-configure-redis-for-cache-queue-and-sessions.md#Dev Notes] + +## Change Log + +- 2026-03-12: Story 0.5 implementation complete — added foundation migrations (permissions, archived_at), replaced DeclarationStatus enum with 5 architecture-specified values, created DeclarationObserver for status transition validation and auto-archive, updated all controllers/seeder/factory/tests referencing old enum values. 91 tests pass, 2 pre-existing Redis config failures unrelated to this story. +- 2026-03-12: Code review — 7 issues found (2 critical, 2 high, 3 medium), all fixed: + - [C1] ConfirmController: changed `termine` → `en_cours` to respect observer transition rules (client confirms = back to accountant) + - [C2] DeclarationMessageController: added `created → en_cours` intermediate transition before setting `en_attente_client` + - [H1] UploadController: guarded status update to only fire when transition is valid + - [H2] Declaration model: added `archived_at` to `casts()` as `datetime` + - [M1] Declaration model: added `archived_at` to `$fillable` + - [M2] DatabaseSeeder: set `archived_at = now()` for `ferme` declarations to ensure data consistency + - [M3] DeclarationController edit form exposes all statuses (deferred — observer catches invalid transitions with validation error) + - 93 tests pass (240 assertions) after fixes + +## Dev Agent Record + +### Agent Model Used + +Claude Opus 4.6 + +### Debug Log References + +- Pint lint failure on import order in AppServiceProvider.php — fixed by reordering imports alphabetically +- RenameFoldersToDeclarationsTest rollback test failed due to new migrations changing step count — fixed by updating `--step` from 2 to 5 + +### Completion Notes List + +- All 7 tasks and 24 subtasks completed successfully +- 3 new migrations created and applied: permissions column, archived_at column, status data migration +- DeclarationStatus enum fully replaced: 9 old values → 5 architecture values with labels() and allowedTransitions() +- DeclarationObserver created with status transition validation + auto-archive on ferme +- 12 new tests added (10 status flow + 2 migration schema tests) +- All controllers, seeder, and factory updated to use new enum values +- 91 tests pass, 2 pre-existing RedisConnectivityTest failures (queue/session config in test env) +- No scope creep — only story-scoped files modified + +### File List + +**Created:** +- database/migrations/2026_03_12_044146_add_permissions_to_workspace_user.php +- database/migrations/2026_03_12_044334_add_archived_at_to_declarations.php +- database/migrations/2026_03_12_044414_migrate_declaration_status_values.php +- app/Observers/DeclarationObserver.php +- tests/Feature/Declaration/DeclarationStatusFlowTest.php +- tests/Feature/Database/FoundationMigrationsTest.php + +**Modified:** +- app/Enums/DeclarationStatus.php +- app/Models/Declaration.php +- app/Models/WorkspaceUser.php +- app/Providers/AppServiceProvider.php +- database/factories/DeclarationFactory.php +- app/Http/Controllers/DeclarationController.php +- app/Http/Controllers/DashboardController.php +- app/Http/Controllers/WorkspaceController.php +- app/Http/Controllers/Client/UploadController.php +- app/Http/Controllers/Client/ConfirmController.php +- app/Http/Controllers/DeclarationMessageController.php +- database/seeders/DatabaseSeeder.php +- tests/Feature/Declaration/DeclarationTypeTest.php +- tests/Feature/Database/RenameFoldersToDeclarationsTest.php diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 5f652bb..da59ae8 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -43,10 +43,10 @@ development_status: # Epic 0: Foundation Migration & Infrastructure Setup epic-0: in-progress 0-1-rename-folders-to-declarations-in-database: done - 0-2-rename-folders-to-declarations-in-backend: backlog - 0-3-rename-folders-to-declarations-in-frontend: backlog - 0-4-configure-redis-for-cache-queue-and-sessions: backlog - 0-5-add-foundation-database-migrations-and-declaration-status-flow: backlog + 0-2-rename-folders-to-declarations-in-backend: done + 0-3-rename-folders-to-declarations-in-frontend: done + 0-4-configure-redis-for-cache-queue-and-sessions: done + 0-5-add-foundation-database-migrations-and-declaration-status-flow: done epic-0-retrospective: optional # Epic 1: Team Management & Permission System diff --git a/app/Enums/FolderPriority.php b/app/Enums/DeclarationPriority.php similarity index 75% rename from app/Enums/FolderPriority.php rename to app/Enums/DeclarationPriority.php index e4cd0c9..02a2788 100644 --- a/app/Enums/FolderPriority.php +++ b/app/Enums/DeclarationPriority.php @@ -4,7 +4,7 @@ namespace App\Enums; use BenSampo\Enum\Enum; -final class FolderPriority extends Enum +final class DeclarationPriority extends Enum { const Low = 'low'; diff --git a/app/Enums/DeclarationStatus.php b/app/Enums/DeclarationStatus.php new file mode 100644 index 0000000..d4e6be3 --- /dev/null +++ b/app/Enums/DeclarationStatus.php @@ -0,0 +1,50 @@ + + */ + public static function labels(): array + { + return [ + self::Created => 'Créé', + self::EnCours => 'En cours', + self::EnAttenteClient => 'En attente client', + self::Termine => 'Terminé', + self::Ferme => 'Fermé', + ]; + } + + /** + * Get the valid next statuses for each status per the architecture status flow. + * + * @return array> + */ + public static function allowedTransitions(): array + { + return [ + self::Created => [self::EnCours], + self::EnCours => [self::EnAttenteClient, self::Termine], + self::EnAttenteClient => [self::EnCours], + self::Termine => [self::Ferme], + self::Ferme => [], + ]; + } +} diff --git a/app/Enums/FolderType.php b/app/Enums/DeclarationType.php similarity index 89% rename from app/Enums/FolderType.php rename to app/Enums/DeclarationType.php index f31a9cd..4bdefeb 100644 --- a/app/Enums/FolderType.php +++ b/app/Enums/DeclarationType.php @@ -4,7 +4,7 @@ namespace App\Enums; use BenSampo\Enum\Enum; -final class FolderType extends Enum +final class DeclarationType extends Enum { const VAT = 'vat'; diff --git a/app/Enums/FolderStatus.php b/app/Enums/FolderStatus.php deleted file mode 100644 index c8b7e59..0000000 --- a/app/Enums/FolderStatus.php +++ /dev/null @@ -1,26 +0,0 @@ -attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; - $folder->load(['client']); + $declaration->load(['client']); return Inertia::render('client/Confirm', [ - 'folder' => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'client_name' => $folder->client->company_name, + 'declaration' => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'client_name' => $declaration->client->company_name, ], 'token' => $token, 'submitUrl' => route('client.confirm.store', ['token' => $token]), @@ -37,19 +37,19 @@ class ConfirmController extends Controller */ public function store(Request $request, string $token): RedirectResponse { - $invitation = $request->attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; $request->validate([ 'signature' => ['required', 'string', 'max:255'], ]); - $folder->update([ + $declaration->update([ 'validated_at' => now(), 'confirmed_by_type' => ActorType::Client, - 'confirmed_by_id' => $folder->client_id, + 'confirmed_by_id' => $declaration->client_id, 'confirmation_signature' => $request->input('signature'), - 'status' => \App\Enums\FolderStatus::Validated, + 'status' => \App\Enums\DeclarationStatus::EnCours, ]); return back()->with('flash', ['type' => 'success', 'message' => 'Validation enregistrée. Merci.']); diff --git a/app/Http/Controllers/Client/RefuseController.php b/app/Http/Controllers/Client/RefuseController.php index 4ef427a..cfcf1e4 100644 --- a/app/Http/Controllers/Client/RefuseController.php +++ b/app/Http/Controllers/Client/RefuseController.php @@ -15,16 +15,16 @@ class RefuseController extends Controller */ public function show(Request $request, string $token): Response { - $invitation = $request->attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; - $folder->load(['client']); + $declaration->load(['client']); return Inertia::render('client/Refuse', [ - 'folder' => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'client_name' => $folder->client->company_name, + 'declaration' => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'client_name' => $declaration->client->company_name, ], 'token' => $token, 'submitUrl' => route('client.refuse.store', ['token' => $token]), @@ -36,14 +36,14 @@ class RefuseController extends Controller */ public function store(Request $request, string $token): RedirectResponse { - $invitation = $request->attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; $request->validate([ 'reason' => ['nullable', 'string', 'max:65535'], ]); - $folder->update([ + $declaration->update([ 'refused_at' => now(), 'refusal_reason' => $request->input('reason'), ]); diff --git a/app/Http/Controllers/Client/UploadController.php b/app/Http/Controllers/Client/UploadController.php index 9fd7271..a06befa 100644 --- a/app/Http/Controllers/Client/UploadController.php +++ b/app/Http/Controllers/Client/UploadController.php @@ -3,10 +3,10 @@ namespace App\Http\Controllers\Client; use App\Enums\ActorType; -use App\Enums\FolderStatus; +use App\Enums\DeclarationStatus; use App\Enums\MessageType; use App\Http\Controllers\Controller; -use App\Mail\FolderTextMessageMail; +use App\Mail\DeclarationTextMessageMail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; @@ -20,11 +20,11 @@ class UploadController extends Controller */ public function show(Request $request, string $token): Response { - $invitation = $request->attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; - $folder->load(['client']); - $documents = $folder->getMedia('documents')->map(fn ($m) => [ + $declaration->load(['client']); + $documents = $declaration->getMedia('documents')->map(fn ($m) => [ 'id' => $m->id, 'name' => $m->name, 'file_name' => $m->file_name, @@ -33,10 +33,10 @@ class UploadController extends Controller ])->values()->all(); return Inertia::render('client/Upload', [ - 'folder' => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'client_name' => $folder->client->company_name, + 'declaration' => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'client_name' => $declaration->client->company_name, ], 'token' => $token, 'documents' => $documents, @@ -50,38 +50,42 @@ class UploadController extends Controller */ public function store(Request $request, string $token): RedirectResponse { - $invitation = $request->attributes->get('folder_invitation'); - $folder = $invitation->folder; + $invitation = $request->attributes->get('declaration_invitation'); + $declaration = $invitation->declaration; $request->validate([ 'files' => ['required', 'array', 'min:1'], 'files.*' => ['file', 'max:10240'], ]); - $message = $folder->messages()->create([ + $message = $declaration->messages()->create([ 'type' => MessageType::Text, 'body' => 'Documents déposés par le client.', 'sent_by_type' => ActorType::Client, - 'sent_by_id' => $folder->client_id, + 'sent_by_id' => $declaration->client_id, 'metadata' => ['invitation_id' => $invitation->id], ]); foreach ($request->file('files') as $file) { - $folder->addMedia($file) + $declaration->addMedia($file) ->withCustomProperties([ 'message_id' => $message->id, 'uploaded_by_type' => ActorType::Client, - 'uploaded_by_id' => $folder->client_id, + 'uploaded_by_id' => $declaration->client_id, ]) ->toMediaCollection('documents'); } - $folder->update(['status' => FolderStatus::DocumentsReceived]); + // Only transition to en_cours if the current status allows it + $allowed = DeclarationStatus::allowedTransitions()[$declaration->status->value] ?? []; + if (in_array(DeclarationStatus::EnCours, $allowed)) { + $declaration->update(['status' => DeclarationStatus::EnCours]); + } - $recipient = $folder->assignee ?? $folder->creator; + $recipient = $declaration->assignee ?? $declaration->creator; if ($recipient?->email) { Mail::to($recipient->email)->send( - new FolderTextMessageMail($folder, 'Le client a déposé des documents.', null) + new DeclarationTextMessageMail($declaration, 'Le client a déposé des documents.', null) ); } diff --git a/app/Http/Controllers/ClientController.php b/app/Http/Controllers/ClientController.php index 51050e2..91e4289 100644 --- a/app/Http/Controllers/ClientController.php +++ b/app/Http/Controllers/ClientController.php @@ -141,7 +141,7 @@ class ClientController extends Controller $client->load(['internalResponsible', 'contacts']); - $folders = $client->folders() + $declarations = $client->declarations() ->with(['assignee']) ->latest() ->limit(50) @@ -153,18 +153,18 @@ class ClientController extends Controller 'status' => $f->status->value, 'due_date' => $f->due_date?->format('Y-m-d'), 'created_at' => $f->created_at->format('Y-m-d'), - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->values() ->all(); - $allFolders = $client->folders()->get(); + $allDeclarations = $client->declarations()->get(); $stats = [ - 'total' => $allFolders->count(), - 'by_status' => $allFolders->groupBy(fn ($f) => $f->status->value) + 'total' => $allDeclarations->count(), + 'by_status' => $allDeclarations->groupBy(fn ($f) => $f->status->value) ->map->count() ->all(), - 'by_type' => $allFolders->groupBy(fn ($f) => $f->type->value) + 'by_type' => $allDeclarations->groupBy(fn ($f) => $f->type->value) ->map->count() ->all(), ]; @@ -185,11 +185,11 @@ class ClientController extends Controller 'status' => $client->status?->value, 'internal_notes' => $client->internal_notes, ], - 'folders' => $folders, + 'declarations' => $declarations, 'stats' => $stats, 'indexUrl' => route('clients.index'), 'editUrl' => route('clients.edit', $client), - 'createFolderUrl' => route('folders.create', ['client_id' => $client->id]), + 'createDeclarationUrl' => route('declarations.create', ['client_id' => $client->id]), ]); } diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 432642b..4807ff2 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,8 +2,8 @@ namespace App\Http\Controllers; -use App\Enums\FolderStatus; -use App\Models\Folder; +use App\Enums\DeclarationStatus; +use App\Models\Declaration; use App\Models\Workspace; use Illuminate\Http\Request; use Inertia\Inertia; @@ -12,7 +12,7 @@ use Inertia\Response; class DashboardController extends Controller { /** - * Display the dashboard with assigned folders and notifications. + * Display the dashboard with assigned declarations and notifications. */ public function __invoke(Request $request): Response { @@ -20,18 +20,18 @@ class DashboardController extends Controller $workspaceId = $request->session()->get('current_workspace_id'); $workspace = $workspaceId ? Workspace::query()->find($workspaceId) : null; - $assignedFolders = []; + $assignedDeclarations = []; $notifications = []; if ($workspace && $user) { - $assignedFolders = $workspace->folders() + $assignedDeclarations = $workspace->declarations() ->where('assigned_to', $user->id) - ->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled]) + ->whereNotIn('status', [DeclarationStatus::Ferme]) ->with('client:id,company_name') ->orderByRaw('CASE WHEN due_date IS NULL THEN 1 ELSE 0 END, due_date ASC') ->limit(50) ->get() - ->map(fn (Folder $f) => [ + ->map(fn (Declaration $f) => [ 'id' => $f->id, 'title' => $f->title, 'type' => $f->type->value, @@ -39,71 +39,71 @@ class DashboardController extends Controller 'status' => $f->status->value, 'due_date' => $f->due_date?->format('Y-m-d'), 'priority' => $f->priority?->value, - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->all(); - $overdue = $workspace->folders() + $overdue = $workspace->declarations() ->where('assigned_to', $user->id) ->where('due_date', '<', now()->startOfDay()) - ->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled]) + ->whereNotIn('status', [DeclarationStatus::Ferme]) ->with('client:id,company_name') ->orderBy('due_date') ->limit(10) ->get() - ->map(fn (Folder $f) => [ + ->map(fn (Declaration $f) => [ 'id' => $f->id, 'title' => $f->title, 'client_name' => $f->client->company_name, 'due_date' => $f->due_date?->format('Y-m-d'), - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->all(); - $dueSoon = $workspace->folders() + $dueSoon = $workspace->declarations() ->where('assigned_to', $user->id) ->whereBetween('due_date', [now()->startOfDay(), now()->addDays(7)->endOfDay()]) - ->whereNotIn('status', [FolderStatus::Closed, FolderStatus::Cancelled]) + ->whereNotIn('status', [DeclarationStatus::Ferme]) ->with('client:id,company_name') ->orderBy('due_date') ->limit(10) ->get() - ->map(fn (Folder $f) => [ + ->map(fn (Declaration $f) => [ 'id' => $f->id, 'title' => $f->title, 'client_name' => $f->client->company_name, 'due_date' => $f->due_date?->format('Y-m-d'), - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->all(); - $documentsReceived = $workspace->folders() + $documentsReceived = $workspace->declarations() ->where('assigned_to', $user->id) - ->where('status', FolderStatus::DocumentsReceived) + ->where('status', DeclarationStatus::EnCours) ->with('client:id,company_name') ->orderBy('updated_at', 'desc') ->limit(10) ->get() - ->map(fn (Folder $f) => [ + ->map(fn (Declaration $f) => [ 'id' => $f->id, 'title' => $f->title, 'client_name' => $f->client->company_name, - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->all(); - $awaitingValidation = $workspace->folders() + $awaitingValidation = $workspace->declarations() ->where('assigned_to', $user->id) - ->where('status', FolderStatus::WaitingClientValidation) + ->where('status', DeclarationStatus::EnAttenteClient) ->with('client:id,company_name') ->orderBy('confirmation_requested_at', 'desc') ->limit(10) ->get() - ->map(fn (Folder $f) => [ + ->map(fn (Declaration $f) => [ 'id' => $f->id, 'title' => $f->title, 'client_name' => $f->client->company_name, - 'showUrl' => route('folders.show', $f), + 'showUrl' => route('declarations.show', $f), ]) ->all(); @@ -116,10 +116,10 @@ class DashboardController extends Controller } return Inertia::render('Dashboard', [ - 'assignedFolders' => $assignedFolders, + 'assignedDeclarations' => $assignedDeclarations, 'notifications' => $notifications, 'workspaceName' => $workspace?->name ?? null, - 'foldersUrl' => $workspace ? route('folders.index') : null, + 'declarationsUrl' => $workspace ? route('declarations.index') : null, 'clientsUrl' => $workspace ? route('clients.index') : null, ]); } diff --git a/app/Http/Controllers/DeclarationController.php b/app/Http/Controllers/DeclarationController.php new file mode 100644 index 0000000..b53c140 --- /dev/null +++ b/app/Http/Controllers/DeclarationController.php @@ -0,0 +1,332 @@ + 'TVA', + DeclarationType::VatMonthly => 'TVA mensuelle', + DeclarationType::VatQuarterly => 'TVA trimestrielle', + DeclarationType::CorporateTax => 'IS', + DeclarationType::IncomeTax => 'IR', + DeclarationType::CNSS => 'CNSS', + DeclarationType::AnnualBalance => 'Bilan', + DeclarationType::Other => 'Autre', + ]; + } + + protected function declarationStatusLabels(): array + { + return DeclarationStatus::labels(); + } + + protected function declarationPriorityLabels(): array + { + return [ + DeclarationPriority::Low => 'Basse', + DeclarationPriority::Medium => 'Normale', + DeclarationPriority::High => 'Haute', + ]; + } + + protected function currentWorkspace(Request $request): Workspace + { + $workspaceId = $request->session()->get('current_workspace_id'); + + return Workspace::query()->findOrFail($workspaceId); + } + + /** + * Display a listing of the declarations. + */ + public function index(Request $request): Response + { + $workspace = $this->currentWorkspace($request); + + $perPage = min(max((int) $request->input('per_page', 10), 10), 100); + + $declarations = $workspace->declarations() + ->with(['client', 'assignee']) + ->latest() + ->paginate($perPage) + ->through(fn (Declaration $declaration) => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'type' => $declaration->type->value, + 'client_name' => $declaration->client->company_name, + 'status' => $declaration->status->value, + 'due_date' => $declaration->due_date?->format('Y-m-d'), + 'showUrl' => route('declarations.show', $declaration), + 'editUrl' => route('declarations.edit', $declaration), + 'destroyUrl' => route('declarations.destroy', $declaration), + ]); + + return Inertia::render('declarations/Index', [ + 'declarations' => $declarations, + 'createUrl' => route('declarations.create'), + 'workspaceName' => $workspace->name, + ]); + } + + /** + * Show the form for creating a new declaration. + */ + public function create(Request $request): Response + { + $workspace = $this->currentWorkspace($request); + $initialClientId = $request->integer('client_id', 0) ?: null; + + return Inertia::render('declarations/Create', [ + 'indexUrl' => route('declarations.index'), + 'storeUrl' => route('declarations.store'), + 'initialClientId' => $initialClientId, + 'declarationTypeLabels' => $this->declarationTypeLabels(), + 'declarationStatusLabels' => $this->declarationStatusLabels(), + 'declarationPriorityLabels' => $this->declarationPriorityLabels(), + 'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [ + 'id' => $c->id, + 'company_name' => $c->company_name, + ])->values()->all(), + 'workspaceUsers' => $workspace->users() + ->orderBy('users.name') + ->select('users.id', 'users.name', 'users.email') + ->get() + ->map(fn ($u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + ])->values()->all(), + ]); + } + + /** + * Store a newly created declaration in storage. + */ + public function store(StoreDeclarationRequest $request): RedirectResponse + { + $workspace = $this->currentWorkspace($request); + $data = $request->validated(); + $data['workspace_id'] = $workspace->id; + $data['created_by'] = $request->user()?->id; + $data['status'] = $data['status'] ?? DeclarationStatus::Created; + + if (($data['type'] ?? '') === 'vat_monthly') { + $data['period_quarter'] = null; + } elseif (($data['type'] ?? '') === 'vat_quarterly') { + $data['period_month'] = null; + } + + Declaration::query()->create($data); + + return to_route('declarations.index'); + } + + /** + * Display the specified declaration. + */ + public function show(Request $request, Declaration $declaration): Response + { + $workspace = $this->currentWorkspace($request); + $this->authorizeDeclaration($workspace, $declaration); + + $declaration->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]); + + $allMedia = $declaration->getMedia('documents'); + $downloadedMediaIds = MediaDownload::query() + ->where('user_id', $request->user()->id) + ->whereIn('media_id', $allMedia->pluck('id')) + ->pluck('media_id') + ->all(); + + $messages = $declaration->messages->map(function ($m) use ($declaration, $allMedia, $downloadedMediaIds) { + $attachments = $allMedia + ->filter(fn ($media) => $media->getCustomProperty('message_id') === $m->id) + ->map(fn ($media) => [ + 'id' => $media->id, + 'file_name' => $media->file_name, + 'mime_type' => $media->mime_type, + 'size' => $media->human_readable_size, + 'downloadUrl' => route('declarations.media.download', ['declaration' => $declaration, 'mediaId' => $media->id]), + 'is_downloaded' => in_array($media->id, $downloadedMediaIds), + ]) + ->values() + ->all(); + + $confirmationStatus = null; + if ($m->type->value === 'confirmation') { + $confirmationStatus = $declaration->refused_at ? 'refused' : ($declaration->validated_at ? 'confirmed' : 'pending'); + } + + return [ + 'id' => $m->id, + 'type' => $m->type->value, + 'body' => $m->body, + 'sent_by_type' => $m->sent_by_type->value, + 'sender_name' => $m->sender_name, + 'created_at' => $m->created_at->format('Y-m-d H:i'), + 'attachments' => $attachments, + 'confirmation_status' => $confirmationStatus, + ]; + })->values()->all(); + + $documents = $allMedia->map(fn ($m) => [ + 'id' => $m->id, + 'name' => $m->name, + 'file_name' => $m->file_name, + 'size' => $m->human_readable_size, + 'created_at' => $m->created_at->format('d/m/Y H:i'), + 'uploaded_by' => $m->getCustomProperty('uploaded_by_type') === 'user' ? 'Comptable' : 'Client', + 'downloadUrl' => route('declarations.media.download', ['declaration' => $declaration, 'mediaId' => $m->id]), + 'is_downloaded' => in_array($m->id, $downloadedMediaIds), + ])->values()->all(); + + return Inertia::render('declarations/Show', [ + 'declaration' => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'type' => $declaration->type->value, + 'client_id' => $declaration->client_id, + 'client_name' => $declaration->client->company_name, + 'period_year' => $declaration->period_year, + 'period_month' => $declaration->period_month, + 'period_quarter' => $declaration->period_quarter, + 'due_date' => $declaration->due_date?->format('Y-m-d'), + 'status' => $declaration->status->value, + 'priority' => $declaration->priority?->value, + 'assigned_to' => $declaration->assigned_to, + 'assignee_name' => $declaration->assignee?->name, + 'validated_at' => $declaration->validated_at?->format('Y-m-d H:i'), + 'closed_at' => $declaration->closed_at?->format('Y-m-d H:i'), + 'notes_internal' => $declaration->notes_internal, + 'notes_client' => $declaration->notes_client, + 'created_at' => $declaration->created_at?->format('Y-m-d H:i'), + ], + 'messages' => $messages, + 'documents' => $documents, + 'messagesStoreUrl' => route('declarations.messages.store', $declaration), + 'mediaStoreUrl' => route('declarations.media.store', $declaration), + 'messageTypeLabels' => [ + 'invite' => 'Invitation', + 'situation' => 'Situation', + 'file_request' => 'Demande de pièces', + 'confirmation' => 'Demande de validation', + 'text' => 'Message', + ], + 'indexUrl' => route('declarations.index'), + 'editUrl' => route('declarations.edit', $declaration), + 'workspaceUsers' => $workspace->users()->orderBy('users.name') + ->select('users.id', 'users.name', 'users.email') + ->get()->map(fn ($u) => ['id' => $u->id, 'name' => $u->name]) + ->values()->all(), + 'mentionStoreUrl' => route('declarations.mentions.store', $declaration), + 'canMention' => in_array( + $workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value, + ['owner', 'manager'] + ), + ]); + } + + /** + * Show the form for editing the specified declaration. + */ + public function edit(Request $request, Declaration $declaration): Response + { + $workspace = $this->currentWorkspace($request); + $this->authorizeDeclaration($workspace, $declaration); + + return Inertia::render('declarations/Edit', [ + 'declaration' => [ + 'id' => $declaration->id, + 'title' => $declaration->title, + 'type' => $declaration->type->value, + 'client_id' => $declaration->client_id, + 'period_year' => $declaration->period_year, + 'period_month' => $declaration->period_month, + 'period_quarter' => $declaration->period_quarter, + 'due_date' => $declaration->due_date?->format('Y-m-d'), + 'status' => $declaration->status->value, + 'priority' => $declaration->priority?->value, + 'assigned_to' => $declaration->assigned_to, + 'notes_internal' => $declaration->notes_internal, + 'notes_client' => $declaration->notes_client, + 'created_at' => $declaration->created_at?->format('Y-m-d H:i'), + ], + 'indexUrl' => route('declarations.index'), + 'updateUrl' => route('declarations.update', $declaration), + 'declarationTypeLabels' => $this->declarationTypeLabels(), + 'declarationStatusLabels' => $this->declarationStatusLabels(), + 'declarationPriorityLabels' => $this->declarationPriorityLabels(), + 'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [ + 'id' => $c->id, + 'company_name' => $c->company_name, + ])->values()->all(), + 'workspaceUsers' => $workspace->users() + ->orderBy('users.name') + ->select('users.id', 'users.name', 'users.email') + ->get() + ->map(fn ($u) => [ + 'id' => $u->id, + 'name' => $u->name, + 'email' => $u->email, + ])->values()->all(), + ]); + } + + /** + * Update the specified declaration in storage. + */ + public function update(UpdateDeclarationRequest $request, Declaration $declaration): RedirectResponse + { + $workspace = $this->currentWorkspace($request); + $this->authorizeDeclaration($workspace, $declaration); + + $data = $request->validated(); + + if (($data['type'] ?? '') === 'vat_monthly') { + $data['period_quarter'] = null; + } elseif (($data['type'] ?? '') === 'vat_quarterly') { + $data['period_month'] = null; + } + + $declaration->update($data); + + return to_route('declarations.index'); + } + + /** + * Remove the specified declaration from storage. + */ + public function destroy(Request $request, Declaration $declaration): RedirectResponse + { + $workspace = $this->currentWorkspace($request); + $this->authorizeDeclaration($workspace, $declaration); + + $declaration->delete(); + + return to_route('declarations.index'); + } + + protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void + { + if ($declaration->workspace_id !== $workspace->id) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/FolderMediaController.php b/app/Http/Controllers/DeclarationMediaController.php similarity index 77% rename from app/Http/Controllers/FolderMediaController.php rename to app/Http/Controllers/DeclarationMediaController.php index 0593f5f..4879829 100644 --- a/app/Http/Controllers/FolderMediaController.php +++ b/app/Http/Controllers/DeclarationMediaController.php @@ -3,7 +3,7 @@ namespace App\Http\Controllers; use App\Enums\ActorType; -use App\Models\Folder; +use App\Models\Declaration; use App\Models\MediaDownload; use App\Models\Workspace; use Illuminate\Http\RedirectResponse; @@ -11,7 +11,7 @@ use Illuminate\Http\Request; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Symfony\Component\HttpFoundation\Response; -class FolderMediaController extends Controller +class DeclarationMediaController extends Controller { protected function currentWorkspace(Request $request): Workspace { @@ -23,11 +23,11 @@ class FolderMediaController extends Controller /** * Store a newly uploaded file. */ - public function store(Request $request, Folder $folder): RedirectResponse + public function store(Request $request, Declaration $declaration): RedirectResponse { $workspace = $this->currentWorkspace($request); - if ($folder->workspace_id !== $workspace->id) { + if ($declaration->workspace_id !== $workspace->id) { abort(404); } @@ -39,7 +39,7 @@ class FolderMediaController extends Controller $user = $request->user(); foreach ($request->file('files') as $file) { - $folder->addMedia($file) + $declaration->addMedia($file) ->withCustomProperties([ 'uploaded_by_type' => ActorType::User, 'uploaded_by_id' => $user->id, @@ -53,17 +53,17 @@ class FolderMediaController extends Controller /** * Download a media file. */ - public function download(Request $request, Folder $folder, int $mediaId): Response + public function download(Request $request, Declaration $declaration, int $mediaId): Response { $workspace = $this->currentWorkspace($request); - if ($folder->workspace_id !== $workspace->id) { + if ($declaration->workspace_id !== $workspace->id) { abort(404); } $media = Media::query() - ->where('model_type', Folder::class) - ->where('model_id', $folder->id) + ->where('model_type', Declaration::class) + ->where('model_id', $declaration->id) ->where('id', $mediaId) ->firstOrFail(); diff --git a/app/Http/Controllers/FolderMentionController.php b/app/Http/Controllers/DeclarationMentionController.php similarity index 65% rename from app/Http/Controllers/FolderMentionController.php rename to app/Http/Controllers/DeclarationMentionController.php index 34b308c..f93ac80 100644 --- a/app/Http/Controllers/FolderMentionController.php +++ b/app/Http/Controllers/DeclarationMentionController.php @@ -2,16 +2,16 @@ namespace App\Http\Controllers; -use App\Http\Requests\StoreFolderMentionRequest; -use App\Models\Folder; +use App\Http\Requests\StoreDeclarationMentionRequest; +use App\Models\Declaration; use App\Models\User; use App\Models\Workspace; -use App\Notifications\FolderMentionNotification; +use App\Notifications\DeclarationMentionNotification; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -class FolderMentionController extends Controller +class DeclarationMentionController extends Controller { protected function currentWorkspace(Request $request): Workspace { @@ -20,17 +20,17 @@ class FolderMentionController extends Controller return Workspace::query()->findOrFail($workspaceId); } - protected function authorizeFolder(Workspace $workspace, Folder $folder): void + protected function authorizeDeclaration(Workspace $workspace, Declaration $declaration): void { - if ($folder->workspace_id !== $workspace->id) { + if ($declaration->workspace_id !== $workspace->id) { abort(404); } } - public function store(StoreFolderMentionRequest $request, Folder $folder): RedirectResponse + public function store(StoreDeclarationMentionRequest $request, Declaration $declaration): RedirectResponse { $workspace = $this->currentWorkspace($request); - $this->authorizeFolder($workspace, $folder); + $this->authorizeDeclaration($workspace, $declaration); $userRole = $workspace->users() ->where('users.id', $request->user()->id) @@ -46,8 +46,8 @@ class FolderMentionController extends Controller $validated = $request->validated(); $targetUser = User::findOrFail($validated['user_id']); - $targetUser->notify(new FolderMentionNotification( - $folder, + $targetUser->notify(new DeclarationMentionNotification( + $declaration, $request->user(), $validated['message'], )); diff --git a/app/Http/Controllers/DeclarationMessageController.php b/app/Http/Controllers/DeclarationMessageController.php new file mode 100644 index 0000000..c994c7c --- /dev/null +++ b/app/Http/Controllers/DeclarationMessageController.php @@ -0,0 +1,156 @@ +session()->get('current_workspace_id'); + + return Workspace::query()->findOrFail($workspaceId); + } + + /** + * Store a newly created message. + */ + public function store(StoreDeclarationMessageRequest $request, Declaration $declaration): RedirectResponse + { + $workspace = $this->currentWorkspace($request); + + if ($declaration->workspace_id !== $workspace->id) { + abort(404); + } + + $user = $request->user(); + $type = MessageType::fromValue($request->input('type')); + $body = $request->input('body'); + + $invitation = $type->is(MessageType::Invite) + ? $this->createInvitation($declaration) + : $this->getOrCreateInvitation($declaration); + + $metadata = ['invitation_id' => $invitation->id]; + $message = $declaration->messages()->create([ + 'type' => $type, + 'body' => $body, + 'sent_by_type' => ActorType::User, + 'sent_by_id' => $user->id, + 'metadata' => $metadata, + ]); + + $mediaIds = []; + + if ($request->hasFile('files')) { + foreach ($request->file('files') as $file) { + $media = $declaration->addMedia($file) + ->withCustomProperties([ + 'message_id' => $message->id, + 'uploaded_by_type' => ActorType::User, + 'uploaded_by_id' => $user->id, + ]) + ->toMediaCollection('documents'); + $mediaIds[] = $media->id; + } + $message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]); + } + + $this->updateDeclarationStatusAndConfirmation($declaration, $type, $mediaIds); + + $emailSent = $this->sendEmailForMessage($declaration, $invitation, $message, $body, $type); + $flashMessage = $emailSent + ? 'Message envoyé.' + : 'Message enregistré, mais l\'email du client n\'est pas configuré.'; + + return back()->with('flash', ['type' => 'success', 'message' => $flashMessage]); + } + + protected function createInvitation(Declaration $declaration): DeclarationInvitation + { + $declaration->load('client.primaryContact'); + + return $declaration->invitations()->create([ + 'email' => $declaration->client->primary_contact_email, + 'expires_at' => Carbon::now()->addDays(7), + ]); + } + + protected function getOrCreateInvitation(Declaration $declaration): DeclarationInvitation + { + $invitation = $declaration->invitations() + ->where('expires_at', '>', now()) + ->latest() + ->first(); + + if ($invitation) { + return $invitation; + } + + return $this->createInvitation($declaration); + } + + /** + * @param array $mediaIds + */ + protected function updateDeclarationStatusAndConfirmation(Declaration $declaration, MessageType $type, array $mediaIds): void + { + // Transition through en_cours first if declaration is still in created status, + // since created → en_attente_client is not a valid direct transition. + if ($declaration->status->is(DeclarationStatus::Created)) { + $declaration->update(['status' => DeclarationStatus::EnCours]); + $declaration->refresh(); + } + + match ($type->value) { + 'invite' => $declaration->update(['status' => DeclarationStatus::EnAttenteClient]), + 'situation', 'file_request' => $declaration->update(['status' => DeclarationStatus::EnAttenteClient]), + 'confirmation' => $declaration->update([ + 'status' => DeclarationStatus::EnAttenteClient, + 'confirmation_requested_at' => now(), + 'confirmation_media_id' => $mediaIds[0] ?? null, + ]), + default => null, + }; + } + + protected function sendEmailForMessage(Declaration $declaration, DeclarationInvitation $invitation, Message $message, string $body, MessageType $type): bool + { + $declaration->load('client.primaryContact'); + $clientEmail = $declaration->client->primary_contact_email; + + if (empty($clientEmail)) { + \Illuminate\Support\Facades\Log::warning("No primary contact email for client #{$declaration->client_id}, skipping email."); + + return false; + } + + match ($type->value) { + 'invite' => Mail::to($clientEmail)->send(new DeclarationInviteMail($declaration, $invitation)), + 'situation' => Mail::to($clientEmail)->send(new DeclarationSituationMail($declaration, $invitation, $body)), + 'file_request' => Mail::to($clientEmail)->send(new DeclarationFileRequestMail($declaration, $invitation, $body)), + 'confirmation' => Mail::to($clientEmail)->send(new DeclarationConfirmationMail($declaration, $invitation, $body)), + 'text' => Mail::to($clientEmail)->send(new DeclarationTextMessageMail($declaration, $body, $invitation->token)), + default => null, + }; + + return true; + } +} diff --git a/app/Http/Controllers/FolderController.php b/app/Http/Controllers/FolderController.php deleted file mode 100644 index 74b5bd6..0000000 --- a/app/Http/Controllers/FolderController.php +++ /dev/null @@ -1,328 +0,0 @@ - 'TVA', - FolderType::VatMonthly => 'TVA mensuelle', - FolderType::VatQuarterly => 'TVA trimestrielle', - FolderType::CorporateTax => 'IS', - FolderType::IncomeTax => 'IR', - FolderType::CNSS => 'CNSS', - FolderType::AnnualBalance => 'Bilan', - FolderType::Other => 'Autre', - ]; - } - - protected function folderStatusLabels(): array - { - return [ - FolderStatus::Draft => 'Brouillon', - FolderStatus::WaitingDocuments => 'En attente documents', - FolderStatus::DocumentsReceived => 'Documents reçus', - FolderStatus::Processing => 'En cours de traitement', - FolderStatus::AdditionalDocumentsRequested => 'Pièces complémentaires demandées', - FolderStatus::WaitingClientValidation => 'En attente validation client', - FolderStatus::Validated => 'Validé', - FolderStatus::Closed => 'Clôturé', - FolderStatus::Cancelled => 'Annulé', - ]; - } - - protected function folderPriorityLabels(): array - { - return [ - FolderPriority::Low => 'Basse', - FolderPriority::Medium => 'Normale', - FolderPriority::High => 'Haute', - ]; - } - - protected function currentWorkspace(Request $request): Workspace - { - $workspaceId = $request->session()->get('current_workspace_id'); - - return Workspace::query()->findOrFail($workspaceId); - } - - /** - * Display a listing of the folders. - */ - public function index(Request $request): Response - { - $workspace = $this->currentWorkspace($request); - - $perPage = min(max((int) $request->input('per_page', 10), 10), 100); - - $folders = $workspace->folders() - ->with(['client', 'assignee']) - ->latest() - ->paginate($perPage) - ->through(fn (Folder $folder) => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'type' => $folder->type->value, - 'client_name' => $folder->client->company_name, - 'status' => $folder->status->value, - 'due_date' => $folder->due_date?->format('Y-m-d'), - 'showUrl' => route('folders.show', $folder), - 'editUrl' => route('folders.edit', $folder), - 'destroyUrl' => route('folders.destroy', $folder), - ]); - - return Inertia::render('folders/Index', [ - 'folders' => $folders, - 'createUrl' => route('folders.create'), - 'workspaceName' => $workspace->name, - ]); - } - - /** - * Show the form for creating a new folder. - */ - public function create(Request $request): Response - { - $workspace = $this->currentWorkspace($request); - $initialClientId = $request->integer('client_id', 0) ?: null; - - return Inertia::render('folders/Create', [ - 'indexUrl' => route('folders.index'), - 'storeUrl' => route('folders.store'), - 'initialClientId' => $initialClientId, - 'folderTypeLabels' => $this->folderTypeLabels(), - 'folderStatusLabels' => $this->folderStatusLabels(), - 'folderPriorityLabels' => $this->folderPriorityLabels(), - 'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [ - 'id' => $c->id, - 'company_name' => $c->company_name, - ])->values()->all(), - 'workspaceUsers' => $workspace->users() - ->orderBy('users.name') - ->select('users.id', 'users.name', 'users.email') - ->get() - ->map(fn ($u) => [ - 'id' => $u->id, - 'name' => $u->name, - 'email' => $u->email, - ])->values()->all(), - ]); - } - - /** - * Store a newly created folder in storage. - */ - public function store(StoreFolderRequest $request): RedirectResponse - { - $workspace = $this->currentWorkspace($request); - $data = $request->validated(); - $data['workspace_id'] = $workspace->id; - $data['created_by'] = $request->user()?->id; - $data['status'] = $data['status'] ?? FolderStatus::Draft->value; - - Folder::query()->create($data); - - return to_route('folders.index'); - } - - /** - * Display the specified folder. - */ - public function show(Request $request, Folder $folder): Response - { - $workspace = $this->currentWorkspace($request); - $this->authorizeFolder($workspace, $folder); - - $folder->load(['client', 'creator', 'assignee', 'messages' => fn ($q) => $q->with(['senderUser', 'senderClient'])->latest()]); - - $allMedia = $folder->getMedia('documents'); - $downloadedMediaIds = MediaDownload::query() - ->where('user_id', $request->user()->id) - ->whereIn('media_id', $allMedia->pluck('id')) - ->pluck('media_id') - ->all(); - - $messages = $folder->messages->map(function ($m) use ($folder, $allMedia, $downloadedMediaIds) { - $attachments = $allMedia - ->filter(fn ($media) => $media->getCustomProperty('message_id') === $m->id) - ->map(fn ($media) => [ - 'id' => $media->id, - 'file_name' => $media->file_name, - 'mime_type' => $media->mime_type, - 'size' => $media->human_readable_size, - 'downloadUrl' => route('folders.media.download', ['folder' => $folder, 'mediaId' => $media->id]), - 'is_downloaded' => in_array($media->id, $downloadedMediaIds), - ]) - ->values() - ->all(); - - $confirmationStatus = null; - if ($m->type->value === 'confirmation') { - $confirmationStatus = $folder->refused_at ? 'refused' : ($folder->validated_at ? 'confirmed' : 'pending'); - } - - return [ - 'id' => $m->id, - 'type' => $m->type->value, - 'body' => $m->body, - 'sent_by_type' => $m->sent_by_type->value, - 'sender_name' => $m->sender_name, - 'created_at' => $m->created_at->format('Y-m-d H:i'), - 'attachments' => $attachments, - 'confirmation_status' => $confirmationStatus, - ]; - })->values()->all(); - - $documents = $allMedia->map(fn ($m) => [ - 'id' => $m->id, - 'name' => $m->name, - 'file_name' => $m->file_name, - 'size' => $m->human_readable_size, - 'created_at' => $m->created_at->format('d/m/Y H:i'), - 'uploaded_by' => $m->getCustomProperty('uploaded_by_type') === 'user' ? 'Comptable' : 'Client', - 'downloadUrl' => route('folders.media.download', ['folder' => $folder, 'mediaId' => $m->id]), - 'is_downloaded' => in_array($m->id, $downloadedMediaIds), - ])->values()->all(); - - return Inertia::render('folders/Show', [ - 'folder' => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'type' => $folder->type->value, - 'client_id' => $folder->client_id, - 'client_name' => $folder->client->company_name, - 'period_year' => $folder->period_year, - 'period_month' => $folder->period_month, - 'period_quarter' => $folder->period_quarter, - 'due_date' => $folder->due_date?->format('Y-m-d'), - 'status' => $folder->status->value, - 'priority' => $folder->priority?->value, - 'assigned_to' => $folder->assigned_to, - 'assignee_name' => $folder->assignee?->name, - 'validated_at' => $folder->validated_at?->format('Y-m-d H:i'), - 'closed_at' => $folder->closed_at?->format('Y-m-d H:i'), - 'notes_internal' => $folder->notes_internal, - 'notes_client' => $folder->notes_client, - 'created_at' => $folder->created_at?->format('Y-m-d H:i'), - ], - 'messages' => $messages, - 'documents' => $documents, - 'messagesStoreUrl' => route('folders.messages.store', $folder), - 'mediaStoreUrl' => route('folders.media.store', $folder), - 'messageTypeLabels' => [ - 'invite' => 'Invitation', - 'situation' => 'Situation', - 'file_request' => 'Demande de pièces', - 'confirmation' => 'Demande de validation', - 'text' => 'Message', - ], - 'indexUrl' => route('folders.index'), - 'editUrl' => route('folders.edit', $folder), - 'workspaceUsers' => $workspace->users()->orderBy('users.name') - ->select('users.id', 'users.name', 'users.email') - ->get()->map(fn ($u) => ['id' => $u->id, 'name' => $u->name]) - ->values()->all(), - 'mentionStoreUrl' => route('folders.mentions.store', $folder), - 'canMention' => in_array( - $workspace->users()->where('users.id', $request->user()->id)->first()?->pivot?->role?->value, - ['owner', 'manager'] - ), - ]); - } - - /** - * Show the form for editing the specified folder. - */ - public function edit(Request $request, Folder $folder): Response - { - $workspace = $this->currentWorkspace($request); - $this->authorizeFolder($workspace, $folder); - - return Inertia::render('folders/Edit', [ - 'folder' => [ - 'id' => $folder->id, - 'title' => $folder->title, - 'type' => $folder->type->value, - 'client_id' => $folder->client_id, - 'period_year' => $folder->period_year, - 'period_month' => $folder->period_month, - 'period_quarter' => $folder->period_quarter, - 'due_date' => $folder->due_date?->format('Y-m-d'), - 'status' => $folder->status->value, - 'priority' => $folder->priority?->value, - 'assigned_to' => $folder->assigned_to, - 'notes_internal' => $folder->notes_internal, - 'notes_client' => $folder->notes_client, - 'created_at' => $folder->created_at?->format('Y-m-d H:i'), - ], - 'indexUrl' => route('folders.index'), - 'updateUrl' => route('folders.update', $folder), - 'folderTypeLabels' => $this->folderTypeLabels(), - 'folderStatusLabels' => $this->folderStatusLabels(), - 'folderPriorityLabels' => $this->folderPriorityLabels(), - 'clients' => $workspace->clients()->orderBy('company_name')->get(['id', 'company_name'])->map(fn (Client $c) => [ - 'id' => $c->id, - 'company_name' => $c->company_name, - ])->values()->all(), - 'workspaceUsers' => $workspace->users() - ->orderBy('users.name') - ->select('users.id', 'users.name', 'users.email') - ->get() - ->map(fn ($u) => [ - 'id' => $u->id, - 'name' => $u->name, - 'email' => $u->email, - ])->values()->all(), - ]); - } - - /** - * Update the specified folder in storage. - */ - public function update(UpdateFolderRequest $request, Folder $folder): RedirectResponse - { - $workspace = $this->currentWorkspace($request); - $this->authorizeFolder($workspace, $folder); - - $folder->update($request->validated()); - - return to_route('folders.index'); - } - - /** - * Remove the specified folder from storage. - */ - public function destroy(Request $request, Folder $folder): RedirectResponse - { - $workspace = $this->currentWorkspace($request); - $this->authorizeFolder($workspace, $folder); - - $folder->delete(); - - return to_route('folders.index'); - } - - protected function authorizeFolder(Workspace $workspace, Folder $folder): void - { - if ($folder->workspace_id !== $workspace->id) { - abort(404); - } - } -} diff --git a/app/Http/Controllers/FolderMessageController.php b/app/Http/Controllers/FolderMessageController.php deleted file mode 100644 index 61e0ac1..0000000 --- a/app/Http/Controllers/FolderMessageController.php +++ /dev/null @@ -1,149 +0,0 @@ -session()->get('current_workspace_id'); - - return Workspace::query()->findOrFail($workspaceId); - } - - /** - * Store a newly created message. - */ - public function store(StoreFolderMessageRequest $request, Folder $folder): RedirectResponse - { - $workspace = $this->currentWorkspace($request); - - if ($folder->workspace_id !== $workspace->id) { - abort(404); - } - - $user = $request->user(); - $type = MessageType::fromValue($request->input('type')); - $body = $request->input('body'); - - $invitation = $type->is(MessageType::Invite) - ? $this->createInvitation($folder) - : $this->getOrCreateInvitation($folder); - - $metadata = ['invitation_id' => $invitation->id]; - $message = $folder->messages()->create([ - 'type' => $type, - 'body' => $body, - 'sent_by_type' => ActorType::User, - 'sent_by_id' => $user->id, - 'metadata' => $metadata, - ]); - - $mediaIds = []; - - if ($request->hasFile('files')) { - foreach ($request->file('files') as $file) { - $media = $folder->addMedia($file) - ->withCustomProperties([ - 'message_id' => $message->id, - 'uploaded_by_type' => ActorType::User, - 'uploaded_by_id' => $user->id, - ]) - ->toMediaCollection('documents'); - $mediaIds[] = $media->id; - } - $message->update(['metadata' => array_merge($metadata, ['media_ids' => $mediaIds])]); - } - - $this->updateFolderStatusAndConfirmation($folder, $type, $mediaIds); - - $emailSent = $this->sendEmailForMessage($folder, $invitation, $message, $body, $type); - $flashMessage = $emailSent - ? 'Message envoyé.' - : 'Message enregistré, mais l\'email du client n\'est pas configuré.'; - - return back()->with('flash', ['type' => 'success', 'message' => $flashMessage]); - } - - protected function createInvitation(Folder $folder): FolderInvitation - { - $folder->load('client.primaryContact'); - - return $folder->invitations()->create([ - 'email' => $folder->client->primary_contact_email, - 'expires_at' => Carbon::now()->addDays(7), - ]); - } - - protected function getOrCreateInvitation(Folder $folder): FolderInvitation - { - $invitation = $folder->invitations() - ->where('expires_at', '>', now()) - ->latest() - ->first(); - - if ($invitation) { - return $invitation; - } - - return $this->createInvitation($folder); - } - - /** - * @param array $mediaIds - */ - protected function updateFolderStatusAndConfirmation(Folder $folder, MessageType $type, array $mediaIds): void - { - match ($type->value) { - 'invite' => $folder->update(['status' => FolderStatus::WaitingDocuments]), - 'situation', 'file_request' => $folder->update(['status' => FolderStatus::AdditionalDocumentsRequested]), - 'confirmation' => $folder->update([ - 'status' => FolderStatus::WaitingClientValidation, - 'confirmation_requested_at' => now(), - 'confirmation_media_id' => $mediaIds[0] ?? null, - ]), - default => null, - }; - } - - protected function sendEmailForMessage(Folder $folder, FolderInvitation $invitation, Message $message, string $body, MessageType $type): bool - { - $folder->load('client.primaryContact'); - $clientEmail = $folder->client->primary_contact_email; - - if (empty($clientEmail)) { - \Illuminate\Support\Facades\Log::warning("No primary contact email for client #{$folder->client_id}, skipping email."); - - return false; - } - - match ($type->value) { - 'invite' => Mail::to($clientEmail)->send(new FolderInviteMail($folder, $invitation)), - 'situation' => Mail::to($clientEmail)->send(new FolderSituationMail($folder, $invitation, $body)), - 'file_request' => Mail::to($clientEmail)->send(new FolderFileRequestMail($folder, $invitation, $body)), - 'confirmation' => Mail::to($clientEmail)->send(new FolderConfirmationMail($folder, $invitation, $body)), - 'text' => Mail::to($clientEmail)->send(new FolderTextMessageMail($folder, $body, $invitation->token)), - default => null, - }; - - return true; - } -} diff --git a/app/Http/Controllers/WorkspaceController.php b/app/Http/Controllers/WorkspaceController.php index 298e393..cfd6008 100644 --- a/app/Http/Controllers/WorkspaceController.php +++ b/app/Http/Controllers/WorkspaceController.php @@ -81,20 +81,19 @@ class WorkspaceController extends Controller $workspace->load('users'); $clientsCount = $workspace->clients()->count(); - $foldersCount = $workspace->folders()->count(); - $foldersByStatus = $workspace->folders() + $declarationsCount = $workspace->declarations()->count(); + $declarationsByStatus = $workspace->declarations() ->selectRaw('status, count(*) as count') ->groupBy('status') ->pluck('count', 'status') ->all(); - $foldersThisMonth = $workspace->folders() + $declarationsThisMonth = $workspace->declarations() ->whereMonth('created_at', now()->month) ->whereYear('created_at', now()->year) ->count(); - $foldersNeedingAttention = $workspace->folders() + $declarationsNeedingAttention = $workspace->declarations() ->whereIn('status', [ - \App\Enums\FolderStatus::WaitingDocuments, - \App\Enums\FolderStatus::WaitingClientValidation, + \App\Enums\DeclarationStatus::EnAttenteClient, ]) ->count(); @@ -112,10 +111,10 @@ class WorkspaceController extends Controller ], 'stats' => [ 'clients' => $clientsCount, - 'folders' => $foldersCount, - 'folders_by_status' => $foldersByStatus, - 'folders_this_month' => $foldersThisMonth, - 'folders_needing_attention' => $foldersNeedingAttention, + 'declarations' => $declarationsCount, + 'declarations_by_status' => $declarationsByStatus, + 'declarations_this_month' => $declarationsThisMonth, + 'declarations_needing_attention' => $declarationsNeedingAttention, ], 'indexUrl' => route('workspaces.index'), 'editUrl' => route('workspaces.edit', $workspace), diff --git a/app/Http/Middleware/ValidateFolderInvitation.php b/app/Http/Middleware/ValidateClientPortalToken.php similarity index 68% rename from app/Http/Middleware/ValidateFolderInvitation.php rename to app/Http/Middleware/ValidateClientPortalToken.php index d462041..a2e28b1 100644 --- a/app/Http/Middleware/ValidateFolderInvitation.php +++ b/app/Http/Middleware/ValidateClientPortalToken.php @@ -2,12 +2,12 @@ namespace App\Http\Middleware; -use App\Models\FolderInvitation; +use App\Models\DeclarationInvitation; use Closure; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; -class ValidateFolderInvitation +class ValidateClientPortalToken { /** * Handle an incoming request. @@ -18,16 +18,16 @@ class ValidateFolderInvitation { $token = $request->route('token'); - $invitation = FolderInvitation::query() + $invitation = DeclarationInvitation::query() ->where('token', $token) - ->with(['folder.client', 'folder.assignee', 'folder.creator']) + ->with(['declaration.client', 'declaration.assignee', 'declaration.creator']) ->first(); if (! $invitation || ! $invitation->isValid()) { abort(404, 'Lien invalide ou expiré.'); } - $request->attributes->set('folder_invitation', $invitation); + $request->attributes->set('declaration_invitation', $invitation); return $next($request); } diff --git a/app/Http/Requests/StoreFolderMentionRequest.php b/app/Http/Requests/StoreDeclarationMentionRequest.php similarity index 93% rename from app/Http/Requests/StoreFolderMentionRequest.php rename to app/Http/Requests/StoreDeclarationMentionRequest.php index 3957859..ea74b9c 100644 --- a/app/Http/Requests/StoreFolderMentionRequest.php +++ b/app/Http/Requests/StoreDeclarationMentionRequest.php @@ -5,7 +5,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreFolderMentionRequest extends FormRequest +class StoreDeclarationMentionRequest extends FormRequest { /** * Determine if the user is authorized to make this request. diff --git a/app/Http/Requests/StoreFolderMessageRequest.php b/app/Http/Requests/StoreDeclarationMessageRequest.php similarity index 95% rename from app/Http/Requests/StoreFolderMessageRequest.php rename to app/Http/Requests/StoreDeclarationMessageRequest.php index 1746171..283b93a 100644 --- a/app/Http/Requests/StoreFolderMessageRequest.php +++ b/app/Http/Requests/StoreDeclarationMessageRequest.php @@ -6,7 +6,7 @@ use App\Enums\MessageType; use BenSampo\Enum\Rules\EnumValue; use Illuminate\Foundation\Http\FormRequest; -class StoreFolderMessageRequest extends FormRequest +class StoreDeclarationMessageRequest extends FormRequest { /** * Determine if the user is authorized to make this request. diff --git a/app/Http/Requests/StoreFolderRequest.php b/app/Http/Requests/StoreDeclarationRequest.php similarity index 89% rename from app/Http/Requests/StoreFolderRequest.php rename to app/Http/Requests/StoreDeclarationRequest.php index d645550..4b8d15f 100644 --- a/app/Http/Requests/StoreFolderRequest.php +++ b/app/Http/Requests/StoreDeclarationRequest.php @@ -2,15 +2,15 @@ namespace App\Http\Requests; -use App\Enums\FolderPriority; -use App\Enums\FolderStatus; -use App\Enums\FolderType; +use App\Enums\DeclarationPriority; +use App\Enums\DeclarationStatus; +use App\Enums\DeclarationType; use BenSampo\Enum\Rules\EnumValue; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; -class StoreFolderRequest extends FormRequest +class StoreDeclarationRequest extends FormRequest { /** * Prepare the data for validation. @@ -59,13 +59,13 @@ class StoreFolderRequest extends FormRequest Rule::exists('clients', 'id')->where('workspace_id', $workspaceId), ], 'title' => ['required', 'string', 'max:255'], - 'type' => ['required', new EnumValue(FolderType::class)], + 'type' => ['required', new EnumValue(DeclarationType::class)], 'period_year' => ['required', 'integer', 'min:2000', 'max:2100'], 'period_month' => ['nullable', 'integer', 'min:1', 'max:12'], 'period_quarter' => ['nullable', 'integer', 'min:1', 'max:4'], 'due_date' => ['nullable', 'date'], - 'status' => ['nullable', Rule::in(FolderStatus::getValues())], - 'priority' => ['nullable', Rule::in(FolderPriority::getValues())], + 'status' => ['nullable', Rule::in(DeclarationStatus::getValues())], + 'priority' => ['nullable', Rule::in(DeclarationPriority::getValues())], 'assigned_to' => [ 'nullable', 'integer', diff --git a/app/Http/Requests/StoreWorkspaceRequest.php b/app/Http/Requests/StoreWorkspaceRequest.php index 88c32a5..d5df065 100644 --- a/app/Http/Requests/StoreWorkspaceRequest.php +++ b/app/Http/Requests/StoreWorkspaceRequest.php @@ -31,7 +31,7 @@ class StoreWorkspaceRequest extends FormRequest 'user_ids' => ['array'], 'user_ids.*' => ['integer', 'exists:users,id'], 'user_roles' => ['nullable', 'array'], - 'user_roles.*' => ['string', 'in:' . implode(',', WorkspaceUserRole::getValues())], + 'user_roles.*' => ['string', 'in:'.implode(',', WorkspaceUserRole::getValues())], ]; } } diff --git a/app/Http/Requests/UpdateFolderRequest.php b/app/Http/Requests/UpdateDeclarationRequest.php similarity index 89% rename from app/Http/Requests/UpdateFolderRequest.php rename to app/Http/Requests/UpdateDeclarationRequest.php index 0e1a484..769af17 100644 --- a/app/Http/Requests/UpdateFolderRequest.php +++ b/app/Http/Requests/UpdateDeclarationRequest.php @@ -2,15 +2,15 @@ namespace App\Http\Requests; -use App\Enums\FolderPriority; -use App\Enums\FolderStatus; -use App\Enums\FolderType; +use App\Enums\DeclarationPriority; +use App\Enums\DeclarationStatus; +use App\Enums\DeclarationType; use BenSampo\Enum\Rules\EnumValue; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Illuminate\Validation\Validator; -class UpdateFolderRequest extends FormRequest +class UpdateDeclarationRequest extends FormRequest { /** * Prepare the data for validation. @@ -59,13 +59,13 @@ class UpdateFolderRequest extends FormRequest Rule::exists('clients', 'id')->where('workspace_id', $workspaceId), ], 'title' => ['required', 'string', 'max:255'], - 'type' => ['required', new EnumValue(FolderType::class)], + 'type' => ['required', new EnumValue(DeclarationType::class)], 'period_year' => ['required', 'integer', 'min:2000', 'max:2100'], 'period_month' => ['nullable', 'integer', 'min:1', 'max:12'], 'period_quarter' => ['nullable', 'integer', 'min:1', 'max:4'], 'due_date' => ['nullable', 'date'], - 'status' => ['nullable', Rule::in(FolderStatus::getValues())], - 'priority' => ['nullable', Rule::in(FolderPriority::getValues())], + 'status' => ['nullable', Rule::in(DeclarationStatus::getValues())], + 'priority' => ['nullable', Rule::in(DeclarationPriority::getValues())], 'assigned_to' => [ 'nullable', 'integer', diff --git a/app/Http/Requests/UpdateWorkspaceRequest.php b/app/Http/Requests/UpdateWorkspaceRequest.php index 68f52e4..6fa9985 100644 --- a/app/Http/Requests/UpdateWorkspaceRequest.php +++ b/app/Http/Requests/UpdateWorkspaceRequest.php @@ -34,7 +34,7 @@ class UpdateWorkspaceRequest extends FormRequest 'user_ids' => ['array'], 'user_ids.*' => ['integer', 'exists:users,id'], 'user_roles' => ['nullable', 'array'], - 'user_roles.*' => ['string', 'in:' . implode(',', WorkspaceUserRole::getValues())], + 'user_roles.*' => ['string', 'in:'.implode(',', WorkspaceUserRole::getValues())], ]; } } diff --git a/app/Mail/FolderConfirmationMail.php b/app/Mail/DeclarationConfirmationMail.php similarity index 71% rename from app/Mail/FolderConfirmationMail.php rename to app/Mail/DeclarationConfirmationMail.php index f623b78..2e6248f 100644 --- a/app/Mail/FolderConfirmationMail.php +++ b/app/Mail/DeclarationConfirmationMail.php @@ -2,15 +2,15 @@ namespace App\Mail; -use App\Models\Folder; -use App\Models\FolderInvitation; +use App\Models\Declaration; +use App\Models\DeclarationInvitation; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class FolderConfirmationMail extends Mailable +class DeclarationConfirmationMail extends Mailable { use Queueable, SerializesModels; @@ -18,8 +18,8 @@ class FolderConfirmationMail extends Mailable * Create a new message instance. */ public function __construct( - public Folder $folder, - public FolderInvitation $invitation, + public Declaration $declaration, + public DeclarationInvitation $invitation, public string $body ) {} @@ -29,7 +29,7 @@ class FolderConfirmationMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Demande de validation - '.$this->folder->title, + subject: 'Demande de validation - '.$this->declaration->title, ); } @@ -39,9 +39,9 @@ class FolderConfirmationMail extends Mailable public function content(): Content { return new Content( - markdown: 'emails.folder-confirmation', + markdown: 'emails.declaration-confirmation', with: [ - 'folderTitle' => $this->folder->title, + 'declarationTitle' => $this->declaration->title, 'body' => $this->body, 'confirmUrl' => route('client.confirm', ['token' => $this->invitation->token]), 'refuseUrl' => route('client.refuse', ['token' => $this->invitation->token]), diff --git a/app/Mail/FolderFileRequestMail.php b/app/Mail/DeclarationFileRequestMail.php similarity index 73% rename from app/Mail/FolderFileRequestMail.php rename to app/Mail/DeclarationFileRequestMail.php index 6eccc11..6e7033e 100644 --- a/app/Mail/FolderFileRequestMail.php +++ b/app/Mail/DeclarationFileRequestMail.php @@ -2,15 +2,15 @@ namespace App\Mail; -use App\Models\Folder; -use App\Models\FolderInvitation; +use App\Models\Declaration; +use App\Models\DeclarationInvitation; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class FolderFileRequestMail extends Mailable +class DeclarationFileRequestMail extends Mailable { use Queueable, SerializesModels; @@ -18,8 +18,8 @@ class FolderFileRequestMail extends Mailable * Create a new message instance. */ public function __construct( - public Folder $folder, - public FolderInvitation $invitation, + public Declaration $declaration, + public DeclarationInvitation $invitation, public string $body ) {} @@ -29,7 +29,7 @@ class FolderFileRequestMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Documents complémentaires demandés - '.$this->folder->title, + subject: 'Documents complémentaires demandés - '.$this->declaration->title, ); } @@ -39,9 +39,9 @@ class FolderFileRequestMail extends Mailable public function content(): Content { return new Content( - markdown: 'emails.folder-file-request', + markdown: 'emails.declaration-file-request', with: [ - 'folderTitle' => $this->folder->title, + 'declarationTitle' => $this->declaration->title, 'body' => $this->body, 'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]), 'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'), diff --git a/app/Mail/FolderInviteMail.php b/app/Mail/DeclarationInviteMail.php similarity index 68% rename from app/Mail/FolderInviteMail.php rename to app/Mail/DeclarationInviteMail.php index a48f55c..fa20726 100644 --- a/app/Mail/FolderInviteMail.php +++ b/app/Mail/DeclarationInviteMail.php @@ -2,15 +2,15 @@ namespace App\Mail; -use App\Models\Folder; -use App\Models\FolderInvitation; +use App\Models\Declaration; +use App\Models\DeclarationInvitation; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class FolderInviteMail extends Mailable +class DeclarationInviteMail extends Mailable { use Queueable, SerializesModels; @@ -18,8 +18,8 @@ class FolderInviteMail extends Mailable * Create a new message instance. */ public function __construct( - public Folder $folder, - public FolderInvitation $invitation + public Declaration $declaration, + public DeclarationInvitation $invitation ) {} /** @@ -28,7 +28,7 @@ class FolderInviteMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Dépôt de documents - '.$this->folder->title, + subject: 'Dépôt de documents - '.$this->declaration->title, ); } @@ -38,9 +38,9 @@ class FolderInviteMail extends Mailable public function content(): Content { return new Content( - markdown: 'emails.folder-invite', + markdown: 'emails.declaration-invite', with: [ - 'folderTitle' => $this->folder->title, + 'declarationTitle' => $this->declaration->title, 'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]), 'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'), ] diff --git a/app/Mail/FolderSituationMail.php b/app/Mail/DeclarationSituationMail.php similarity index 69% rename from app/Mail/FolderSituationMail.php rename to app/Mail/DeclarationSituationMail.php index ecc44a7..6124e8f 100644 --- a/app/Mail/FolderSituationMail.php +++ b/app/Mail/DeclarationSituationMail.php @@ -2,15 +2,15 @@ namespace App\Mail; -use App\Models\Folder; -use App\Models\FolderInvitation; +use App\Models\Declaration; +use App\Models\DeclarationInvitation; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class FolderSituationMail extends Mailable +class DeclarationSituationMail extends Mailable { use Queueable, SerializesModels; @@ -18,8 +18,8 @@ class FolderSituationMail extends Mailable * Create a new message instance. */ public function __construct( - public Folder $folder, - public FolderInvitation $invitation, + public Declaration $declaration, + public DeclarationInvitation $invitation, public string $body ) {} @@ -29,7 +29,7 @@ class FolderSituationMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Situation mise à jour - '.$this->folder->title, + subject: 'Situation mise à jour - '.$this->declaration->title, ); } @@ -39,9 +39,9 @@ class FolderSituationMail extends Mailable public function content(): Content { return new Content( - markdown: 'emails.folder-situation', + markdown: 'emails.declaration-situation', with: [ - 'folderTitle' => $this->folder->title, + 'declarationTitle' => $this->declaration->title, 'body' => $this->body, 'uploadUrl' => route('client.upload', ['token' => $this->invitation->token]), 'expiresAt' => $this->invitation->expires_at->format('d/m/Y à H:i'), diff --git a/app/Mail/FolderTextMessageMail.php b/app/Mail/DeclarationTextMessageMail.php similarity index 71% rename from app/Mail/FolderTextMessageMail.php rename to app/Mail/DeclarationTextMessageMail.php index a77b598..2e192c5 100644 --- a/app/Mail/FolderTextMessageMail.php +++ b/app/Mail/DeclarationTextMessageMail.php @@ -2,24 +2,24 @@ namespace App\Mail; -use App\Models\Folder; +use App\Models\Declaration; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; -class FolderTextMessageMail extends Mailable +class DeclarationTextMessageMail extends Mailable { use Queueable, SerializesModels; /** * Create a new message instance. * - * @param string|null $token When set, recipient is client (use token-based URL). When null, recipient is comptable (use folders.show). + * @param string|null $token When set, recipient is client (use token-based URL). When null, recipient is comptable (use declarations.show). */ public function __construct( - public Folder $folder, + public Declaration $declaration, public string $body, public ?string $token = null ) {} @@ -30,7 +30,7 @@ class FolderTextMessageMail extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Nouveau message - '.$this->folder->title, + subject: 'Nouveau message - '.$this->declaration->title, ); } @@ -41,12 +41,12 @@ class FolderTextMessageMail extends Mailable { $messagesUrl = $this->token ? route('client.upload', ['token' => $this->token]) - : route('folders.show', ['folder' => $this->folder]).'?tab=messages'; + : route('declarations.show', ['declaration' => $this->declaration]).'?tab=messages'; return new Content( - markdown: 'emails.folder-text-message', + markdown: 'emails.declaration-text-message', with: [ - 'folderTitle' => $this->folder->title, + 'declarationTitle' => $this->declaration->title, 'body' => $this->body, 'messagesUrl' => $messagesUrl, ] diff --git a/app/Models/Client.php b/app/Models/Client.php index b50b7ea..88d04bd 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -16,7 +16,7 @@ use Spatie\Activitylog\Traits\LogsActivity; class Client extends Model { /** @use HasFactory<\Database\Factories\ClientFactory> */ - use HasFactory, SoftDeletes, LogsActivity; + use HasFactory, LogsActivity, SoftDeletes; /** * The attributes that are mass assignable. @@ -101,13 +101,13 @@ class Client extends Model } /** - * Get the folders for the client. + * Get the declarations for the client. * - * @return HasMany + * @return HasMany */ - public function folders(): HasMany + public function declarations(): HasMany { - return $this->hasMany(Folder::class); + return $this->hasMany(Declaration::class); } public function getActivitylogOptions(): LogOptions diff --git a/app/Models/Folder.php b/app/Models/Declaration.php similarity index 68% rename from app/Models/Folder.php rename to app/Models/Declaration.php index 307b1f5..5dd88a5 100644 --- a/app/Models/Folder.php +++ b/app/Models/Declaration.php @@ -2,9 +2,10 @@ namespace App\Models; -use App\Enums\FolderPriority; -use App\Enums\FolderStatus; -use App\Enums\FolderType; +use App\Enums\DeclarationPriority; +use App\Enums\DeclarationStatus; +use App\Enums\DeclarationType; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -15,11 +16,13 @@ use Spatie\Activitylog\Traits\LogsActivity; use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; -class Folder extends Model implements HasMedia +class Declaration extends Model implements HasMedia { - /** @use HasFactory<\Database\Factories\FolderFactory> */ + /** @use HasFactory<\Database\Factories\DeclarationFactory> */ use HasFactory, InteractsWithMedia, LogsActivity, SoftDeletes; + protected $table = 'declarations'; + /** * The attributes that are mass assignable. * @@ -49,6 +52,7 @@ class Folder extends Model implements HasMedia 'refusal_reason', 'notes_internal', 'notes_client', + 'archived_at', 'created_at', ]; @@ -60,13 +64,14 @@ class Folder extends Model implements HasMedia protected function casts(): array { return [ - 'type' => FolderType::class, - 'status' => FolderStatus::class, - 'priority' => FolderPriority::class, + 'type' => DeclarationType::class, + 'status' => DeclarationStatus::class, + 'priority' => DeclarationPriority::class, 'validated_at' => 'datetime', 'closed_at' => 'datetime', 'confirmation_requested_at' => 'datetime', 'refused_at' => 'datetime', + 'archived_at' => 'datetime', 'due_date' => 'date', ]; } @@ -80,7 +85,7 @@ class Folder extends Model implements HasMedia } /** - * Get the workspace that owns the folder. + * Get the workspace that owns the declaration. * * @return BelongsTo */ @@ -90,7 +95,7 @@ class Folder extends Model implements HasMedia } /** - * Get the client that owns the folder. + * Get the client that owns the declaration. * * @return BelongsTo */ @@ -100,7 +105,7 @@ class Folder extends Model implements HasMedia } /** - * Get the user who created the folder. + * Get the user who created the declaration. * * @return BelongsTo */ @@ -110,7 +115,7 @@ class Folder extends Model implements HasMedia } /** - * Get the user assigned to the folder. + * Get the user assigned to the declaration. * * @return BelongsTo */ @@ -120,7 +125,7 @@ class Folder extends Model implements HasMedia } /** - * Get the messages for the folder. + * Get the messages for the declaration. * * @return HasMany */ @@ -130,13 +135,29 @@ class Folder extends Model implements HasMedia } /** - * Get the invitations for the folder. + * Get the invitations for the declaration. * - * @return HasMany + * @return HasMany */ public function invitations(): HasMany { - return $this->hasMany(FolderInvitation::class); + return $this->hasMany(DeclarationInvitation::class); + } + + /** + * Scope a query to only include active (non-archived) declarations. + */ + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('archived_at'); + } + + /** + * Scope a query to only include archived declarations. + */ + public function scopeArchived(Builder $query): Builder + { + return $query->whereNotNull('archived_at'); } public function getActivitylogOptions(): LogOptions diff --git a/app/Models/FolderInvitation.php b/app/Models/DeclarationInvitation.php similarity index 75% rename from app/Models/FolderInvitation.php rename to app/Models/DeclarationInvitation.php index 580b02d..99cdd67 100644 --- a/app/Models/FolderInvitation.php +++ b/app/Models/DeclarationInvitation.php @@ -6,15 +6,17 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; -class FolderInvitation extends Model +class DeclarationInvitation extends Model { + protected $table = 'declaration_invitations'; + /** * The attributes that are mass assignable. * * @var list */ protected $fillable = [ - 'folder_id', + 'declaration_id', 'token', 'email', 'expires_at', @@ -41,7 +43,7 @@ class FolderInvitation extends Model { parent::boot(); - static::creating(function (FolderInvitation $invitation) { + static::creating(function (DeclarationInvitation $invitation) { if (empty($invitation->token)) { $invitation->token = Str::uuid()->toString(); } @@ -49,13 +51,13 @@ class FolderInvitation extends Model } /** - * Get the folder that owns the invitation. + * Get the declaration that owns the invitation. * - * @return BelongsTo + * @return BelongsTo */ - public function folder(): BelongsTo + public function declaration(): BelongsTo { - return $this->belongsTo(Folder::class); + return $this->belongsTo(Declaration::class); } /** diff --git a/app/Models/Message.php b/app/Models/Message.php index ba5fba2..794d0a1 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -15,7 +15,7 @@ class Message extends Model * @var list */ protected $fillable = [ - 'folder_id', + 'declaration_id', 'type', 'body', 'sent_by_type', @@ -38,13 +38,13 @@ class Message extends Model } /** - * Get the folder that owns the message. + * Get the declaration that owns the message. * - * @return BelongsTo + * @return BelongsTo */ - public function folder(): BelongsTo + public function declaration(): BelongsTo { - return $this->belongsTo(Folder::class); + return $this->belongsTo(Declaration::class); } /** diff --git a/app/Models/User.php b/app/Models/User.php index c118154..cdc2263 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -16,7 +16,7 @@ use Spatie\Activitylog\Traits\LogsActivity; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, SoftDeletes, TwoFactorAuthenticatable, LogsActivity; + use HasFactory, LogsActivity, Notifiable, SoftDeletes, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Models/Workspace.php b/app/Models/Workspace.php index 2d297a4..ad94028 100644 --- a/app/Models/Workspace.php +++ b/app/Models/Workspace.php @@ -14,7 +14,7 @@ use Spatie\Activitylog\Traits\LogsActivity; class Workspace extends Model { /** @use HasFactory<\Database\Factories\WorkspaceFactory> */ - use HasFactory, SoftDeletes, LogsActivity; + use HasFactory, LogsActivity, SoftDeletes; /** * The attributes that are mass assignable. @@ -57,13 +57,13 @@ class Workspace extends Model } /** - * Get the folders for the workspace. + * Get the declarations for the workspace. * - * @return HasMany + * @return HasMany */ - public function folders(): HasMany + public function declarations(): HasMany { - return $this->hasMany(Folder::class); + return $this->hasMany(Declaration::class); } /** diff --git a/app/Models/WorkspaceUser.php b/app/Models/WorkspaceUser.php index 79d1d1c..e933dd3 100644 --- a/app/Models/WorkspaceUser.php +++ b/app/Models/WorkspaceUser.php @@ -21,6 +21,7 @@ class WorkspaceUser extends Pivot */ protected $fillable = [ 'role', + 'permissions', ]; /** @@ -32,6 +33,7 @@ class WorkspaceUser extends Pivot { return [ 'role' => WorkspaceUserRole::class, + 'permissions' => 'array', ]; } } diff --git a/app/Notifications/FolderMentionNotification.php b/app/Notifications/DeclarationMentionNotification.php similarity index 63% rename from app/Notifications/FolderMentionNotification.php rename to app/Notifications/DeclarationMentionNotification.php index 7d75f7c..c52ca58 100644 --- a/app/Notifications/FolderMentionNotification.php +++ b/app/Notifications/DeclarationMentionNotification.php @@ -2,19 +2,19 @@ namespace App\Notifications; -use App\Models\Folder; +use App\Models\Declaration; use App\Models\User; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Notification; -class FolderMentionNotification extends Notification implements ShouldQueue +class DeclarationMentionNotification extends Notification implements ShouldQueue { use Queueable; public function __construct( - public Folder $folder, + public Declaration $declaration, public User $mentionedBy, public string $message, ) {} @@ -33,24 +33,24 @@ class FolderMentionNotification extends Notification implements ShouldQueue public function toDatabase(object $notifiable): array { return [ - 'folder_id' => $this->folder->id, - 'folder_title' => $this->folder->title, + 'declaration_id' => $this->declaration->id, + 'declaration_title' => $this->declaration->title, 'mentioned_by_id' => $this->mentionedBy->id, 'mentioned_by_name' => $this->mentionedBy->name, 'message' => $this->message, - 'url' => route('folders.show', $this->folder), + 'url' => route('declarations.show', $this->declaration), ]; } public function toMail(object $notifiable): MailMessage { return (new MailMessage) - ->subject('Vous avez été mentionné - '.$this->folder->title) - ->markdown('emails.folder-mention', [ - 'folderTitle' => $this->folder->title, + ->subject('Vous avez été mentionné - '.$this->declaration->title) + ->markdown('emails.declaration-mention', [ + 'declarationTitle' => $this->declaration->title, 'mentionedByName' => $this->mentionedBy->name, 'message' => $this->message, - 'url' => route('folders.show', $this->folder), + 'url' => route('declarations.show', $this->declaration), ]); } } diff --git a/app/Observers/DeclarationObserver.php b/app/Observers/DeclarationObserver.php new file mode 100644 index 0000000..5a1feb6 --- /dev/null +++ b/app/Observers/DeclarationObserver.php @@ -0,0 +1,42 @@ +isDirty('status')) { + return; + } + + $oldStatus = $declaration->getOriginal('status'); + $newStatus = $declaration->status; + + // Handle both string and enum values + $oldValue = $oldStatus instanceof DeclarationStatus ? $oldStatus->value : (string) $oldStatus; + $newValue = $newStatus instanceof DeclarationStatus ? $newStatus->value : (string) $newStatus; + + $allowed = DeclarationStatus::allowedTransitions()[$oldValue] ?? []; + + if (! in_array($newValue, $allowed)) { + throw ValidationException::withMessages([ + 'status' => "Invalid status transition from '{$oldValue}' to '{$newValue}'.", + ]); + } + + // Auto-archive when status becomes "ferme" + if ($newValue === DeclarationStatus::Ferme) { + $declaration->archived_at = now(); + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..05a4c49 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\Declaration; +use App\Observers\DeclarationObserver; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; @@ -24,6 +26,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureDefaults(); + + Declaration::observe(DeclarationObserver::class); } /** diff --git a/bootstrap/app.php b/bootstrap/app.php index 60fa40b..17a780d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -19,7 +19,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class, 'workspace' => \App\Http\Middleware\EnsureUserHasWorkspace::class, - 'folder.invitation' => \App\Http\Middleware\ValidateFolderInvitation::class, + 'client-portal' => \App\Http\Middleware\ValidateClientPortalToken::class, ]); $middleware->web(append: [ diff --git a/compose.yaml b/compose.yaml index 2b7cb8a..f9084b5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,7 +22,22 @@ services: networks: - sail depends_on: - - mailpit + mailpit: + condition: service_started + redis: + condition: service_healthy + redis: + image: 'redis:alpine' + ports: + - '${FORWARD_REDIS_PORT:-6379}:6379' + volumes: + - 'sail-redis:/data' + networks: + - sail + healthcheck: + test: ["CMD", "redis-cli", "ping"] + retries: 3 + timeout: 5s mailpit: image: 'axllent/mailpit:latest' ports: @@ -33,3 +48,6 @@ services: networks: sail: driver: bridge +volumes: + sail-redis: + driver: local diff --git a/composer.json b/composer.json index 1f3efbf..a8069b1 100644 --- a/composer.json +++ b/composer.json @@ -53,12 +53,12 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:work --tries=3 --timeout=30\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" ], "dev:ssr": [ "npm run build:ssr", "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:work --tries=3 --timeout=30\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr\" --names=server,queue,logs,ssr --kill-others" ], "lint": [ "pint --parallel" diff --git a/database/database.sqlite b/database/database.sqlite index e59b3e5..d9498d5 100644 Binary files a/database/database.sqlite and b/database/database.sqlite differ diff --git a/database/factories/FolderFactory.php b/database/factories/DeclarationFactory.php similarity index 85% rename from database/factories/FolderFactory.php rename to database/factories/DeclarationFactory.php index a57ca99..6397465 100644 --- a/database/factories/FolderFactory.php +++ b/database/factories/DeclarationFactory.php @@ -2,17 +2,17 @@ namespace Database\Factories; -use App\Enums\FolderPriority; -use App\Enums\FolderStatus; -use App\Enums\FolderType; +use App\Enums\DeclarationPriority; +use App\Enums\DeclarationStatus; +use App\Enums\DeclarationType; use App\Models\Client; use App\Models\Workspace; use Illuminate\Database\Eloquent\Factories\Factory; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Folder> + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Declaration> */ -class FolderFactory extends Factory +class DeclarationFactory extends Factory { /** * Define the model's default state. @@ -25,7 +25,7 @@ class FolderFactory extends Factory $client = Client::factory()->create(['workspace_id' => $workspace->id]); $year = fake()->numberBetween(2024, 2026); - $excludeOldVat = array_filter(FolderType::getValues(), fn ($v) => $v !== 'vat'); + $excludeOldVat = array_filter(DeclarationType::getValues(), fn ($v) => $v !== 'vat'); $type = fake()->randomElement(array_values($excludeOldVat)); $isVatMonthly = $type === 'vat_monthly'; @@ -41,8 +41,8 @@ class FolderFactory extends Factory 'period_month' => $isVatMonthly ? fake()->numberBetween(1, 12) : null, 'period_quarter' => $isVatQuarterly ? fake()->numberBetween(1, 4) : null, 'due_date' => fake()->dateTimeBetween('now', '+3 months'), - 'status' => fake()->randomElement(FolderStatus::getValues()), - 'priority' => fake()->randomElement(FolderPriority::getValues()), + 'status' => DeclarationStatus::Created, + 'priority' => fake()->randomElement(DeclarationPriority::getValues()), 'assigned_to' => null, 'validated_at' => null, 'closed_at' => null, diff --git a/database/migrations/2026_02_28_102716_create_activity_log_table.php b/database/migrations/2026_02_28_102716_create_activity_log_table.php index 7c05bc8..b788f65 100644 --- a/database/migrations/2026_02_28_102716_create_activity_log_table.php +++ b/database/migrations/2026_02_28_102716_create_activity_log_table.php @@ -1,8 +1,8 @@ where('model_type', 'App\\Models\\Folder') + ->update(['model_type' => 'App\\Models\\Declaration']); + + DB::table('activity_log') + ->where('subject_type', 'App\\Models\\Folder') + ->update(['subject_type' => 'App\\Models\\Declaration']); + } + + public function down(): void + { + DB::table('media') + ->where('model_type', 'App\\Models\\Declaration') + ->update(['model_type' => 'App\\Models\\Folder']); + + DB::table('activity_log') + ->where('subject_type', 'App\\Models\\Declaration') + ->update(['subject_type' => 'App\\Models\\Folder']); + } +}; diff --git a/database/migrations/2026_03_12_044146_add_permissions_to_workspace_user.php b/database/migrations/2026_03_12_044146_add_permissions_to_workspace_user.php new file mode 100644 index 0000000..9346e57 --- /dev/null +++ b/database/migrations/2026_03_12_044146_add_permissions_to_workspace_user.php @@ -0,0 +1,28 @@ +json('permissions')->nullable()->default(null)->after('role'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('workspace_user', function (Blueprint $table) { + $table->dropColumn('permissions'); + }); + } +}; diff --git a/database/migrations/2026_03_12_044334_add_archived_at_to_declarations.php b/database/migrations/2026_03_12_044334_add_archived_at_to_declarations.php new file mode 100644 index 0000000..277dffb --- /dev/null +++ b/database/migrations/2026_03_12_044334_add_archived_at_to_declarations.php @@ -0,0 +1,30 @@ +timestamp('archived_at')->nullable()->after('deleted_at'); + $table->index('archived_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('declarations', function (Blueprint $table) { + $table->dropIndex(['archived_at']); + $table->dropColumn('archived_at'); + }); + } +}; diff --git a/database/migrations/2026_03_12_044414_migrate_declaration_status_values.php b/database/migrations/2026_03_12_044414_migrate_declaration_status_values.php new file mode 100644 index 0000000..56743b6 --- /dev/null +++ b/database/migrations/2026_03_12_044414_migrate_declaration_status_values.php @@ -0,0 +1,39 @@ + 'created', + 'waiting_documents' => 'en_cours', + 'documents_received' => 'en_cours', + 'processing' => 'en_cours', + 'additional_documents_requested' => 'en_attente_client', + 'waiting_client_validation' => 'en_attente_client', + 'validated' => 'termine', + 'closed' => 'ferme', + 'cancelled' => 'ferme', + ]; + + foreach ($mapping as $old => $new) { + DB::table('declarations') + ->where('status', $old) + ->update(['status' => $new]); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Data migration cannot be reversed — old status distinctions are lost + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index c67d308..5b26ff6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,15 +3,15 @@ namespace Database\Seeders; use App\Enums\ClientStatus; -use App\Enums\FolderPriority; -use App\Enums\FolderStatus; -use App\Enums\FolderType; +use App\Enums\DeclarationPriority; +use App\Enums\DeclarationStatus; +use App\Enums\DeclarationType; use App\Enums\LegalForm; use App\Enums\UserGroup; use App\Enums\WorkspaceUserRole; use App\Models\Client; use App\Models\ClientContact; -use App\Models\Folder; +use App\Models\Declaration; use App\Models\User; use App\Models\Workspace; use Illuminate\Database\Seeder; @@ -99,7 +99,7 @@ class DatabaseSeeder extends Seeder $client = Client::create($data); ClientContact::create([ 'client_id' => $client->id, - 'full_name' => $data['contact_first_name'] . ' ' . $data['contact_last_name'], + 'full_name' => $data['contact_first_name'].' '.$data['contact_last_name'], 'job_title' => $data['contact_job_title'], 'email' => $data['contact_email'], 'phone' => $data['contact_phone'], @@ -125,7 +125,7 @@ class DatabaseSeeder extends Seeder $client = Client::create($data); ClientContact::create([ 'client_id' => $client->id, - 'full_name' => $data['contact_first_name'] . ' ' . $data['contact_last_name'], + 'full_name' => $data['contact_first_name'].' '.$data['contact_last_name'], 'job_title' => $data['contact_job_title'], 'email' => $data['contact_email'], 'phone' => $data['contact_phone'], @@ -133,57 +133,55 @@ class DatabaseSeeder extends Seeder ]); } - // --- Folders (dossiers) for Casablanca clients --- - $folderTypes = [ - ['type' => FolderType::VatMonthly, 'label' => 'TVA mensuelle'], - ['type' => FolderType::VatQuarterly, 'label' => 'TVA trimestrielle'], - ['type' => FolderType::CorporateTax, 'label' => 'IS'], - ['type' => FolderType::IncomeTax, 'label' => 'IR'], - ['type' => FolderType::CNSS, 'label' => 'CNSS'], - ['type' => FolderType::AnnualBalance, 'label' => 'Bilan annuel'], + // --- Declarations for Casablanca clients --- + $declarationTypes = [ + ['type' => DeclarationType::VatMonthly, 'label' => 'TVA mensuelle'], + ['type' => DeclarationType::VatQuarterly, 'label' => 'TVA trimestrielle'], + ['type' => DeclarationType::CorporateTax, 'label' => 'IS'], + ['type' => DeclarationType::IncomeTax, 'label' => 'IR'], + ['type' => DeclarationType::CNSS, 'label' => 'CNSS'], + ['type' => DeclarationType::AnnualBalance, 'label' => 'Bilan annuel'], ]; $statuses = [ - FolderStatus::Draft, - FolderStatus::WaitingDocuments, - FolderStatus::DocumentsReceived, - FolderStatus::Processing, - FolderStatus::WaitingClientValidation, - FolderStatus::Validated, - FolderStatus::Closed, + DeclarationStatus::Created, + DeclarationStatus::EnCours, + DeclarationStatus::EnAttenteClient, + DeclarationStatus::Termine, + DeclarationStatus::Ferme, ]; - $priorities = [FolderPriority::Low, FolderPriority::Medium, FolderPriority::High]; + $priorities = [DeclarationPriority::Low, DeclarationPriority::Medium, DeclarationPriority::High]; - $folderIndex = 0; + $declarationIndex = 0; foreach ($createdCasaClients as $client) { - // Each client gets 2-4 folders - $numFolders = fake()->numberBetween(2, 4); - $selectedTypes = fake()->randomElements($folderTypes, $numFolders); + // Each client gets 2-4 declarations + $numDeclarations = fake()->numberBetween(2, 4); + $selectedTypes = fake()->randomElements($declarationTypes, $numDeclarations); - foreach ($selectedTypes as $ft) { + foreach ($selectedTypes as $dt) { $year = fake()->randomElement([2025, 2026]); - $status = $statuses[$folderIndex % count($statuses)]; - $isVatMonthly = $ft['type'] === FolderType::VatMonthly; - $isVatQuarterly = $ft['type'] === FolderType::VatQuarterly; + $status = $statuses[$declarationIndex % count($statuses)]; + $isVatMonthly = $dt['type'] === DeclarationType::VatMonthly; + $isVatQuarterly = $dt['type'] === DeclarationType::VatQuarterly; $month = $isVatMonthly ? fake()->numberBetween(1, 3) : null; $quarter = $isVatQuarterly ? fake()->numberBetween(1, 4) : null; $periodSuffix = $isVatMonthly ? " — Mois $month" : ($isVatQuarterly ? " — T$quarter" : ''); - Folder::create([ + $declaration = Declaration::create([ 'workspace_id' => $wsCasa->id, 'client_id' => $client->id, - 'created_by' => $responsibles[$folderIndex % count($responsibles)]->id, - 'title' => "Déclaration {$ft['label']} $year{$periodSuffix}", - 'type' => $ft['type'], + 'created_by' => $responsibles[$declarationIndex % count($responsibles)]->id, + 'title' => "Déclaration {$dt['label']} $year{$periodSuffix}", + 'type' => $dt['type'], 'period_year' => $year, 'period_month' => $month, 'period_quarter' => $quarter, 'due_date' => fake()->dateTimeBetween('2026-01-01', '2026-06-30'), 'status' => $status, - 'priority' => $priorities[$folderIndex % count($priorities)], - 'assigned_to' => $responsibles[$folderIndex % count($responsibles)]->id, + 'priority' => $priorities[$declarationIndex % count($priorities)], + 'assigned_to' => $responsibles[$declarationIndex % count($responsibles)]->id, 'notes_internal' => fake()->optional(0.4)->randomElement([ 'En attente des relevés bancaires.', 'Client à relancer pour les factures manquantes.', @@ -193,7 +191,12 @@ class DatabaseSeeder extends Seeder ]), ]); - $folderIndex++; + // Set archived_at for ferme declarations (observer only fires on updating, not creating) + if ($status === DeclarationStatus::Ferme) { + $declaration->forceFill(['archived_at' => now()])->saveQuietly(); + } + + $declarationIndex++; } } } diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 2d93e6f..dde459f 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,14 @@ @@ -111,11 +132,8 @@ function nextMonth() { > diff --git a/resources/js/components/folders/MessageBubble.vue b/resources/js/components/declarations/MessageBubble.vue similarity index 73% rename from resources/js/components/folders/MessageBubble.vue rename to resources/js/components/declarations/MessageBubble.vue index 757053f..e2ce60c 100644 --- a/resources/js/components/folders/MessageBubble.vue +++ b/resources/js/components/declarations/MessageBubble.vue @@ -1,6 +1,6 @@