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>
18 KiB
Story 3.2: One-Click Nudge System
Status: review
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
-
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
-
AC2: Nudge dispatches notification on send
- Given a NudgePopover is open
- When the user clicks "Send Nudge"
- Then a
NudgeNotificationis dispatched to the assigned worker viaNudgeController@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
-
AC3: No message composition required
- Then no message composition is required — it is a pure signal ("Your attention is needed on this declaration")
-
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
-
AC5: NudgeController validates sender role
- Then the
NudgeControllervalidates that the sender is Owner or Manager - And the
NudgeControllervalidates that the declaration belongs to the current workspace
- Then the
-
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
NudgeNotificationMailfrom Story 3.1 — verify integration)
- Then the nudge email contains: declaration type, client name, deadline (
-
AC7: Nudge is logged via Spatie Activity Log
- Then the nudge is logged via Spatie Activity Log (actor: sender, target: declaration, action: "nudged")
-
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"
-
AC9: Dashboard nudge dropdown enabled (D-1 resolution)
- Then the "Relancer" dropdown menu item in
Dashboard.vueis enabled for Owner/Manager roles and triggers the NudgePopover - (Resolves deferred code review item D-1 from Epic 2 Story 2.3)
- Then the "Relancer" dropdown menu item in
Tasks / Subtasks
-
Task 1: Create
NudgeController(AC: #2, #5, #7, #8)- 1.1 Create
app/Http/Controllers/NudgeController.phpwithstore(Request $request, Declaration $declaration)method - 1.2 Use
HasWorkspaceScopetrait — call$this->authorizeWorkspaceAccess($declaration)to verify declaration belongs to workspace - 1.3 Authorize sender: check user's role on current workspace is
ownerormanager—abort(404)if not - 1.4 Validate declaration has an
assigned_touser — return error if no assignee - 1.5 Debounce check: query
notificationstable for existing nudge on same declaration within 1 hour — returnback()->with('flash', ['type' => 'warning', 'message' => 'Relance déjà envoyée récemment'])if found - 1.6 Dispatch
NudgeNotificationto the declaration's assignee:$declaration->assignee->notify(new NudgeNotification($declaration, $request->user())) - 1.7 Log via Spatie Activity Log:
activity()->performedOn($declaration)->causedBy($request->user())->log('nudged') - 1.8 Clear assignee's notification cache:
Cache::forget("user:{$declaration->assigned_to}:unread_notifications") - 1.9 Return
back()->with('flash', ['type' => 'success', 'message' => 'Relance envoyée à '.$declaration->assignee->name])
- 1.1 Create
-
Task 2: Register nudge route (AC: #5)
- 2.1 Add route inside
middleware('workspace')group inroutes/web.php:Route::post('declarations/{declaration}/nudge', [NudgeController::class, 'store'])->name('declarations.nudge')->middleware('throttle:10,1');
- 2.1 Add route inside
-
Task 3: Create
NudgePopoverVue component (AC: #1, #2, #3)- 3.1 Create
resources/js/components/declarations/NudgePopover.vue - 3.2 Props:
declaration(withassigneeName,id),nudgeUrl(string) - 3.3 UI: shadcn-vue
Popover+PopoverTrigger(Send icon button) +PopoverContent - 3.4 Content: Show assignee name, "Envoyer une relance" button
- 3.5 On click "Envoyer":
router.post(nudgeUrl)via Inertia — popover auto-closes on success - 3.6 Handle loading state (disable button during submission via
form.processing) - 3.7 Use
useFormfrom Inertia for the POST request — no payload needed
- 3.1 Create
-
Task 4: Enable nudge in Dashboard table (AC: #4, #9)
- 4.1 In
Dashboard.vue, replace the disabled "Relancer"DropdownMenuItemwithNudgePopovercomponent (or make it functional via click handler) - 4.2 Pass
nudgeUrlasroute('declarations.nudge', declaration.id)— URL must come from controller props - 4.3 Only show/enable nudge for Owner/Manager — use the existing
isWorkerprop to conditionally render (if!isWorker) - 4.4 Pass
canNudgeboolean prop fromDashboardController(true for owner/manager, false for worker) - 4.5 Hide nudge action entirely when
!canNudge
- 4.1 In
-
Task 5: Add nudge to Declarations Index page (AC: #1, #4)
- 5.1 In
resources/js/pages/declarations/Index.vue, add nudge icon/button per row for Owner/Manager - 5.2 Pass
canNudgeprop fromDeclarationController@index - 5.3 Add
nudgeUrlto each declaration in the serialized data from controller - 5.4 Integrate
NudgePopovercomponent for each row
- 5.1 In
-
Task 6: Update
DashboardControllerto pass nudge props (AC: #4, #9)- 6.1 Add
canNudgeto the Inertia props:truewhen user role isownerormanager - 6.2 Add
nudgeBaseUrlor per-declarationnudgeUrlto declaration data passed to frontend - 6.3 Use Wayfinder route generation for URLs passed as props
- 6.1 Add
-
Task 7: Update
DeclarationController@indexto pass nudge props (AC: #4)- 7.1 Add
canNudgeto the Inertia props - 7.2 Add
nudgeUrlto each declaration data object
- 7.1 Add
-
Task 8: Write tests (all ACs)
- 8.1 Create
tests/Feature/Notifications/NudgeControllerTest.php - 8.2 Test: Owner can send nudge — notification dispatched, activity logged, success flash
- 8.3 Test: Manager can send nudge — same behavior as owner
- 8.4 Test: Worker cannot send nudge —
abort(404) - 8.5 Test: Nudge on declaration in wrong workspace —
abort(404) - 8.6 Test: Nudge on declaration with no assignee — returns error
- 8.7 Test: Duplicate nudge within 1 hour — debounce prevents, returns warning flash
- 8.8 Test: Nudge after 1 hour — allowed (debounce expired)
- 8.9 Test: Activity log entry created with correct actor/target/action
- 8.10 Test: Unauthenticated user cannot nudge — redirected to login
- 8.11 Test: Notification cache cleared for assignee after nudge
- 8.1 Create
Retrospective Intelligence
From Epic 2 Retrospective (2026-03-24):
- D-1 (Deferred Code Review Item): Dashboard "Relancer" and "Réassigner" dropdown items are unconditionally
disabledinDashboard.vuelines 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(notdeadline), noDeclaration::workspace()scope,mise_en_demeurestatus. 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
canNudgeis 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 queriesCache::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. UsesNudgeNotificationMailfor 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— hasindex,markAsRead,markAllAsRead. Do NOT modify.- Frontend types —
resources/js/types/notification.ts—NotificationType,NotificationData,AppNotificationtypes exist. DeclarationMentionController—app/Http/Controllers/DeclarationMentionController.php— reference pattern for how to authorize owner/manager and dispatch a notification. Usesabort(403)for role check (NOTE: architecture saysabort(404)— follow architecture conventionabort(404)in NudgeController).
Controller Pattern to Follow
The DeclarationMentionController is the closest reference:
// 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):
use App\Concerns\HasWorkspaceScope;
// $workspace = $this->currentWorkspace();
// $this->authorizeWorkspaceAccess($declaration);
Debounce Implementation
Query the notifications table for existing nudge within 1 hour:
$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):
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:
<DropdownMenuItem disabled>
<Send class="mr-2 h-4 w-4" />
Relancer
</DropdownMenuItem>
Replace with functional nudge trigger. Options:
- Inline Popover approach — wrap in
NudgePopovercomponent within the dropdown - 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_dateNOTdeadline— already handled in NudgeNotificationMail - Declaration has
assignee()relationship —belongsTo(User::class, 'assigned_to')atDeclaration.php:123 - No
Declaration::workspace()scope — use$declaration->workspace_iddirectly - 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 isWorkerprop already exists on Dashboard — passed fromDashboardController, use to conditionally show nudge- Workspace resolution — session-based via
current_workspace_id, useHasWorkspaceScopetrait - 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,1to nudge route (same as mentions route pattern)
Testing Standards
- Pest syntax with
test()closures Notification::fake()for verifying dispatch, then assertNotification::assertSentTo($assignee, NudgeNotification::class)- Feature tests in
tests/Feature/Notifications/NudgeControllerTest.php - Use
route()helper for URLs, never hardcoded paths RefreshDatabaseis auto-applied viaPest.php— don't add manuallywithoutVite()is globally applied inPest.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_atwithin/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 patternresources/js/components/declarations/NudgePopover.vue— new componenttests/Feature/Notifications/NudgeControllerTest.php— new test file
Existing files to modify:
resources/js/pages/Dashboard.vue— enable "Relancer" dropdown item, integrate NudgePopoverresources/js/pages/declarations/Index.vue— add nudge icon/button per rowapp/Http/Controllers/DashboardController.php— addcanNudgepropapp/Http/Controllers/DeclarationController.php— addcanNudgeandnudgeUrlprops to indexroutes/web.php— add nudge route
Files NOT to touch:
app/Notifications/NudgeNotification.php— already complete from Story 3.1app/Mail/NudgeNotificationMail.php— already complete from Story 3.1resources/views/emails/nudge-notification.blade.php— already completeapp/Enums/NotificationType.php— already completeresources/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}/nudgewith 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
canNudgeand per-declarationnudgeUrlprops - DeclarationController@index passes
canNudgeand per-declarationnudgeUrlandassignee_nameprops - Updated DashboardDeclaration type to include
nudgeUrl, DashboardProps to includecanNudge - 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