Safety Systems
Every organisation runs one safety / induction system, picked at setup and stored on organizations.safety_system. Two values exist today: lacroo_safety (built in) and beakon.
That single field drives three things:
- Which integration fields a project and a field user carry. The registry at
apps/web/src/lib/safety-systems.tsmaps a safety system to its field list; the create-project wizard, the org projects edit modal, the project config screen and the field-user form all render from it. Nothing hardcodes Beakon. - Which identifier fields the field sign-up form collects. The mobile mirror at
apps/mobile/src/lib/safety-systems.tsdrives the sign-up screen the same way. - Whether the Beakon connection card appears on Org → Settings → Organization.
| Safety system | Project fields | Field-user fields |
|---|---|---|
lacroo_safety | — | — |
beakon | Beakon Project ID (projects.beacon_id) | Beakon Code, Beakon ID |
Beakon
Beakon is a cloud safety and compliance platform. Under the hood it is a SuiteCRM instance, which explains the shape of the integration: each customer gets their own tenant at https://au.beakon.io/<tenant>, auth is OAuth2 client-credentials, and reads go to the SuiteCRM V8 JSON:API.
Beakon’s Users module holds the field workforce, not logins. Two identifiers matter:
| Beakon field | Lacroo field | Example |
|---|---|---|
user_id | Beakon Code | 100412f1-eb04-… |
record_name | Beakon ID | CON_81553 |
Capturing a worker’s identifiers
Sign-up is one form. After scanning the project QR (or typing the invite code) and confirming the project, the worker fills everything on a single screen — account, contact, emergency contact, supplier and project details — and submits once.
On a Beakon org that form opens with one extra field: Email, phone or Beakon ID. The worker types whichever one they know, and Lacroo looks them up in the org’s safety system. A match fills in their name, email, phone and both Beakon identifiers — everything stays editable, nothing they already typed is overwritten, and a worker the safety system already knows arrives verified as far as that system is concerned. An identifier that names two people, or nobody, fills nothing and says nothing; the form simply keeps what was typed.
The Beakon Code and Beakon ID fields sit on the same form. No custom-field setup is involved — the fields come from the org’s safety system, so orgs that used to hand-build them as worker-signup custom fields can retire those.
Both are optional. A worker who does not know their code still registers, and an admin fills it in later on the field user’s Verify dialog (Overview → Integration IDs). Whatever the worker types lands on users.beakon_code / users.beakon_id, the same columns the admin form writes.
The server re-reads the joining org’s safety system before it writes: identifiers submitted for an org that is not on Beakon are dropped, not stored, and the pre-fill lookup answers nothing for such an org either.
Matching a worker to their Beakon record
Once a worker registers, Lacroo tries to find them in Beakon and fill in what they did not type. It runs off the sign-up request, so nobody waits on it.
The search runs against the Beakon tenant of the org that owns the project — the one holding the connection — not the worker’s employer.
The search runs in a fixed order and stops at the first answer:
- Beakon ID — resolved live against the tenant by
record_name. - Beakon Code — resolved live by reading the record’s GUID directly.
- Email — resolved against the roster index.
- Phone — resolved against the roster index, on the last nine digits, so
+61 412 345 678and0412 345 678are the same number.
Email and phone go through the index rather than the tenant because Beakon cannot be asked either question.
Three rules decide every outcome, and all three prefer no match at all:
- An identifier naming two people names nobody. A shared inbox like
admin@subbie.com.au, or a site mobile two workers share, produces no match rather than the wrong one. - A match must contradict nothing. A record whose worker code differs from the one the worker typed is not that worker, whatever found it.
- Only blank fields are filled. Nothing a worker or an admin entered is ever overwritten.
A match fills the missing half of the identifier pair and, if it is blank, the phone number. Nothing else — Beakon also holds date of birth, postcode and emergency contacts, and Lacroo either has nowhere to put them or collects them from the worker directly.
The roster index
Email and phone matching read a nightly snapshot of the org’s Beakon roster, held in beakon_directory_entries.
It rebuilds itself every night at 16:00 UTC (02:00 Sydney), one crawl per Beakon org, run as a chain of short paged jobs rather than one long one. A crawl writes under its own run id that no lookup can see, and becomes live in a single swap at the end — so a crawl that fails half way leaves the previous night’s snapshot answering matches untouched.
Only a crawl that reached the end of the roster goes live. One that comes back empty or stops short is discarded, and the previous night’s keeps answering.
Nothing reads these rows but the matchers — the post-registration match and the sign-up form’s pre-fill lookup, which share one resolver, and the nightly re-check below. No endpoint ever returns the rows themselves: the pre-fill answers with a single worker’s editable form values or nothing at all.
Re-checking unmatched workers every night
Workers often register before their Beakon induction exists, so the sign-up match finds nothing. Without a Beakon Code none of their sign-ons can be sent.
So every night, straight after a complete roster goes live, Lacroo re-checks every field worker (labour, operators and plant hire) on that org’s projects who is still missing a Beakon Code or Beakon ID. Office staff are not checked. It never runs after a failed or partial crawl.
It uses the sign-up rules, held tighter because nobody is watching:
- The same order: Beakon ID, Beakon Code, email, phone — all against the roster index.
- The first identifier that finds anything decides. If an email names two Beakon records, the worker stays unmatched. The re-check does not fall back to their phone.
- A typed Beakon ID or Code decides on its own. A record that contradicts what the worker typed is not them.
- An email match must agree with the phone. If the phone names a different person, there is no match.
- A shared identifier is skipped. An email two of our workers hold, or a phone two workers in one company hold, is not used.
- One Beakon record, one account. A record another active account already holds, or that two workers match on the same night, goes to neither. Usually it is one person with two accounts; sometimes it is two different people. Fix these by hand.
- Only the blank Beakon Code and Beakon ID are filled. The re-check never writes a phone, and never overwrites anything.
Each fill writes an audit entry (beakon_rematch_filled) with the worker’s previous empty values, so one night’s fills can be listed and undone. The run reports its counts on the nightly roster run. No worker is named in them.
A worker matched overnight has their sign-ons sent to Beakon from their next sign-on onwards.
Sending sign-ons to Beakon
Every prestart sign-on becomes a Beakon check-in — Beakon’s record that the person was on the site. Standalone office sign-ons go across too.
A sign-on is sent when all four of these are true: the org runs Beakon and its connection is filled in, the project has a Beakon Project ID, and the worker has a Beakon Code. Anything else and nothing is sent — no error, nothing to fix.
The check-in carries the worker’s Beakon Code, the project’s Beakon Project ID, and a time in UTC, which Beakon shows in each viewer’s own time zone. When workers sign themselves on, the time is the moment they did. When a supervisor or the office signs someone on, often after the fact, the time is the prestart’s start time.
One check-in per worker, per project, per shift of a day. The first sign-on of each shift on a project is sent, so a Day and a Night sign-on on the same date both reach Beakon. A second sign-on for the same shift is not sent, and neither is a sign-on entered later for a shift that already has a check-in. A sign-on with no shift counts against the whole day.
A sign-on is never sent twice. Beakon cannot recognise a repeat, so Lacroo keeps its own record of what it has already sent: a sign-on that has landed is never sent again, however many times the push is retried or re-triggered.
A push that does not land keeps retrying for about four hours. If it still has not landed, the project shows a Sign-ons not sent to Beakon banner with the count outstanding.
Nothing is given up on. Every hour Lacroo has another go at whatever is still outstanding, so an outage across one morning costs a delay rather than the record — fix the connection at noon and the morning’s sign-ons arrive within the hour. The banner clears itself once they have all gone across, and also once they stop being sendable at all: an org taken off Beakon, or a worker whose Beakon Code was removed, leaves nothing to count.
Dismissing the banner acknowledges it. It comes back only when another push runs out of attempts — not minutes later off the same sign-ons.
Configuring the connection
Only orgsettings.manage holders (master admin, org manager) can see or change it. Go to Org → Settings → Organization — the Beakon connection card sits below Details and appears only for a Beakon org.
| Field | Notes |
|---|---|
| Beakon URL | Tenant base URL, e.g. https://au.beakon.io/acme. HTTPS only. |
| Client ID | OAuth client id. |
| Client secret | Required the first time. Leave blank on later saves to keep it. |
| Host | Optional. SFTP host for the roster file. |
| Username | Optional. |
| Password | Optional. Leave blank on later saves to keep it. |
Test connection mints a token and reads the tenant’s module list. It never reads a worker record, so verifying a connection pulls no personal data.
A failed check reports one of three things: the tenant could not be reached (usually the URL), Beakon rejected the credentials, or Beakon returned an error. The underlying cause goes to the logs, not the screen.
Where the settings live
The whole block is AES-256-GCM encrypted inside organizations.metadata.safety_credentials.beakon, using the same master key (LACROO_ERP_CRED_KEY) the ERP credentials use — one key, one rotation story. It is a sibling of metadata.erp_credentials, not a reuse of it, so a Beakon blob can never surface as a phantom ERP integration.
Neither secret is ever returned. The API answers with has_client_secret / has_sftp_password booleans instead.
Notes for anyone extending the client
backend/pkg/beakon is the HTTP client. Three rules it exists to hold:
- Tokens are cached. Minting takes ~2.5s and the token lives an hour. The cache key hashes the client secret in, so rotating the secret retires the old token rather than serving it until it expires.
- Every Users read carries
fields[Users]=…. An unfiltered read returnsuser_hash, the password hash. The sparse fieldset is a security boundary, not an optimisation. - No raw Beakon response leaves the package. Reads decode into narrow structs, so a new upstream attribute cannot travel outward through a Lacroo response or log line.
The product’s one write is CreateCheckIn. Three things about it are easy to get wrong:
- SuiteCRM creates a record by POSTing to the bare
/Api/V8/modulewith the module named in the body. - The worker field (
users_ve_visitorentry_1users_ida) takes the worker’s Beakon Code: their Beakon Users record GUID, stored asusers.beakon_code. The worker number (CON_81553,users.beakon_id) is not a code. Beakon answers it with a 201 and files a check-in attached to nobody. date_time_check_in_cis stored exactly as sent and read as UTC, so it must be sent as UTC inYYYY-MM-DD HH:MM:SS.
GetCheckIn, UpdateCheckIn and ListCheckIns exist for one-off corrections of check-ins already sent (see backend/db/backfills/2026-10-09-georgiou-beakon-checkin-repair). An update also answers 201 when the worker code matches nobody and silently drops the link, so a caller must compare the returned worker and time with what it sent.
One thing Beakon cannot do: look a worker up by email. email1 is a SuiteCRM relate field, not a column, so it cannot be filtered, and the EmailAddress → User relationship has no route. Anything email-driven needs a prebuilt index — which is what the nightly roster crawl builds.
The crawl is the one read that asks for email1, on its own wider fieldset. Keeping it off the fieldset the sign-up lookups use means that if a tenant ever rejects the relate field, the crawl fails on its own and worker sign-up keeps matching.
The SFTP fields on the connection card are stored but not yet read by anything: the roster is built from the API today.