Skip to content

Case Report Developer Guide

Ed Mozley edited this page Oct 22, 2026 · 3 revisions

πŸ› οΈ Case reports - Developer Guide

How the case report (#204) is built. For what it does and how to use it, see Case reports.

In one paragraph: an entry page reads the reader's choices from the query string, CaseReport gathers the ticket (or task) and everything under it into one array - checking each part against the reader's access and counting what it leaves out - and caseReportPage() lays that array out as a single HTML page styled as paper. The browser's Print -> Save as PDF makes the file. Printing through the page's own button records the export on the record's history.


1. πŸ“ The files

File What it does
includes/case_report.php CaseReport - gathers the report: forTicket(), forTask(), options(), recordExport(), body()
includes/case_report_page.php caseReportPage() - lays it out as one printable page: banner, choices panel, cover, contents, sections. Helpers caseReportTask(), caseReportTaskHistories(), caseReportEvent(), caseReportChange(), caseReportCountTasks(), caseReportRecipients(), caseReportSize(), caseReportWhere()
tickets/case-report.php Entry for a ticket: ?id=. Gated on Tickets
tasks/case-report.php Entry for a task on its own: ?id=. Gated on Tasks
lang/en/case-report.php Its strings (namespace case-report); it also reuses tasks.evidence.* for checklist results and task events
assets/js/inbox.js openCaseReport() and the Export toolbar button (pane-secondary, so it folds into More)
assets/js/tasks.js, tasks/index.php openTaskCaseReport() and the document button in the task window header
tickets/help.php The Case reports help section (strings tickets.help.case.*), linked from Comments & attachments
tests/case-report.php The test - see section 10

The page URL is handed to the JavaScript as window.CASE_REPORT_URL, built from BASE_URL in each page, rather than worked out from API_BASE.


2. πŸ”‘ Why a printed page and not Report Packs

Report Packs already makes PDFs, so it was the obvious thing to reuse. It is the wrong tool here, for a reason that is easy to miss: assets/js/report-packs/render-pdf.js draws text in the PDF standard fonts, which hold Western European Latin only. Its safe() turns every other character into ? - a Polish Ε‚, Ukrainian, every Indian script. Adding a font would not fix it either: jsPDF does not shape Indic or Arabic text.

A case report reprints people's own words. Turning a name or an error message into ? is rewriting the record, which is the one thing it must not do. So it follows the evidence exports already in the product (change-management/evidence.php, tasks/evidence/report.php): the server builds the page, and the browser's Print -> Save as PDF makes the file. The browser prints every script the screen can show, keeps the text selectable, and runs long sections on across pages.

What Report Packs did have worth borrowing - a cover, contents, section breaks, page numbers - is reproduced in print CSS (section 6).


3. πŸ”„ The request flow

Export button (inbox.js)            document button (tasks.js)
        |                                     |
tickets/case-report.php?id=N        tasks/case-report.php?id=N
        |  requireModuleAccess('tickets')     |  requireModuleAccess('tasks')
        v                                     v
CaseReport::options($_GET)  ->  ['sections', 'internal', 'cover', 'contents', 'breaks', 'pagenums']
        |
CaseReport::forTicket() / forTask()   -> array, or null (404 "not found")
        |
?print=1 ?  -> CaseReport::recordExport()   (failure -> $recordError, report still prints)
        |
caseReportPage($r, $print, $recordError)  -> HTML; on print, window.print() after load
  • The choices are the query string. The panel is a GET form whose checkboxes submit on change, so every view is a plain URL - bookmarkable, and nothing is stored server-side.
  • set=1 marks a submitted form. Without it (a first visit from the Export button) options() applies the defaults: every section, internal notes off, cover + contents + page numbers on, breaks off. With it, an unticked box is off - otherwise unticking the last section would snap back to "everything".
  • internal only counts with notes. options() drops it when the Notes section is not chosen, so the audit line never claims internal notes went out when no notes did.
  • The Print button is a link to the same URL plus print=1. That load records the export, shows a "recorded" tick (screen only), and calls window.print() 300ms after load. A browser Ctrl+P prints the same page without recording - the user page says so.

4. πŸ—ΊοΈ Where each section comes from

Section Source Method
Overview tickets joined to statuses, priorities, departments, types, categories (and closure category), resolution codes, teams, analysts (assignee, owner), origins, users, tenants ticketRow()
- custom fields the custom ticket fields service, values only customFields()
- assets ticket_assets -> assets linkedAssets()
- configuration items ticket_cmdb_objects -> cmdb_objects linkedCis()
- related tickets ticket_links (either direction) linkedTickets()
Conversation emails WHERE ticket_id, by received_datetime, id conversation()
Notes ticket_notes + author notes()
Ticket checklists ticket_checklists -> ticket_checklist_items ticketChecklists()
History ticket_audit + analyst ticketHistory()
Work performed tasks WHERE ticket_id (top level only, see trap 4), then per task: tasks (+ statuses, priorities, assignee, team, creator, completer, supplier), task_comments, task_checklists -> task_checklist_items, task_checklist_item_events, TaskForms::forTask(), TaskProcedures::forTask(), TaskFollowUps::forTask(), task_audit + task_events, child tasks ticketTaskIds(), taskBlock() (recursive)
Evidence index document_links -> documents (not deleted) for ticket, each included ticket_note, and each task; email_attachments -> emails documentsFor(), emailAttachments(), collectTaskDocuments()

Tasks are read when either Work performed or the Evidence index is chosen, because the index lists task documents. If only the index is chosen the task blocks are built for their documents and then dropped ($r['tasks'] = []) before the page sees them.

The supplier name is COALESCE(trading_name, legal_name) - suppliers have no name column.


5. 🧱 The report array

forTicket() and forTask() both start from shell(), so the page can rely on every key existing:

Key Holds
kind 'ticket' or 'task'
ref, title ticket number (or #id for a task) and subject/title
produced_utc, tz, by, viewer when, in which zone it is printed, who (name and id)
options the options() result - the page reads layout choices from here
ticket the ticketRow() row, or null for a task
multi multi-company install - controls whether the company is printed
fields, assets, cis, links overview extras
conversation, notes, checklists, history ticket sections (empty for a task)
tasks task blocks (below); for a task report, exactly one
evidence ['attachments' => [...], 'documents' => [...]]; each document carries where (ticket, note, task)
hidden counts of what was left out for access: tasks, subtasks_deeper, assets, cis, links, documents, internal_notes
notes_scope reasons printed as notices, e.g. tasks_no_module
unavailable sections whose read failed (section 8)

A task block (taskBlock()):

Key Holds
id, task the task row with its joined names
ticket_ref task reports only: the ticket number, false if the reader may not see it ("belongs to a ticket you cannot open"), null if none
comments, checklists, steps comments; checklists with items; step change events
forms, procedures, follow_ups from the task services, each with its own visibility rule
history task_audit rows (kind => 'audit') and task_events rows (kind => 'event'), merged by time
documents the task's documents (collected into the evidence index)
subtasks child blocks, to SUBTASK_DEPTH (3)
unavailable parts of this task that could not be read

6. πŸ“„ Layout - the useful bits of Report Packs, in print CSS

Choice (options() key) How
Cover (cover) A .cr-cover section with break-after: page, a fixed height: 238mm in print so it fits inside Letter (Chrome's default) as well as A4
Contents (contents) A .cr-toc list of #sec-* and #task-* anchors; Chrome keeps internal links in the saved PDF. No page numbers - target-counter() is not supported in Chrome
Breaks (breaks) body.breaks h2.sec { break-before: page }
Header and page numbers (pagenums) @page margin boxes (@top-left, @bottom-right with counter(page) / counter(pages)), Chrome 131+ and Edge; ignored elsewhere. Off on the cover via @page :first

Numbering. caseReportPage() builds $printed - overview then each chosen section in SECTIONS order (a task report forces tasks in second) - and flips it into $num. The contents and the $head() headings both read $num, so they can never disagree. Headings carry id="sec-<key>"; each task block carries id="task-<id>".

On screen vs paper. .noprint hides the banner and panel; .cr-paper is a white sheet on a grey desk on screen and plain in print. print-color-adjust: exact keeps the accent colours (#0078d4 ticket, #7c3aed task) without the user ticking Background graphics. Below 860px the shell becomes one column and the panel sits above the paper.

Page counter string. "Page {page} of {pages}" is one translatable template, split around its placeholders into the CSS content value ("Page " counter(page) " of " counter(pages)), so a translator never sees two glued fragments and can reorder the numbers. Each margin box sets its own font-family - margin boxes do not inherit from body and otherwise print in the browser's serif.

Notices (in the bin, unavailable, hidden counts, export not recorded) sit under the Overview heading, not above it, so a section break never strands them on a page of their own.


7. πŸ”’ Permissions - checked per part, counted not named

forTicket() returns null (shown as "not found") unless analystCanAccessTicket() passes; forTask() needs Tasks access and analystCanAccessTask(). Then every part is checked again with the rule its own screen uses:

Part Rule
Linked tasks and subtasks analystCanAccessModule('tasks') and analystCanAccessTask(), each - a subtask can sit in another company
Linked assets analystCanAccessModule('assets') and the asset's company (tenantOk())
Configuration items analystCanAccessModule('cmdb')
Related tickets analystCanAccessTicket()
Documents documentCanViewParent() for the ticket, each included note (ticket_note) and each task
Form answers TaskForms::forTask(..., true) - answers only where mayReadAnswers()
Procedures, follow-ups TaskProcedures::forTask(), TaskFollowUps::forTask() - their own visibility rules
A task-only report's ticket named only with Tickets access and analystCanAccessTicket()

What fails a check goes into $r['hidden'] as a count. The page prints the count and never the title, file name or link. A reader without Tasks gets notes_scope[] = 'tasks_no_module' and a sentence saying why the tasks are missing, rather than a count that looks like a per-task decision.

Internal notes (ticket_notes.is_internal = 1) are left out unless ?internal=1 is chosen, and counted in hidden['internal_notes']. Documents on an included note are only gathered for the notes that made it in, so an internal note's attachments do not leak through the evidence index.


8. ⚠️ "Could not be read" is not "empty"

Every read runs through CaseReport::attempt() (or the per-task $try). A failure is logged and the section is marked in $r['unavailable'] (or $block['unavailable']), and the page prints a red notice instead of "None recorded". A missing table on an older install is the usual cause.


9. 🧾 Recording the export, and rendering stored text

?print=1 calls CaseReport::recordExport(): one ticket_audit row (field Case report) for a ticket, or one task_audit row (source = 'app') for a task, with the value Exported by <name>: <sections> plus (with internal notes), cut to 500 characters. Previewing and changing the ticks record nothing. If the row cannot be written the error is logged, the report still prints, and a notice says the export was not recorded.

Everything stored was written by a person. HTML (email bodies, notes, task descriptions, comments) goes through CaseReport::body(), which is sanitiseUserHtml() - the allow-list cleaner. It removes <img> as well as script and event handlers, which is deliberate: a remote image would beacon the reader's address when the page opens. A message whose body_type is text (a chat channel) is escaped with its line breaks kept. Everything else goes out through htmlspecialchars.

Email bodies are printed as stored - the reading pane's stripQuotedThread() is not applied, because trimming is a change to the record.

Times are UTC at rest and printed with TaskEvidence::when() in the install's zone, which the head of the report names. Due and start dates use TaskEvidence::day() and are not converted; scheduled work is printed as entered.


10. πŸ§ͺ Testing it

The automated test

php tests/case-report.php - database, inside one transaction that is rolled back, so nothing it makes outlives the run. It builds a ZZ ticket with a hostile subject, email, description and comment (<script>, <img onerror>, a javascript: link), a shared and an internal note, a task with a passing and a failing checklist step and a step event, a subtask, and a ZZ analyst with Tickets only. It proves:

  • no script, live image tag, event handler or javascript: link reaches the page, and the subject is escaped (with a control that the live-tag pattern does catch a live tag)
  • the internal note is absent by default, counted, and the report says how many - and present when ticked in (the control)
  • checklist results, the exception note, the step event and the subtask are all there
  • a reader without Tasks gets the ticket, no task names, the reason - and no task-only report
  • a section left out is left out and named as such
  • printing writes the Case report audit row with the scope

Checking the paper by eye

The test proves content, not layout. For any change to the print CSS, print a real ticket to PDF and look at the pages - the screen view tells you nothing about margin boxes or breaks. Headless Chrome's Page.printToPDF over the DevTools protocol does this from PHP (as tests/task-form-embed.php already does with cdpEvaluate()); render the PDF's pages with the bundled pdf.js to look at them. Check, at least: the cover is one page on Letter (Chrome's default, and the size that broke first), the header and "Page n of m" appear on every page but the cover, the contents links jump, and turning on breaks does not leave a page holding only a notice.


11. βž• Adding a section

  1. Add its key to CaseReport::SECTIONS in the position it should print. The panel, contents, numbering and "left out by choice" line all follow from that list.
  2. Add a reader in forTicket() behind if ($on('<key>')), wrapped in self::attempt($r, '<key>', ...). If it counts what it leaves out, use function () use (..., &$r) - not an arrow function (trap 1).
  3. Add the key to shell() with an empty default, so the page never meets an undefined index.
  4. Render it in caseReportPage() with $head('<key>', $count) so it is numbered and linkable, and add its count to $secCount if the contents should show one.
  5. Add section_<key> (and any panel_ text) to lang/en/case-report.php.
  6. Decide what a reader without access sees - a count in hidden, or a notes_scope reason - and add a test for it, with a control.
  7. Say whether it applies to a task report; if not, leave it out of $sectionsHere for kind === 'task'.

12. πŸ”΄ Traps

# Trap Why
1 Arrow functions lose the counts. Reads that update $r['hidden'] must be function () use (..., &$r) fn() => captures $r by value; the first build printed "0 left out" for everything. tests/case-report.php catches it
2 The sanitiser strips <img> On purpose (no beacons). Don't "fix" missing pictures in emails by loosening it
3 Margin boxes are Chrome 131+ / Edge only Other browsers silently ignore @page margin boxes; the report must still read without them, so never put content only in a margin box
4 ticketTaskIds() returns top-level tasks only A subtask also carries the ticket's ticket_id; listing it at top level would print it twice. The query keeps tasks whose parent is not on the same ticket
5 Letter, not A4, is the default The first cover fitted A4 and spilled onto a second Letter page. It is 238mm
6 Documents on a ticket in the bin documentCanViewParent() refuses a deleted parent, so they are counted as hidden; the report shows an in the bin notice so the count is not read as an access problem
7 Notices above the Overview heading With breaks on they printed alone on page 2. They live under the heading
8 Cache-busters inbox.js and tasks.js are cached hard - bump ?v= in tickets/index.php / tasks/index.php with any change to the buttons
9 pdf.js is an ES module To read a printed PDF in a harness: import * as pdfjsLib and getDocument({url}), not a script tag and a path string

13. 🌍 Translation

Strings live in lang/en/case-report.php (case-report.*); checklist results and task event wording reuse tasks.evidence.*, so the two evidence reports say the same thing in every language. The page counter, running header and footer are whole templates with placeholders, never glued fragments. The help text is tickets.help.case.* and tasks.help.case_report_*. New strings ship in English and are checked by the i18n audit; see Adding a language for how locales are filled.


14. πŸ“ Known limits

  • No page numbers on the contents page - waits on target-counter() in Chrome.
  • Ticket checklists show their current state only - ticket_checklist_items keeps no change history (task checklists do, via task_checklist_item_events).
  • Email attachments have no fingerprint - only documents store a SHA-256; the index lists attachments by name and size.
  • Subtasks stop at depth 3 - deeper ones are counted in hidden['subtasks_deeper'].
  • No server-side PDF - deliberate (section 2). A scheduled or emailed case report would need a headless browser on the server, which FreeITSM does not require.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally