feat: add one-click nudge system with popover, throttling, and email notifications (Story 3.2)
Add NudgeController with 1-hour throttling per declaration, NudgePopover component on declarations index and dashboard, shadcn-vue popover primitives, and per-declaration nudge tracking. Owners/managers can nudge assigned workers with one click. Includes 10 feature tests covering authorization, throttling, and cache invalidation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
# Story 3.2: One-Click Nudge System
|
||||
|
||||
Status: review
|
||||
|
||||
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
|
||||
|
||||
## Story
|
||||
|
||||
As a firm owner or manager,
|
||||
I want to nudge a team member about a specific declaration with one click,
|
||||
so that I can quickly signal that something needs attention without composing a message or making a phone call.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. **AC1: NudgePopover appears on declaration row click**
|
||||
- Given an Owner or Manager is viewing a declaration list (dashboard table or declarations index)
|
||||
- When they click the nudge icon on a declaration row
|
||||
- Then a NudgePopover appears showing the assigned worker's name and a "Send Nudge" button
|
||||
|
||||
2. **AC2: Nudge dispatches notification on send**
|
||||
- Given a NudgePopover is open
|
||||
- When the user clicks "Send Nudge"
|
||||
- Then a `NudgeNotification` is dispatched to the assigned worker via `NudgeController@store`
|
||||
- And the notification is saved to the database (in-app) AND queued as an email
|
||||
- And a success toast confirms: "Relance envoyée à [worker name]"
|
||||
- And the popover closes automatically after sending
|
||||
|
||||
3. **AC3: No message composition required**
|
||||
- Then no message composition is required — it is a pure signal ("Your attention is needed on this declaration")
|
||||
|
||||
4. **AC4: Nudge icon hidden for unauthorized users**
|
||||
- Given a Worker or a user without nudge permission
|
||||
- When they view declaration rows
|
||||
- Then the nudge icon is not displayed
|
||||
|
||||
5. **AC5: NudgeController validates sender role**
|
||||
- Then the `NudgeController` validates that the sender is Owner or Manager
|
||||
- And the `NudgeController` validates that the declaration belongs to the current workspace
|
||||
|
||||
6. **AC6: Nudge email contains declaration context**
|
||||
- Then the nudge email contains: declaration type, client name, deadline (`due_date`), and a direct link to the declaration detail page
|
||||
- (Already implemented by `NudgeNotificationMail` from Story 3.1 — verify integration)
|
||||
|
||||
7. **AC7: Nudge is logged via Spatie Activity Log**
|
||||
- Then the nudge is logged via Spatie Activity Log (actor: sender, target: declaration, action: "nudged")
|
||||
|
||||
8. **AC8: Duplicate nudge debounce (1-hour window)**
|
||||
- Then duplicate nudges on the same declaration within 1 hour are prevented (debounce) with a user-friendly message: "Relance déjà envoyée récemment"
|
||||
|
||||
9. **AC9: Dashboard nudge dropdown enabled (D-1 resolution)**
|
||||
- Then the "Relancer" dropdown menu item in `Dashboard.vue` is enabled for Owner/Manager roles and triggers the NudgePopover
|
||||
- (Resolves deferred code review item D-1 from Epic 2 Story 2.3)
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [x] Task 1: Create `NudgeController` (AC: #2, #5, #7, #8)
|
||||
- [x] 1.1 Create `app/Http/Controllers/NudgeController.php` with `store(Request $request, Declaration $declaration)` method
|
||||
- [x] 1.2 Use `HasWorkspaceScope` trait — call `$this->authorizeWorkspaceAccess($declaration)` to verify declaration belongs to workspace
|
||||
- [x] 1.3 Authorize sender: check user's role on current workspace is `owner` or `manager` — `abort(404)` if not
|
||||
- [x] 1.4 Validate declaration has an `assigned_to` user — return error if no assignee
|
||||
- [x] 1.5 Debounce check: query `notifications` table for existing nudge on same declaration within 1 hour — return `back()->with('flash', ['type' => 'warning', 'message' => 'Relance déjà envoyée récemment'])` if found
|
||||
- [x] 1.6 Dispatch `NudgeNotification` to the declaration's assignee: `$declaration->assignee->notify(new NudgeNotification($declaration, $request->user()))`
|
||||
- [x] 1.7 Log via Spatie Activity Log: `activity()->performedOn($declaration)->causedBy($request->user())->log('nudged')`
|
||||
- [x] 1.8 Clear assignee's notification cache: `Cache::forget("user:{$declaration->assigned_to}:unread_notifications")`
|
||||
- [x] 1.9 Return `back()->with('flash', ['type' => 'success', 'message' => 'Relance envoyée à '.$declaration->assignee->name])`
|
||||
|
||||
- [x] Task 2: Register nudge route (AC: #5)
|
||||
- [x] 2.1 Add route inside `middleware('workspace')` group in `routes/web.php`:
|
||||
`Route::post('declarations/{declaration}/nudge', [NudgeController::class, 'store'])->name('declarations.nudge')->middleware('throttle:10,1');`
|
||||
|
||||
- [x] Task 3: Create `NudgePopover` Vue component (AC: #1, #2, #3)
|
||||
- [x] 3.1 Create `resources/js/components/declarations/NudgePopover.vue`
|
||||
- [x] 3.2 Props: `declaration` (with `assigneeName`, `id`), `nudgeUrl` (string)
|
||||
- [x] 3.3 UI: shadcn-vue `Popover` + `PopoverTrigger` (Send icon button) + `PopoverContent`
|
||||
- [x] 3.4 Content: Show assignee name, "Envoyer une relance" button
|
||||
- [x] 3.5 On click "Envoyer": `router.post(nudgeUrl)` via Inertia — popover auto-closes on success
|
||||
- [x] 3.6 Handle loading state (disable button during submission via `form.processing`)
|
||||
- [x] 3.7 Use `useForm` from Inertia for the POST request — no payload needed
|
||||
|
||||
- [x] Task 4: Enable nudge in Dashboard table (AC: #4, #9)
|
||||
- [x] 4.1 In `Dashboard.vue`, replace the disabled "Relancer" `DropdownMenuItem` with `NudgePopover` component (or make it functional via click handler)
|
||||
- [x] 4.2 Pass `nudgeUrl` as `route('declarations.nudge', declaration.id)` — URL must come from controller props
|
||||
- [x] 4.3 Only show/enable nudge for Owner/Manager — use the existing `isWorker` prop to conditionally render (if `!isWorker`)
|
||||
- [x] 4.4 Pass `canNudge` boolean prop from `DashboardController` (true for owner/manager, false for worker)
|
||||
- [x] 4.5 Hide nudge action entirely when `!canNudge`
|
||||
|
||||
- [x] Task 5: Add nudge to Declarations Index page (AC: #1, #4)
|
||||
- [x] 5.1 In `resources/js/pages/declarations/Index.vue`, add nudge icon/button per row for Owner/Manager
|
||||
- [x] 5.2 Pass `canNudge` prop from `DeclarationController@index`
|
||||
- [x] 5.3 Add `nudgeUrl` to each declaration in the serialized data from controller
|
||||
- [x] 5.4 Integrate `NudgePopover` component for each row
|
||||
|
||||
- [x] Task 6: Update `DashboardController` to pass nudge props (AC: #4, #9)
|
||||
- [x] 6.1 Add `canNudge` to the Inertia props: `true` when user role is `owner` or `manager`
|
||||
- [x] 6.2 Add `nudgeBaseUrl` or per-declaration `nudgeUrl` to declaration data passed to frontend
|
||||
- [x] 6.3 Use Wayfinder route generation for URLs passed as props
|
||||
|
||||
- [x] Task 7: Update `DeclarationController@index` to pass nudge props (AC: #4)
|
||||
- [x] 7.1 Add `canNudge` to the Inertia props
|
||||
- [x] 7.2 Add `nudgeUrl` to each declaration data object
|
||||
|
||||
- [x] Task 8: Write tests (all ACs)
|
||||
- [x] 8.1 Create `tests/Feature/Notifications/NudgeControllerTest.php`
|
||||
- [x] 8.2 Test: Owner can send nudge — notification dispatched, activity logged, success flash
|
||||
- [x] 8.3 Test: Manager can send nudge — same behavior as owner
|
||||
- [x] 8.4 Test: Worker cannot send nudge — `abort(404)`
|
||||
- [x] 8.5 Test: Nudge on declaration in wrong workspace — `abort(404)`
|
||||
- [x] 8.6 Test: Nudge on declaration with no assignee — returns error
|
||||
- [x] 8.7 Test: Duplicate nudge within 1 hour — debounce prevents, returns warning flash
|
||||
- [x] 8.8 Test: Nudge after 1 hour — allowed (debounce expired)
|
||||
- [x] 8.9 Test: Activity log entry created with correct actor/target/action
|
||||
- [x] 8.10 Test: Unauthenticated user cannot nudge — redirected to login
|
||||
- [x] 8.11 Test: Notification cache cleared for assignee after nudge
|
||||
|
||||
## Retrospective Intelligence
|
||||
|
||||
**From Epic 2 Retrospective (2026-03-24):**
|
||||
|
||||
- **D-1 (Deferred Code Review Item):** Dashboard "Relancer" and "Réassigner" dropdown items are unconditionally `disabled` in `Dashboard.vue` lines 349-364. This story MUST enable the "Relancer" item. The "Réassigner" item stays disabled until a future story.
|
||||
- **Team Agreement — Load retro as context:** Non-negotiable. This story incorporates all retro intelligence.
|
||||
- **Team Agreement — Pre-existing test failures must not carry:** Run full test suite before and after implementation. Zero failures tolerance. Story 3.1 raised test count to 237.
|
||||
- **Context management is the team's superpower:** Previous story intelligence prevents repeated mistakes.
|
||||
- **Architecture doc drift was corrected** in commit `6956f7b` — `due_date` (not `deadline`), no `Declaration::workspace()` scope, `mise_en_demeure` status. All corrected and reflected in this story.
|
||||
- **D-3 (Cache not invalidated on role change):** 5-min TTL mitigates. Not directly relevant to this story but be aware that `canNudge` is computed per request, not cached.
|
||||
|
||||
**From Epic 1 Retrospective:**
|
||||
|
||||
- **withPivot gotchas:** When loading workspace members, always chain `->withPivot('role', 'permissions')` on workspace users() relationship.
|
||||
|
||||
**From Story 3.1 Completion Notes:**
|
||||
|
||||
- All notification infrastructure is in place — `NudgeNotification`, `NudgeNotificationMail`, `NotificationType::Nudge`, notification routes, TypeScript types
|
||||
- 237 tests passing, zero failures
|
||||
- `whereJsonContains('data->workspace_id', $workspace->id)` is the pattern for workspace-scoped notification queries
|
||||
- `Cache::forget("user:{$userId}:unread_notifications")` on every notification state change
|
||||
|
||||
## Dev Notes
|
||||
|
||||
### Existing Infrastructure (REUSE — do not recreate)
|
||||
|
||||
- **`NudgeNotification`** — `app/Notifications/NudgeNotification.php` — already created in Story 3.1. Channels: `['database', 'mail']`, `$tries = 3`, `$backoff = 60`. Constructor: `Declaration $declaration, User $sender`. Uses `NudgeNotificationMail` for email.
|
||||
- **`NudgeNotificationMail`** — `app/Mail/NudgeNotificationMail.php` — Markdown mailable with sender name, client name, declaration type, `due_date`, direct link button. Template: `resources/views/emails/nudge-notification.blade.php`.
|
||||
- **`NotificationType::Nudge`** — `app/Enums/NotificationType.php` — enum value exists.
|
||||
- **`NotificationController`** — `app/Http/Controllers/NotificationController.php` — has `index`, `markAsRead`, `markAllAsRead`. Do NOT modify.
|
||||
- **Frontend types** — `resources/js/types/notification.ts` — `NotificationType`, `NotificationData`, `AppNotification` types exist.
|
||||
- **`DeclarationMentionController`** — `app/Http/Controllers/DeclarationMentionController.php` — reference pattern for how to authorize owner/manager and dispatch a notification. Uses `abort(403)` for role check (NOTE: architecture says `abort(404)` — follow architecture convention `abort(404)` in NudgeController).
|
||||
|
||||
### Controller Pattern to Follow
|
||||
|
||||
The `DeclarationMentionController` is the closest reference:
|
||||
```php
|
||||
// Role check pattern (use abort(404) per architecture, not 403):
|
||||
$workspaceUser = $workspace->users()
|
||||
->where('users.id', $request->user()->id)
|
||||
->first();
|
||||
if (!in_array($workspaceUser?->pivot?->role?->value, ['owner', 'manager'])) {
|
||||
abort(404);
|
||||
}
|
||||
```
|
||||
|
||||
Use `HasWorkspaceScope` trait (preferred pattern from NotificationController):
|
||||
```php
|
||||
use App\Concerns\HasWorkspaceScope;
|
||||
// $workspace = $this->currentWorkspace();
|
||||
// $this->authorizeWorkspaceAccess($declaration);
|
||||
```
|
||||
|
||||
### Debounce Implementation
|
||||
|
||||
Query the `notifications` table for existing nudge within 1 hour:
|
||||
```php
|
||||
$recentNudge = $declaration->assignee
|
||||
->notifications()
|
||||
->where('type', NudgeNotification::class)
|
||||
->whereJsonContains('data->declaration_id', $declaration->id)
|
||||
->where('created_at', '>=', now()->subHour())
|
||||
->exists();
|
||||
```
|
||||
|
||||
### Spatie Activity Log Pattern
|
||||
|
||||
From existing codebase (DashboardController reads activity logs):
|
||||
```php
|
||||
activity()
|
||||
->performedOn($declaration)
|
||||
->causedBy($request->user())
|
||||
->log('nudged');
|
||||
```
|
||||
|
||||
### Dashboard Integration (D-1 Resolution)
|
||||
|
||||
In `Dashboard.vue` lines 349-364, the "Relancer" dropdown item is currently:
|
||||
```vue
|
||||
<DropdownMenuItem disabled>
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Relancer
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
Replace with functional nudge trigger. Options:
|
||||
1. **Inline Popover approach** — wrap in `NudgePopover` component within the dropdown
|
||||
2. **Click-to-nudge approach** — directly post on click (simpler, since nudge requires no message)
|
||||
|
||||
Architecture specifies "Popover, not full modal" for nudge dialog. Recommended: use `Popover` component from shadcn-vue.
|
||||
|
||||
### Declarations Index Integration
|
||||
|
||||
`declarations/Index.vue` currently has NO dropdown menu or inline actions. Add a nudge icon button per row (only visible for owner/manager). Use the same `NudgePopover` component.
|
||||
|
||||
### Critical Gotchas
|
||||
|
||||
- **Declaration column is `due_date`** NOT `deadline` — already handled in NudgeNotificationMail
|
||||
- **Declaration has `assignee()` relationship** — `belongsTo(User::class, 'assigned_to')` at `Declaration.php:123`
|
||||
- **No `Declaration::workspace()` scope** — use `$declaration->workspace_id` directly
|
||||
- **Authorization returns `abort(404)`** not 403 — architecture convention, prevents workspace boundary information leakage
|
||||
- **All URLs passed as props** from PHP controllers — never hardcode in Vue. Use Wayfinder or `route()` helper
|
||||
- **`isWorker` prop** already exists on Dashboard — passed from `DashboardController`, use to conditionally show nudge
|
||||
- **Workspace resolution** — session-based via `current_workspace_id`, use `HasWorkspaceScope` trait
|
||||
- **Cache invalidation** — always call `Cache::forget("user:{$userId}:unread_notifications")` after dispatching notification
|
||||
- **Flash messages** — return `back()->with('flash', ['type' => 'success/warning', 'message' => '...'])` — Inertia shares flash via middleware
|
||||
- **throttle middleware** — add `throttle:10,1` to nudge route (same as mentions route pattern)
|
||||
|
||||
### Testing Standards
|
||||
|
||||
- Pest syntax with `test()` closures
|
||||
- `Notification::fake()` for verifying dispatch, then assert `Notification::assertSentTo($assignee, NudgeNotification::class)`
|
||||
- Feature tests in `tests/Feature/Notifications/NudgeControllerTest.php`
|
||||
- Use `route()` helper for URLs, never hardcoded paths
|
||||
- `RefreshDatabase` is auto-applied via `Pest.php` — don't add manually
|
||||
- `withoutVite()` is globally applied in `Pest.php` — compatible with backend tests
|
||||
- Use `actingAs($user)` for authentication
|
||||
- Create workspace, users, declaration via factories in test setup
|
||||
- Test the debounce by creating a notification record with `created_at` within/outside the 1-hour window
|
||||
- Run `composer test` — must pass all 237+ existing tests plus new ones, zero failures
|
||||
|
||||
### Project Structure Notes
|
||||
|
||||
**New files to create:**
|
||||
- `app/Http/Controllers/NudgeController.php` — follows existing controller pattern
|
||||
- `resources/js/components/declarations/NudgePopover.vue` — new component
|
||||
- `tests/Feature/Notifications/NudgeControllerTest.php` — new test file
|
||||
|
||||
**Existing files to modify:**
|
||||
- `resources/js/pages/Dashboard.vue` — enable "Relancer" dropdown item, integrate NudgePopover
|
||||
- `resources/js/pages/declarations/Index.vue` — add nudge icon/button per row
|
||||
- `app/Http/Controllers/DashboardController.php` — add `canNudge` prop
|
||||
- `app/Http/Controllers/DeclarationController.php` — add `canNudge` and `nudgeUrl` props to index
|
||||
- `routes/web.php` — add nudge route
|
||||
|
||||
**Files NOT to touch:**
|
||||
- `app/Notifications/NudgeNotification.php` — already complete from Story 3.1
|
||||
- `app/Mail/NudgeNotificationMail.php` — already complete from Story 3.1
|
||||
- `resources/views/emails/nudge-notification.blade.php` — already complete
|
||||
- `app/Enums/NotificationType.php` — already complete
|
||||
- `resources/js/components/ui/*` — shadcn-vue, never modify
|
||||
|
||||
### References
|
||||
|
||||
- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.2]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#Notification Patterns — "Nudge dialog: Popover, not full modal"]
|
||||
- [Source: _bmad-output/planning-artifacts/architecture.md#Phase 3 File Structure — NudgeController]
|
||||
- [Source: _bmad-output/planning-artifacts/ux-design-specification.md#Nudge UX Pattern]
|
||||
- [Source: _bmad-output/planning-artifacts/prd.md#FR29-FR31]
|
||||
- [Source: _bmad-output/implementation-artifacts/3-1-notification-infrastructure-setup.md]
|
||||
- [Source: _bmad-output/implementation-artifacts/epic-2-retro-2026-03-24.md#Deferred Items D-1]
|
||||
- [Source: app/Http/Controllers/DeclarationMentionController.php — reference controller pattern]
|
||||
- [Source: app/Concerns/HasWorkspaceScope.php — workspace resolution trait]
|
||||
- [Source: app/Models/Declaration.php:123 — assignee() relationship]
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Agent Model Used
|
||||
|
||||
Claude Opus 4.6 (1M context)
|
||||
|
||||
### Debug Log References
|
||||
|
||||
None — clean implementation with no blockers.
|
||||
|
||||
### Completion Notes List
|
||||
|
||||
- Created NudgeController with HasWorkspaceScope trait, owner/manager authorization, debounce (1-hour window), NudgeNotification dispatch, Spatie activity logging, cache invalidation, and flash messages
|
||||
- Registered POST route `declarations/{declaration}/nudge` with throttle:10,1 middleware
|
||||
- Created NudgePopover Vue component using shadcn-vue Popover, with useForm for Inertia POST, loading state, and auto-close on success
|
||||
- Dashboard "Relancer" dropdown item now functional for owner/manager via direct `router.post()` click (D-1 resolved)
|
||||
- Declarations Index page now shows NudgePopover per row for owner/manager
|
||||
- DashboardController passes `canNudge` and per-declaration `nudgeUrl` props
|
||||
- DeclarationController@index passes `canNudge` and per-declaration `nudgeUrl` and `assignee_name` props
|
||||
- Updated DashboardDeclaration type to include `nudgeUrl`, DashboardProps to include `canNudge`
|
||||
- 10 new feature tests covering all ACs: authorization, debounce, activity log, cache invalidation, edge cases
|
||||
- All 247 tests pass (1307 assertions), zero regressions
|
||||
- Installed shadcn-vue Popover component (reka-ui based)
|
||||
|
||||
### File List
|
||||
|
||||
**New files:**
|
||||
- app/Http/Controllers/NudgeController.php
|
||||
- resources/js/components/declarations/NudgePopover.vue
|
||||
- tests/Feature/Notifications/NudgeControllerTest.php
|
||||
- resources/js/components/ui/popover/Popover.vue (shadcn-vue install)
|
||||
- resources/js/components/ui/popover/PopoverAnchor.vue (shadcn-vue install)
|
||||
- resources/js/components/ui/popover/PopoverContent.vue (shadcn-vue install)
|
||||
- resources/js/components/ui/popover/PopoverTrigger.vue (shadcn-vue install)
|
||||
- resources/js/components/ui/popover/index.ts (shadcn-vue install)
|
||||
|
||||
**Modified files:**
|
||||
- routes/web.php — added nudge route
|
||||
- app/Http/Controllers/DashboardController.php — added canNudge + nudgeUrl props
|
||||
- app/Http/Controllers/DeclarationController.php — added canNudge + nudgeUrl + assignee_name props
|
||||
- resources/js/pages/Dashboard.vue — enabled "Relancer" dropdown item for owner/manager
|
||||
- resources/js/pages/declarations/Index.vue — added NudgePopover per row
|
||||
- resources/js/types/dashboard.ts — added nudgeUrl to DashboardDeclaration, canNudge to DashboardProps
|
||||
|
||||
### Change Log
|
||||
|
||||
- 2026-03-25: Implemented Story 3.2 One-Click Nudge System — NudgeController, NudgePopover, dashboard/index integration, 10 tests
|
||||
@@ -34,6 +34,7 @@ class DashboardController extends Controller
|
||||
'workspaceName' => null,
|
||||
'roleLabel' => null,
|
||||
'isWorker' => false,
|
||||
'canNudge' => false,
|
||||
'declarationsUrl' => null,
|
||||
'clientsUrl' => null,
|
||||
'viewAllAlertsUrl' => null,
|
||||
@@ -50,6 +51,7 @@ class DashboardController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
|
||||
$cacheKey = "dashboard:{$workspace->id}:{$user->id}";
|
||||
|
||||
$dashboardData = Cache::remember($cacheKey, 300, function () use ($workspace, $user, $workspaceUser) {
|
||||
@@ -106,11 +108,11 @@ class DashboardController extends Controller
|
||||
'statusLabel' => DeclarationStatus::labels()[$d->status->value] ?? $d->status->value,
|
||||
'dueDate' => $d->due_date?->format('Y-m-d'),
|
||||
'showUrl' => route('declarations.show', $d),
|
||||
'nudgeUrl' => ! $isWorker ? route('declarations.nudge', $d) : null,
|
||||
])
|
||||
->all();
|
||||
|
||||
$roleLabel = $this->roleLabels()[$workspaceUser->role->value] ?? $workspaceUser->role->value;
|
||||
$isWorker = $workspaceUser->role->is(WorkspaceUserRole::Worker);
|
||||
|
||||
$assigneeParam = $isWorker ? ['assignee' => $user->id] : [];
|
||||
|
||||
@@ -150,6 +152,7 @@ class DashboardController extends Controller
|
||||
'workspaceName' => $workspace->name,
|
||||
'roleLabel' => $roleLabel,
|
||||
'isWorker' => $isWorker,
|
||||
'canNudge' => ! $isWorker,
|
||||
'declarationsUrl' => route('declarations.index'),
|
||||
'clientsUrl' => route('clients.index'),
|
||||
'viewAllAlertsUrl' => route('declarations.index', ['filter' => 'alerts']),
|
||||
|
||||
@@ -76,11 +76,13 @@ class DeclarationController extends Controller
|
||||
'title' => $declaration->title,
|
||||
'type' => $declaration->type->value,
|
||||
'client_name' => $declaration->client->company_name,
|
||||
'assignee_name' => $declaration->assignee?->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),
|
||||
'nudgeUrl' => ! $isWorker ? route('declarations.nudge', $declaration) : null,
|
||||
]);
|
||||
|
||||
return Inertia::render('declarations/Index', [
|
||||
@@ -90,6 +92,7 @@ class DeclarationController extends Controller
|
||||
'canCreate' => ! $isWorker,
|
||||
'canEdit' => ! $isWorker,
|
||||
'canDelete' => ! $isWorker,
|
||||
'canNudge' => ! $isWorker,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
61
app/Http/Controllers/NudgeController.php
Normal file
61
app/Http/Controllers/NudgeController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Concerns\HasWorkspaceScope;
|
||||
use App\Models\Declaration;
|
||||
use App\Notifications\NudgeNotification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class NudgeController extends Controller
|
||||
{
|
||||
use HasWorkspaceScope;
|
||||
|
||||
public function store(Request $request, Declaration $declaration): RedirectResponse
|
||||
{
|
||||
$this->authorizeWorkspaceAccess($declaration);
|
||||
|
||||
$workspace = $this->currentWorkspace();
|
||||
|
||||
$userRole = $workspace->users()
|
||||
->where('users.id', $request->user()->id)
|
||||
->first()
|
||||
?->pivot
|
||||
?->role
|
||||
?->value;
|
||||
|
||||
if (! in_array($userRole, ['owner', 'manager'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$assignee = $declaration->assignee;
|
||||
|
||||
if (! $assignee) {
|
||||
return back()->with('flash', ['type' => 'warning', 'message' => 'Cette déclaration n\'a pas de collaborateur assigné.']);
|
||||
}
|
||||
|
||||
$recentNudge = $assignee
|
||||
->notifications()
|
||||
->where('type', NudgeNotification::class)
|
||||
->where('data->declaration_id', $declaration->id)
|
||||
->where('created_at', '>=', now()->subHour())
|
||||
->exists();
|
||||
|
||||
if ($recentNudge) {
|
||||
return back()->with('flash', ['type' => 'warning', 'message' => 'Relance déjà envoyée récemment']);
|
||||
}
|
||||
|
||||
$assignee->notify(new NudgeNotification($declaration, $request->user()));
|
||||
|
||||
activity()
|
||||
->performedOn($declaration)
|
||||
->causedBy($request->user())
|
||||
->log('nudged');
|
||||
|
||||
Cache::forget("user:{$assignee->id}:workspace:{$workspace->id}:unread_notifications");
|
||||
|
||||
return back()->with('flash', ['type' => 'success', 'message' => 'Relance envoyée à '.$assignee->name]);
|
||||
}
|
||||
}
|
||||
68
resources/js/components/declarations/NudgePopover.vue
Normal file
68
resources/js/components/declarations/NudgePopover.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3';
|
||||
import { Send } from 'lucide-vue-next';
|
||||
import { ref } from 'vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
type Props = {
|
||||
assigneeName: string | null;
|
||||
nudgeUrl: string;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const open = ref(false);
|
||||
const form = useForm({});
|
||||
|
||||
function sendNudge() {
|
||||
form.post(props.nudgeUrl, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
open.value = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
@click.stop
|
||||
>
|
||||
<Send class="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
class="w-64"
|
||||
align="end"
|
||||
@click.stop
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm">
|
||||
Envoyer une relance à
|
||||
<span class="font-medium">{{
|
||||
assigneeName ?? 'Non assigné'
|
||||
}}</span>
|
||||
</p>
|
||||
<Button
|
||||
class="w-full"
|
||||
size="sm"
|
||||
:disabled="form.processing || !assigneeName"
|
||||
@click="sendNudge"
|
||||
>
|
||||
<Send class="mr-2 h-4 w-4" />
|
||||
Envoyer une relance
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
19
resources/js/components/ui/popover/Popover.vue
Normal file
19
resources/js/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from "reka-ui"
|
||||
import { PopoverRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
15
resources/js/components/ui/popover/PopoverAnchor.vue
Normal file
15
resources/js/components/ui/popover/PopoverAnchor.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverAnchorProps } from "reka-ui"
|
||||
import { PopoverAnchor } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverAnchorProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverAnchor
|
||||
data-slot="popover-anchor"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
</template>
|
||||
45
resources/js/components/ui/popover/PopoverContent.vue
Normal file
45
resources/js/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
align: "center",
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
15
resources/js/components/ui/popover/PopoverTrigger.vue
Normal file
15
resources/js/components/ui/popover/PopoverTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from "reka-ui"
|
||||
import { PopoverTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
4
resources/js/components/ui/popover/index.ts
Normal file
4
resources/js/components/ui/popover/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as Popover } from "./Popover.vue"
|
||||
export { default as PopoverAnchor } from "./PopoverAnchor.vue"
|
||||
export { default as PopoverContent } from "./PopoverContent.vue"
|
||||
export { default as PopoverTrigger } from "./PopoverTrigger.vue"
|
||||
@@ -62,6 +62,7 @@ const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
||||
const hasWorkspace = computed(() => !!props.workspaceName);
|
||||
const showFeed = ref(false);
|
||||
const nudgingIds = ref(new Set<number>());
|
||||
|
||||
const isWorkerEmpty = computed(
|
||||
() =>
|
||||
@@ -347,7 +348,21 @@ function navigateToDeclaration(declaration: DashboardDeclaration): void {
|
||||
Voir
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled
|
||||
v-if="canNudge"
|
||||
:disabled="nudgingIds.has(declaration.id)"
|
||||
@click.stop="
|
||||
if (!nudgingIds.has(declaration.id)) {
|
||||
nudgingIds.add(declaration.id);
|
||||
router.post(
|
||||
declaration.nudgeUrl,
|
||||
{},
|
||||
{
|
||||
preserveScroll: true,
|
||||
onFinish: () => nudgingIds.delete(declaration.id),
|
||||
},
|
||||
);
|
||||
}
|
||||
"
|
||||
>
|
||||
<Send
|
||||
class="mr-2 h-4 w-4"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { FolderOpen } from 'lucide-vue-next';
|
||||
import NudgePopover from '@/components/declarations/NudgePopover.vue';
|
||||
import Heading from '@/components/Heading.vue';
|
||||
import Pagination from '@/components/Pagination.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -11,11 +12,13 @@ type Declaration = {
|
||||
title: string;
|
||||
type: string;
|
||||
client_name: string;
|
||||
assignee_name: string | null;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
showUrl: string;
|
||||
editUrl: string;
|
||||
destroyUrl: string;
|
||||
nudgeUrl: string | null;
|
||||
};
|
||||
|
||||
type PaginatedData<T> = {
|
||||
@@ -40,6 +43,7 @@ type Props = {
|
||||
canCreate: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
canNudge: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -167,7 +171,16 @@ const statusLabels: Record<string, string> = {
|
||||
<td class="px-4 py-3 text-muted-foreground">
|
||||
{{ declaration.due_date || '—' }}
|
||||
</td>
|
||||
<td class="space-x-2 px-4 py-3 text-right">
|
||||
<td
|
||||
class="flex items-center gap-2 px-4 py-3 text-right"
|
||||
>
|
||||
<NudgePopover
|
||||
v-if="props.canNudge"
|
||||
:assignee-name="
|
||||
declaration.assignee_name
|
||||
"
|
||||
:nudge-url="declaration.nudgeUrl"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -16,6 +16,7 @@ export type DashboardDeclaration = {
|
||||
statusLabel: string;
|
||||
dueDate: string | null;
|
||||
showUrl: string;
|
||||
nudgeUrl: string | null;
|
||||
};
|
||||
|
||||
export type StatCardLink = {
|
||||
@@ -56,6 +57,7 @@ export type DashboardProps = {
|
||||
workspaceName: string | null;
|
||||
roleLabel: string | null;
|
||||
isWorker: boolean;
|
||||
canNudge: boolean;
|
||||
declarationsUrl: string | null;
|
||||
clientsUrl: string | null;
|
||||
viewAllAlertsUrl: string | null;
|
||||
|
||||
@@ -25,16 +25,20 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('declarations/{declaration}/mentions', [\App\Http\Controllers\DeclarationMentionController::class, 'store'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('declarations.mentions.store');
|
||||
Route::post('declarations/{declaration}/nudge', [\App\Http\Controllers\NudgeController::class, 'store'])
|
||||
->middleware('throttle:10,1')
|
||||
->name('declarations.nudge');
|
||||
|
||||
Route::get('team', [\App\Http\Controllers\TeamController::class, 'index'])->name('team.index');
|
||||
Route::post('team/invite', [\App\Http\Controllers\TeamController::class, 'invite'])->name('team.invite');
|
||||
Route::patch('team/{workspaceUserId}/role', [\App\Http\Controllers\TeamController::class, 'updateRole'])->name('team.updateRole');
|
||||
Route::put('team/{workspaceUserId}/permissions', [\App\Http\Controllers\TeamController::class, 'updatePermissions'])->name('team.updatePermissions');
|
||||
Route::delete('team/{workspaceUserId}', [\App\Http\Controllers\TeamController::class, 'remove'])->name('team.remove');
|
||||
});
|
||||
|
||||
Route::get('notifications', [\App\Http\Controllers\NotificationController::class, 'index'])->name('notifications.index');
|
||||
Route::post('notifications/mark-all-read', [\App\Http\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.readAll');
|
||||
Route::post('notifications/{id}/read', [\App\Http\Controllers\NotificationController::class, 'markAsRead'])->name('notifications.read');
|
||||
Route::post('notifications/read-all', [\App\Http\Controllers\NotificationController::class, 'markAllAsRead'])->name('notifications.readAll');
|
||||
});
|
||||
|
||||
Route::middleware('admin')->group(function () {
|
||||
Route::resource('users', \App\Http\Controllers\UserController::class);
|
||||
|
||||
219
tests/Feature/Notifications/NudgeControllerTest.php
Normal file
219
tests/Feature/Notifications/NudgeControllerTest.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Declaration;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Notifications\NudgeNotification;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
function setupNudgeTest(string $senderRole = 'owner'): array
|
||||
{
|
||||
$sender = User::factory()->create();
|
||||
$worker = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$workspace->users()->attach($sender->id, ['role' => $senderRole, 'permissions' => []]);
|
||||
$workspace->users()->attach($worker->id, ['role' => 'worker', 'permissions' => []]);
|
||||
|
||||
$declaration = Declaration::factory()->forWorkspace($workspace)->create([
|
||||
'assigned_to' => $worker->id,
|
||||
]);
|
||||
|
||||
session(['current_workspace_id' => $workspace->id]);
|
||||
|
||||
return [$sender, $worker, $workspace, $declaration];
|
||||
}
|
||||
|
||||
// ── AC2, AC5, AC7: Owner can send nudge ──────────────────
|
||||
|
||||
test('owner can send nudge — notification dispatched, activity logged, success flash', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => 'Relance envoyée à '.$worker->name,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($worker, NudgeNotification::class);
|
||||
});
|
||||
|
||||
// ── AC2, AC5: Manager can send nudge ─────────────────────
|
||||
|
||||
test('manager can send nudge — same behavior as owner', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('manager');
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => 'Relance envoyée à '.$worker->name,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($worker, NudgeNotification::class);
|
||||
});
|
||||
|
||||
// ── AC5: Worker cannot send nudge ────────────────────────
|
||||
|
||||
test('worker cannot send nudge — abort 404', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
$response = $this->actingAs($worker)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertNotFound();
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
// ── AC5: Nudge on declaration in wrong workspace ─────────
|
||||
|
||||
test('nudge on declaration in wrong workspace — abort 404', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
// Create a different workspace and set it as current
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$otherWorkspace->users()->attach($sender->id, ['role' => 'owner', 'permissions' => []]);
|
||||
session(['current_workspace_id' => $otherWorkspace->id]);
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertNotFound();
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
// ── Nudge on declaration with no assignee ────────────────
|
||||
|
||||
test('nudge on declaration with no assignee — returns error', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
$declaration->update(['assigned_to' => null]);
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', function (array $flash) {
|
||||
return $flash['type'] === 'warning';
|
||||
});
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
// ── AC8: Duplicate nudge within 1 hour — debounce ────────
|
||||
|
||||
test('duplicate nudge within 1 hour — debounce prevents, returns warning flash', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
// Create a recent nudge notification manually in the database
|
||||
$worker->notifications()->create([
|
||||
'id' => \Illuminate\Support\Str::uuid(),
|
||||
'type' => NudgeNotification::class,
|
||||
'data' => [
|
||||
'workspace_id' => $workspace->id,
|
||||
'declaration_id' => $declaration->id,
|
||||
'sender_id' => $sender->id,
|
||||
],
|
||||
'created_at' => now()->subMinutes(30),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'warning',
|
||||
'message' => 'Relance déjà envoyée récemment',
|
||||
]);
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
// ── AC8: Nudge after 1 hour — allowed ────────────────────
|
||||
|
||||
test('nudge after 1 hour — allowed (debounce expired)', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
// Create an old nudge notification (more than 1 hour ago)
|
||||
$worker->notifications()->create([
|
||||
'id' => \Illuminate\Support\Str::uuid(),
|
||||
'type' => NudgeNotification::class,
|
||||
'data' => [
|
||||
'workspace_id' => $workspace->id,
|
||||
'declaration_id' => $declaration->id,
|
||||
'sender_id' => $sender->id,
|
||||
],
|
||||
'created_at' => now()->subMinutes(61),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('flash', [
|
||||
'type' => 'success',
|
||||
'message' => 'Relance envoyée à '.$worker->name,
|
||||
]);
|
||||
|
||||
Notification::assertSentTo($worker, NudgeNotification::class);
|
||||
});
|
||||
|
||||
// ── AC7: Activity log entry created ──────────────────────
|
||||
|
||||
test('activity log entry created with correct actor/target/action', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
$this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$activity = Activity::query()
|
||||
->where('subject_type', Declaration::class)
|
||||
->where('subject_id', $declaration->id)
|
||||
->where('causer_id', $sender->id)
|
||||
->where('description', 'nudged')
|
||||
->first();
|
||||
|
||||
expect($activity)->not->toBeNull();
|
||||
expect($activity->causer_id)->toBe($sender->id);
|
||||
expect($activity->subject_id)->toBe($declaration->id);
|
||||
});
|
||||
|
||||
// ── Unauthenticated user cannot nudge ────────────────────
|
||||
|
||||
test('unauthenticated user cannot nudge — redirected to login', function () {
|
||||
$declaration = Declaration::factory()->create();
|
||||
|
||||
$response = $this->post(route('declarations.nudge', $declaration));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
// ── Cache cleared for assignee after nudge ───────────────
|
||||
|
||||
test('notification cache cleared for assignee after nudge', function () {
|
||||
Notification::fake();
|
||||
|
||||
[$sender, $worker, $workspace, $declaration] = setupNudgeTest('owner');
|
||||
|
||||
// Pre-populate cache
|
||||
Cache::put("user:{$worker->id}:workspace:{$workspace->id}:unread_notifications", 5);
|
||||
|
||||
$this->actingAs($sender)->post(route('declarations.nudge', $declaration));
|
||||
|
||||
expect(Cache::has("user:{$worker->id}:workspace:{$workspace->id}:unread_notifications"))->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user