From 4719cbab8bbdb5543d3ad6f1f931a7905342e7e7 Mon Sep 17 00:00:00 2001 From: Joel Wedemire Date: Tue, 28 Jul 2026 12:58:24 -0400 Subject: [PATCH] Ticketing joins the notification platform (closes #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No ticketing.* category existed, so the shared Notification Log offered a filter for every other app and nothing for this one. Verified after this change: 2 categories register, 39 total. THIS WAS NOT A MAIL SWEEP. Every other app's integration redirected an existing Mail:: call; here there was nothing to redirect. TicketMessageController::notifySubmitter() was an empty stub: // Placeholder for email notification // In production: Mail::to($submitterEmail)->send(new TicketReplyMail(…)); So nobody has ever been told their ticket was answered. The reporter read the absence of a Notifications directory as "not wired up", and it was more literal than that. TWO CATEGORIES, matching the two moments somebody is waiting on another person: ticketing.replied → the submitter, when an agent replies. Internal notes are excluded at the call site, which already distinguishes them — the entire point of an internal note is that the submitter does not see it. ticketing.assigned → the agent a ticket is handed to. The previous assignee is captured BEFORE the write so a real hand-off is distinguishable from a save that merely touched other fields, and assigning to yourself sends nothing. Neither is mandatory: a reply notice is a convenience, not a security or financial record, so it is opt-out in a person's preferences. Bridge shape copied from Dashboard\Tools\Support\ToolsNotifier: one class owning the platform contact, available() false when the platform is absent or dark, and a queue failure logged rather than thrown — sending a reply must succeed even when the email cannot be queued. DELIBERATELY NOT a manifest dependency, for the documented reason: requires.apps is a HARD requirement and Notifications is still beta and ships dark. There is no direct-mail fallback, unlike the swept apps, because there was never any direct mail to fall back to. With Notifications dark, ticketing behaves exactly as it does today: silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg --- src/Http/Controllers/TicketController.php | 25 ++++ .../Controllers/TicketMessageController.php | 29 +++- src/Support/TicketingNotifier.php | 132 ++++++++++++++++++ src/TicketingServiceProvider.php | 4 + 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src/Support/TicketingNotifier.php diff --git a/src/Http/Controllers/TicketController.php b/src/Http/Controllers/TicketController.php index c0943d8..ae2e2a1 100644 --- a/src/Http/Controllers/TicketController.php +++ b/src/Http/Controllers/TicketController.php @@ -493,8 +493,33 @@ class TicketController extends Controller } } + // Issue #8: capture the previous assignee BEFORE the write, so a genuine + // hand-off can be told from a save that merely touched other fields. + $previousAssignee = $ticket->assigned_to; + $ticket->update($validated); + $newAssignee = $ticket->assigned_to; + $handedOver = $newAssignee + && $newAssignee !== $previousAssignee + && $newAssignee !== Auth::id(); // assigning to yourself needs no telling + + if ($handedOver) { + $agent = \App\Models\User::find($newAssignee); + $email = trim((string) ($agent?->email ?? '')); + + if ($email !== '') { + \Dashboard\Ticketing\Support\TicketingNotifier::deliver( + \Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED, + $email, + "[{$ticket->number}] Assigned to you: {$ticket->title}", + '

'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.

' + .'

View the ticket

', + $ticket, + ); + } + } + return back()->with('success', 'Ticket updated.'); } diff --git a/src/Http/Controllers/TicketMessageController.php b/src/Http/Controllers/TicketMessageController.php index faa63f4..bd02632 100644 --- a/src/Http/Controllers/TicketMessageController.php +++ b/src/Http/Controllers/TicketMessageController.php @@ -62,9 +62,34 @@ class TicketMessageController extends Controller return back()->with('success', 'Message sent.'); } + /** + * Issue #8: this was an empty placeholder, so nobody has ever been told their + * ticket was answered. Goes through the Notifications platform so it lands in the + * shared Notification Log and honours the person's preferences, rather than + * reaching for Mail:: directly as the old comment suggested. + * + * Only ever called for a non-internal agent reply — see the caller. + */ private function notifySubmitter(Ticket $ticket, TicketMessage $message): void { - // Placeholder for email notification - // In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message)); + $submitter = \App\Models\User::find($ticket->submitter_id); + $email = trim((string) ($submitter?->email ?? '')); + + if ($email === '') { + return; // a ticket raised on behalf of somebody with no address + } + + $url = route('ticketing.show', $ticket->id); + $author = $message->user_id ? (\App\Models\User::find($message->user_id)?->name ?? 'An agent') : 'An agent'; + + \Dashboard\Ticketing\Support\TicketingNotifier::deliver( + \Dashboard\Ticketing\Support\TicketingNotifier::REPLIED, + $email, + "[{$ticket->number}] {$ticket->title}", + '

'.e($author).' replied to your ticket:

' + .'
'.nl2br(e($message->body)).'
' + .'

View the ticket

', + $ticket, + ); } } diff --git a/src/Support/TicketingNotifier.php b/src/Support/TicketingNotifier.php new file mode 100644 index 0000000..d9793ee --- /dev/null +++ b/src/Support/TicketingNotifier.php @@ -0,0 +1,132 @@ +send(new TicketReplyMail(…))", so + * nobody has ever been told their ticket was answered. + * + * TWO CATEGORIES, matching the two moments a person is waiting on somebody else: + * + * - ticketing.replied → the submitter, when an agent answers. Internal notes are + * excluded by the caller: the whole point of an internal note is that the + * submitter does not see it. + * - ticketing.assigned → the agent a ticket has just been handed to. Skipped when + * somebody assigns a ticket to themselves, which needs no telling. + * + * Neither is mandatory. A reply notice is a convenience, not a security or + * financial record, so a person may turn it off in their notification preferences. + * + * SHIP-DARK SAFETY. available() is false when the platform is absent, and every + * caller treats a false return as "nothing was sent" rather than an error — there is + * no direct-mail fallback to run, because there was never any direct mail here. + * DELIBERATELY NOT a manifest dependency: requires.apps is a HARD requirement (a + * merely-disabled dep fails dashboard:doctor, which gates update.sh and promote.sh), + * and Notifications is a beta app that ships dark. Add it once Notifications is + * stability: stable. + */ +final class TicketingNotifier +{ + public const REPLIED = 'ticketing.replied'; + + public const ASSIGNED = 'ticketing.assigned'; + + /** @var array key => [label, description] */ + public const CATEGORIES = [ + self::REPLIED => [ + 'Your help desk ticket was answered', + 'Sent to the person who raised a ticket when an agent replies. Internal notes never trigger this.', + ], + self::ASSIGNED => [ + 'A help desk ticket was assigned to you', + 'Sent to an agent when a ticket is assigned to them by somebody else.', + ], + ]; + + /** Test hook — behave as though Notifications isn't installed. */ + private static bool $pretendMissing = false; + + public static function available(): bool + { + return ! self::$pretendMissing + && class_exists(\Central\Notifications\Support\Notify::class) + && class_exists(\Central\Notifications\Support\Message::class); + } + + public static function pretendPlatformMissing(bool $missing = true): void + { + self::$pretendMissing = $missing; + } + + /** Declare the categories. Called from TicketingServiceProvider::boot(). */ + public static function registerCategories(): void + { + if (! class_exists(\Central\Notifications\Support\NotificationRegistry::class)) { + return; + } + + try { + foreach (self::CATEGORIES as $key => [$label, $description]) { + \Central\Notifications\Support\NotificationRegistry::register( + $key, $label, $description, ['mail'], mandatory: false, + ); + } + } catch (\Throwable $e) { + Log::warning('Ticketing: could not register notification categories: '.$e->getMessage()); + } + } + + /** + * Queue one email through the platform. + * + * @param string $to recipient address, verbatim + * @return array{handled:bool,status:?string,error:?string,id:?int} + */ + public static function deliver( + string $category, + string $to, + string $subject, + string $bodyHtml, + ?Model $context = null, + ): array { + $unhandled = ['handled' => false, 'status' => null, 'error' => null, 'id' => null]; + + if (! self::available() || trim($to) === '') { + return $unhandled; + } + + try { + $row = \Central\Notifications\Support\Notify::send(new \Central\Notifications\Support\Message( + category: $category, + to: $to, + subject: $subject, + body: $bodyHtml, + context: $context, + )); + } catch (\Throwable $e) { + // Never let a notification failure break the action that caused it: + // sending a reply must succeed even if the email cannot be queued. + Log::warning("Ticketing: notification '{$category}' could not be queued: ".$e->getMessage()); + + return $unhandled; + } + + return [ + 'handled' => true, + 'status' => $row->status, + 'error' => $row->status === 'failed' ? ($row->error ?: 'The notification could not be queued.') : null, + 'id' => $row->id, + ]; + } +} diff --git a/src/TicketingServiceProvider.php b/src/TicketingServiceProvider.php index b3d8987..3167a72 100644 --- a/src/TicketingServiceProvider.php +++ b/src/TicketingServiceProvider.php @@ -19,6 +19,10 @@ class TicketingServiceProvider extends ServiceProvider $this->loadMigrationsFrom(__DIR__.'/../database/migrations'); $this->loadRoutesFrom(__DIR__.'/routes/ticketing.php'); + // Declare ticketing.* notification categories (issue #8). Safe to call when + // Notifications is absent or dark — the notifier no-ops. + \Dashboard\Ticketing\Support\TicketingNotifier::registerCategories(); + if ($this->app->runningInConsole()) { $this->commands([ \Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,