Ticketing joins the notification platform (closes #8)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
This commit is contained in:
2026-07-28 12:58:24 -04:00
parent 1175c20a57
commit 4719cbab8b
4 changed files with 188 additions and 2 deletions

View File

@@ -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}",
'<p>'.e(Auth::user()?->name ?? 'Somebody').' assigned this ticket to you.</p>'
.'<p><a href="'.e(route('ticketing.show', $ticket->id)).'">View the ticket</a></p>',
$ticket,
);
}
}
return back()->with('success', 'Ticket updated.');
}

View File

@@ -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}",
'<p>'.e($author).' replied to your ticket:</p>'
.'<blockquote>'.nl2br(e($message->body)).'</blockquote>'
.'<p><a href="'.e($url).'">View the ticket</a></p>',
$ticket,
);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Dashboard\Ticketing\Support;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
/**
* The ONE place Ticketing reaches into the Notifications platform app (bridge
* shape: Dashboard\Tools\Support\ToolsNotifier).
*
* Issue #8: ticketing had NO notification integration at all — no `ticketing.*`
* category existed, so the shared Notification Log listed a category for every
* other app and nothing for this one. Note this was not a mail sweep like the
* other apps: there was no existing Mail:: call to redirect.
* TicketMessageController::notifySubmitter() was an empty stub with the comment
* "In production: Mail::to($submitterEmail)->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<string, array{0:string,1:string}> 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,
];
}
}

View File

@@ -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,