Commit Graph

20 Commits

Author SHA1 Message Date
central-worker-6
e4d0622361 #6081: statuses store 422s on oversized sort_order instead of 500
sort_order was validated as integer|min:0 with no upper bound, but the
column is an unsigned smallint (0..65535). A client-supplied value
above the ceiling passed validation and overflowed the column on
INSERT/UPDATE, surfacing as an unhandled 500. Added max:65535 to both
storeStatus and updateStatus, which take the same client field through
the same rule.
2026-11-03 06:52:19 -07:00
central-worker-4
abd21f73f2 #5002: the rescue stamp is spent once the appointee uses it
VERIFY #5008 attacked the state #5002's own tests never built: not the rescue
that goes wrong and is undone within the minute, but the one that WORKS. A
group that was orphaned, was rescued, and is now being run by the person the
rescue appointed — with `recovered_by` still sitting on that person's row,
because the only thing that cleared it was the appointee re-saving their own
row, a write nobody makes.

So arm 3 of requireRemoveAccess() stayed open on the sole working manager of a
group that is plainly somebody else's business, which is the line the method's
own docblock says the stamp is narrow enough not to cross: strip the manager,
then "rescue" the group that press just orphaned, and a site admin who never
ran the desk manages it with the manager who staffed it gone. Two presses.

settleRescueStamp() clears the stamp on the actor's own row the moment they
ACT as the manager the rescue made them — staff the group, rename it, archive
it, give it a project or an email connection, remove a row: anything this file
lets a manager do to their own group. It is called from the three gates, not
from each action, because the gates are where "this write was authorised by
the actor's OWN manager row" is already decided, and a read of the page never
comes through them. The undo therefore lasts exactly as long as the mistake it
is for: until somebody takes the appointment up.

removableAgentIds follows without a change — it reads `recovered_by`, so the
button disappears with the gate.

  green: test_a_successful_rescue_does_not_leave_the_rescuer_able_to_take_the_group_over
         test_a_stamp_earned_on_one_group_is_not_a_claim_on_another
         all 17 of OrphanRecoveryIsReversibleTest, 215 Feature/Ticketing,
         3917 tests/Unit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-21 06:58:49 -07:00
central-worker-4
51073fba1e #5002: the orphan recovery goes both ways
#4806 gave a site admin one write on a help desk group nobody is left to
run, and #4926 made the Person field of that form a people search. Neither
closed the other half: `storeAgent()` was gated on
requireManagerAccessOrOrphanRecovery(), `destroyAgent()` on plain
requireManagerAccess(). So the rescue could appoint and never un-appoint —
the row it had just written sat in the rescuer's own Agents list marked
"Not yours to manage", with no Remove control and a 403 behind the DELETE.

A `manager` press was worse: the write ends the orphan state, so the group
left `orphanedGroupIds` and the admin could not remove the row, appoint
anybody else, or appoint themselves. The only person who could undo it was
the person wrongly appointed — #4806's frozen group, reached through
#4806's cure.

