Skip to content

Catalogue Request Approvals Developer Guide

Ed Mozley edited this page Jul 23, 2026 · 1 revision

Catalogue Request Approvals β€” Developer Guide

How catalogue-request approvals work, and why the shape is the way it is. Shipped as #928 (the gate + approver inbox + auto-raise) and #929 (the requester's dashboard view).

The user-facing page is Catalogue Request Approvals.


1. πŸ“ The files involved

Colour key: πŸ—„οΈ schema Β· βš™οΈ engine Β· πŸ”Œ API Β· πŸ–₯️ UI Β· 🎨 CSS Β· 🌍 i18n Β· πŸ“„ docs

🎨 File What it does
πŸ—„οΈ database/freeitsm.sql forms.requires_approval / approver_id; form_submissions.approval_status / approver_id / approval_decided_by_id / approval_decided_datetime / approval_comment; the four FKs to analysts
πŸ—„οΈ includes/db_verify_schema.php the same columns (columns only β€” FKs live in freeitsm.sql)
βš™οΈ includes/catalogue_approvals.php the engine. catalogueApprovalDecide(), catalogueCreateTicketFromSubmission(), catalogueApprovalsList(), catalogueApprovalGate(), catalogueSubmissionBodyHtml()
βš™οΈ includes/services/forms.php FormsService::submitForm() gates the submission; saveForm() stores the per-item settings
πŸ”Œ api/forms/catalogue_approvals.php the inbox list (?filter=mine|all|decided)
πŸ”Œ api/forms/decide_catalogue_approval.php approve / reject one request
πŸ”Œ api/forms/save_form.php unchanged β€” passes requires_approval / approver_id straight through to the service
πŸ”Œ api/forms/get_forms.php returns the approval columns + approver_name for the list
πŸ”Œ api/self-service/get_dashboard.php returns the requester's requests[] with their state (#929)
πŸ–₯️ forms/index.php the πŸ›‘οΈ shield button, the Approval settings modal, the amber pill
πŸ–₯️ forms/approvals.php the approver inbox (cloned from change-management/approvals.php)
πŸ–₯️ forms/includes/header.php the Approvals nav item
πŸ–₯️ self-service/index.php the Your requests dashboard section (#929)
🌍 lang/en/forms.php + lang/pt-BR/forms.php the approval block, same commit
🌍 lang/en/self-service.php + lang/pt-BR/self-service.php the dashboard req_* keys (#929)
πŸ“„ CHANGELOG.local.md, this wiki #928 / #929

There is no new table: a catalogue item is a forms row and a request is a form_submissions row, so the approval state hangs off columns on those, mirroring how is_portal_visible was added.


2. πŸ”‘ Why a designated analyst, and why it clones (not shares) the Change model

The classic version routes a request to the requester's line manager. Portal users (users) have no manager relationship β€” only tenant_id (their company) β€” so v1 reuses Change Management's single-approver idea instead: one analyst signs a catalogue item off.

The Change approval code is a good template but nothing is shareable: it's welded to the changes table and to analysts, with status-name string literals. So this is a parallel, smaller implementation of the approver + inbox pattern from Change Management.


3. βš™οΈ Gating a submission

FormsService::submitForm() decides at submit time whether to gate:

$gateApproverId = ($portalUserId !== null && !empty($form['requires_approval']) && !empty($form['approver_id']))
    ? (int) $form['approver_id'] : null;
$approvalStatus = $gateApproverId !== null ? 'pending' : 'not_required';

Three deliberate rules live in that one line:

  • Portal only ($portalUserId !== null). The feature auto-raises a ticket for the requester; an analyst filling a form internally has none, so it's never gated.
  • Unconfigured β‰  gated. requires_approval on with no approver_id falls through to not_required β€” a form must never strand a request with nobody able to clear it.
  • The approver is snapshotted onto form_submissions.approver_id here, not read from the form at decision time. Editing the catalogue item later re-routes future requests, never ones already waiting.

πŸ”‘ A gated submission fires catalogue_request.submitted, NOT form.submitted. An admin's create-ticket workflow rule on form.submitted would otherwise raise the ticket immediately and jump the gate. The outcome events are catalogue_request.approved / .rejected. All three are best-effort (guarded by class_exists('WorkflowEngine') and try/catch) β€” notification is a bonus, never the mechanism.


4. βš™οΈ Deciding, and the auto-raise

catalogueApprovalDecide($conn, $actorId, $submissionId, $decision, $comment):

  1. Loads the submission; refuses if it isn't pending.
  2. Refuses unless the actor is approver_id β€” or sessionIsAdmin().
  3. On approve, inside one transaction: raise the ticket, then stamp approval_status='approved', approval_decided_by_id, timestamp, comment and ticket_id. On reject: the same minus the ticket.

catalogueCreateTicketFromSubmission() deliberately mirrors api/self-service/create_ticket.php β€” the correct portal path:

  • requester resolved by users.id (never an email string β€” that's the bug in WorkflowEngine::action_create_ticket());
  • company from the requester's own tenant_id, falling to NULL/Default exactly as the portal's new-ticket path does;
  • status Open, the install's default priority;
  • body built by catalogueSubmissionBodyHtml() β€” the answers as a fully-escaped table, so a customer's field values can't inject markup into the analyst's reading pane.

πŸ”‘ This is also where promote-to-ticket finally landed. form_submissions.ticket_id had been a reserved "not yet actioned" column that nothing wrote. The approve path is its first writer.


5. πŸ–₯️ The three surfaces

  • Config (forms/index.php): the shield button opens a self-contained modal (#approvalModal). Active analysts are fetched once from api/tickets/get_analysts.php. Save posts only {id, requires_approval, approver_id} β€” a partial update, the same contract as the portal togglePortal, so nothing else about the form is touched.
  • Approver inbox (forms/approvals.php): cloned from change-management/approvals.php β€” filter sidebar (mine/all/decided) + card list, each card showing the answers and Approve/Reject with an optional note. showToast comes from the shared header, so the page doesn't load toast.js itself.
  • Requester view (self-service/index.php, #929): a Your requests section, hidden until the dashboard payload carries any. api/self-service/get_dashboard.php reads them in a guarded query β€” a pre-upgrade instance without the approval columns degrades to no requests rather than a broken dashboard.

6. βœ… How this was verified

  1. php -l across every changed file.
  2. Engine end-to-end against the dev DB: seed a pending request β†’ catalogueApprovalsList shows it β†’ catalogueApprovalDecide(approve) raises a ticket, stamps ticket_id, sets approved; requester (user_id), company and the answers-in-body all correct; re-approve and wrong-approver both refused with their specific messages.
  3. Both analyst pages rendered with a forged analyst session (HTTP 200, no PHP errors), inline JS parsed clean in headless Chrome.
  4. #929: get_dashboard.php returns the three states (pending / approved+ticket_number / rejected) via a forged portal session; the dashboard renders clean.

7. Extending it

  • Manager-based routing β€” the headline next step. Needs users.manager_id (or equivalent) and a way to populate it (LDAP/SSO/manual), then gate on the requester's manager instead of a fixed analyst. That relationship is its own mini-project.
  • A multi-approver board β€” lift Change Management's voteCab (enum + membership check + double-vote block + all/majority threshold) into a catalogue_request_approvers table.
  • Show the rejection reason to the requester β€” the note is stored (approval_comment); surfacing it on the portal is a product decision (flip the approver's note from internal to requester-visible).
  • Attachments β€” forms have no file fields today; if they gain uploads, carry them onto the ticket in catalogueCreateTicketFromSubmission() (the pattern is in create_ticket.php).
  • A "raise ticket" button for NON-gated submissions β€” forms/submissions.php still has none; the approve path only covers gated ones.

See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally