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
790 lines
30 KiB
PHP
790 lines
30 KiB
PHP
<?php
|
|
|
|
namespace Dashboard\Ticketing\Http\Controllers;
|
|
|
|
use App\Models\Setting;
|
|
use Dashboard\Ticketing\Models\PriorityLevel;
|
|
use Dashboard\Ticketing\Models\Ticket;
|
|
use Dashboard\Ticketing\Models\TicketAttachment;
|
|
use Dashboard\Ticketing\Models\TicketingAgentAccess;
|
|
use Dashboard\Ticketing\Models\TicketingGroup;
|
|
use Dashboard\Ticketing\Models\TicketingProject;
|
|
use Dashboard\Ticketing\Models\TicketMessage;
|
|
use Dashboard\Ticketing\Models\TicketParticipant;
|
|
use Dashboard\Ticketing\Models\TicketStatus;
|
|
use Dashboard\Ticketing\Models\TicketView;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controller;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\Rule;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response;
|
|
|
|
class TicketController extends Controller
|
|
{
|
|
private function agentGroupIds(): array
|
|
{
|
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
|
->pluck('group_id')
|
|
->toArray();
|
|
}
|
|
|
|
private function isAgent(int $groupId = null): bool
|
|
{
|
|
$query = TicketingAgentAccess::where('user_id', Auth::id());
|
|
if ($groupId) {
|
|
$query->where('group_id', $groupId);
|
|
}
|
|
return $query->exists();
|
|
}
|
|
|
|
private function isManager(int $groupId): bool
|
|
{
|
|
return TicketingAgentAccess::where('user_id', Auth::id())
|
|
->where('group_id', $groupId)
|
|
->where('role', 'manager')
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* Filter messages for submitters — strip internal notes.
|
|
*/
|
|
private function filterMessagesForRole($messages, bool $isAgent)
|
|
{
|
|
if ($isAgent) {
|
|
return $messages;
|
|
}
|
|
return $messages->filter(fn($m) => !$m->is_internal)->values();
|
|
}
|
|
|
|
private function loadUserPrefs(): array
|
|
{
|
|
$raw = Setting::get('ticketing.pref.' . Auth::id(), '{}');
|
|
$decoded = json_decode($raw, true);
|
|
return is_array($decoded) ? $decoded : [];
|
|
}
|
|
|
|
public function index(Request $request): Response
|
|
{
|
|
$user = Auth::user();
|
|
$agentGroupIds = $this->agentGroupIds();
|
|
$isAgent = count($agentGroupIds) > 0;
|
|
|
|
// Merge saved prefs as defaults — URL params always win
|
|
$pref = $this->loadUserPrefs();
|
|
if (! $request->filled('filter') && ! empty($pref['filter'])) {
|
|
$request->merge(['filter' => $pref['filter']]);
|
|
}
|
|
|
|
// If no groups exist at all, render a first-run / bootstrap state
|
|
$totalGroups = TicketingGroup::count();
|
|
if ($totalGroups === 0) {
|
|
return Inertia::render('Ticketing/Index', [
|
|
'tickets' => ['data' => [], 'total' => 0, 'per_page' => 20, 'current_page' => 1, 'last_page' => 1],
|
|
'groups' => [],
|
|
'priorities' => [],
|
|
'projects' => [],
|
|
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
|
'isAgent' => $isAgent,
|
|
'filters' => [],
|
|
'ticketDetail' => null,
|
|
'detailAgents' => [],
|
|
'viewCounts' => ['all' => 0, 'mine' => 0, 'unassigned' => 0, 'pending' => 0, 'resolved' => 0],
|
|
'isBootstrap' => true,
|
|
'userPrefs' => ['view_mode' => 'list', 'filter' => 'all'],
|
|
]);
|
|
}
|
|
|
|
$closedSlugs = TicketStatus::where('is_closed', true)->pluck('slug')->toArray();
|
|
|
|
$baseQuery = Ticket::query();
|
|
|
|
if ($isAgent) {
|
|
$baseQuery->whereIn('group_id', $agentGroupIds);
|
|
} else {
|
|
$baseQuery->where('submitter_id', $user->id);
|
|
}
|
|
|
|
$viewCounts = $isAgent
|
|
? [
|
|
'all' => (clone $baseQuery)->whereNotIn('status', $closedSlugs)->count(),
|
|
'mine' => (clone $baseQuery)->where('assigned_to', $user->id)->whereNotIn('status', $closedSlugs)->count(),
|
|
'unassigned' => (clone $baseQuery)->whereNull('assigned_to')->whereNotIn('status', $closedSlugs)->count(),
|
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
|
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
|
]
|
|
: [
|
|
'all' => (clone $baseQuery)->count(),
|
|
'mine' => 0,
|
|
'unassigned' => 0,
|
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
|
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
|
];
|
|
|
|
$query = Ticket::with(['group', 'priority', 'project']);
|
|
|
|
if ($isAgent) {
|
|
$query->whereIn('group_id', $agentGroupIds);
|
|
|
|
if ($request->filled('group_id')) {
|
|
$query->where('group_id', $request->group_id);
|
|
}
|
|
if ($request->filled('status')) {
|
|
$query->where('status', $request->status);
|
|
}
|
|
if ($request->filled('priority_id')) {
|
|
$query->where('priority_id', $request->priority_id);
|
|
}
|
|
if ($request->filled('project_id')) {
|
|
$query->where('project_id', $request->project_id);
|
|
}
|
|
if ($request->filter === 'mine') {
|
|
$query->where('assigned_to', $user->id)->whereNotIn('status', ['resolved', 'closed']);
|
|
} elseif ($request->filter === 'unassigned') {
|
|
$query->whereNull('assigned_to')->whereNotIn('status', ['resolved', 'closed']);
|
|
} elseif ($request->filter === 'pending') {
|
|
$query->where('status', 'pending');
|
|
} elseif ($request->filter === 'resolved') {
|
|
$query->whereIn('status', $closedSlugs);
|
|
} elseif (!$request->filled('status')) {
|
|
$query->whereNotIn('status', $closedSlugs);
|
|
}
|
|
} else {
|
|
$query->where('submitter_id', $user->id);
|
|
if ($request->filled('project_id')) {
|
|
$query->where('project_id', $request->project_id);
|
|
}
|
|
if ($request->filter === 'pending') {
|
|
$query->where('status', 'pending');
|
|
} elseif ($request->filter === 'resolved') {
|
|
$query->whereIn('status', $closedSlugs);
|
|
}
|
|
}
|
|
|
|
$tickets = $query->latest()->paginate(30)->withQueryString();
|
|
|
|
$userIds = $tickets->pluck('submitter_id')->merge($tickets->pluck('assigned_to'))->filter()->unique();
|
|
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
|
|
|
$tickets->getCollection()->transform(function ($ticket) use ($users) {
|
|
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
|
|
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
|
|
return $ticket;
|
|
});
|
|
|
|
// Picker data: the group switcher/filter and the detail pane's transfer
|
|
// select both read this, so archived groups are excluded. The tickets
|
|
// themselves are NOT filtered — an archived group's tickets stay in the
|
|
// list and stay workable; Index.vue re-adds the current group to the
|
|
// transfer options when it happens to be archived.
|
|
$groups = TicketingGroup::active()
|
|
->when($isAgent, fn ($q) => $q->whereIn('id', $agentGroupIds))
|
|
->get();
|
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
|
$projects = TicketingProject::when($isAgent, fn ($q) => $q->whereIn('group_id', $agentGroupIds))
|
|
->where('status', 'active')
|
|
->get();
|
|
|
|
$projectCounts = (clone $baseQuery)
|
|
->select('project_id', DB::raw('count(*) as aggregate'))
|
|
->whereNotNull('project_id')
|
|
->groupBy('project_id')
|
|
->pluck('aggregate', 'project_id');
|
|
|
|
$projects->transform(function ($project) use ($projectCounts) {
|
|
$project->ticket_count = (int) ($projectCounts[$project->id] ?? 0);
|
|
return $project;
|
|
});
|
|
|
|
$ticketDetail = null;
|
|
$detailAgents = [];
|
|
if ($request->filled('detail')) {
|
|
$detailQuery = Ticket::with([
|
|
'group',
|
|
'priority',
|
|
'project',
|
|
'messages.attachments',
|
|
'attachments',
|
|
'views',
|
|
])->whereKey($request->detail);
|
|
|
|
if ($isAgent) {
|
|
$detailQuery->whereIn('group_id', $agentGroupIds);
|
|
} else {
|
|
$detailQuery->where('submitter_id', $user->id);
|
|
}
|
|
|
|
$dt = $detailQuery->first();
|
|
|
|
if ($dt) {
|
|
$viewUserIds = $isAgent ? $dt->views->pluck('user_id') : collect();
|
|
$detailUserIds = collect([$dt->submitter_id, $dt->assigned_to])
|
|
->merge($dt->messages->pluck('user_id'))
|
|
->merge($viewUserIds)
|
|
->filter()
|
|
->unique();
|
|
$detailUsers = DB::table('users')->whereIn('id', $detailUserIds)->get(['id', 'name', 'email'])->keyBy('id');
|
|
|
|
$dt->submitter = $detailUsers[$dt->submitter_id] ?? null;
|
|
$dt->assignee = $dt->assigned_to ? ($detailUsers[$dt->assigned_to] ?? null) : null;
|
|
|
|
// Filter internal notes for non-agents
|
|
$visibleMessages = $this->filterMessagesForRole($dt->messages, $isAgent);
|
|
$visibleMessages->each(function ($msg) use ($detailUsers) {
|
|
$msg->author = $msg->user_id ? ($detailUsers[$msg->user_id] ?? null) : null;
|
|
});
|
|
$dt->setRelation('messages', $visibleMessages);
|
|
|
|
// Augment views with user names (agents only — don't expose to submitters)
|
|
if ($isAgent) {
|
|
$dt->views->each(fn($v) => $v->user = $detailUsers[$v->user_id] ?? null);
|
|
} else {
|
|
$dt->setRelation('views', collect());
|
|
}
|
|
|
|
$ticketDetail = $dt;
|
|
|
|
if ($isAgent) {
|
|
$agentIds = TicketingAgentAccess::where('group_id', $dt->group_id)->pluck('user_id');
|
|
$detailAgents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Inertia::render('Ticketing/Index', [
|
|
'tickets' => $tickets,
|
|
'groups' => $groups,
|
|
'priorities' => $priorities,
|
|
'projects' => $projects,
|
|
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
|
'isAgent' => $isAgent,
|
|
'filters' => $request->only(['group_id', 'status', 'priority_id', 'project_id', 'filter', 'detail']),
|
|
'ticketDetail' => $ticketDetail,
|
|
'detailAgents' => $detailAgents,
|
|
'viewCounts' => $viewCounts,
|
|
'isBootstrap' => false,
|
|
'userPrefs' => [
|
|
'view_mode' => $pref['view_mode'] ?? 'list',
|
|
'filter' => $pref['filter'] ?? 'all',
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function create(): Response
|
|
{
|
|
$user = Auth::user();
|
|
$agentGroupIds = $this->agentGroupIds();
|
|
$isAgent = count($agentGroupIds) > 0;
|
|
|
|
// New tickets can only be filed against a live group.
|
|
$groups = TicketingGroup::active()
|
|
->when($isAgent, fn($q) => $q->whereIn('id', $agentGroupIds))
|
|
->get();
|
|
|
|
// No groups at all? Show first-run state.
|
|
if ($groups->isEmpty() && TicketingGroup::count() === 0) {
|
|
return Inertia::render('Ticketing/Create', [
|
|
'groups' => [],
|
|
'priorities' => [],
|
|
'isBootstrap' => true,
|
|
'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']),
|
|
]);
|
|
}
|
|
|
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
|
|
|
return Inertia::render('Ticketing/Create', [
|
|
'groups' => $groups,
|
|
'priorities' => $priorities,
|
|
'isBootstrap' => false,
|
|
'isSiteAdmin' => in_array(Auth::user()->role ?? '', ['admin', 'super_admin']),
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'title' => 'required|string|max:255',
|
|
'description' => 'required|string',
|
|
// Archived groups are gone from the picker; they must also be
|
|
// unreachable by posting a stale id.
|
|
'group_id' => ['required', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')],
|
|
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
|
'due_date' => 'nullable|date',
|
|
'attachments' => 'nullable|array|max:5',
|
|
'attachments.*' => 'file|max:20480',
|
|
]);
|
|
|
|
// Ensure priority belongs to the chosen group (or is global)
|
|
if (!empty($validated['priority_id'])) {
|
|
$priority = PriorityLevel::find($validated['priority_id']);
|
|
if ($priority && $priority->group_id !== null && $priority->group_id != $validated['group_id']) {
|
|
abort(422, 'Priority does not belong to the selected group.');
|
|
}
|
|
}
|
|
|
|
$group = TicketingGroup::findOrFail($validated['group_id']);
|
|
$number = $group->nextTicketNumber();
|
|
|
|
$ticket = Ticket::create([
|
|
...collect($validated)->except('attachments')->all(),
|
|
'number' => $number,
|
|
'submitter_id' => Auth::id(),
|
|
'status' => 'open',
|
|
]);
|
|
|
|
if ($request->hasFile('attachments')) {
|
|
$disk = config('ticketing.storage_disk', 'local');
|
|
foreach ($request->file('attachments') as $file) {
|
|
if (!$file->isValid()) continue;
|
|
$path = $file->store('ticketing/attachments/' . $ticket->id, $disk);
|
|
\Dashboard\Ticketing\Models\TicketAttachment::create([
|
|
'ticket_id' => $ticket->id,
|
|
'message_id' => null,
|
|
'filename' => $file->getClientOriginalName(),
|
|
'path' => $path,
|
|
'mime_type' => $file->getMimeType(),
|
|
'size' => $file->getSize(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
return redirect()->route('ticketing.show', $ticket);
|
|
}
|
|
|
|
public function show(Ticket $ticket): Response
|
|
{
|
|
$user = Auth::user();
|
|
$isAgent = $this->isAgent($ticket->group_id);
|
|
$isSubmitter = $ticket->submitter_id === $user->id;
|
|
|
|
if (!$isAgent && !$isSubmitter) {
|
|
abort(403);
|
|
}
|
|
|
|
$ticket->load(['group', 'priority', 'project', 'messages', 'attachments', 'participants']);
|
|
|
|
// Bug #2: Filter internal notes for submitters
|
|
$visibleMessages = $this->filterMessagesForRole($ticket->messages, $isAgent);
|
|
|
|
$participantUserIds = $ticket->participants->pluck('user_id');
|
|
$userIds = $visibleMessages->pluck('user_id')
|
|
->merge($participantUserIds)
|
|
->push($ticket->submitter_id)
|
|
->push($ticket->assigned_to)
|
|
->filter()->unique();
|
|
$users = DB::table('users')->whereIn('id', $userIds)->get(['id', 'name', 'email'])->keyBy('id');
|
|
|
|
$visibleMessages->each(function ($msg) use ($users) {
|
|
$msg->author = $msg->user_id ? ($users[$msg->user_id] ?? null) : null;
|
|
});
|
|
|
|
$ticket->setRelation('messages', $visibleMessages);
|
|
|
|
// Attach submitter and assignee info
|
|
$ticket->submitter = $users[$ticket->submitter_id] ?? null;
|
|
$ticket->assignee = $ticket->assigned_to ? ($users[$ticket->assigned_to] ?? null) : null;
|
|
|
|
// Augment participants with user info
|
|
$ticket->participants->each(function ($p) use ($users) {
|
|
$p->user = $users[$p->user_id] ?? null;
|
|
});
|
|
|
|
$agents = [];
|
|
if ($isAgent) {
|
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
|
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
|
}
|
|
|
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
|
|
|
return Inertia::render('Ticketing/Show', [
|
|
'ticket' => $ticket,
|
|
'isAgent' => $isAgent,
|
|
'isManager' => $isAgent && $this->isManager($ticket->group_id),
|
|
'agents' => $agents,
|
|
'priorities' => $priorities,
|
|
'ticketStatuses' => TicketStatus::orderBy('sort_order')->get(),
|
|
]);
|
|
}
|
|
|
|
public function edit(Ticket $ticket): Response
|
|
{
|
|
if (!$this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$ticket->load(['group', 'priority', 'project']);
|
|
$priorities = PriorityLevel::orderBy('sort_order')->get();
|
|
$agentIds = TicketingAgentAccess::where('group_id', $ticket->group_id)->pluck('user_id');
|
|
$agents = DB::table('users')->whereIn('id', $agentIds)->get(['id', 'name', 'email']);
|
|
$projects = TicketingProject::where('group_id', $ticket->group_id)->get();
|
|
|
|
return Inertia::render('Ticketing/Edit', [
|
|
'ticket' => $ticket,
|
|
'priorities' => $priorities,
|
|
'agents' => $agents,
|
|
'projects' => $projects,
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, Ticket $ticket)
|
|
{
|
|
if (!$this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
// Determine effective group (may be changing via a transfer)
|
|
$targetGroupId = $request->filled('group_id')
|
|
? (int) $request->input('group_id')
|
|
: $ticket->group_id;
|
|
|
|
// Valid assignees are agents of the target group
|
|
$validAssigneeIds = TicketingAgentAccess::where('group_id', $targetGroupId)
|
|
->pluck('user_id')
|
|
->toArray();
|
|
|
|
// Build valid status list from DB (exclude 'merged' — set only by the merge operation)
|
|
$validStatusSlugs = TicketStatus::where('slug', '!=', 'merged')->pluck('slug')->implode(',');
|
|
|
|
$rules = [
|
|
'title' => 'sometimes|required|string|max:255',
|
|
'description' => 'sometimes|required|string',
|
|
'status' => "sometimes|in:{$validStatusSlugs}",
|
|
'priority_id' => 'nullable|exists:ticketing_priority_levels,id',
|
|
'due_date' => 'nullable|date',
|
|
'project_id' => 'nullable|exists:ticketing_projects,id',
|
|
// A ticket may not be transferred INTO an archived group, but a
|
|
// ticket already sitting in one must still be editable — so its own
|
|
// group id always validates.
|
|
'group_id' => ['nullable', 'exists:ticketing_groups,id',
|
|
function ($attribute, $value, $fail) use ($ticket) {
|
|
if (! $value || (int) $value === (int) $ticket->group_id) {
|
|
return;
|
|
}
|
|
if (TicketingGroup::whereKey($value)->archived()->exists()) {
|
|
$fail('That group is archived — choose an active group.');
|
|
}
|
|
},
|
|
],
|
|
'assigned_to' => 'nullable|in:' . implode(',', $validAssigneeIds ?: [0]),
|
|
];
|
|
|
|
$validated = $request->validate($rules);
|
|
|
|
// Group transfer: requester must be a manager of the target group
|
|
if (!empty($validated['group_id']) && $validated['group_id'] !== $ticket->group_id) {
|
|
if (! $this->isManager((int) $validated['group_id'])) {
|
|
abort(403, 'You must be a manager of the destination group to transfer tickets.');
|
|
}
|
|
// Clear assignee and project if they don't belong to the new group
|
|
$validated['assigned_to'] = null;
|
|
$validated['project_id'] = null;
|
|
} else {
|
|
unset($validated['group_id']); // no-op if unchanged
|
|
}
|
|
|
|
// project_id must belong to the effective group
|
|
if (!empty($validated['project_id'])) {
|
|
$project = TicketingProject::find($validated['project_id']);
|
|
if ($project && $project->group_id !== ($validated['group_id'] ?? $ticket->group_id)) {
|
|
abort(422, 'Project does not belong to this ticket\'s group.');
|
|
}
|
|
}
|
|
|
|
// 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.');
|
|
}
|
|
|
|
/**
|
|
* Record that an agent viewed this ticket (called after 10s of the pane being open).
|
|
* Throttled: at most one record per user per 2 hours per ticket.
|
|
*/
|
|
public function recordView(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
return response()->json(['ok' => false], 403);
|
|
}
|
|
|
|
$alreadySeen = TicketView::where('ticket_id', $ticket->id)
|
|
->where('user_id', Auth::id())
|
|
->where('viewed_at', '>=', now()->subHours(2))
|
|
->exists();
|
|
|
|
if (! $alreadySeen) {
|
|
TicketView::create([
|
|
'ticket_id' => $ticket->id,
|
|
'user_id' => Auth::id(),
|
|
'viewed_at' => now(),
|
|
]);
|
|
}
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function savePrefs(Request $request)
|
|
{
|
|
$validated = $request->validate([
|
|
'view_mode' => 'sometimes|in:list,kanban',
|
|
'filter' => 'sometimes|in:all,mine,unassigned,pending,resolved',
|
|
]);
|
|
|
|
if (empty($validated)) {
|
|
return response()->json(['ok' => false], 422);
|
|
}
|
|
|
|
$key = 'ticketing.pref.' . Auth::id();
|
|
$current = json_decode(Setting::get($key, '{}'), true) ?: [];
|
|
Setting::set($key, json_encode(array_merge($current, $validated)));
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
|
|
public function destroy(Ticket $ticket)
|
|
{
|
|
if (!$this->isManager($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$ticket->delete();
|
|
return redirect()->route('ticketing.index');
|
|
}
|
|
|
|
/**
|
|
* Merge another ticket INTO this one.
|
|
* The source ticket's messages and attachments move here; source is marked merged.
|
|
*/
|
|
public function merge(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'source_number' => 'required|string',
|
|
]);
|
|
|
|
$source = Ticket::where('number', $validated['source_number'])->first();
|
|
|
|
if (! $source) {
|
|
return back()->withErrors(['source_number' => 'Ticket not found.']);
|
|
}
|
|
if ($source->id === $ticket->id) {
|
|
return back()->withErrors(['source_number' => 'Cannot merge a ticket into itself.']);
|
|
}
|
|
if ($source->status === 'merged') {
|
|
return back()->withErrors(['source_number' => 'That ticket has already been merged.']);
|
|
}
|
|
if (! $this->isAgent($source->group_id)) {
|
|
return back()->withErrors(['source_number' => 'You do not have access to that ticket.']);
|
|
}
|
|
|
|
DB::transaction(function () use ($ticket, $source) {
|
|
// Move messages to this ticket
|
|
TicketMessage::where('ticket_id', $source->id)
|
|
->update(['ticket_id' => $ticket->id]);
|
|
|
|
// Move attachments to this ticket
|
|
TicketAttachment::where('ticket_id', $source->id)
|
|
->update(['ticket_id' => $ticket->id]);
|
|
|
|
// Add a system note marking the merge
|
|
TicketMessage::create([
|
|
'ticket_id' => $ticket->id,
|
|
'user_id' => Auth::id(),
|
|
'body' => "Merged from ticket {$source->number}.",
|
|
'is_internal' => true,
|
|
'source' => 'system',
|
|
]);
|
|
|
|
// Add source's submitter as a participant (if not already the submitter of target)
|
|
if ($source->submitter_id !== $ticket->submitter_id) {
|
|
TicketParticipant::firstOrCreate(
|
|
['ticket_id' => $ticket->id, 'user_id' => $source->submitter_id],
|
|
['added_by' => Auth::id()]
|
|
);
|
|
}
|
|
|
|
// Mark source as merged
|
|
$source->update([
|
|
'status' => 'merged',
|
|
'merged_into_id' => $ticket->id,
|
|
]);
|
|
});
|
|
|
|
return redirect()->route('ticketing.show', $ticket)
|
|
->with('success', "Ticket {$source->number} merged in.");
|
|
}
|
|
|
|
/**
|
|
* Split this ticket — create a copy with a new number, same metadata, duplicated messages.
|
|
*/
|
|
public function split(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$newTicket = DB::transaction(function () use ($ticket) {
|
|
$group = TicketingGroup::findOrFail($ticket->group_id);
|
|
$number = $group->nextTicketNumber();
|
|
|
|
$newTicket = Ticket::create([
|
|
'number' => $number,
|
|
'group_id' => $ticket->group_id,
|
|
'submitter_id' => $ticket->submitter_id,
|
|
'assigned_to' => $ticket->assigned_to,
|
|
'project_id' => $ticket->project_id,
|
|
'title' => $ticket->title . ' (split)',
|
|
'description' => $ticket->description,
|
|
'status' => 'open',
|
|
'priority_id' => $ticket->priority_id,
|
|
'due_date' => $ticket->due_date,
|
|
]);
|
|
|
|
// Duplicate messages (exclude system notes)
|
|
$ticket->load('messages');
|
|
foreach ($ticket->messages as $msg) {
|
|
TicketMessage::create([
|
|
'ticket_id' => $newTicket->id,
|
|
'user_id' => $msg->user_id,
|
|
'author_email' => $msg->author_email,
|
|
'body' => $msg->body,
|
|
'is_internal' => $msg->is_internal,
|
|
'source' => $msg->source,
|
|
]);
|
|
}
|
|
|
|
// Cross-reference notes on both tickets
|
|
TicketMessage::create([
|
|
'ticket_id' => $ticket->id,
|
|
'user_id' => Auth::id(),
|
|
'body' => "Split: new ticket {$newTicket->number} created from this ticket.",
|
|
'is_internal' => true,
|
|
'source' => 'system',
|
|
]);
|
|
TicketMessage::create([
|
|
'ticket_id' => $newTicket->id,
|
|
'user_id' => Auth::id(),
|
|
'body' => "Split from ticket {$ticket->number}.",
|
|
'is_internal' => true,
|
|
'source' => 'system',
|
|
]);
|
|
|
|
return $newTicket;
|
|
});
|
|
|
|
return redirect()->route('ticketing.show', $newTicket)
|
|
->with('success', "Ticket {$newTicket->number} created from split.");
|
|
}
|
|
|
|
/**
|
|
* Add a participant to a ticket.
|
|
*/
|
|
public function addParticipant(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'user_id' => 'required|integer|exists:users,id',
|
|
]);
|
|
|
|
TicketParticipant::firstOrCreate(
|
|
['ticket_id' => $ticket->id, 'user_id' => $validated['user_id']],
|
|
['added_by' => Auth::id()]
|
|
);
|
|
|
|
return back()->with('success', 'Participant added.');
|
|
}
|
|
|
|
/**
|
|
* Remove a participant from a ticket.
|
|
*/
|
|
public function removeParticipant(Ticket $ticket, int $userId)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
TicketParticipant::where('ticket_id', $ticket->id)
|
|
->where('user_id', $userId)
|
|
->delete();
|
|
|
|
return back()->with('success', 'Participant removed.');
|
|
}
|
|
|
|
/**
|
|
* Search users by name/email — for the participant picker.
|
|
* Returns up to 10 matches, excluding existing participants and the submitter.
|
|
*/
|
|
public function searchUsers(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
$q = $request->input('q', '');
|
|
if (strlen($q) < 2) {
|
|
return response()->json([]);
|
|
}
|
|
|
|
$excludeIds = $ticket->participants->pluck('user_id')
|
|
->push($ticket->submitter_id)
|
|
->filter()
|
|
->unique()
|
|
->values();
|
|
|
|
$users = DB::table('users')
|
|
->where(function ($query) use ($q) {
|
|
$query->where('name', 'like', "%{$q}%")
|
|
->orWhere('email', 'like', "%{$q}%");
|
|
})
|
|
->whereNotIn('id', $excludeIds)
|
|
->limit(10)
|
|
->get(['id', 'name', 'email']);
|
|
|
|
return response()->json($users);
|
|
}
|
|
|
|
public function myTickets(): Response
|
|
{
|
|
$tickets = Ticket::where('submitter_id', Auth::id())
|
|
->with(['group', 'priority'])
|
|
->latest()
|
|
->paginate(20);
|
|
|
|
return Inertia::render('Ticketing/MyTickets', [
|
|
'tickets' => $tickets,
|
|
]);
|
|
}
|
|
}
|