-
Notifications
You must be signed in to change notification settings - Fork 16
Tasks on Tickets Developer Guide
How follow-up tasks are created from and attached to a ticket: why there is no new button, why creating and linking are one control, and the two invariants that are not expressible as scope checks.
Requested in discussion #83 by dschipfel. Shipped as #1176 (company scoping) and #1177 (the feature), commits 5de974cf, 5161b5ff, e4de579c.
The user-facing pages are Tickets β Follow-up tasks and Tasks β Linking.
Related: Linking equipment to tickets Β· Multi-Tenancy Developer Guide Β· Tasks
Worth knowing before you touch anything: tasks.ticket_id had always been a column, and api/tasks/search_links.php plus assets/js/tasks.js already let you open a task and attach a ticket to it.
So the relationship, the storage and one direction of the UI were all built. What was missing was the ticket's side: no way to get from a ticket to its tasks, and no way to see them without leaving the ticket. That is what #83 actually asked for, and it made the job far smaller than the request reads.
The general lesson: before scoping any "link A to B" request, look for the reverse direction. A feature that sounds absent is sometimes present and pointing the other way.
Named to match the equipment linking work, because they do the same job for a different thing.
| File | Role |
|---|---|
api/tickets/get_ticket_tasks.php |
The strip's pills: title, status, assignee, subtask progress, plus open_count for the close warning |
api/tickets/search_linkable_tasks.php |
The picker's results. Excludes tasks already on this ticket, and subtasks |
api/tickets/save_ticket_task.php |
Both verbs β link an existing task, or create one via TasksService
|
api/tickets/delete_ticket_task.php |
Unlink. Never deletes |
| File | Role |
|---|---|
assets/js/inbox.js |
loadTicketTasks / renderTicketTasks (pills), openLinkTaskPicker (the one picker), openContextLinkTask (right-click), and the close warning inside assignStatus
|
tickets/index.php |
The Link to task⦠context-menu item |
assets/css/inbox.css |
.task-badge / .task-done
|
| File | Role |
|---|---|
includes/services/tasks.php |
Company scoping and the creation rules β see Multi-Tenancy Progress |
The ticket screen is busy. Rather than add a control, tasks became the seventh entry in the Link to⦠menu that already existed, and task pills joined the strip that already held problems, changes, tickets, equipment, CMDB objects and tracker issues.
That strip is where an analyst already looks to answer "what else is this connected to", and the codebase had already made this argument once β the comment introducing tracker pills reads:
a Jira issue IS a link, so it belongs in this strip rather than in a panel of its own
A task is the same kind of thing. The cost of the whole feature, in screen furniture, is one menu row.
Creating a task and linking an existing one are the same gesture. Typing searches; if nothing matches, the first row creates a task with exactly what was typed.
This is not a space saving. Two separate actions β Create task and Link task β make the user decide which they are doing before they know whether the task already exists, which is precisely the decision they cannot make yet. One box shows them the near matches as they type, so the duplicate is prevented rather than tidied up afterwards.
Structurally the create row is just entry 0 of the same flat list:
current = [{ kind: 'create', title: typed }]
.concat(rows.map(r => Object.assign({ kind: 'task' }, r)));which keeps one render path and one click handler:
const pick = (r) => {
if (r.kind === 'create') createTaskForTicket(ticketId, r.title);
else linkTaskToTicket(ticketId, r.id);
};Every other link item on the context menu opens its own modal. openContextLinkTask() does not: it loads the ticket and opens the real picker on the strip.
A second copy of the picker in a modal would be two implementations of "find me a task", and issue #77 is what happens when the same question gets answered in two places. The ticket you right-clicked is the one you are about to work on, so opening it costs nothing.
Both endpoints gate the ticket (analystCanAccessTicket) and the task (analystCanAccessTask). Both gates passing is not sufficient.
A task and its ticket must belong to the same company. An analyst with access to two companies passes both checks and could still attach one client's task to another client's ticket, creating exactly the straddle the scoping work exists to prevent. So the companies are compared explicitly:
if (isMultiTenant($conn)) {
$default = getDefaultTenantId($conn);
$taskCo = $task['tenant_id'] === null ? $default : (int)$task['tenant_id'];
$ticketCo = $ticketTenant === null ? $default : (int)$ticketTenant;
if ($taskCo !== $ticketCo) {
throw new Exception('That task belongs to a different company from this ticket.');
}
}Note both sides resolve NULL to Default before comparing. Skip that and a Default-company ticket never matches a NULL-company task β which is every task predating the scoping work. CMDB draws the same line for the same reason, and the Multi-Tenancy Developer Guide explains why NULL means Default here rather than "shared".
A subtask cannot be linked to a ticket on its own. It belongs to its parent's work, and a pill whose parent is nowhere in sight is a dead end. search_linkable_tasks.php excludes them and save_ticket_task.php refuses them.
get_ticket_tasks.php filters the tasks it hydrates even though the ticket is already gated:
[$tSql, $tArgs] = activeTenantFilter($conn, $analystId, 'tk');A link made before a company was split, or by an all-access analyst, can straddle. The gate on the parent is not evidence about the children β the same reasoning get_ticket_assets.php documents.
| Title | What was typed |
| Company | Inherited from the ticket |
| Assignee | Whoever created it |
| Link back | tasks.ticket_id |
| No | |
| No | |
| No |
Copying priority or dates makes a second copy of the truth that drifts the moment either side changes, and a task showing a stale priority is worse than one showing none. The link is the connection; the ticket is one click away.
The assignee changed after review. It started as unassigned, on the argument that handing work to a person should be deliberate. That is right in the Tasks module and wrong here: raising a task while working a ticket almost always means "I am going to do this", and an unassigned task appears on nobody's list. Reassigning takes one click; noticing an unowned task can take days. The Tasks module keeps its own behaviour β this applies to the ticket path only.
Closing a ticket with unfinished tasks warns and never blocks, matching collision detection.
It costs no request: the strip has already loaded the tasks, and ticketStatuses already carries is_closed, so assignStatus() can answer from what is in memory.
const closing = ticketStatuses.some(s => s.name === status && s.is_closed);
const openTasks = (tasksForTicket || []).filter(tk => !tk.status_is_closed).length;Two details that matter more than they look:
-
tasksForTicketis cleared at the start of every load, not just on success. Otherwise a failed fetch leaves the previous ticket's tasks in place and the warning reports a count belonging to a ticket the analyst is no longer looking at. - If the load failed, the array is empty and the warning simply does not appear. That is the right way round: a warning that cannot be shown must never become a block that cannot be cleared.
The first cut of the picker looked completely dead β type, and nothing happened.
.strip-picker-results is display: none until an active class is added:
.strip-picker-results { display: none; }
.strip-picker-results.active { display: block; }The equipment picker adds it. Mine wrote innerHTML and never did, so the results were fetched, rendered and painted into a hidden container. Everything worked except being visible.
A second bug sat behind it: the rows used .link-search-result, which is a Tasks-module class with no rule in inbox.css, so they would have been unstyled even once shown.
Two lessons. Copy the working example's behaviour, not just its shape β the class that reveals the container is as load-bearing as the markup. And an end-to-end test that stops at "the endpoint returned the right JSON" would have passed this bug twice: the API was correct throughout, and so was the rendering. Only driving the real page caught it.
The rewrite also removed a genuine escaping bug: the create row's click handler was built by concatenating the typed text into an onclick attribute, so a task title containing an apostrophe would have broken it. Handlers are now bound with data-idx.
Driving the real page beats asserting on JSON, and this feature is the proof. A same-origin harness that iframes tickets/index.php, seeds the session cookie from PHP, then:
-
loadTicketById(...)so the strip and its picker host exist openLinkTaskPicker(...)- sets
input.valueand dispatches a bubblinginputevent - waits out the debounce and the fetch before asserting
Step 4 is not optional β an early check reported one row where there were two, which looks exactly like a bug and was not one.
Assert on getComputedStyle(results).display, not just on innerHTML. The whole defect above was content that existed and could not be seen.
Server-side, the differential that matters is the cross-company one: attempt the link as an all-access analyst. A test using a company-restricted analyst passes whether or not the explicit company comparison exists, because the scope gate already refuses it β so it proves nothing about the invariant that needed proving.
- Tickets β Follow-up tasks Β· Tasks β Linking β the user-facing side
- Linking equipment to tickets β the strip pattern this follows
-
Multi-Tenancy Developer Guide β why
NULLmeans Default for a task - Mail could only ever be collected from Inbox β the same-question-answered-twice failure the right-click entry avoids
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)