Skip to content

Starters and Leavers Developer Guide

Ed Mozley edited this page Oct 31, 2026 · 5 revisions

Starters & leavers - Developer Guide

Building a module out of other modules. Starters & leavers (4.6.0) is deliberately small: it owns four tables and one service class, and everything else - the work, the kit, the account, the documents, the automation, the person's page - belongs to a module that already existed. This page is the worked example: what it reuses, how, the rules that make it safe, and the list of every place a new module has to be registered.

User guide: Starters & leavers.


1. What it owns, and what it does not

It owns only what nothing else holds - who, which kind, which date, which template:

Table Holds
jml_templates A template: kind (starter / mover / leaver), name, on/off, the questions (form_id) and the rule that picks it from a form's answers (match_rule).
jml_template_steps Its steps: title, team, analyst, due_offset_days, checklist_template_id, "only if" (condition_rule), order.
jml_cases A case: kind, the person (user_id, or a name and email for a starter not in Users yet), effective_date, status, owner, template, parent_task_id, the answers (form_submission_id), steps_pending, company.
jml_audit The case's history.

Everything else is borrowed:

Need Owned by Reached through
The work, who does it, when it is due Tasks TasksService::saveTask - one parent task per case, a subtask per step
Progress Tasks read from the subtasks' statuses (task_statuses.is_closed), never stored
A checklist on a step Checklists ChecklistsService::attachTemplateToTask
Issuing and recovering kit Assets AssetsService::assignUser / unassignUser (custody, history, asset.assigned events)
Retiring recovered kit Assets AssetsService::updateFields(['asset_status_id' => ...]) - a status ticked Retired retires it
What the person holds Assets AssetsService::assetsForUser
Finding a person Assets AssetsService::people (already scoped by company)
Finding an asset to issue Assets the asset list's own endpoint, api/assets/get_assets.php?search=
The handover form Assets asset-management/handover.php?user_id=
Switching a leaver off Users userActiveSets(false) in includes/users.php - extracted from the person screen so both make the identical change
Documents on a case Documents one entry in the includes/documents.php registry + FreeITSMDocuments.mount()
Automation Workflows WorkflowEngine::dispatch('jml.case_created' / 'jml.case_completed')
The person's page People StartersService::casesForPerson(), rendered by pplSectionStarters()
Links to anything Deep links entityLink('task' / 'person' / 'asset' / 'jml_case', $id)
An analyst leaving Analyst lifecycle analystReferenceRegistry() + a starters live group moved by StartersService::setOwner()
The questions Forms a template's form_id; the answers are an ordinary form_submissions row (jml_cases.form_submission_id)
Reading the answers Forms catalogueSubmissionAnswers() - the same reader Projects intake uses
Testing an answer Forms formLogicTestRule() from includes/form_logic.php - the operators of a form's show/hide rules
Asking them on the case's task Tasks (forms on tasks, GH #190) TaskForms::add(); TaskForms::submit() calls StartersService::taskFormSubmitted()
A manager's request opening a case Forms + Workflows the workflow action open_jml_case, put on a form's own on submit / on approval list (or any workflow)

The result: no task table, no custody table, no notification code, no file storage, no email sending. Each of those already has its rules - company scope, permissions, history, events - and they all still apply, because the module goes through the front door every time.

2. The service: includes/starters/service.php

final class StartersService, plus two enums (JmlKind, JmlStatus). Pages and endpoints are thin: they authorise and call it.

Opening a case - createCase()

  1. Validate the kind and date; resolve the person (analystCanAccessUser first - out of reach reads as "not found").
  2. Insert the jml_cases row first.
  3. TasksService::saveTask for the parent task (due on the date), then store its id as parent_task_id.
  4. For each template step: saveTask a subtask (parent_task_id, team, analyst, due = date + offset), then attachTemplateToTask if it has a checklist.
  5. WorkflowEngine::dispatch('jml.case_created', ...).

πŸ”‘ No outer transaction, on purpose. saveTask and the checklist service fire their own workflow events as they go. Wrapping them in a transaction would let a workflow act on a task that is then rolled back. Writing the case row first means a failure part-way leaves a case that says what it has - never tasks that belong to nothing. A step that fails is written to the case's history and returned in $warnings; the rest still go in, and the case id is still returned so nobody opens it twice.

Progress is read, not stored

getCase() lists the parent task's subtasks with their status; listCases() and casesForPerson() count them in the same query:

(SELECT COUNT(*) FROM tasks s WHERE s.parent_task_id = c.parent_task_id) AS steps_total,
(SELECT COUNT(*) FROM tasks s JOIN task_statuses ss ON ss.id = s.status_id
  WHERE s.parent_task_id = c.parent_task_id AND ss.is_closed = 1) AS steps_done

A step closed on the Tasks board is ticked on the case with no code in between - there is no second copy to fall out of step. A subtask added by hand in Tasks counts too.

Questions from Forms

A template may name a form (jml_templates.form_id), a rule that picks it (match_rule) and an "only if" per step (jml_template_steps.condition_rule). Rules are JSON {match: all|any, rules: [{question, op, value}]}, tested by StartersService::ruleMet(), which hands each comparison to the Forms module's own formLogicTestRule() (plus any_of, which is equals for each comma-separated value).

πŸ”‘ Rules are kept by question LABEL, not field id. Every form version is a new forms row with new form_fields ids, so a rule written against an id would silently stop matching the day someone edits the form. rawAnswers() keys the stored answers by lower-cased label; formRoot() walks parent_form_id so "the same form" means any version of it.

applyTemplate() is the one place steps are made, from three callers:

Caller When
createCase() a case opened with a template
useTemplate() a template chosen later (opened without one, or no rule matched)
taskFormSubmitted() the questions answered on the case's task

If the template has a form and the case has no answers yet, applyTemplate() puts the form on the parent task (TaskForms::add, required, blocking), sets steps_pending = 1 and stops. Otherwise each step is skipped unless ruleMet(), has {Question} filled by fillAnswers(), and the parent task's description gains the answers table.

openFromSubmission() is the workflow action's body: it reads name / email / date / job title from the answers by question type and wording (or the action's own args), matches a mover or leaver to Users by email, takes the company from the requester (a company-guarded workflow may act only for its own), and picks the template with chooseTemplate(). It runs as nobody (actor 0): TasksService::saveTask credits such tasks to the first active administrator, as the workflow Create a task action always has (tasks.created_by_id is NOT NULL).

Text written into other modules

The parent task's title and description and the custody note on issued kit are saved text that everybody reads, so they are written once in the case's language - writeLocale(): the owner's saved interface_language, else the acting analyst's, else English - through I18n::tFor() and the starters.written.* strings. A workflow or portal opening has no language of its own. The date uses IntlDateFormatter where intl is installed and an ISO date where it is not.

Done never closes anybody's work

setStatus() changes the case only. A step still open is someone's job; closing it silently would hide that. The UI says how many are open before Done.

Kit and the account

issueKit() / recoverKit() check analystCanAccessAsset (the custody methods do not check scope themselves) and then call Assets. markLeft() refuses a directory-managed person (users.is_managed) - the next sync would undo it - and otherwise runs UPDATE users SET + userActiveSets(false).

3. Endpoints: api/starters/*.php

Every endpoint starts with includes/starters/api_bootstrap.php (signed in, requireModuleAccessJson('starters'), JSON, $conn/$ctx/$analystId, schema ready), then one call into the service inside startersApiRun(), which turns a ServiceError into {success:false, error}. Writes call startersApiRequirePost(). The bootstrap is listed in tests/module-access-coverage.php's guard pattern.

Endpoint Does
list.php GET - cases, with progress
get.php GET ?id= - one case, with links from entityLink() (only for modules the analyst can open)
create.php POST - open a case; returns id and warnings
status.php POST - done / cancelled / open (cancel and reopen: owner or starters.manage)
link_user.php POST - link a starter's case to their Users record
kit.php POST - issue / recover (needs Assets too)
mark_left.php POST - switch a leaver off
lookups.php GET - templates, teams, analysts, checklist templates, asset statuses, forms; ?people= searches people
templates.php GET - templates with steps; POST save/delete (needs starters.templates)
form_questions.php GET ?form_id= - the answerable questions of the newest version of a form (label, type, options), for the rule and wording editors
use_template.php POST - apply a template to a case opened without one, or where no rule matched

4. Every place a module is registered

The checklist for the next module. Each is one line or one entry.

Where What
includes/functions.php getModuleRegistry() the module key starters
includes/waffle-menu.php name, path, icon
includes/module-colors.php header colours
assets/css/theme.css --jml-accent tokens, light and dark
assets/css/inbox.css .starters-header in the header-nav selectors
lang/en/common.php modules.starters name and description
lang/en/starters.php the module's own strings
database/freeitsm.sql, includes/db_verify_schema.php, FKs in api/system/db_verify.php the tables (indexes regenerated with scripts/gen_db_verify_indexes.php)
includes/db_verify_modules.php 'jml_' => 'starters' so Database Verification groups the tables under the module
includes/capabilities.php + starters/settings/manifest.php starters.manage, starters.templates
includes/entity_links.php jml_case β†’ starters/view.php?id=
workflow/includes/engine.php the two triggers, their fields, 'jml' => 'starters' in TRIGGER_MODULES
includes/workflow_tenancy.php 'jml_case' => ['jml_cases', 'default'] - a workflow's company
includes/documents.php the jml_case registry entry (module, table, URL, title, scope)
includes/people.php + people/includes/render.php the People section
includes/analyst_lifecycle.php every column that points at an analyst, and the starters live group
includes/feature_bingo.php + includes/feature_bingo/cards/starters.php the module and its cards
tests/module-access-coverage.php the API bootstrap in the guard pattern

Two tests catch a missed one: tests/analyst-lifecycle.php fails for an analyst column not in the map, and tests/module-access-coverage.php fails for an endpoint without the module guard.

5. Front end

Plain JS, no build. assets/js/starters.js is shared (Jml.api, the renderers, a type-ahead searchBox, the New case dialog JmlNew); starters-list.js, starters-view.js and starters-settings.js are one per page. The dialogs are the shared .modal from inbox.css; the confirmations are showConfirm(); the documents panel is FreeITSMDocuments.mount(el, {parentType: 'jml_case', ...}). CSRF is added to every request by csrf.js, which the waffle menu injects.

6. Demo data

The demo (database/demo-data/starters.json) follows the same rule as the module: it owns only its own tables. A demo import clears every is_demo row of each table it writes, so two demo modules must never write the same table - Starters writing tasks would delete the Tasks demo on every re-import, and the other way round. So:

Data Lives in
The case tasks and their steps (with checklists) tasks.json
The New starter request form and its 5 submissions forms.json (form form1)
Templates, steps, cases, history starters.json (jml_template_steps and jml_audit carry is_demo for this)
People and kit for the leavers and the mover Core and Assets, matched by email / hostname

Another file's refs are not visible to an import, so demoAfterStarters() in includes/demo_data.php joins them up afterwards: each case finds its parent task by ": <name>" in the title (any language - a re-import finds it already rewritten), its answers by the submission whose Full name is the person, and the starter templates are pointed at the demo form by id (an older install's untagged "New Starter Request" would win a case-insensitive title match). Then StartersService::refreshTaskText() writes the title and description opening the case would have written, with the date of the import.

The steps in the demo were produced by evaluating each template's "only if" rules against that case's answers, so a demo case never has a step its answers would not have made.

7. Tests

tests/starters.php (45 checks) - writes, so it needs both FREEITSM_TEST_DATABASE and FREEITSM_BASE_URL pointing at a throwaway install, and removes everything it makes. It checks that steps become subtasks due on the right days, that progress follows a step closed in Tasks, that kit goes through Assets custody and a Retired status retires it, that Left switches the person off (and is refused for a directory-managed one), that the People page lists the cases, that a form's answers choose the template, skip the steps whose "only if" fails and fill {Question}, that a template with questions waits for answers given on the task, and that a GET cannot change a case.

8. Not built yet

  • The REST API (/api/v1) - cases are not exposed there yet.
  • A Watchtower card and Report Pack blocks.
  • The other locales - German and Spanish arrived with 4.6.0; the rest follow through the i18n pipeline.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally