Starters Steps As Tickets Developer Guide - edmozley/freeitsm GitHub Wiki

๐ŸŽซ Starters & leavers: steps as tickets - Developer Guide

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.


1. Where it differs from the design

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.


2. The files

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

3. Data

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.


4. Opening a case: applyTemplate()

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.


5. raiseTicket()

private static function raiseTicket(PDO $conn, ActorContext $ctx, array $c, int $caseId, array $template,
                                    array $group, callable $fill, array $answers, array &$warnings): int

In order:

  1. 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_requester is written to the history and a warning returned. The work is never lost.

  2. The due date - the earliest of the group's effective_date + due_offset_days.

  3. The ticket - TicketsService::createTicket(), the front door every ticket uses, so its rules, audit, numbering and ticket.created all 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 in department_teams; otherwise null, and createTicket() applies the company's default department, as for any ticket.

  4. 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
  5. The schedule - updateTicket(['work_start_at' => "$due 00:00:00", 'work_all_day' => 1]).

  6. 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 through attachTemplateToTicket(..., skipIfAttached: true) - a task-only checklist is refused there and becomes a warning.

  7. The history - ticket_raised with 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() fires ticket.created, and a workflow must never act on a ticket that is then rolled back.


6. Not emailing the requester

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_assigned still goes - its audience is the analyst, not the requester.
  • Kept per ticket. mute_requester is 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.

7. Progress, everywhere

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.


8. Where it shows

  • The case - caseTickets() returns number, name, subject, team, analyst, scheduled date, status, closed, in the trash, and checklist done / total; api/starters/get.php adds each one's url only for an analyst with Tickets who can open that ticket (analystCanAccessTicket - its department and company). starters-view.js draws a Tickets panel under the steps, in the same style.
  • The ticket - get_email_detail.php adds jml_case (id, kind, person, date) for an analyst who can use Starters & leavers; buildLinksSection() in inbox.js draws ๐Ÿงณ 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.

9. Deleting, merging, Done

  • Permanently deleting a ticket (or emptying the trash, or the D002 debug tool) removes its jml_case_tickets row - 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.php lists 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.

10. Testing

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.php returns 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.


11. Not built

  • 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.

Related

โš ๏ธ **GitHub.com Fallback** โš ๏ธ