feat: add notification infrastructure with database channel, enum, and notification classes (Story 3.1)
Set up Laravel notification system with NotificationType enum (5 types), NudgeNotification, DocumentUploadedNotification, and DeclarationOverdueNotification classes with database + mail channels. Add email templates, infrastructure tests, and fix existing NotificationController tests for workspace compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
# Story 3.1: Notification Infrastructure Setup
|
||||
|
||||
Status: review
|
||||
|
||||
## Story
|
||||
|
||||
As a developer,
|
||||
I want the Laravel notification system configured with database and mail channels, a NotificationType enum, and foundational notification classes,
|
||||
so that all future notification features (nudges, alerts, bulk notifications) have a reliable, consistent foundation to build on.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **AC1: Notifications table migration exists and runs**
|
||||
- Given Redis queue is configured (from Story 0.4)
|
||||
- When `php artisan migrate` is run
|
||||
- Then the `notifications` table exists with Laravel's standard schema (uuid id, type, morphs notifiable, text data, read_at, timestamps)
|
||||
|
||||
2. **AC2: User model confirms Notifiable trait**
|
||||
- Then `User` model uses `Illuminate\Notifications\Notifiable` trait
|
||||
|
||||
3. **AC3: NotificationType enum created**
|
||||
- Then `app/Enums/NotificationType.php` exists using `bensampo/laravel-enum`
|
||||
- And values include: `nudge`, `declaration_overdue`, `document_uploaded`, `bulk_notification`, `status_changed`
|
||||
|
||||
4. **AC4: Base notification pattern established**
|
||||
- Then all new notification classes implement `ShouldQueue`, use `Queueable` trait
|
||||
- And all notification `toArray()`/`toDatabase()` payloads include `workspace_id`
|
||||
- And all notification `toArray()`/`toDatabase()` payloads include `notification_type` from the `NotificationType` enum
|
||||
|
||||
5. **AC5: NudgeNotification class created**
|
||||
- Then `app/Notifications/NudgeNotification.php` exists with `database` and `mail` channels
|
||||
- And payload includes: `workspace_id`, `declaration_id`, `sender_id`, `notification_type` = `nudge`
|
||||
- And `toMail()` returns a queued `NudgeNotificationMail` Markdown mailable
|
||||
|
||||
6. **AC6: DocumentUploadedNotification class created**
|
||||
- Then `app/Notifications/DocumentUploadedNotification.php` exists with `database` channel only (no email — reduces noise)
|
||||
- And payload includes: `workspace_id`, `declaration_id`, `client_id`, `notification_type` = `document_uploaded`
|
||||
|
||||
7. **AC7: DeclarationOverdueNotification class created**
|
||||
- Then `app/Notifications/DeclarationOverdueNotification.php` exists with `database` and `mail` channels
|
||||
- And payload includes: `workspace_id`, `declaration_id`, `notification_type` = `declaration_overdue`
|
||||
- And queued email sending with up to 3 retries (`$tries = 3` per NFR26)
|
||||
|
||||
8. **AC8: NudgeNotificationMail mailable created**
|
||||
- Then `app/Mail/NudgeNotificationMail.php` exists as a Markdown mailable
|
||||
- And it renders with sender name, declaration details (client name, type, due_date), and a direct link button ("Voir la declaration")
|
||||
- And uses professional French copy
|
||||
|
||||
9. **AC9: Notification queries are workspace-scoped**
|
||||
- Then when fetching notifications, they are filtered by `workspace_id` in the JSON data payload
|
||||
- And `NotificationController` is extended with an `index` method returning workspace-scoped notifications
|
||||
|
||||
10. **AC10: Routes registered for notification endpoints**
|
||||
- Then `GET /notifications` route exists → `NotificationController@index` (Inertia page)
|
||||
- And `POST /notifications/mark-all-read` route exists → `NotificationController@markAllAsRead`
|
||||
- And existing `POST /notifications/{id}/read` route remains functional
|
||||
|
||||
11. **AC11: All notification classes have retry configuration**
|
||||
- Then notification classes with mail channel set `public $tries = 3` (NFR26)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Verify existing notifications infrastructure (AC: #1, #2)
|
||||
- [x] 1.1 Confirm `notifications` table migration already exists (`2026_03_09_000004_create_notifications_table.php`) — do NOT create a duplicate
|
||||
- [x] 1.2 Confirm `User` model already has `Notifiable` trait — already present, no changes needed
|
||||
- [x] 1.3 Run `php artisan migrate` to confirm table exists
|
||||
|
||||
- [x] Task 2: Create NotificationType enum (AC: #3)
|
||||
- [x] 2.1 Create `app/Enums/NotificationType.php` using `bensampo/laravel-enum` pattern
|
||||
- [x] 2.2 Values: `nudge`, `declaration_overdue`, `document_uploaded`, `bulk_notification`, `status_changed`
|
||||
- [x] 2.3 Follow existing enum patterns (see `DeclarationStatus.php` for reference)
|
||||
|
||||
- [x] Task 3: Create NudgeNotification class (AC: #4, #5, #11)
|
||||
- [x] 3.1 Create `app/Notifications/NudgeNotification.php`
|
||||
- [x] 3.2 Implement `ShouldQueue`, use `Queueable` trait, set `$tries = 3`
|
||||
- [x] 3.3 Channels: `['database', 'mail']`
|
||||
- [x] 3.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `sender_id`, `notification_type` → `nudge`
|
||||
- [x] 3.5 `toMail()` returns `NudgeNotificationMail` mailable
|
||||
|
||||
- [x] Task 4: Create DocumentUploadedNotification class (AC: #4, #6)
|
||||
- [x] 4.1 Create `app/Notifications/DocumentUploadedNotification.php`
|
||||
- [x] 4.2 Implement `ShouldQueue`, use `Queueable` trait
|
||||
- [x] 4.3 Channels: `['database']` only — no mail
|
||||
- [x] 4.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `client_id`, `notification_type` → `document_uploaded`
|
||||
|
||||
- [x] Task 5: Create DeclarationOverdueNotification class (AC: #4, #7, #11)
|
||||
- [x] 5.1 Create `app/Notifications/DeclarationOverdueNotification.php`
|
||||
- [x] 5.2 Implement `ShouldQueue`, use `Queueable` trait, set `$tries = 3`
|
||||
- [x] 5.3 Channels: `['database', 'mail']`
|
||||
- [x] 5.4 `toDatabase()` payload: `workspace_id`, `declaration_id`, `notification_type` → `declaration_overdue`
|
||||
- [x] 5.5 `toMail()` returns MailMessage with declaration details and link
|
||||
|
||||
- [x] Task 6: Create NudgeNotificationMail mailable (AC: #8)
|
||||
- [x] 6.1 Create `app/Mail/NudgeNotificationMail.php` as Markdown mailable
|
||||
- [x] 6.2 Create Markdown template `resources/views/emails/nudge-notification.blade.php`
|
||||
- [x] 6.3 Include: sender name, declaration client name, declaration type, due_date, direct link button ("Voir la declaration")
|
||||
- [x] 6.4 Professional French copy, include workspace firm name in header
|
||||
|
||||
- [x] Task 7: Extend NotificationController with index (AC: #9, #10)
|
||||
- [x] 7.1 Add `index()` method to existing `NotificationController`
|
||||
- [x] 7.2 Fetch user notifications, filter by `workspace_id` in JSON data matching current workspace
|
||||
- [x] 7.3 Paginate 25 per page, reverse chronological order
|
||||
- [x] 7.4 Return Inertia render to `notifications/Index` page
|
||||
- [x] 7.5 Register `GET /notifications` route in `web.php`
|
||||
|
||||
- [x] Task 8: Clean up legacy notification (no AC — housekeeping)
|
||||
- [x] 8.1 Evaluate `FolderMentionNotification.php` — this references the deprecated `Folder` model and `folders.show` route. Flag for removal or skip if out of scope
|
||||
|
||||
- [x] Task 9: Create notification type definitions for frontend (AC: #9)
|
||||
- [x] 9.1 Create `resources/js/types/notification.ts` with TypeScript types for notification data
|
||||
|
||||
- [x] Task 10: Write tests (all ACs)
|
||||
- [x] 10.1 Create `tests/Feature/Notifications/NotificationInfrastructureTest.php`
|
||||
- [x] 10.2 Test: NotificationType enum has all expected values
|
||||
- [x] 10.3 Test: NudgeNotification sends via database and mail channels (`Notification::fake()`)
|
||||
- [x] 10.4 Test: DocumentUploadedNotification sends via database channel only
|
||||
- [x] 10.5 Test: DeclarationOverdueNotification sends via database and mail channels
|
||||
- [x] 10.6 Test: All notification payloads include `workspace_id` and `notification_type`
|
||||
- [x] 10.7 Test: NotificationController@index returns paginated, workspace-scoped notifications
|
||||
- [x] 10.8 Test: NotificationController@markAsRead marks notification as read
|
||||
- [x] 10.9 Test: NotificationController@markAllAsRead marks all as read
|
||||
- [x] 10.10 Test: Unauthenticated users cannot access notification routes
|
||||
|
||||
## Retrospective Intelligence
|
||||
|
||||
**From Epic 2 Retrospective (2026-03-24):**
|
||||
|
||||
- **CRITICAL — Pre-existing test failures:** 15 tests were failing across 3 retrospectives. Commit `716e9fc` resolved them. Verify all 222+ tests still pass before starting and after finishing this story.
|
||||
- **Team Agreement — Load retro as context:** This story was created with full retrospective intelligence (this section proves it).
|
||||
- **Team Agreement — Pre-existing test failures must not carry:** Run full test suite before declaring this story done. Zero failures tolerance.
|
||||
- **Deferred Item D-1:** Nudge/Reassign dropdown items in dashboard are unconditionally disabled — Story 3.2 will enable them. This story creates the `NudgeNotification` they depend on.
|
||||
- **Architecture doc drift was corrected** in commit `6956f7b` — `due_date` (not `deadline`), no `Declaration::workspace()` scope, `mise_en_demeure` status documented.
|
||||
- **`withoutVite()` global workaround** in `tests/Pest.php` — confirmed compatible with notification tests (no Vite manifest needed for backend tests).
|
||||
- **Context management is the team's superpower** — rich story specs with previous intelligence prevent errors across stories.
|
||||
|
||||
**From Epic 1 Retrospective:**
|
||||
|
||||
- **withPivot gotchas now documented** in project-context.md (commit `6956f7b`). When loading workspace members, always chain `->withPivot('role', 'permissions')`.
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Existing Infrastructure (DO NOT recreate)
|
||||
|
||||
- **`notifications` table migration ALREADY EXISTS:** `database/migrations/2026_03_09_000004_create_notifications_table.php` — standard Laravel schema with uuid id, type, morphs notifiable, text data, read_at, timestamps. Do NOT run `php artisan notifications:table` or create a duplicate.
|
||||
- **`User` model ALREADY has `Notifiable` trait:** Line 19 of `app/Models/User.php` — `use HasFactory, LogsActivity, Notifiable, SoftDeletes, TwoFactorAuthenticatable;`
|
||||
- **`NotificationController` ALREADY EXISTS:** `app/Http/Controllers/NotificationController.php` with `markAsRead()` and `markAllAsRead()` methods. Extend this controller, do not replace it.
|
||||
- **Existing routes:** `POST /notifications/{id}/read` and `POST /notifications/read-all` already registered in `web.php`. Add the new `GET /notifications` route alongside them.
|
||||
- **Existing notification classes:** `DeclarationMentionNotification.php` (database + mail, ShouldQueue, Queueable — good pattern to follow) and `FolderMentionNotification.php` (legacy — references deprecated `Folder` model).
|
||||
|
||||
### Existing Notification Pattern to Follow
|
||||
|
||||
The `DeclarationMentionNotification` is the reference implementation:
|
||||
- Constructor with promoted properties: `public Declaration $declaration, public User $mentionedBy, public string $message`
|
||||
- `via()` returns `['database', 'mail']`
|
||||
- `toDatabase()` returns array with declaration details + URL via `route()` helper
|
||||
- `toMail()` returns `MailMessage` with `->markdown()` template
|
||||
- French subject line pattern: `'Vous avez été mentionné - '.$this->declaration->title`
|
||||
|
||||
### Architecture Constraints
|
||||
|
||||
- **D8:** Use Laravel's built-in `DatabaseNotification` system — no custom notification tables
|
||||
- **D5:** Redis queue driver — all notification classes implement `ShouldQueue` + `Queueable`
|
||||
- **D10:** No real-time features for MVP — notifications load on page visit only
|
||||
- **Workspace scoping:** Filter notifications by `workspace_id` in the JSON `data` column, not by a direct relationship. Use `whereJsonContains('data->workspace_id', $workspace->id)` or filter after fetching.
|
||||
- **Notification type enum values:** snake_case (`nudge`, `declaration_overdue`, etc.)
|
||||
- **Route pattern from architecture:** `Route::get('notifications', [NotificationController::class, 'index'])->name('notifications.index');`
|
||||
|
||||
### Critical Gotchas
|
||||
|
||||
- **Declaration column is `due_date`** NOT `deadline` — use `$declaration->due_date` everywhere
|
||||
- **Declaration scoping:** No `Declaration::workspace()` scope — use `Declaration::where('workspace_id', $workspace->id)`
|
||||
- **Declaration statuses:** 6 values — `created`, `en_cours`, `en_attente_client`, `mise_en_demeure`, `termine`, `ferme`
|
||||
- **Authorization returns 404** not 403 for workspace boundary violations
|
||||
- **Workspace is session-based** — resolve from `$request->user()->currentWorkspace` or session `current_workspace_id`
|
||||
- **Enum pattern:** Use `bensampo/laravel-enum` — look at `DeclarationStatus.php` for exact syntax
|
||||
- **All URLs passed as props** from PHP controllers — never hardcode in Vue
|
||||
- **Use `route()` helper** for URLs in notification payloads and tests
|
||||
|
||||
### Testing Standards
|
||||
|
||||
- Pest syntax with `test()` closures
|
||||
- `Notification::fake()` for testing notification dispatch
|
||||
- `Mail::fake()` if testing mail separately
|
||||
- Feature tests in `tests/Feature/Notifications/`
|
||||
- Use `route()` helper for URLs, never hardcoded paths
|
||||
- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually
|
||||
- Run `composer test` to verify all tests pass (currently 222+ tests)
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**New files to create:**
|
||||
- `app/Enums/NotificationType.php` — follows existing enum pattern in `app/Enums/`
|
||||
- `app/Notifications/NudgeNotification.php` — follows existing `DeclarationMentionNotification.php` pattern
|
||||
- `app/Notifications/DocumentUploadedNotification.php` — database channel only
|
||||
- `app/Notifications/DeclarationOverdueNotification.php` — database + mail channels
|
||||
- `app/Mail/NudgeNotificationMail.php` — Markdown mailable, follows existing mail pattern in `app/Mail/`
|
||||
- `resources/views/emails/nudge-notification.blade.php` — Markdown mail template
|
||||
- `resources/js/types/notification.ts` — TypeScript type definitions
|
||||
- `tests/Feature/Notifications/NotificationInfrastructureTest.php`
|
||||
|
||||
**Existing files to modify:**
|
||||
- `app/Http/Controllers/NotificationController.php` — add `index()` method
|
||||
- `routes/web.php` — add `GET /notifications` route
|
||||
|
||||
**Files NOT to touch:**
|
||||
- `database/migrations/2026_03_09_000004_create_notifications_table.php` — already exists
|
||||
- `app/Models/User.php` — already has `Notifiable` trait
|
||||
- `resources/js/components/ui/*` — shadcn-vue, never modify
|
||||
|
||||
### References
|
||||
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#D8 — In-App Notification System]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#D5 — Queue Driver]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#Notification Patterns]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#Phase 3 File Structure]
|
||||
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.1]
|
||||
- [Source: _bmad-output/planning-artifacts/prd.md#FR29-FR33]
|
||||
- [Source: _bmad-output/implementation-artifacts/epic-2-retro-2026-03-24.md#Action Items]
|
||||
- [Source: _bmad-output/project-context.md#Critical Implementation Rules]
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Debug Log References
|
||||
- Baseline test suite: 223 tests passed before implementation
|
||||
- Final test suite: 237 tests passed (14 new notification tests added), zero failures
|
||||
- One test fix: created `notifications/Index.vue` page component to satisfy Inertia page existence check in test
|
||||
|
||||
### Completion Notes List
|
||||
- Verified existing infrastructure: notifications migration, User Notifiable trait, NotificationController with markAsRead/markAllAsRead
|
||||
- Created NotificationType enum with 5 values following bensampo/laravel-enum pattern
|
||||
- Created NudgeNotification (database+mail, $tries=3, toMail returns NudgeNotificationMail)
|
||||
- Created DocumentUploadedNotification (database only, no mail to reduce noise)
|
||||
- Created DeclarationOverdueNotification (database+mail, $tries=3, French subject line)
|
||||
- Created NudgeNotificationMail as Markdown mailable with professional French copy
|
||||
- Created declaration-overdue email template
|
||||
- Extended NotificationController with workspace-scoped index() using whereJsonContains
|
||||
- Added GET /notifications and POST /notifications/mark-all-read routes inside workspace middleware
|
||||
- Created notifications/Index.vue page with basic notification list UI
|
||||
- Flagged FolderMentionNotification as legacy (references deprecated Folder model) — skipped removal as out of scope
|
||||
- Created TypeScript notification types in resources/js/types/notification.ts
|
||||
- 14 comprehensive tests covering all ACs
|
||||
|
||||
### File List
|
||||
- `app/Enums/NotificationType.php` (new)
|
||||
- `app/Notifications/NudgeNotification.php` (new)
|
||||
- `app/Notifications/DocumentUploadedNotification.php` (new)
|
||||
- `app/Notifications/DeclarationOverdueNotification.php` (new)
|
||||
- `app/Mail/NudgeNotificationMail.php` (new)
|
||||
- `resources/views/emails/nudge-notification.blade.php` (new)
|
||||
- `resources/views/emails/declaration-overdue.blade.php` (new)
|
||||
- `resources/js/pages/notifications/Index.vue` (new)
|
||||
- `resources/js/types/notification.ts` (new)
|
||||
- `resources/js/types/index.ts` (modified — added notification export)
|
||||
- `app/Http/Controllers/NotificationController.php` (modified — added index method)
|
||||
- `routes/web.php` (modified — added GET /notifications and POST /notifications/mark-all-read routes)
|
||||
- `tests/Feature/Notifications/NotificationInfrastructureTest.php` (new)
|
||||
|
||||
## Change Log
|
||||
- 2026-03-24: Story 3.1 implementation complete — notification infrastructure with enum, 3 notification classes, mailable, controller index, routes, frontend types, and 14 tests
|
||||
34
app/Enums/NotificationType.php
Normal file
34
app/Enums/NotificationType.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use BenSampo\Enum\Enum;
|
||||
|
||||
final class NotificationType extends Enum
|
||||
{
|
||||
const Nudge = 'nudge';
|
||||
|
||||
const DeclarationOverdue = 'declaration_overdue';
|
||||
|
||||
const DocumentUploaded = 'document_uploaded';
|
||||
|
||||
const BulkNotification = 'bulk_notification';
|
||||
|
||||
const StatusChanged = 'status_changed';
|
||||
|
||||
/**
|
||||
* Get French display labels for each notification type.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function labels(): array
|
||||
{
|
||||
return [
|
||||
self::Nudge => 'Relance',
|
||||
self::DeclarationOverdue => 'Déclaration en retard',
|
||||
self::DocumentUploaded => 'Document téléversé',
|
||||
self::BulkNotification => 'Notification groupée',
|
||||
self::StatusChanged => 'Statut modifié',
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Mail/NudgeNotificationMail.php
Normal file
43
app/Mail/NudgeNotificationMail.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NudgeNotificationMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Declaration $declaration,
|
||||
public User $sender,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Relance - '.($this->declaration->title ?? 'Sans titre'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.nudge-notification',
|
||||
with: [
|
||||
'senderName' => $this->sender->name,
|
||||
'clientName' => $this->declaration->client?->name,
|
||||
'declarationType' => $this->declaration->type?->value,
|
||||
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
|
||||
'url' => route('declarations.show', $this->declaration),
|
||||
'firmName' => $this->declaration->workspace?->name,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
56
app/Notifications/DeclarationOverdueNotification.php
Normal file
56
app/Notifications/DeclarationOverdueNotification.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Enums\NotificationType;
|
||||
use App\Models\Declaration;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class DeclarationOverdueNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public bool $deleteWhenMissingModels = true;
|
||||
|
||||
public function __construct(
|
||||
public Declaration $declaration,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $this->declaration->workspace_id,
|
||||
'declaration_id' => $this->declaration->id,
|
||||
'notification_type' => NotificationType::DeclarationOverdue,
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Déclaration en retard - '.($this->declaration->title ?? 'Sans titre'))
|
||||
->markdown('emails.declaration-overdue', [
|
||||
'declarationTitle' => $this->declaration->title ?? 'Sans titre',
|
||||
'dueDate' => $this->declaration->due_date?->format('d/m/Y'),
|
||||
'url' => route('declarations.show', $this->declaration),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
app/Notifications/DocumentUploadedNotification.php
Normal file
42
app/Notifications/DocumentUploadedNotification.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Enums\NotificationType;
|
||||
use App\Models\Declaration;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class DocumentUploadedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public bool $deleteWhenMissingModels = true;
|
||||
|
||||
public function __construct(
|
||||
public Declaration $declaration,
|
||||
public int $clientId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $this->declaration->workspace_id,
|
||||
'declaration_id' => $this->declaration->id,
|
||||
'client_id' => $this->clientId,
|
||||
'notification_type' => NotificationType::DocumentUploaded,
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Notifications/NudgeNotification.php
Normal file
53
app/Notifications/NudgeNotification.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Enums\NotificationType;
|
||||
use App\Mail\NudgeNotificationMail;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class NudgeNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public bool $deleteWhenMissingModels = true;
|
||||
|
||||
public function __construct(
|
||||
public Declaration $declaration,
|
||||
public User $sender,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database', 'mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'workspace_id' => $this->declaration->workspace_id,
|
||||
'declaration_id' => $this->declaration->id,
|
||||
'sender_id' => $this->sender->id,
|
||||
'notification_type' => NotificationType::Nudge,
|
||||
];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): NudgeNotificationMail
|
||||
{
|
||||
return new NudgeNotificationMail($this->declaration, $this->sender);
|
||||
}
|
||||
}
|
||||
13
resources/views/emails/declaration-overdue.blade.php
Normal file
13
resources/views/emails/declaration-overdue.blade.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<x-mail::message>
|
||||
# Déclaration en retard
|
||||
|
||||
Bonjour,
|
||||
|
||||
La déclaration **{{ $declarationTitle }}** a dépassé son échéance prévue le **{{ $dueDate }}**.
|
||||
|
||||
Veuillez traiter cette déclaration dans les meilleurs délais.
|
||||
|
||||
<x-mail::button :url="$url" color="primary">
|
||||
Voir la déclaration
|
||||
</x-mail::button>
|
||||
</x-mail::message>
|
||||
15
resources/views/emails/nudge-notification.blade.php
Normal file
15
resources/views/emails/nudge-notification.blade.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<x-mail::message>
|
||||
# Relance
|
||||
|
||||
Bonjour,
|
||||
|
||||
**{{ $senderName }}** de **{{ $firmName }}** vous envoie une relance concernant la déclaration suivante :
|
||||
|
||||
- **Client :** {{ $clientName }}
|
||||
- **Type :** {{ $declarationType }}
|
||||
- **Échéance :** {{ $dueDate }}
|
||||
|
||||
<x-mail::button :url="$url" color="primary">
|
||||
Voir la déclaration
|
||||
</x-mail::button>
|
||||
</x-mail::message>
|
||||
@@ -5,11 +5,13 @@ use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\DeclarationMentionNotification;
|
||||
use App\Notifications\DocumentUploadedNotification;
|
||||
|
||||
test('user can mark own notification as read', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user, ['role' => 'owner']);
|
||||
$workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||
$declaration = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
@@ -37,7 +39,9 @@ test('cannot mark another user notification as read', function () {
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($other, ['role' => 'owner']);
|
||||
$workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]);
|
||||
$workspace->users()->attach($other, ['role' => 'worker', 'permissions' => json_encode([])]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||
$declaration = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
@@ -60,20 +64,16 @@ test('cannot mark another user notification as read', function () {
|
||||
test('user can mark all notifications as read', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user, ['role' => 'owner']);
|
||||
$workspace->users()->attach($user, ['role' => 'owner', 'permissions' => json_encode([])]);
|
||||
$user->update(['current_workspace_id' => $workspace->id]);
|
||||
$client = Client::factory()->create(['workspace_id' => $workspace->id]);
|
||||
$mentionedBy = User::factory()->create();
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$declaration = Declaration::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'client_id' => $client->id,
|
||||
]);
|
||||
$user->notify(new DeclarationMentionNotification(
|
||||
$declaration,
|
||||
$mentionedBy,
|
||||
"Message $i",
|
||||
));
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $client->id));
|
||||
}
|
||||
|
||||
expect($user->unreadNotifications()->count())->toBe(3);
|
||||
@@ -81,5 +81,6 @@ test('user can mark all notifications as read', function () {
|
||||
$response = $this->actingAs($user)->post(route('notifications.readAll'));
|
||||
$response->assertRedirect();
|
||||
|
||||
$user->refresh();
|
||||
expect($user->unreadNotifications()->count())->toBe(0);
|
||||
});
|
||||
|
||||
203
tests/Feature/Notifications/NotificationInfrastructureTest.php
Normal file
203
tests/Feature/Notifications/NotificationInfrastructureTest.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\NotificationType;
|
||||
use App\Enums\WorkspaceUserRole;
|
||||
use App\Mail\NudgeNotificationMail;
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\DeclarationOverdueNotification;
|
||||
use App\Notifications\DocumentUploadedNotification;
|
||||
use App\Notifications\NudgeNotification;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
|
||||
function setupNotificationUser(string $role = 'owner'): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$workspace->users()->attach($user->id, [
|
||||
'role' => $role,
|
||||
'permissions' => [],
|
||||
]);
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$user, $workspace];
|
||||
}
|
||||
|
||||
// ── NotificationType Enum ──────────────────────────────────
|
||||
|
||||
test('notification type enum has all expected values', function () {
|
||||
expect(NotificationType::getValues())->toContain('nudge')
|
||||
->toContain('declaration_overdue')
|
||||
->toContain('document_uploaded')
|
||||
->toContain('bulk_notification')
|
||||
->toContain('status_changed');
|
||||
|
||||
expect(NotificationType::getValues())->toHaveCount(5);
|
||||
});
|
||||
|
||||
// ── NudgeNotification ──────────────────────────────────────
|
||||
|
||||
test('nudge notification sends via database and mail channels', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
$recipient = User::factory()->create();
|
||||
$workspace->users()->attach($recipient->id, ['role' => 'worker', 'permissions' => []]);
|
||||
|
||||
$recipient->notify(new NudgeNotification($declaration, $user));
|
||||
|
||||
Notification::assertSentTo($recipient, NudgeNotification::class, function ($notification, $channels) {
|
||||
return in_array('database', $channels) && in_array('mail', $channels);
|
||||
});
|
||||
});
|
||||
|
||||
test('nudge notification payload includes workspace_id and notification_type', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$notification = new NudgeNotification($declaration, $user);
|
||||
$payload = $notification->toDatabase($user);
|
||||
|
||||
expect($payload)->toHaveKey('workspace_id', $workspace->id)
|
||||
->toHaveKey('declaration_id', $declaration->id)
|
||||
->toHaveKey('sender_id', $user->id)
|
||||
->toHaveKey('notification_type', NotificationType::Nudge);
|
||||
});
|
||||
|
||||
test('nudge notification toMail returns NudgeNotificationMail', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$notification = new NudgeNotification($declaration, $user);
|
||||
$mailable = $notification->toMail($user);
|
||||
|
||||
expect($mailable)->toBeInstanceOf(NudgeNotificationMail::class);
|
||||
});
|
||||
|
||||
// ── DocumentUploadedNotification ────────────────────────────
|
||||
|
||||
test('document uploaded notification sends via database channel only', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
|
||||
|
||||
Notification::assertSentTo($user, DocumentUploadedNotification::class, function ($notification, $channels) {
|
||||
return $channels === ['database'];
|
||||
});
|
||||
});
|
||||
|
||||
test('document uploaded notification payload includes workspace_id and notification_type', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$notification = new DocumentUploadedNotification($declaration, $declaration->client_id);
|
||||
$payload = $notification->toDatabase($user);
|
||||
|
||||
expect($payload)->toHaveKey('workspace_id', $workspace->id)
|
||||
->toHaveKey('declaration_id', $declaration->id)
|
||||
->toHaveKey('client_id', $declaration->client_id)
|
||||
->toHaveKey('notification_type', NotificationType::DocumentUploaded);
|
||||
});
|
||||
|
||||
// ── DeclarationOverdueNotification ──────────────────────────
|
||||
|
||||
test('declaration overdue notification sends via database and mail channels', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$user->notify(new DeclarationOverdueNotification($declaration));
|
||||
|
||||
Notification::assertSentTo($user, DeclarationOverdueNotification::class, function ($notification, $channels) {
|
||||
return in_array('database', $channels) && in_array('mail', $channels);
|
||||
});
|
||||
});
|
||||
|
||||
test('declaration overdue notification payload includes workspace_id and notification_type', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$notification = new DeclarationOverdueNotification($declaration);
|
||||
$payload = $notification->toDatabase($user);
|
||||
|
||||
expect($payload)->toHaveKey('workspace_id', $workspace->id)
|
||||
->toHaveKey('declaration_id', $declaration->id)
|
||||
->toHaveKey('notification_type', NotificationType::DeclarationOverdue);
|
||||
});
|
||||
|
||||
test('declaration overdue notification has retry configuration', function () {
|
||||
$declaration = Declaration::factory()->make();
|
||||
$notification = new DeclarationOverdueNotification($declaration);
|
||||
|
||||
expect($notification->tries)->toBe(3);
|
||||
});
|
||||
|
||||
test('nudge notification has retry configuration', function () {
|
||||
$declaration = Declaration::factory()->make();
|
||||
$sender = User::factory()->make();
|
||||
$notification = new NudgeNotification($declaration, $sender);
|
||||
|
||||
expect($notification->tries)->toBe(3);
|
||||
});
|
||||
|
||||
// ── NotificationController ──────────────────────────────────
|
||||
|
||||
test('notification controller index returns paginated workspace-scoped notifications', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
// Create a notification in the database for this user
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
|
||||
|
||||
$response = $this->actingAs($user)->get(route('notifications.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('notifications/Index')
|
||||
->has('notifications')
|
||||
->has('markAllReadUrl')
|
||||
);
|
||||
});
|
||||
|
||||
test('notification controller markAsRead marks notification as read', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
|
||||
$notification = $user->notifications()->first();
|
||||
|
||||
expect($notification->read_at)->toBeNull();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('notifications.read', $notification->id));
|
||||
|
||||
$response->assertRedirect();
|
||||
expect($notification->fresh()->read_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('notification controller markAllAsRead marks all as read', function () {
|
||||
[$user, $workspace] = setupNotificationUser();
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create();
|
||||
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
|
||||
$user->notify(new DocumentUploadedNotification($declaration, $declaration->client_id));
|
||||
|
||||
expect($user->unreadNotifications)->toHaveCount(2);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('notifications.readAll'));
|
||||
|
||||
$response->assertRedirect();
|
||||
$user->refresh();
|
||||
expect($user->unreadNotifications)->toHaveCount(0);
|
||||
});
|
||||
|
||||
test('unauthenticated users cannot access notification routes', function () {
|
||||
$this->get(route('notifications.index'))->assertRedirect(route('login'));
|
||||
$this->post(route('notifications.readAll'))->assertRedirect(route('login'));
|
||||
});
|
||||
Reference in New Issue
Block a user