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:
@@ -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);
|
$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.');
|
return back()->with('success', 'Ticket updated.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,9 +62,34 @@ class TicketMessageController extends Controller
|
|||||||
return back()->with('success', 'Message sent.');
|
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
|
private function notifySubmitter(Ticket $ticket, TicketMessage $message): void
|
||||||
{
|
{
|
||||||
// Placeholder for email notification
|
$submitter = \App\Models\User::find($ticket->submitter_id);
|
||||||
// In production: Mail::to($submitterEmail)->send(new TicketReplyMail($ticket, $message));
|
$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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/Support/TicketingNotifier.php
Normal file
132
src/Support/TicketingNotifier.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ class TicketingServiceProvider extends ServiceProvider
|
|||||||
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
||||||
$this->loadRoutesFrom(__DIR__.'/routes/ticketing.php');
|
$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()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->commands([
|
$this->commands([
|
||||||
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
\Dashboard\Ticketing\Console\Commands\AutoCloseTickets::class,
|
||||||
|
|||||||
Reference in New Issue
Block a user