Case Report Developer Guide - edmozley/freeitsm GitHub Wiki
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.
| 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.
Report Packs already makes PDFs, so it was the obvious thing to reuse. When the case report was built it was the wrong tool here, for a reason that is easy to miss: assets/js/report-packs/render-pdf.js drew text in the PDF standard fonts, which hold Western European Latin only. Its safe() turned every other character into ? - a Polish Ε, Ukrainian, every Indian script. Adding a font would not have fixed it either: jsPDF does not shape Indic or Arabic text.
Since #254 that reason has gone: every PDF in FreeITSM is made by the shared PDF writer on PDFKit, with bundled Noto fonts and text shaping, so Report Packs print every language FreeITSM ships (Chinese, Japanese and Korean excepted). See PDF exports moved to PDFKit (#254). The case report is still a printed page, built as described below.
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).
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
GETform whose checkboxes submit on change, so every view is a plain URL - bookmarkable, and nothing is stored server-side. -
set=1marks 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". -
internalonly counts withnotes.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 callswindow.print()300ms afterload. A browser Ctrl+P prints the same page without recording - the user page says so.
| 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; by_system when analyst_id is NULL (a workflow's or an imported note), shown as FreeITSM - not "former analyst", which is for a deleted analyst (#274) |
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.
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 |
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.
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.
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.
?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.
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 reportaudit row with the scope
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.
- Add its key to
CaseReport::SECTIONSin the position it should print. The panel, contents, numbering and "left out by choice" line all follow from that list. - Add a reader in
forTicket()behindif ($on('<key>')), wrapped inself::attempt($r, '<key>', ...). If it counts what it leaves out, usefunction () use (..., &$r)- not an arrow function (trap 1). - Add the key to
shell()with an empty default, so the page never meets an undefined index. - Render it in
caseReportPage()with$head('<key>', $count)so it is numbered and linkable, and add its count to$secCountif the contents should show one. - Add
section_<key>(and anypanel_text) tolang/en/case-report.php. - Decide what a reader without access sees - a count in
hidden, or anotes_scopereason - and add a test for it, with a control. - Say whether it applies to a task report; if not, leave it out of
$sectionsHereforkind === 'task'.
| # | 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 |
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.
-
No page numbers on the contents page - waits on
target-counter()in Chrome. -
Ticket checklists show their current state only -
ticket_checklist_itemskeeps no change history (task checklists do, viatask_checklist_item_events). -
Email attachments have no fingerprint - only
documentsstore 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.
- Case reports
-
Repeating task evidence - Developer Guide -
TaskEvidence::when()and the per-task readers this reuses - Report Packs internals, part 2 - the PDF builder, and its standard-font limit