Repository navigation
RFP Builder Developer Guide
User page: Contracts - RFP Builder Β· pages contracts/rfp-builder/ Β· endpoints api/rfp-builder/ Β· helpers includes/rfp_ai.php, includes/rfp_scope.php, includes/rfp_docx_parser.php
An RFP starts as a pile of Word documents, one per department, each saying what that department wants from a new system. The RFP Builder reads the text out of each document, asks an AI to list every requirement in it, then asks the AI once more to merge the lists from every department into one set: sorted into categories, given a priority, with contradictions flagged. The analyst tidies that set by hand and locks it. Only then does the AI write the document - an introduction, scope and response instructions, plus one section per category - which the analyst edits and prints. Invited suppliers are then scored 0-5 against every requirement, and a compare page ranks them.
Everything below an RFP belongs to that RFP and follows its company. The AI settings (provider, model, key) are the RFP Builder's own, not the shared AI Providers panel.
| File | Job |
|---|---|
contracts/rfp-builder/index.php |
The RFP list, with New / Edit / Delete (get_rfps, get_rfp, save_rfp, delete_rfp) |
contracts/rfp-builder/view.php |
One RFP's overview: phase tiles linking to the pages below, and the AI activity panel (get_processing_log) |
contracts/rfp-builder/documents.php |
Upload, view text, re-extract, run AI extraction on, and delete the source .docx files |
contracts/rfp-builder/extracted.php |
Every extracted requirement across the RFP's documents, filter by department and type, edit and delete |
contracts/rfp-builder/consolidate.php |
The consolidation workbench (1,712 lines) - see consolidate.php |
contracts/rfp-builder/coverage.php |
Category x department heatmap (get_coverage) |
contracts/rfp-builder/document.php |
The generated document: framing sections, per-category sections, Generate all, TinyMCE edit, version history, restyle |
contracts/rfp-builder/preview.php |
Print-friendly render of the whole document, built server-side with no API call. PDF is the browser's print |
contracts/rfp-builder/suppliers.php |
Invite existing suppliers, create a prospective one inline, demo dates and notes |
contracts/rfp-builder/scoring.php |
Score one supplier at a time, 0-5 per requirement, autosave, radar chart |
contracts/rfp-builder/compare.php |
Read-only cross-supplier compare: ranked cards, radar overlay, category winners (get_compare_data) |
contracts/rfp-builder/audit.php |
The full, filterable rfp_processing_log for one RFP |
contracts/rfp-builder/help.php |
Static in-app help |
contracts/rfp-builder/uploads/ |
Where uploaded .docx files are stored - see Uploads |
includes/rfp_ai.php |
Every AI call: settings, the HTTP calls, streaming, the prompts, the log |
includes/rfp_scope.php |
Company scoping: rfpGate(), rfpCanView(), rfpListSql() and friends |
includes/rfp_docx_parser.php |
rfpExtractDocxText() - plain text out of a .docx |
contracts/settings/index.php |
The two settings tabs, RFP departments and RFP AI |
tests/rfp-companies.php |
Fails if an endpoint is added without the company gate - see Company scoping |
Every page calls requireModuleAccess('contracts'). Pages fetch from api/rfp-builder/ through a API_BASE constant set to '../../api/rfp-builder/'.
The code's comments number the AI calls as passes; the user page numbers the phases the analyst sees. Both are below.
-
Create the RFP -
save_rfp.php. Status starts atdraft. The allowed statuses aredraft,collecting,consolidating,generating,scoring,closed,abandoned. -
Upload (
documents.php->upload_document.php) -.docxonly, 20 MB max (RFP_UPLOAD_MAX_BYTES). Each document can carry anrfp_departmentsid. The file is saved, arfp_documentsrow is written with statusuploaded, and text extraction runs straight away. -
Text extraction -
rfpExtractDocxText()turns the .docx into flat text intorfp_documents.raw_text, statusextracted. A failure leaves the upload in place with statuserror;extract_document.phpre-runs extraction on the stored file. -
Pass 1 - AI extraction, per document (
extract_requirements.php->rfpAiExtractRequirements()). It deletes that document's earlier extracted rows first, then inserts onerfp_extracted_requirementsrow per item: text, type (requirement,pain_pointorchallenge- anything else becomesrequirement), source quote, confidence clamped to 0-1. Document status becomesprocessed. This is a plain request, not a stream. -
Pass 2 - consolidation, one call for the whole RFP (
consolidate.php->run_consolidation.php->rfpAiConsolidateStreaming()). The AI returns one JSON object withcategories(asked for 8 to 20),consolidated_requirements(each with a priority,critical/high/medium/low, a rationale and thesource_extracted_idsit came from) andconflicts. Only after the stream finishes does one transaction delete the RFP's consolidated requirements and categories and write the new ones, therfp_consolidated_sourceslinks (source ids not in the RFP are ignored) andrfp_conflictsrows with resolutionopen. Status moves fromdraft/collectingtoconsolidating. -
Hand editing on
consolidate.php- edit, add, split, merge, delete, resolve conflicts. -
Lock -
lock_consolidation.phpsetsis_lockedon every consolidated row of the RFP (or clears it) and moves the status togenerating(or back toconsolidating). -
Pass 3 - section per category (
document.php->generate_section.php->rfpAiGenerateSectionStreaming()), HTML intorfp_output_sections. -
Pass 5 - framing (
generate_framing.php->rfpAiGenerateFramingStreaming()):introduction,scopeandresponse_instructions(RFP_AI_FRAMING_KEYS) intorfp_document_sections. It sends the RFP name, the analyst's context note (rfps.framing_context_text, saved byupdate_framing_context.php) and the category list - not the requirements. -
Pass 4 - restyle (
restyle_section.php,restyle_framing.php->rfpAiRestyleStreaming()): re-applies the style guide to existing HTML without changing its meaning. -
Output -
preview.phprenders framing then category sections for printing. There is no .docx or PDF writer; "export" is the browser's print. -
Suppliers and scoring -
invite_supplier.php/create_prospective_supplier.phpintorfp_invited_suppliers;save_score.phpupsertsrfp_scores;get_scores.phpandget_compare_data.phproll them up.
Steps 8 to 10 refuse while any consolidated requirement of the RFP is unlocked - the check is in each of the four endpoints, answered as a stream error. The two restyle endpoints also refuse when there are no consolidated rows at all; the two generate endpoints do not count that case as unlocked.
rfpAiGetSettings() reads four system_settings rows: rfp_ai_provider, rfp_ai_api_key (encrypted, listed in includes/encryption.php), rfp_ai_model and rfp_ai_verify_ssl. The provider must be one of RFP_AI_VALID_PROVIDERS = ['anthropic', 'openai']; a missing provider, key or model throws a RuntimeException telling the analyst to set it under Contracts -> Settings -> RFP AI.
This is separate from AiProvider in includes/ai_provider.php. That file's header says its request shapes mirror rfp_ai.php and that it is "intentionally independent so the RFP builder isn't affected". Azure, OpenRouter and OpenAI-compatible servers are not available here. AI Providers says the same from the other side.
Settings are written by save_ai_settings.php, read (key masked) by get_ai_settings.php and tried by test_ai_connection.php, which sends a tiny prompt with its own rfpTestAnthropic() / rfpTestOpenAI(). The same tab saves rfp_default_style_guide.
| Function | What it does |
|---|---|
rfpAiCall() |
One non-streaming request, Anthropic or OpenAI by setting. Defaults max_tokens 4096, temperature 0.0 |
rfpAiCallAnthropic() / rfpAiCallOpenAI()
|
The two request shapes. Anthropic marks the system prompt for caching |
rfpAiHttpPostWithRetry() |
cURL POST, retries 429, 5xx and network errors up to RFP_AI_RETRY_MAX (3) with backoff from RFP_AI_RETRY_BACKOFF_MS (2000 ms, doubling); timeout RFP_AI_HTTP_TIMEOUT (120 s) |
rfpAiCallAnthropicStreaming() |
Streaming request to Anthropic. Calls $onEvent('text', {delta}) and $onEvent('usage', {...}) as data arrives. One attempt, no retry - once text has gone to the browser it cannot be replayed. No overall timeout; it gives up after 60 s with no bytes |
rfpAiExtractRequirements() |
Pass 1, via rfpAiCall(), max_tokens 8192 |
rfpAiConsolidate() / rfpAiConsolidateStreaming()
|
Pass 2, max_tokens 16000. Only the streaming one has a caller (run_consolidation.php) |
rfpAiGenerateSectionStreaming() |
Pass 3. Loads the category's consolidated requirements (ordered by priority) and the departments behind each one through rfp_consolidated_sources
|
rfpAiGenerateFramingStreaming() |
Pass 5, one framing key at a time |
rfpAiRestyleStreaming() |
Pass 4 |
rfpAiResolveStyleGuide() |
rfps.style_guide, else system_settings.rfp_default_style_guide, else RFP_AI_FALLBACK_STYLE_GUIDE
|
rfpAiLogAction() |
One rfp_processing_log row per call, success or error |
Every helper strips a stray ```json fence before json_decode(), and Pass 2 refuses a reply missing any of its three keys.
rfpAiCallAnthropicStreaming() throws "Streaming is currently only supported with the Anthropic provider." for any other provider. Consolidation, generation, framing and restyle all stream, so with openai saved only Pass 1 extraction (and Test) works - everything from consolidation on stops with that error.
rfpAiCallAnthropicStreaming() takes an optional $settingsOverride so another feature can stream on its own key. api/forms/ai_generate.php, api/knowledge/writeup_stream.php and api/tickets/ai_cleanup_reply.php call it this way, and api/tickets/ai_merge_summary.php requires the file. Changing its signature or event shape changes those features too.
rfp_ai_verify_ssl is still saved and read, but every cURL call goes through sslApplyCurl(), which follows the global switch alone - see SSL Verification Global Setting. The parameter comments on rfpAiHttpPostWithRetry() and the test functions say so.
The five streaming endpoints are run_consolidation.php, generate_section.php, generate_framing.php, restyle_section.php and restyle_framing.php. Each:
- is a GET (the browser's
EventSourcecannot POST), with ids in the query string; - calls
csrfRequireToken()because it writes -assets/js/csrf.jsadds?_csrf=to everyEventSourceURL (CSRF Protection); - opens with
session_start(['read_and_close' => true]), turns off output buffering, sendsX-Accel-Buffering: nofor nginx, andset_time_limit(0); - checks module access with
analystCanAccessModule()and the RFP withrfpCanView(), answering both as anerrorevent, becauserequireModuleAccessJson()orrfpGate()would print JSON into the stream; - sends
phase,text,usage, thencompleteorerrorthrough a localsse()function.generate_section.phpcan also sendskipped.
The pages close the EventSource on complete and on error.
generate_section.php hashes the inputs (category name and description, each requirement's id, text, type, priority and departments, and the style guide) and sends skipped without calling the AI when the hash equals rfp_output_sections.requirements_hash, force is not set and the section is not manually edited. A manually edited section is therefore always regenerated - the old content goes to rfp_section_history first.
rfp_default_style_guide, while the stored hash comes from rfpAiGenerateSectionStreaming(), which uses rfpAiResolveStyleGuide(). When an RFP has no guide of its own but a system default is saved, the two hashes never match and the skip never fires. Keep the two in step if either changes.
generate_framing.php reads inputs_hash but its skip block is empty: framing always calls the AI and saves.
rfp_output_sections.version goes up on every generate, manual edit (update_section.php), restyle and restore (restore_section_version.php), and the previous content is copied into rfp_section_history each time. Manual edits and restores set is_manually_edited = 1; generate and restyle set it to 0. Framing (rfp_document_sections) has no history - a restyle or regenerate overwrites it.
The biggest page in the module. It loads everything from get_consolidated.php (loadAll(), render()), shows requirements grouped by category with their source quotes (toggleSources()), the AI rationale, and a conflicts panel.
| UI | Function | Endpoint |
|---|---|---|
| Run consolidation (stream modal) | runConsolidation() |
run_consolidation.php |
| Edit / add custom |
openEditModal(), openAddModal(), saveEdit()
|
update_consolidated.php, add_consolidated.php
|
| Delete | deleteRow() |
delete_consolidated.php |
| Split one into several |
openSplitModal(), saveSplit()
|
split_consolidated.php |
| Merge selected rows |
onSelectRow(), openMergeModal(), saveMerge()
|
merge_consolidated.php (rfpGate('consolidated', 'ids') checks every id) |
| Resolve / reopen a conflict |
openResolveModal(), saveResolve(), reopenConflict()
|
resolve_conflict.php |
| Merge from a conflict | mergeFromConflict() |
merge_consolidated.php - the conflict row goes by cascade |
| Lock / unlock |
toggleLock(), applyLockState()
|
lock_consolidation.php |
While the stream runs, updateProgressTracker() counts "name":, "requirement_text": and "consolidated_a_index": in the partial JSON to show live category, requirement and conflict counts.
Split drops any source id not given to a new row, and deletes the original row's conflicts with it. Add custom sets ai_rationale to "Manually added".
applyLockState() hides the Run button while locked, but run_consolidation.php and the edit, split, merge and delete endpoints do not check is_locked. Only the four generate and restyle endpoints refuse on it.
All created by Database Verification (includes/db_verify_schema.php) and in database/freeitsm.sql.
| Table | Holds |
|---|---|
rfps |
The RFP: tenant_id, name, status, contract_id, chosen_supplier_id, style_guide, framing_context_text, created_by_analyst_id
|
rfp_departments |
Shared department list (name unique, colour, sort_order, is_active) - Contracts -> Settings |
rfp_documents |
One upload: department_id, stored filename, original_filename, file_path, raw_text, status
|
rfp_extracted_requirements |
Pass 1 output: document_id, requirement_text, requirement_type, source_quote, ai_confidence, is_consolidated
|
rfp_categories |
Pass 2 categories, sort_order
|
rfp_consolidated_requirements |
Pass 2 output: category_id, text, type, priority, ai_rationale, is_locked
|
rfp_consolidated_sources |
Which extracted rows fed which consolidated row (unique pair) |
rfp_conflicts |
Two consolidated rows, ai_explanation, resolution, resolution_notes, who and when |
rfp_output_sections |
One per category: section_content HTML, version, is_manually_edited, requirements_hash
|
rfp_section_history |
Earlier versions of an output section |
rfp_document_sections |
Framing: section_key (unique per RFP), content, inputs_hash, is_manually_edited
|
rfp_invited_suppliers |
Supplier on an RFP (unique pair), demo_date, notes
|
rfp_scores |
One per RFP, supplier, analyst and consolidated requirement (unique): score (NULL or 0-5), notes
|
rfp_processing_log |
Every AI call - see Audit |
Every child table cascades from rfps, so delete_rfp.php deletes one row and then unlinks the files it listed first. Cascades to know about:
-
Re-running consolidation deletes the categories and consolidated rows, which cascades to
rfp_output_sections(and their history),rfp_conflictsandrfp_scores. Generated sections and supplier scores do not survive a re-run. -
Re-running Pass 1 on a document deletes its extracted rows, which removes their
rfp_consolidated_sourceslinks. -
rfp_scoresdoes not cascade fromrfp_invited_suppliers, soremove_invited_supplier.phpdeletes the scores itself. - Deleting a supplier (
ContractsService::deleteSupplier()) clearsrfps.chosen_supplier_idand deletes its invitations and scores; deleting a contract clearsrfps.contract_id.
is_consolidated on extracted rows is returned by get_extracted.php but nothing in the RFP Builder sets it.
Covered on the user page. In short, from includes/rfp_scope.php:
-
rfps.tenant_idis scoped data; NULL is the Default company. Children have no company column - they follow their RFP. -
rfpGate($kind, $key)runs straight afterrequireModuleAccessJson('contracts')in every JSON endpoint about an RFP. It reads$keyfrom the query, form or JSON body, finds the RFP withrfpIdOf()(kindsrfp,document,extracted,consolidated,conflict,section,framing,invited) and answers{"success":false,"error":"RFP not found"}for one out of reach - never "forbidden", so ids cannot be probed. A list of ids must all pass. On a single-company install, or beforetenant_idexists (rfpTenantReady()), it does nothing. - The streams use
rfpCanView();get_rfps.phpusesrfpListSql()(the active company, or every reachable one under All companies);preview.phpanswers 404. -
save_rfp.phprefuses a company the analyst cannot reach and a contract in another company. The Default company is stored as NULL. -
rfp_departmentsand suppliers are shared by every company.get_rfp_departments.php, the department endpoints and the AI settings endpoints take no RFP and have no gate. -
ContractsService::assertMovable()refuses to move a contract to another company while an RFP of the old company links to it.
A new endpoint must call the gate - php tests/rfp-companies.php fails otherwise. See Multi-Tenancy Developer Guide.
The RFP Builder has no capability of its own for using it: Contracts module access is the whole gate for RFPs, documents, requirements, sections, suppliers and scores. Two capabilities in includes/capabilities.php cover the settings tabs only:
| Capability | Key | Checked by |
|---|---|---|
Cap::CONTRACTS_RFP_DEPARTMENTS |
contracts.rfp_departments |
save_rfp_department.php, delete_rfp_department.php, the RFP departments tab |
Cap::CONTRACTS_RFP_AI |
contracts.rfp_ai |
get_ai_settings.php, save_ai_settings.php, test_ai_connection.php, the RFP AI tab |
Endpoints check them with requireCapabilityJson(). See Roles Developer Guide.
Scoring is single-blind: get_scores.php returns the analyst's own scores plus other analysts' count and average, never their names or individual scores.
Files go to contracts/rfp-builder/uploads/ as rfp<id>_<time>_<random>_<safe name>.docx; rfp_documents.file_path holds the full path on disk. The folder's .htaccess and web.config deny all web access, deploy/nginx/freeitsm.conf returns 404 for it (Running on nginx), and includes/uploads.php lists it in UPLOAD_HTACCESS_ROOTS. Nothing serves the files back - the text is read from raw_text (get_document_text.php).
rfpExtractDocxText() opens the file with ZipArchive, reads word/document.xml with DOMDocument, and returns paragraphs outside tables as lines, then each table row as cell | cell | cell. Headings, lists, images, comments, tracked changes, headers and footers are dropped.
Without the zip extension new ZipArchive() is a PHP Error, which catch (Exception) does not catch, so the function checks class_exists('ZipArchive') first and throws a plain RuntimeException saying the extension is needed. upload_document.php and extract_document.php catch it and set the document to error. Read the text from a .docx anywhere else through this function, not ZipArchive directly. Background: Docker Image Missing PHP Extensions.
rfp_processing_log is the RFP Builder's audit trail, and it records AI calls only: action (extract, consolidate, generate_section, generate_framing, restyle_section), status (success/error), document_id or section_id, tokens_in, tokens_out, and details - JSON with counts, duration, cache figures and model for a success, the error message for a failure. get_processing_log.php feeds the overview's AI activity panel and audit.php.
Hand edits (editing, splitting, merging, scoring, locking) are not logged anywhere; the record of section content is rfp_section_history.
There is one TRAP: comment in these files:
-
upload_document.php- upload error messages are an array lookup, notmatch(). A file the web server runs directly must parse on old PHP, orincludes/php_version_guard.phpnever gets to say "this needs PHP 8.1" - PHP reads the whole file before running any of it.
Not marked TRAP: but worth knowing, all described above: streaming is Anthropic only; rfpAiCallAnthropicStreaming() is shared with Forms, Knowledge and Tickets; re-running consolidation deletes sections and scores; the lock is only enforced on generation; and the section skip hash differs from the stored one when only a system style guide is set.
- Contracts - the user page
- AI Providers - the shared AI settings the RFP Builder does not use
- CSRF Protection - GET streams that write
- Multi-Tenancy Developer Guide
- Code Traps
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
-
Architecture
- β³ π§Ή Root folder tidy (URL routing)
- β³ π Database Integrity (FKs & constraints)
- β³ π οΈ Database Verification β Developer Guide
- β³ π§© Service Layer (UI + API share code)
- β³ πͺ€ Code traps (the TRAP: comments)
- β³ π Commenting for the Engineering wiki
- β³ π οΈ Engineering wiki scanner - Developer Guide
- β³ π Service-Layer progress
- π€ Contributing (read before a pull request)
- π§ͺ Developer tests
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- π Date & Time Formats
- π± Currencies
- π§ The sidebar (optional module rail)
- π§© Preference templates
- π Display order (modules, Watchtower cards) β Developer Guide
- Theming & Dark Mode
- ποΈ Recent β getting back to what you were doing
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π Mobile: Forms
- β³ π Mobile: Contracts
- β³ π Mobile: Domains
- β³ π Mobile: People
- β³ π Mobile: Projects
- β³ ποΈ Mobile: Files
- β³ π Mobile: LMS
- β³ πΊοΈ Mobile: CMDB
- β³ πΊοΈ Mobile: Network Mapper
- β³ π§ Mobile: Process Mapper
- β³ βοΈ Mobile: Workflow
- β³ π₯οΈ Mobile: System
- β³ π Mobile: Reporting
- β³ π Mobile: System Wiki
- β³ π Mobile: Self-Service Portal
- β³ π§° Mobile: Techniques & Tricks
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- β³ π‘οΈ CSRF protection (S4) β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- π CardDAV contact sync
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π Proposal: custom fields, inventory import and change evidence (#194)
- β³ π οΈ Custom fields, evidence + inventory - review (#195-#197)
- β³ π οΈ Long forms in a task's Work view - review (#198)
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π REST API: Domains
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ π·οΈ REST API: Cost centres
- β³ π§βπΌ REST API: Analysts
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ π οΈ Inbox.js map β Developer Guide
- β³ π οΈ Tickets settings β Developer Guide
- β³ π Ticket workspace - design (PR #206)
- β³ π Rota copy and paste β Developer Deep Dive
- β³ β Checklists & SOPs
- β³ βοΈ Mandatory fields
- β³ π§Ύ Custom ticket fields
- β³ π·οΈ Ticket categories
- β³ π₯ Assigning tickets to a team, and escalation
- β³ π’ One board across every company
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ π Attachment size limits and large emails
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
-
β³
βοΈ Telegram channel - β³ β CSAT company scope and filters β Developer Guide
- β³ π₯ Microsoft Teams channel
- β³ π¨οΈ Mattermost channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ β Record previews
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ π¨ Telling the analyst a ticket is theirs
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ π Confidential tickets
- β³ π₯ Portal managers
- β³ π Who has seen a ticket
- β³ π Reading long tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π½ Just my tickets, or no closed ones
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Case reports - a ticket and its work as one PDF
- β³ π Scheduled work in your own calendar
- Problem Management
- Tasks
- π Projects
-
Assets
- β³ π’ Moving an asset between companies
- β³ π Shared asset locations
- β³ π§βπΌ Assigning assets to analysts
- β³ π Warranty and lease alerts
- β³ π Leases
- β³ π Saved table views
- β³ π¨οΈ Recording anything, and importing it
- β³ π§© Asset type layouts
- β³ π Inventory freshness
- β³ π Connected assets
- β³ π Asset review
- β³ π·οΈ QR asset labels
- β³ π Who holds what, and handover documents
- β³ π₯οΈ The inventory agent (PowerShell)
- β³ ποΈ Proxmox VE servers
- β³ βοΈ VMware Cloud Director servers
- β³ π Linking equipment to tickets
- β³ βοΈ Follow-up tasks on a ticket
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
-
Forms
- β³ π¨ The form designer β Developer Guide
- β³ π Layout & the grid β Developer Guide
- β³ ποΈ Collections β grouping submissions
- β³ π Submissions as PDFs
- β³ β‘ What happens next β a form's own actions
- β³ π οΈ Sections & conditional logic β Developer Guide
- β³ π οΈ Lookup fields β Developer Guide
- β³ π‘οΈ Catalogue request approvals
- People
- Domains
- ποΈ Files
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- π‘ Zabbix (monitoring)
- π¬ Microsoft Teams alerts
- π 3CX (phone system)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ π’ One board across every company
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)
- What this is
-
π Bugs resolved
- β³ π³ The Docker image was missing PHP extensions
- β³ π A failed DNS lookup read as a missing record
- β³ π A link could change data
- β³ π Access outlived its removal
- β³ π Knowledge articles could run scripts
- β³ π Knowledge writes ignored permissions
- β³ π Company boundaries (Oct 2026)
- β³ π Smaller gaps (Oct 2026)
- β³ π Escaped text could leave its attribute
- β³ π Task descriptions were not cleaned
- β³ π Task documents ignored company scope
- β³ π Repeating tasks ignored company scope
- β³ π¬ #183 Mailbox checks overlapped
- β³ π #188 Transfer lock shown off when unknown
- β³ β #184 Subtask names and Overdue
- β³ π§© #186 Teams: the buttons did nothing
- β³ π Text in a change could run script
- β³ π AI settings changed without the AI permission
- β³ πͺ #178 Deleting an analyst failed
- β³ π« A team with no departments showed 0 tickets
- β³ π Projects: adding a phase did nothing on Save
- β³ π Leavers could still sign in
- β³ πΌοΈ Logo and courses broke on Apache with PHP-FPM
- β³ π’ Chat tickets ignored your ticket numbering
- β³ π Dates shown as a dash, or in server time
- β³ π Assets β Users showed people from other companies
- β³ π Restricted analysts could read other modules' data
- β³ πΌοΈ Replies with a picture in the thread failed to send
- β³ π Reply attachments never reached the customer
- β³ π οΈ Outbound email attachments β Developer Guide
- β³ π A global SSO provider was missing from the portal
- β³ π Behind a proxy, the SSO redirect said http
- β³ βοΈ The portal tagline moved when you saved it
- β³ π¨ The portal settings screen forgot what you saved
- β³ π‘οΈ The approvals inbox said "Error" and nothing else
- β³ π A table's answers were missing from the PDF
- β³ β A single-select column let you tick every option
- β³ π The portal ignored a form's field widths
- β³ π The tasks board stopped taking clicks
- β³ ποΈ #121 The index list is out of date after upgrading
- β³ π #133 The calendar subscription was empty
- β³ π #131 Tasks always reopened on the board
- β³ π₯ #129 Every page returned HTTP 500 after upgrading
- β³ π³ #127 A PHP warning above the System page
- β³ π #126 Notes stamped with the server's clock
- β³ π Storing every date in UTC
- β³ πͺ The portal was down for everyone signed in
- β³ βοΈ #120 Workflow notes could never be written
- β³ βοΈ #123 Three errors when running Database Verification
- β³ π #122 The description box was a stub in the corner
- β³ π£ Demo data deleted real accounts
- β³ π #117 Sign-in redirected to the wrong address
- β³ π¨ #108 The priority dot was invisible
- β³ β±οΈ #116 Time logged from the right-click menu
- β³ π #114 API keys refused by our own guard
- β³ ποΈ #110 Assigning a task told nobody
- β³ πͺ #107 Signed out while still working
- β³ π #103 "Share with Requester" reached nobody
- β³ π #102 Search found nothing for hyphens
- β³ πͺ #101 Source code editor opened behind
- β³ βοΈ #88 Subtasks could not be ticked off
- β³ π» #84 Asset deep link selected nothing
- β³ π« #79 A new ticket arrived with no status
- β³ π§ #79 A ticket from email did not say so
- β³ π #78 Bell opened to nothing
- β³ π¬ #77 Mail only collected from Inbox
- β³ π #74 The default password could not be changed
- β³ π¦ #70 Renaming an impact level
- β³ π€ #67 App-only mailboxes could not send
- β³ π #45 Verify only ever worked for Microsoft
- β³ π #45 IMAP reported as not authenticated
- β³ βοΈ An email template stopped escaping itself
- β³ π The portal dashboard showed the wrong time
- β³ π’ The folder said 99 and the list showed 96