Files
L-Ami-Fiduciaire/_bmad-output/implementation-artifacts/3-2-one-click-nudge-system.md
Saad Zoubir c7ecbd0ee7 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>
2026-03-26 11:26:22 +01:00

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

  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

  • Task 1: Create NudgeController (AC: #2, #5, #7, #8)

    • 1.1 Create app/Http/Controllers/NudgeController.php with store(Request $request, Declaration $declaration) method
    • 1.2 Use HasWorkspaceScope trait — call $this->authorizeWorkspaceAccess($declaration) to verify declaration belongs to workspace
    • 1.3 Authorize sender: check user's role on current workspace is owner or managerabort(404) if not
    • 1.4 Validate declaration has an assigned_to user — return error if no assignee
    • 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
    • 1.6 Dispatch NudgeNotification to 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])
  • Task 2: Register nudge route (AC: #5)

    • 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');
  • Task 3: Create NudgePopover Vue component (AC: #1, #2, #3)

    • 3.1 Create resources/js/components/declarations/NudgePopover.vue
    • 3.2 Props: declaration (with assigneeName, 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 useForm from Inertia for the POST request — no payload needed
  • Task 4: Enable nudge in Dashboard table (AC: #4, #9)

    • 4.1 In Dashboard.vue, replace the disabled "Relancer" DropdownMenuItem with NudgePopover component (or make it functional via click handler)
    • 4.2 Pass nudgeUrl as route('declarations.nudge', declaration.id) — URL must come from controller props
    • 4.3 Only show/enable nudge for Owner/Manager — use the existing isWorker prop to conditionally render (if !isWorker)
    • 4.4 Pass canNudge boolean prop from DashboardController (true for owner/manager, false for worker)
    • 4.5 Hide nudge action entirely when !canNudge
  • 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 canNudge prop from DeclarationController@index
    • 5.3 Add nudgeUrl to each declaration in the serialized data from controller
    • 5.4 Integrate NudgePopover component for each row
  • Task 6: Update DashboardController to pass nudge props (AC: #4, #9)

    • 6.1 Add canNudge to the Inertia props: true when user role is owner or manager
    • 6.2 Add nudgeBaseUrl or per-declaration nudgeUrl to declaration data passed to frontend
    • 6.3 Use Wayfinder route generation for URLs passed as props
  • Task 7: Update DeclarationController@index to pass nudge props (AC: #4)

    • 7.1 Add canNudge to the Inertia props
    • 7.2 Add nudgeUrl to each declaration data object
  • 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

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 6956f7bdue_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)

  • NudgeNotificationapp/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.
  • NudgeNotificationMailapp/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::Nudgeapp/Enums/NotificationType.php — enum value exists.
  • NotificationControllerapp/Http/Controllers/NotificationController.php — has index, markAsRead, markAllAsRead. Do NOT modify.
  • Frontend typesresources/js/types/notification.tsNotificationType, NotificationData, AppNotification types exist.
  • DeclarationMentionControllerapp/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:

// 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:

  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() relationshipbelongsTo(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