Home›Fixora›Manual
Fixora · Manual

Fixora Manual

Fixora — User Manual

Assets, preventive maintenance and work orders, on your own server. Version 4.1.3 [src: app/controllers/api.php:17].

About this manual

Every statement here was written by reading Fixora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

What Fixora is

Assets, preventive-maintenance schedules, and work orders that move through a small enforced state machine [src: app/controllers/mcp.php:52].

Everything a buyer would want to audit lives in one engine as pure, testable functions, alongside the write paths the controllers actually use — so the test suite exercises the real insert and update code on both database engines [src: app/src/Maintenance.php:3].

Money is integer cents [src: app/src/Maintenance.php:10], and maintenance dates are plain calendar dates in your business timezone, because a service happens on a business day rather than at an instant [src: app/src/Maintenance.php:8].

Preventive maintenance, and the absence of a cron

Next due is last done plus the interval, computed when you look at it — the source is explicit that there is deliberately no cron dependency [src: app/src/Maintenance.php:9].

Nothing can be missed because a scheduled task failed to run, and an installation switched off for two weeks shows exactly what fell due while it was off.

An email is different: something has to send it. The optional daily digest — PM overdue or due soon, and parts at their minimum, to your notification address — goes when cron/pm-digest.php runs or when you press Send the PM digest now in Settings [src: app/controllers/app.php:100]. It is off until switched on, sent only when there is something on it, and at most once a day [src: app/controllers/app.php:116].

Two triggers, and the one that comes first wins

A schedule can be driven by days, by a meter reading, or by both [src: app/src/Maintenance.php:112].

  • By days: overdue when the date has passed, due-soon inside your window, otherwise fine. Exactly on the window boundary still counts as due-soon [src: app/src/Maintenance.php:78].
  • By usage: units remaining is the last-done reading plus the interval, minus the current reading [src: app/src/Maintenance.php:89]. The due-soon window is 10% of the interval, rounded up, never less than one unit [src: app/src/Maintenance.php:95], and zero remaining counts as due now [src: app/src/Maintenance.php:100].

Where both apply, the status becomes whichever is worse — the trigger that comes first wins [src: app/src/Maintenance.php:113]. The row also tells you which one drove it: days, usage, or both [src: app/src/Maintenance.php:116].

A schedule with no meter, or no usage interval, behaves exactly as a date-only schedule [src: app/src/Maintenance.php:114].

Meters only move forward

A reading below the current one is refused, and the reasoning is worth having [src: app/controllers/mcp.php:55]:

A meter that goes backwards means the device was swapped or the number was mistyped, and correcting that rebases the preventive-maintenance baselines. Ask a person to do it in the app.

So a mistyped reading is not something an automation quietly fixes. It is a decision with consequences for every schedule hanging off that meter, and it is handed back to a human.

Work orders

Four statuses [src: app/src/Maintenance.php:23], four priorities [src: app/src/Maintenance.php:22], and two types — corrective and preventive [src: app/src/Maintenance.php:21].

The transitions are enforced [src: app/src/Maintenance.php:27]:

FromMay move to
openin-progress, done, canceled
in-progressopen (paused), done, canceled
doneopen — the source calls it an honest-mistake reopen
cancellednothing — terminal

Note the asymmetry, which is deliberate: a job marked done by mistake can be reopened, but a canceled one cannot [src: app/src/Maintenance.php:29].

Priority, on every door

The priorities are low, medium, high and urgent [src: app/src/Maintenance.php:22], and every door agrees.

The technician's "My jobs" list puts urgent first, then high, medium and low [src: app/controllers/v3.php:901]; before 3.1.4 it ordered by critical, a value the product never stores, so urgent work sank to the bottom beside low. The agent tools offer exactly the four values [src: app/controllers/mcp.php:65], [src: app/controllers/mcp.php:118], and the REST API refuses anything else with 422, reading critical as urgent [src: app/controllers/api.php:67]; before 3.1.4 an unknown value was filed as medium with no error. The browser form can only send the four.

⭐ Requests from people without a login (3.2)

A tenant, another department or a customer reports a fault from one link or from the QR tag on the machine, with a photo if they like, and follows it on a link of their own. No login, no seat to count. Off until you switch it on in Settings → Request form [src: app/controllers/v32.php:144].

  • What lands is a request, not a work order. It waits in Requests for the team: accept it into a corrective work order, which carries the requester's name and contact, their words and their photo [src: app/src/Requests.php:108], or decline it with a reason the requester reads on their link [src: app/src/Requests.php:143]. Accepting needs the same permission as opening a work order; a viewer can read the inbox but not act.
  • Only through the portal key. Switching the form on mints a random key, and every public address carries it: the form at /fault/<key> and, on the printed tags, /fault/<key>/<tag> [src: app/src/Requests.php:37]. A guessed asset tag opens nothing. Make a new link closes every old link and tag at once (reprint the tags). The tag sheet prints report-a-problem tags while the form is on, and the sign-in lookup tags with ?for=staff.
  • Spam guards [src: app/src/Requests.php:63]: a hidden trap field (a bot that fills it is thanked and nothing is kept) [src: app/controllers/v32.php:46], at most 5 reports an hour from one address, at most 10 waiting reports a day for one machine. The address is stored as a salted hash. A photo is checked by its bytes and must be PNG or JPEG.
  • The requester's page shows their own words and the outcome, and the work order's number and state once accepted; no other request, no staff names [src: app/src/Requests.php:156].
  • You hear about it. A new request emails your notification address (the switch is on the same card) and fires the request.created webhook; the dashboard lists new requests under "Needs your action", and the sidebar counts them.

Checklists on PM work (3.2)

