Starters Steps As Tickets Developer Guide - edmozley/freeitsm GitHub Wiki
How a Starters & leavers template step can be raised as a ticket instead of a task, several steps can share one ticket, and the ticket stays tied to its case. Built for #316 (5.2.0), from discussion #298. User docs: Starters & leavers โ Steps as tickets. The proposal it came from: design. The module itself: Starters & leavers - Developer Guide.
The one-line version: applyTemplate() sets aside the steps marked ticket, groups them by ticket name, and raiseTicket() makes one ticket per group through TicketsService::createTicket() - then links it in jml_case_tickets, which every progress count, the case page, the ticket's Links strip and the requester-email switch read.
The design was written before the code was read closely. Four things changed, each Ed's decision (2026-11-13):
| Design said | Built | Why |
|---|---|---|
| Requester: the form's submitter, or nobody | the submitter, else the case's manager, else the person; none โ the steps become tasks | Every ticket must have a requester (createTicket() refuses one without). A ticket with no requester would touch requester emails, CSAT, the portal and reports across Tickets. |
| Due: the earliest due date | the ticket is scheduled (all day) on it, and the subject says the date | Tickets have no due date. The schedule puts it on the Calendar. |
| No requester emails | a template setting, off by default, kept per ticket | Some organisations do want the manager told. |
| Done: offer to close the open tickets | Done never closes a ticket | The same rule as tasks: Done never closes anybody's work. |
The case's own shape (one task with a checklist, or task + subtasks) was left as it is.
| File | ๐จ | What changed |
|---|---|---|
database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php, api/system/db_verify.php
|
๐๏ธ | the columns, jml_case_tickets, three foreign keys |
includes/starters/service.php |
โ๏ธ |
saveTemplate(), applyTemplate() โ addTaskStep() + raiseTicket(), ticketRequester(), teamDepartment(), ticketsReady(), progressSql(), caseTickets(), getCase(), listCases(), casesForPerson()
|
includes/services/checklists.php |
โ๏ธ |
addTicketChecklist() - a checklist from a list of steps, not a template |
includes/ticket_requester_mute.php |
โ๏ธ |
new: ticketRequesterEmailsMuted()
|
includes/template_email.php, includes/csat.php
|
โ๏ธ | ask it before a requester email, and before an automatic survey |
includes/report_packs/blocks_starters.php |
๐ | progress, ready on the day and overdue count tickets |
api/starters/lookups.php, api/starters/get.php
|
๐ | ticket types; each ticket's link |
api/tickets/get_email_detail.php, assets/js/inbox.js
|
๐๐ฅ๏ธ | the ticket's ๐งณ case pill |
api/tickets/permanently_delete_ticket.php, empty_trash.php, api/system/debug-tools/D002_delete_ticket.php, includes/ticket_merge.php
|
๐๏ธ | remove the link; a merge leaves it with the merged ticket |
starters/settings/index.php, assets/js/starters-settings.js, assets/css/starters.css
|
๐ฅ๏ธ | Raise as / Ticket, the ticket settings, the team warning |
starters/view.php, assets/js/starters-view.js
|
๐ฅ๏ธ | the Tickets panel |
lang/en/starters.php, lang/en/tickets.php, starters/help.php
|
๐ | strings and the help section |
includes/feature_bingo/cards/starters.php |
๐ฏ | starters.step_as_ticket |
tests/starters-tickets.php |
๐งช | new: 28 checks |
ALTER TABLE jml_templates
ADD ticket_type_id INT NULL, -- FK ticket_types, ON DELETE SET NULL
ADD email_requester TINYINT(1) NOT NULL DEFAULT 0;
ALTER TABLE jml_template_steps
ADD raise_as VARCHAR(10) NOT NULL DEFAULT 'task', -- task | ticket
ADD ticket_name VARCHAR(100) NULL; -- same name = one ticket
CREATE TABLE jml_case_tickets (
id INT NOT NULL AUTO_INCREMENT,
case_id INT NOT NULL, -- FK jml_cases, CASCADE
ticket_id INT NOT NULL, -- FK tickets, CASCADE
ticket_name VARCHAR(100) NULL,
mute_requester TINYINT(1) NOT NULL DEFAULT 1, -- the template's switch WHEN RAISED
created_datetime DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_jml_case_ticket (case_id, ticket_id),
KEY idx_jml_case_tickets_ticket (ticket_id)
);raise_as defaults to task, so no existing template changes by itself. Before Database Verification none of it is there, and StartersService::ticketsReady() (cached per request) keeps everything working the old way: the editor disables Raise as with a hint, saveTemplate() writes only the old columns, applyTemplate() makes every step a task, and progress counts tasks only.
The loop that made every step a subtask now sets ticket steps aside, keyed by their ticket name (case-insensitive - IT accounts and it accounts are one ticket) or, for a blank name, by the step's own id:
foreach (self::steps($conn, (int)$template['id']) as $step) {
if (!self::ruleMet($step['condition_rule'], $raw)) { $skipped++; continue; }
if ($canTicket && ($step['raise_as'] ?? 'task') === 'ticket') {
$key = trim((string)($step['ticket_name'] ?? '')) !== '' ? 'n:' . mb_strtolower(trim((string)$step['ticket_name'])) : 's:' . $step['id'];
$ticketGroups[$key][] = $step;
continue;
}
if (self::addTaskStep($conn, $ctx, $c, $caseId, $step, $fill, $warnings)) $added++;
}
foreach ($ticketGroups as $group) {
$added += self::raiseTicket($conn, $ctx, $c, $caseId, $template, $group, $fill, $answers, $warnings);
}Only if rules work exactly as before: a step whose rule fails never joins a group, and a group with no steps left raises no ticket. addTaskStep() is the old loop body, unchanged, pulled out so raiseTicket() can fall back to it.
private static function raiseTicket(PDO $conn, ActorContext $ctx, array $c, int $caseId, array $template,
array $group, callable $fill, array $answers, array &$warnings): intIn order:
-
The requester -
ticketRequester():// whoever asked for the case, else the manager, else the person - each only if they exist in users $ids[] = form_submissions.submitted_by_user_id; // the portal user who filled in the form $ids[] = $c['manager_user_id']; $ids[] = $c['user_id'];
None โ each step of the group goes through
addTaskStep()instead,ticket_no_requesteris written to the history and a warning returned. The work is never lost. -
The due date - the earliest of the group's
effective_date + due_offset_days. -
The ticket -
TicketsService::createTicket(), the front door every ticket uses, so its rules, audit, numbering andticket.createdall apply:$in = array_filter([ 'subject' => mb_substr($subject, 0, 255), // "IT accounts: Jane Smith - due 3 Nov 2026" 'description' => implode("\n", $lines), // plain text: createTicket() escapes it 'user_id' => $requester, 'assigned_team_id' => $first['assigned_team_id'], 'assigned_analyst_id' => $first['assigned_analyst_id'], 'department_id' => self::teamDepartment($conn, $first['assigned_team_id']), 'ticket_type_id' => $template['ticket_type_id'] ?: null, ], fn($v) => $v !== null); $ticketId = TicketsService::createTicket($conn, $ctx, $tenantId, $in, null, 'Raised by Starters & leavers case #' . $caseId);
teamDepartment()returns the team's department only when it has exactly one indepartment_teams; otherwise null, andcreateTicket()applies the company's default department, as for any ticket. -
The link, at once - from here on everything the ticket does knows it belongs to a case:
INSERT INTO jml_case_tickets (case_id, ticket_id, ticket_name, mute_requester) VALUES (?, ?, ?, ?) -- mute_requester = empty($template['email_requester']) ? 1 : 0
-
The schedule -
updateTicket(['work_start_at' => "$due 00:00:00", 'work_all_day' => 1]). -
The checklist -
ChecklistsService::addTicketChecklist($conn, $ctx, $ticketId, $name, $items): one mandatory item per step (title and description filled), closure warn. Then each step's own checklist template throughattachTemplateToTicket(..., skipIfAttached: true)- a task-only checklist is refused there and becomes a warning. -
The history -
ticket_raisedwith the ticket number.
TRAP: the schedule and the checklist are not allowed to lose the ticket. Each is in its own try; a failure is a warning. Only createTicket() failing abandons the ticket - and then the case history says so, as for a task step that fails.
๐ No outer transaction, for the module's usual reason:
createTicket()firesticket.created, and a workflow must never act on a ticket that is then rolled back.
The requester is usually a manager who filled in a form. The template's Email the requester about these tickets decides whether they hear about the work; off by default.
// includes/ticket_requester_mute.php
function ticketRequesterEmailsMuted(PDO $conn, int $ticketId): bool
{
// SELECT 1 FROM jml_case_tickets WHERE ticket_id = ? AND mute_requester = 1 - cached; false if the table is missing
}Asked in exactly two places - the choke points every automatic requester email and survey goes through:
// includes/template_email.php - sendTemplateEmail(), after the merge data is built
if (templateEventAudience($eventTrigger) === 'requester' && ticketRequesterEmailsMuted($conn, $ticketId)) {
emailLogSkipped($conn, templateGetMailboxForTicket($conn, $ticketId), 'template', $mergeData['requester_email'] ?? '',
'Automatic email: ' . $eventTrigger,
'This ticket was raised by a Starters & leavers case whose template does not email the requester.', $ticketId);
return;
}
// includes/csat.php - csatAutoOnClose()
if (ticketRequesterEmailsMuted($conn, $ticketId)) return;- Logged, not silent - each one not sent is a skipped row in the mailbox's send log, so "why did the manager hear nothing?" has an answer.
-
analyst_assignedstill goes - its audience is the analyst, not the requester. -
Kept per ticket.
mute_requesteris copied from the template when the ticket is raised, so ticking the box later changes new cases, not tickets already out. The test checks exactly that. - By hand still works - an analyst's Reply is not a template email.
A ticket is one step: total when it is not in the trash, done when its status is closed. One definition, used by every count:
public static function progressSql(PDO $conn): string
{
$tasksTotal = "(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = c.parent_task_id)";
...
$ticketsTotal = "(SELECT COUNT(*) FROM jml_case_tickets ct JOIN tickets tk ON tk.id = ct.ticket_id
WHERE ct.case_id = c.id AND tk.deleted_datetime IS NULL)";
$ticketsDone = "(... JOIN ticket_statuses ts ON ts.id = tk.status_id ... AND ts.is_closed = 1)";
return "($tasksTotal + $ticketsTotal) AS steps_total, ($tasksDone + $ticketsDone) AS steps_done";
}| Where | How |
|---|---|
| The case page |
getCase(): progressOf(steps + tickets not in the trash)
|
| The case list |
listCases(): progressSql()
|
| The person's page |
casesForPerson(): progressSql()
|
| Report Packs - list |
rpStartersProgressCols($conn) โ progressSql()
|
| Report Packs - ready on the day | a ticket must also be closed (closed_datetime) before the day after the case's date; a case with only tickets counts |
| Report Packs - overdue count and list |
rpStartersOpenWorkSql(): open subtasks UNION ALL open tickets, a ticket's due = the day it is scheduled on |
Before this, four copies of the subtask count lived in three files. TRAP: a new kind of step means one change, in progressSql() - and the overdue union.
-
The case -
caseTickets()returns number, name, subject, team, analyst, scheduled date, status, closed, in the trash, and checklist done / total;api/starters/get.phpadds each one'surlonly for an analyst with Tickets who can open that ticket (analystCanAccessTicket- its department and company).starters-view.jsdraws a Tickets panel under the steps, in the same style. -
The ticket -
get_email_detail.phpaddsjml_case(id, kind, person, date) for an analyst who can use Starters & leavers;buildLinksSection()ininbox.jsdraws ๐งณ Starter: Jane Smith in the Links strip, opening the case. -
The editor - a Raise as column (select + a ticket-name box shown only for tickets, with a
<datalist>of the names already used); Steps raised as tickets under the steps (type, the requester switch);ticketBits()re-checks on every change and warns when one name's steps have different teams.
-
Permanently deleting a ticket (or emptying the trash, or the D002 debug tool) removes its
jml_case_ticketsrow - it also cascades, but an install missing that foreign key must not keep a link to nothing. -
Merging leaves the link with the merged-away ticket (
includes/ticket_merge.phplists it as STAY): it describes that ticket. The merged ticket goes to the trash, so it leaves the case's progress. -
Done changes the case only; its confirmation already counts open steps from
progress, which now includes tickets. Reopen never reopens anything.
FREEITSM_TEST_DATABASE=<throwaway db> FREEITSM_BASE_URL=http://localhost/<that install> php tests/starters-tickets.php
28 checks, with a ZZ team in exactly one ZZ department, a ZZ ticket type and a ZZ manager:
- the template - the type and switch saved; Raise as / Ticket per step, a task keeping no ticket name; an unknown type refused;
-
a case - two tickets (IT accounts from two steps named in different case, MFA alone); subject; requester = the manager; team, its department, the type; scheduled on the earliest date; a checklist of the two steps with
{name}filled; text with the case link and a step's description; the task step still a subtask; the link muted; - progress - 0 of 3, then 1 of 3 after closing a ticket - on the case, the list and Report Packs;
-
the requester - muted;
sendTemplateEmail('ticket_closed')logs skipped with the reason; another ticket not muted; - Done leaves the open ticket open;
- with the switch on, new tickets are not muted - and the first case's still is;
- nobody to raise for - no tickets, four tasks, two history lines and two warnings;
-
the endpoint -
get.phpreturns both tickets with a link.
Still passing: tests/starters.php (46), report-packs-starters.php (17), csat-delay.php (24), ticket-checklists.php (30), checklists-api.php (21), ticket-merge-undo.php (5). A headless Chrome harness on the throwaway install checked the editor (the column, the suggestions, the team warning, a task switched to a ticket), the case page (the Tickets panel, 0 of 2 steps done), the ticket's jml_case, and the ๐งณ pill escaping the name - with no script errors.
- Add a step to one open case (the design's Later).
- Demo data - the demo templates are all tasks; a ticket step would need the Tickets demo to own its tickets (two demo modules must never write the same table).
- The other languages - English only until the release's translation pass.