destroyAgent() now asks requireRemoveAccess(): a manager of the group, or a
site admin while the group is STILL orphaned (symmetric with the write and
strictly narrower — on such a group they could appoint themselves and then
remove anything anyway), or a site admin undoing a row their own rescue
wrote. That last arm needs provenance, hence `recovered_by`: "the group
would be orphaned without this row" cannot tell a mistaken appointment from
the sole working manager of a healthy group, and admitting the second would
let any admin strip a manager and seize the desk (#4710). The stamp is
cleared the moment a manager of the group writes the row themselves.

`removableAgentIds` is that gate in prop form, so the Remove button and the
DELETE cannot disagree, and the confirmation says when a removal hands the
group back to having nobody.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-21 06:22:24 -07:00
central-worker-3
8b4f788e43 #4932: the picker's two-character floor counts characters, not bytes
VERIFY #4932's named red — test_a_single_non_ascii_letter_is_still_a_one_
character_query — stood: searchUsers() guarded with strlen($q) < 2 under a
comment that calls the floor "two characters". A single Ł, É, ü or a CJK
surname is one letter and two or three bytes, so one keystroke cleared the
byte floor and answered with full chip rows — the directory spill the floor
exists to prevent, on exactly the names a school has the most trouble typing.

mb_strlen, matching admin.settings.users.search (the endpoint this picker's
row shape is keyed against), which answers [] for the same query. The
verifier's test is carried onto this branch so the fix lands with its proof.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-20 07:28:24 -07:00
central-worker-3
a977aedc95 #4926: appointing a help desk agent is a people search, and the receipt names them
The Agents tab's add-an-agent form opened with

    <label class="form-label">User ID</label>
    <input v-model="agentForm.user_id" required type="number"
           placeholder="User ID" class="form-input" />
    <p class="form-hint">Find IDs in Admin → Users</p>

— a primary key asked of an office administrator, on the one screen #4806
documents as the only recovery for a help desk group whose last manager can no
longer act. `exists:users,id` refuses an id nobody holds, so the failure that
mattered was never a typo that errors: 47 typed for 74 is two real accounts,
and the one written is handed `agent` — or `manager` — over a whole queue, with
the person named nowhere before the press or after it. #4781's audit row was
therefore the first place anybody could find out.

Two writes here, both small:

  * `searchUsers()` — `q` over name and email, answered by `PersonChip::rows()`,
    the platform's one serializer for a searchable person (#871), so the
    operator's People setting reaches this picker like every other one. The
    gate is the page's own requireAgentAccess() and not a per-group one: the
    form picks the PERSON before the GROUP, and the write still goes through
    requireManagerAccessOrOrphanRecovery() unchanged.

  * `storeAgent()`'s flash names the appointee, through `PersonNames::display()`
    so it reads the way the chip above it does (#524). The recovery sentence
    keeps every word it had and gains the other half of itself.

The shell half — the picker's markup, its proofs and the chip-surface
registration — is on dashboard-shell card/4926-….

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-20 07:07:46 -07:00
central-worker-5
94fb6ac53c #4806: a help desk group whose last manager can no longer act is recoverable
Every write in TicketingSettingsController is requireManagerAccess() — an
existing manager of that exact group, or 403 — and settings.agents.store, the
only route that writes a `ticketing_agent_access` row, is gated the same way.
storeGroup() auto-appoints a manager only for a group it creates. So the
moment a group's last manager stopped being able to act, the group froze for
good: no rename, no archive, no delete, no project, no agent, and no ticket in
it ever assigned, worked or closed. The page's own answer on the Agents tab
was "Ask a manager of the group to add you", which on such a group names
nobody.

On dev that was every group: all 15 held exactly one agent row, the same
person on every one, and that account had been rewritten into an
admissions-only applicant (#4202/#4203) and could not reach /app/ticketing at
all. Fifteen groups, fourteen tickets, nobody who could administer any of it,
and nothing a super admin could press — the "ticketing lifecycle is DEAD on
dev" observation of 2026-10-18 is this, not a seed gap.

One carve-out, and no more: a site admin may write an agent row for a group
that has NO manager left who could still use this page. "Still" is the
operative word and canStillAdminister() is its definition — the account must
exist, be active, and hold `ticketing.settings`, which is exactly what the
route itself demands, so it answers "could this manager have done this write
themselves". A manager row outlives all three.

#4710's rule is untouched: the carve-out is a property of the GROUP, not of
the caller, so a group somebody is still running remains none of an admin's
business, and the rescue grants the rescuer nothing beyond the row they wrote
— appoint somebody else and the very next request is refused again.

The page gets `orphanedGroupIds` so the Agents tab can offer the recovery,
label those groups "— no manager", and explain what appointing one does
(shell side).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-19 05:15:12 -07:00
central-worker-3
eb198ab05a #4710: Help Desk Settings shows the help desk that exists
The Groups, Agents and Projects tabs were scoped to the viewer's
`ticketing_agent_access` rows. A site admin holds none of those, so the
lists came back empty and the Vue — which decided between "first run"
and "nothing for you" on list length alone — rendered the first-run
copy: "No groups yet. Create your first group above.", on a page whose
own props said `isBootstrap: false` and `isSiteAdmin: true`. The two
empty states on that page contradicted each other and the one that won
was the one inviting a duplicate of a group that already existed.

On dev that was the normal case, not a corner: every pre-existing group
had zero agent-access rows, so the whole configuration was invisible to
the one role meant to own it.

The list is now what this page is for: a site admin sees every group
(archived ones included, as before), agent, project and email
connection. What they may DO is unchanged — requireManagerAccess()
still gates every write per group, which is what GroupRetirementTest
already demanded of exactly this user. So the page also ships
`myManagedGroupIds`: the rows a viewer cannot act on say "Not yours to
manage" instead of offering an Edit that 403s, and the add-agent /
add-project group pickers offer only groups the viewer manages.

Known and deliberately left: a group with zero agent-access rows is
still frozen — adding its first agent needs a manager row on it, which
nobody holds. It is now visible and labelled rather than invisible, but
thawing it is a separate decision (operator notified).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-17 04:58:55 -07:00
central-worker-4
420ef78603 A row the merge bug minted is visible, fixable, and repairable in bulk (#938)
#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>
2026-09-04 02:53:23 -07:00
afbbd3fcde Reject non-scalar ids with 422 instead of 500 (#10)
`group_id: [14]` — a one-element array of a REAL group id — returned a 500.
The reporter's analysis was right: `exists` given an array validates that
EVERY element exists, which is multi-select behaviour, so an array of one
valid id passed the rule and reached the controller. findOrFail() then
returned a Collection and ->nextTicketNumber() on it fatalled.

What hid it is that the rule looked like it was working: `['a','b']` was
correctly rejected by `exists` with a clean 422. Only arrays made of valid
ids got through.

Adding `integer` ahead of `exists` fixes the reported field, but the hole
was a property of `exists`, not of group_id — so every id on the endpoint
had it. Fixed all nine sites across both controllers: store()'s group_id
and priority_id, update()'s priority_id/project_id/group_id, and
storeAgent/storeProject/storeEmailConnection's group_id (plus user_id,
which fed straight into an access check).

Tests pin the reported repro, the equivalent on priority_id, and both
shapes that must keep behaving: a nonexistent id still 422s (so the exists
check cannot be dropped later while the array tests still pass) and a
normal scalar create still succeeds. Verified the array test FAILS with the
rules reverted — it reproduces the 500 rather than merely passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDA3fQbsa3gaHCFoV1Q8Gg
2026-07-28 19:24:12 -04:00
1175c20a57 Ticketing groups can be retired (e2e dashboard-ticketing#7)
There was no destroy route at all — only settings.groups.store and .update — so
the e2e CRUD spec, which creates an ephemeral group as its documented setup step,
could never clean up (a DELETE-spoofed POST returned 405). Every sweep left litter
behind, and an operator who mistyped a group was stuck with it forever.

Deleting a group cannot be unconditional: tickets are the audit record of real
correspondence, and tickets.group_id is a RESTRICT FK. So destroyGroup counts the
tickets and picks:

  0 tickets  -> real delete (agent access / projects / email connections are
                cascadeOnDelete, so nothing dangles). This is the teardown and
                undo-a-typo path.
  >=1 ticket -> archived_at = now(). Tickets untouched and still readable.

The caller is TOLD which happened rather than inferring it from a 302: an
expectsJson caller gets {"outcome":"deleted"|"archived","ticket_count":N}, the UI
gets distinct flash wording, and the confirm modal says which it will be BEFORE
you click ("This group has 1 ticket and will be archived, not deleted").
Archiving is idempotent; restore is a separate route.

Archived groups leave the PICKERS but nothing else: index() and create() use
scopeActive, store() validation adds whereNull('archived_at') so a stale id cannot
bypass the picker, and update() rejects transfers INTO an archived group while
always allowing the ticket's own group — otherwise a ticket stranded in an
archived group would become uneditable. Index.vue re-adds that own group to the
transfer select labelled "(archived)" so the select is never blank. Deliberately
NOT filtered: the Settings groups list (archived rows show a badge + Restore), and
agentGroupIds()/isAgent()/isManager(), which read ticketing_agent_access — so
existing agents keep working the tickets that remain. No global scope; exclusion
is per call site.

Gating is byte-identical to updateGroup: inside the existing
permission:ticketing.settings group plus requireManagerAccess(), so a super admin
with no manager row on that group is still refused (asserted).

Migration is additive — one nullable timestamp. down() is a deliberate NO-OP,
matching the myedpuller-sync warnings-column precedent: DashboardDoctor greps the
whole file for drop/rename patterns, so a dropColumn even in down() would raise a
"pending destructive migration" advisory on every host where it is still pending,
which is a confusing warning for a purely additive change. Verified against
doctor's exact regex, not a looser grep.

Tests: 9 (69 assertions). 10 mutations, all 10 detected — including forcing a hard
delete on a group with tickets, which throws a QueryException from the RESTRICT FK,
i.e. the database backs up the application check.

Browser-verified on dev: the confirm modal wording for both cases, the ARCHIVED
badge and Restore, the archived group vanishing from both the create picker and the
All-Tickets switcher while its ticket stays listed, and ZERO native
confirm/alert dialogs. doctor exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 19:35:04 -04:00
e49a1e2ac9 Ticketing: settings tabs are deep-linkable routes (/settings/{tab})
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jh8RnYXrC8E6z79LWs8ggd
2026-06-21 20:19:04 -04:00
Joel Wedemire
dd0a458250 feat: ticket views, statuses, participants, merge; mobile layout fixes (#5)
- New migrations: ticket views, statuses, participants, merge support
- New models: TicketView, TicketStatus, TicketParticipant
- New seeder: EmailTemplatesSeeder
- Console commands for ticketing
- Mobile: sidebar min-w-0/overflow-hidden, tab nav overflow-x-auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 22:22:45 -07:00
Joel Wedemire
9527147c32 fix: replace role check with permission check in TicketingSettingsController
isSiteAdmin() used the dropped role field. Now checks is_super_admin
or ticketing.settings permission. Closes #2.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:33:47 -07:00
Joel Wedemire
bce98c0d4b fix(ticketing): restrict settings link to admins and protect global priorities
- Show 'Go to Settings' bootstrap link only for admin/super_admin users
- Pass isSiteAdmin prop to Create.vue to control settings CTA visibility
- Require site admin for updatePriority/destroyPriority when priority is global (group_id = null)
- Closes: non-admin users seeing forbidden settings link; agents mutating global priorities
2026-04-09 14:32:19 -07:00
Joel Wedemire
f1d161793d Add email connections to ticketing settings 2026-04-08 22:01:50 -07:00
Joel Wedemire
4f65724f90 Allow site admins into ticketing settings 2026-04-08 21:56:17 -07:00
Joel Wedemire
f2ca83d2a7 Add ticketing project settings 2026-04-08 20:05:47 -07:00
Joel Wedemire
3c65f9a4fd Improve ticketing settings 2026-04-08 19:48:32 -07:00
Joel Wedemire
652829ab90 fix: bootstrap blocker + 4 security bugs
- Bootstrap (critical): settings/create/index no longer 403 on fresh install.
  Site admins (admin/super_admin) can access settings when 0 groups exist.
  First group creation seeds default priorities (Low/Medium/High/Urgent).
  Index shows friendly first-run splash. Create shows warning + settings link.

- Internal notes leak (high): submitters can no longer receive is_internal
  messages via ticket show, index detail panel, or any Inertia prop.
  filterMessagesForRole() strips internal notes for non-agents.

- Arbitrary assignee (med/high): update() now validates assigned_to against
  actual agent-access users for the ticket's group server-side.

- Cross-group priority/project forgery (medium): store() and update() now
  verify priority_id and project_id belong to the ticket's own group (or
  are global for priorities).

- Foreign message_id on attachment upload (medium): message_id is now
  validated to belong to the current ticket, not just any message row.
2026-04-08 18:31:51 -07:00
Joel Wedemire
391699220f feat: full dashboard-ticketing scaffold with data model, controllers, Vue pages 2026-04-08 17:10:30 -07:00