Give a PM schedule a checklist, one step per line, on its form. Log completion then opens a page that asks for an answer to every step: done / pass, fail or not applicable, each with a note; the PM is not logged until every step has an answer [src: app/src/Checklists.php:109], and the answers are kept on the completed work order with who gave them [src: app/src/Checklists.php:121]. A schedule with no checklist logs with one click, as before.

  • A failed step raises a corrective work order on the same asset, high priority, describing the step and your note, and the step links to it. One per step, even on a double submit [src: app/src/Checklists.php:93]. It fires the workorder.created webhook and the "work order opened" email like any other.
  • Steps on any work order. Add steps to an open work order on its page and answer them there, or through POST /api/checklist-steps/{id}.
  • One rule at every door: a work order with an unanswered step cannot be marked done, whether from its page, the board, the API or an agent through advance_status, because they all go through the same transition [src: app/src/Maintenance.php:656]. A closed work order's answers do not change.

Saved repair comments (3.2)

Keep the resolution notes your team writes again and again in Settings → Saved repair comments (up to 50; administrators edit the list). They appear under every resolution box (the work order, the technician's own view and the work-order form), and a tap drops one in; the text stays editable before it is saved.

The weekly report for people without a login (3.3)

A facility manager, an owner or a client often wants to know what maintenance happened without having a seat in the app.

  • Who gets it. In Settings → Weekly report for people without a login, list up to 10 addresses, one per line, and tick Send the weekly report [src: app/controllers/app.php:200]. Addresses are checked, lower-cased and kept once. Nobody on the list needs a login.
  • What it says. It covers the ISO week just ended, Monday to Sunday [src: app/src/WeeklyReport.php:53]: work orders completed that week and opened that week. Then, as they stand on the day it goes: what is still open (oldest first), preventive maintenance overdue or due soon (the same rows as the PM screen), and parts at or below their minimum [src: app/src/WeeklyReport.php:78]. No prices go out: the report is about the work.
  • How it goes. It is sent through your own SMTP server, one email per address, so no address sees another. Each email carries its own stop link, signed for that address [src: app/src/WeeklyReport.php:116]. Opening the link shows a button, so a mail scanner that follows links changes nothing, and pressing it takes that one address off the list [src: app/controllers/app.php:233].
  • When it goes. Schedule cron/weekly-report.php for Monday mornings, or press Send last week's report now. It goes at most once per week: the week is claimed before the mail server is contacted, and given back if no address could be sent to, so the next run tries again [src: app/src/WeeklyReport.php:140]. Preview shows the email as it would go, and sends nothing.

Costs

Labor is minutes times the hourly rate in cents, divided by 60, rounded half up [src: app/src/Maintenance.php:234] — integer arithmetic throughout, with zero or negative inputs giving zero rather than a negative cost [src: app/src/Maintenance.php:236].

A work order's total is that labor plus its parts cost, with parts floored at zero [src: app/src/Maintenance.php:243].

Users and roles

Three roles [src: app/controllers/v3.php:27]. The source names what stays with the owner: an interval is a budget decision, and lengthening one is a risk decision [src: app/controllers/v3.php:24]; and deleting is reserved because a work order is the maintenance record a machine is judged by [src: app/controllers/v3.php:25].

Permissionviewermemberadmin
.view permissionsyesyesyes
audit.viewyesyesyes
wo.create / wo.advance / wo.edit—yesyes
meter.record—yesyes
part.consume—yesyes
photo.upload—yesyes
pm.complete—yesyes
schedules, settings, team, deletion——yes

The audit trail is not the viewer's

The viewer's grants are listed one by one — assets, parts, PM, reports and work orders [src: app/controllers/v3.php:32] — so the audit trail is not among them. Before 3.1.4 the viewer held the wildcard *.view, which matched audit.view as well.

Backups follow the model

Fixora's database backup downloads are permission-gated, requiring the settings permission [src: app/controllers/api.php:332], as are the restore page and the scheduled-backup token. A viewer cannot download the database.

The API and agent access

Three tools write — creating a work order, advancing its status, and recording a meter reading [src: app/controllers/mcp.php:58].

The instructions warn an agent about the hyphen in in-progress [src: app/controllers/mcp.php:53], which is the kind of detail that otherwise costs a round trip, and about the forward-only meter rule [src: app/controllers/mcp.php:55].

They state the priority values the product stores: low, medium, high and urgent [src: app/controllers/mcp.php:54].

What Fixora does not do

It does not open a work order from a stranger's report by itself. A request from the form waits for the team to accept or decline it [src: app/src/Requests.php:108].

It does not need a cron job [src: app/src/Maintenance.php:9]. Due-ness is computed on view; only the optional daily email digest needs something to run it.

It does not let a meter go backwards [src: app/controllers/mcp.php:55], and it does not let an agent correct one.

It does not let a canceled work order be reopened [src: app/src/Maintenance.php:29].

It does not accept a priority it does not have from the API or an agent [src: app/controllers/api.php:67]; the browser form cannot send one.

Looking after your install (from 4.0)

Updates from inside Fixora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Fixora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Fixora is unavailable for about a minute while this runs. The update replaces Fixora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Fixora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Fixora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a facilities crew's locations, assets and work orders to try things on. You can remove it in one click. [src: app/install/index.php:129]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample locations, assets and work orders? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:81]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Fixora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Fixora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/fixora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Fixora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Fixora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. Single sign-on starts a fresh session. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Fixora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

Single sign-on now starts a fresh session too [src: app/controllers/v3.php:538].

On Ownware Cloud, opening Fixora from the console starts a fresh session [src: app/index.php:57].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:20].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Fixora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Fixora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Fixora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Fixora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Fixora · Quickstart · API · Test run