#920 made a merged ticket read-only on the server and shipped NO data change, deliberately: its guard reads `status === 'merged'` and not `merged_into_id !== null`, so the rows the bug had already produced — the pointer set, the status back to `open` — stayed EDITABLE and could be corrected in the product instead of in SQL. What it left behind was rows that are simultaneously live in the All Open queue and merged into another ticket, with nothing on any screen saying so and nothing offering to fix them. Both halves of the card ship, because option A is a BEHAVIOUR RULE and a behaviour rule is a setting: B — the office fixes them one at a time. `merged_inconsistent` rides on the model (Ticket::$appends), so the list row, the index detail pane and the Show header all draw the same amber badge — "merged_into set but status open — fix?" — off one predicate rather than three copies of an expression. The badge IS the button: it opens a modal that says what will change and what will not, and posts to a new `mark-merged` verb that writes `status` and nothing else. A new "Merged" view lists every ticket folded into another one BY THE POINTER, so the consistent and the broken appear side by side; a status filter would show one or the other and never the pair worth comparing. A — the migration re-marks all of them at once, and it is INERT unless `ticketing.auto_repair_merged_on_deploy` is on. It defaults OFF, which is also what a box that has never heard of the setting does, so this can ship ahead of the operator's decision and change nothing unasked. The toggle is on the Automation tab with the live count beside it, because nobody should arm a bulk data change without seeing how many rows it touches. `MergedTicketRepair` is one implementation with three callers for one reason: "which rows are broken" and "what fixing one means" must not drift, or the badge offers a fix on a row the migration skips. THE PROD COUNT THE CARD ASKS FOR IS NOT IN HERE, and it could not be: this worker box holds no ssh private key, so central-prod is unreachable from it and there is no prod container locally. `php artisan ticketing:repair-merged --dry-run` is the read-only answer for whoever does have access — it prints the card's own SELECT COUNT and lists the rows, and `--force` (not the bare form) is the only spelling that writes. Repairing NEVER reverts. Messages, participants, assignee and title edits made to a row while it was wrongly editable are somebody's work and the only copy of it; there is no record of what the fields held before the bad save, so "reverting" would invent a previous state rather than restore one. Proved by tests/Feature/Ticketing/MergedTicketInconsistentRowsTest.php (22 tests) in dashboard-shell, over the fixture the card names — one inconsistent row beside a genuinely merged one, both produced by the real merge endpoint. The NEGCTRL half is what keeps it honest: the consistent row and an ordinary open ticket must come out of every path untouched, and a repaired row must keep the reply and the rename it was given after the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
945 lines
38 KiB
PHP
945 lines
38 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 Dashboard\Ticketing\Support\MergedTicketRepair;
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* REFUSE A WRITE TO A MERGED TICKET — [central-ticketing] #920.
|
|
*
|
|
* The Show page has always announced "This ticket has been merged and is
|
|
* read-only." and hidden the reply composer and the Merge button. Nothing
|
|
* enforced it: the Edit form was still linked, still rendered (with its Status
|
|
* select blank, because `merged` is not one of its options), and `update()`
|
|
* happily wrote `status = open` while leaving `merged_into_id` pointing at the
|
|
* target. That produced a ticket that was live and assignable in the All Open
|
|
* queue and simultaneously merged into another one, with none of its own
|
|
* messages — an empty duplicate of the ticket it had been folded into.
|
|
*
|
|
* So the check lives HERE, on the server, once, and every write path that the
|
|
* page presents as unavailable calls it. Returning a refusal rather than
|
|
* `abort(403)` on purpose: the realistic way to arrive here is a page that was
|
|
* already open when somebody else merged the ticket, and that operator is
|
|
* better served by the reason on the ticket than by an error screen.
|
|
* `FormErrors` renders any key it is handed, so the sentence shows up.
|
|
*
|
|
* @return \Illuminate\Http\RedirectResponse|null null when the write may proceed
|
|
*/
|
|
private function refuseIfMerged(Ticket $ticket, bool $stayPut = false)
|
|
{
|
|
if (! $ticket->isMerged()) {
|
|
return null;
|
|
}
|
|
|
|
$errors = ['ticket' => Ticket::READ_ONLY_REASON];
|
|
|
|
return $stayPut
|
|
? back()->withErrors($errors)
|
|
: redirect()->route('ticketing.show', $ticket)->withErrors($errors);
|
|
}
|
|
|
|
/**
|
|
* 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(),
|
|
// #938: the pointer, not the status — this view exists to show the
|
|
// rows where the two disagree, so filtering by `status = merged`
|
|
// would hide exactly the ones worth looking at.
|
|
'merged' => (clone $baseQuery)->whereNotNull('merged_into_id')->count(),
|
|
'merged_inconsistent' => (clone $baseQuery)->inconsistentlyMerged()->count(),
|
|
]
|
|
: [
|
|
'all' => (clone $baseQuery)->count(),
|
|
'mine' => 0,
|
|
'unassigned' => 0,
|
|
'pending' => (clone $baseQuery)->where('status', 'pending')->count(),
|
|
'resolved' => (clone $baseQuery)->whereIn('status', $closedSlugs)->count(),
|
|
'merged' => 0,
|
|
'merged_inconsistent' => 0,
|
|
];
|
|
|
|
$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->filter === 'merged') {
|
|
// Every ticket that has been folded into another one, whatever its
|
|
// status says — which is what makes this the place the office
|
|
// finds the rows #938 is about. `merged` is a closed slug, so the
|
|
// whereNotIn below would drop the consistent half of the list.
|
|
$query->whereNotNull('merged_into_id');
|
|
} 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.
|
|
//
|
|
// `integer` before `exists` is load-bearing, not tidiness: `exists`
|
|
// given an ARRAY checks that every element exists, so `group_id: [14]`
|
|
// sailed through with one valid id and reached the controller as an
|
|
// array. findOrFail() then returned a Collection, ->nextTicketNumber()
|
|
// on it fatalled, and the endpoint 500'd instead of 422ing. Only some
|
|
// array shapes showed it — `['a','b']` was rejected by exists itself,
|
|
// which is why this hid behind a passing validation rule.
|
|
'group_id' => ['required', 'integer', Rule::exists('ticketing_groups', 'id')->whereNull('archived_at')],
|
|
'priority_id' => 'nullable|integer|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(),
|
|
]);
|
|
}
|
|
|
|
/** @return Response|\Illuminate\Http\RedirectResponse — merged tickets are sent back to Show. */
|
|
public function edit(Ticket $ticket)
|
|
{
|
|
if (!$this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
|
return $refusal;
|
|
}
|
|
|
|
$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,
|
|
/*
|
|
* The form's Status select used to hard-code five slugs, so any ticket
|
|
* whose status was not one of them — `merged`, or any status added on
|
|
* the Statuses settings tab — rendered with nothing selected and the
|
|
* first save silently rewrote the status to whatever the operator
|
|
* happened to leave showing. Same defect as #920, one layer up: drive
|
|
* the options from the same table `update()` validates against.
|
|
*/
|
|
'ticketStatuses' => TicketStatus::where('slug', '!=', Ticket::STATUS_MERGED)
|
|
->orderBy('sort_order')->get(),
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, Ticket $ticket)
|
|
{
|
|
if (!$this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
|
return $refusal;
|
|
}
|
|
|
|
// 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', '!=', Ticket::STATUS_MERGED)->pluck('slug')->implode(',');
|
|
|
|
$rules = [
|
|
'title' => 'sometimes|required|string|max:255',
|
|
'description' => 'sometimes|required|string',
|
|
'status' => "sometimes|in:{$validStatusSlugs}",
|
|
// `integer` guards the same array-through-`exists` hole as store().
|
|
'priority_id' => 'nullable|integer|exists:ticketing_priority_levels,id',
|
|
'due_date' => 'nullable|date',
|
|
'project_id' => 'nullable|integer|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', 'integer', '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);
|
|
|
|
// The agent, not their address — see TicketingNotifier::deliver().
|
|
// A bare string loses to_user_id and to_name and skips the opt-out.
|
|
if ($agent) {
|
|
\Dashboard\Ticketing\Support\TicketingNotifier::deliver(
|
|
\Dashboard\Ticketing\Support\TicketingNotifier::ASSIGNED,
|
|
$agent,
|
|
"[{$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,merged',
|
|
]);
|
|
|
|
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->isMerged()) {
|
|
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::system($ticket, "Merged from ticket {$source->number}.");
|
|
|
|
// 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' => Ticket::STATUS_MERGED,
|
|
'merged_into_id' => $ticket->id,
|
|
]);
|
|
});
|
|
|
|
return redirect()->route('ticketing.show', $ticket)
|
|
->with('success', "Ticket {$source->number} merged in.");
|
|
}
|
|
|
|
/**
|
|
* MARK ONE INCONSISTENT ROW AS MERGED — option B of [central-ticketing] #938.
|
|
*
|
|
* The rows the merge bug minted carry `merged_into_id` while their status
|
|
* says `open`. #920 left them editable ON PURPOSE, so that they could be
|
|
* corrected in the product; this is the correction, and until it existed the
|
|
* only way to make one was to type the right status into the Edit form and
|
|
* know that `merged` was not in the list.
|
|
*
|
|
* Note what it is NOT: it is not `update()` with a status of `merged`, which
|
|
* `refuseIfMerged()` would have to be weakened to allow, and it does not
|
|
* touch messages, participants or any field somebody edited after the merge.
|
|
* It writes one column, on a row that is provably in the broken shape, and
|
|
* it is a no-op on anything else — so a double-click, a stale page and a row
|
|
* a colleague fixed thirty seconds ago all land somewhere harmless.
|
|
*/
|
|
public function markMerged(Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $ticket->isInconsistentlyMerged()) {
|
|
// Deliberately not an error: the button is only rendered on a row in
|
|
// the broken shape, so arriving here means somebody else has already
|
|
// fixed it, and being told so is the right answer.
|
|
return redirect()->route('ticketing.show', $ticket)
|
|
->with('success', 'That ticket is already consistent — nothing to fix.');
|
|
}
|
|
|
|
MergedTicketRepair::repair($ticket, 'user '.Auth::id());
|
|
|
|
return redirect()->route('ticketing.show', $ticket)
|
|
->with('success', "Ticket {$ticket->number} is marked merged and is read-only again.");
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
if ($refusal = $this->refuseIfMerged($ticket)) {
|
|
return $refusal;
|
|
}
|
|
|
|
$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' => static::splitTitle($ticket->title),
|
|
'description' => $ticket->description,
|
|
'status' => 'open',
|
|
'priority_id' => $ticket->priority_id,
|
|
'due_date' => $ticket->due_date,
|
|
]);
|
|
|
|
/*
|
|
* Duplicate messages, EXCLUDING system notes.
|
|
*
|
|
* This comment has always said "exclude" and the code never did. It cost
|
|
* nothing while the enum rejected 'system', because no system row could
|
|
* exist to copy — #415 makes them writable, so without this a split of a
|
|
* previously-merged ticket would carry "Merged from ticket QAX-0005." into
|
|
* a brand-new ticket that was never merged with anything.
|
|
*
|
|
* A system note is an audit record of something that happened to THIS
|
|
* ticket. The copy gets its own, written below.
|
|
*/
|
|
$ticket->load('messages');
|
|
foreach ($ticket->messages as $msg) {
|
|
if ($msg->isSystem()) {
|
|
continue;
|
|
}
|
|
|
|
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::system($ticket, "Split: new ticket {$newTicket->number} created from this ticket.");
|
|
TicketMessage::system($newTicket, "Split from ticket {$ticket->number}.");
|
|
|
|
return $newTicket;
|
|
});
|
|
|
|
return redirect()->route('ticketing.show', $newTicket)
|
|
->with('success', "Ticket {$newTicket->number} created from split.");
|
|
}
|
|
|
|
/**
|
|
* The split ticket's title: the original plus a suffix, guaranteed to fit.
|
|
*
|
|
* `tickets.title` is varchar(255) and both store() and update() validate the
|
|
* title at `max:255`. Appending " (split)" to a title already at the limit
|
|
* produced 263 characters and the same hard 500 as #415 — a second, unreported
|
|
* instance of it on the very action being fixed, needing only a long enough
|
|
* title to trigger.
|
|
*
|
|
* ── WHY THIS IS BOUNDED AND NOT WIDENED ─────────────────────────────────
|
|
*
|
|
* The sibling defect in central-radius (#410) was fixed the other way: a
|
|
* 510-character skip reason was widened into the column rather than shortened,
|
|
* because the reason was the point and the column was simply too small.
|
|
*
|
|
* The rule that separates them is what the limit MEANS. 255 here is not an
|
|
* incidental column width — it is the product's own declared, user-facing title
|
|
* limit, enforced by two validation rules. Widening the column to fit 263 would
|
|
* mint rows the edit form then refuses to save, so the ticket could never be
|
|
* corrected by anyone: a row the product will not let you fix is worse than a
|
|
* truncated title. So: widen when the data is right and the column is wrong;
|
|
* bound when the column expresses a real product rule.
|
|
*
|
|
* Multibyte-safe by character, because varchar(255) counts characters.
|
|
*/
|
|
private static function splitTitle(string $title): string
|
|
{
|
|
$suffix = ' (split)';
|
|
$room = 255 - mb_strlen($suffix);
|
|
|
|
return (mb_strlen($title) > $room ? mb_substr($title, 0, $room) : $title).$suffix;
|
|
}
|
|
|
|
/**
|
|
* Add a participant to a ticket.
|
|
*/
|
|
public function addParticipant(Request $request, Ticket $ticket)
|
|
{
|
|
if (! $this->isAgent($ticket->group_id)) {
|
|
abort(403);
|
|
}
|
|
|
|
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
|
return $refusal;
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
if ($refusal = $this->refuseIfMerged($ticket, stayPut: true)) {
|
|
return $refusal;
|
|
}
|
|
|
|
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,
|
|
]);
|
|
}
|
|
}
|