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}", '

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

' .'

View the ticket

', $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, ]); } }