# Working in this repository

## The database holds data that cannot be recreated — never reset or dump it

The MySQL database (`solar_system`) holds real work: the customer and job data
imported from the install schedule (7,256 customers, 8,239 jobs and 7,676 sites)
alongside the demo records. The workbook it came from is the only other copy, so
treat every row as precious, and count before and after you touch it.

Never run, for any reason, without the owner explicitly asking:

- `php artisan migrate:fresh`, `migrate:refresh`, `migrate:rollback` on this
  database, `db:wipe`, or `db:seed` against a database that already holds data
- `DROP` or `TRUNCATE`, or a delete that is not scoped to rows you created
- anything that exports the database or its credentials — `.env` values,
  `mysqldump`, password hashes, or seeding output — into a file, a commit, or
  the console

When data has to change, change it in a scoped, reversible way:

- `UPDATE`/`DELETE` with an explicit `WHERE`, plus the statement that puts it back
- stamp everything you create so it can be found again on its own.
  `ImportInstallSchedule` marks every row it writes with
  `lead_source_detail = 'Install Schedule.xlsx'`; its `--fresh` flag deletes only
  customers carrying that mark, with their own jobs and sites, and never touches
  a row it did not create
- count the affected rows before and after, and report both numbers

If a reset looks necessary, stop and ask. There is no automatic recovery here.

The live database also holds demo and seeded records, so its totals run slightly
ahead of the install-schedule figures above. That difference is expected; it is
not a sign that the import went wrong.

## Back up before every database change

Every change to the schema or to stored data is preceded by a database backup
that has been verified. The application does this itself, and the commands are:

    php artisan crm:backup --type=database --label="what you are about to do"
    php artisan crm:backup --type=files
    php artisan crm:backup --type=full

`crm:backup` writes an archive, reads it back, checks every component against
its recorded checksum, and only then reports success. It runs inline, so the
command does not return until the backup is on disk: a caller that checks the
exit code has something to fall back on. `/settings/backups` in the application
runs that same command in the request that asks for it, so a backup from the
screen needs no queue worker, and a failure is reported on the screen in the
words the dump produced. `crm:backup --queue` and the backup jobs still exist for
a host that would rather hand the work to a worker:

    php artisan queue:work --queue=default

Reading the archive back is the default and stays on unless somebody turns it
off on `/settings/backups`. That switch is `backup.verify` in the settings
table; on the command line the same thing is `crm:backup --no-verify`, and the
scheduler adds that flag only when the switch is off. It reaches backups taken
by hand, from the queue and by the scheduler, and nothing else: the backups an
import or a schema change waits on are written by the importers, which call the
service without ever passing the flag, so they always verify. An archive written
with verification off is recorded as **unverified** rather than completed-and-
proven: it can still be downloaded, Check can verify it later, and it never
stands in for the last verified backup.

Rules that hold whatever the circumstances:

- if the backup fails or cannot be verified, the change does not happen. Fix the
  backup problem first, and never "just this once" skip it
- never delete the last verified backup. Deleting an older one once a newer
  verified backup exists is fine; deleting the only recovery point is not
- archives live in `storage/app/backups`, outside the public web root, and are
  never served over HTTP except through the authenticated download route. They
  are also excluded from later backups, so they cannot grow the history
  exponentially
- `BACKUP_KEEP` controls retention and is 0 by default, which means nothing is
  ever pruned automatically
- a restore is never automatic. An archive is restored either from
  `/settings/backups` (Restore on a verified archive, or upload one) or with
  `php artisan crm:restore <backup-id> --confirm`. Both follow the same order:
  the archive is read back and checksummed, the current state is captured in a
  fresh verified full backup, and only then is anything written. If the
  safety backup fails, the restore does not happen. A restore that replaces
  the database then applies any migration newer than the archive and signs
  everybody out
- an archive can be applied in one of two ways. **Replace everything** writes
  the archive's database and files over what is running. **Add what is
  missing** (`--merge`, or the choice on the restore dialog) only creates the
  tables this database does not have and adds the rows it is missing: a row
  that is already here, matched on its key, is left exactly as it is. Nothing
  is dropped, updated or deleted, nobody is signed out, and no migration runs,
  because the schema that is running stays the schema. `MergeSqlWriter`
  enforces that on the dump itself — drops and locks are stripped, creates
  become `IF NOT EXISTS`, inserts become `INSERT IGNORE` (or `INSERT OR
  IGNORE` on SQLite), and trigger bodies are passed through untouched. An
  archive is not always the same shape as the database it is merged into —
  `users.google_id` exists this month and not last — so every insert also names
  its columns, read from the archive's own CREATE and narrowed to the columns
  the table here actually has; a column the archive holds and this database
  does not is dropped from the row, and a column added since gets its default.
  A merge therefore never fails on a column count that has drifted in either
  direction. The
  queue, session, cache and migration-log tables are never merged, so an old
  archive cannot resurrect abandoned work or rewrite the schema's history. A
  merge takes a verified backup first, like any restore, and that backup is
  the way back
- only a **verified** archive is offered for restoring. An unverified one can
  be downloaded; pressing Check proves it, and it becomes restorable. A
  damaged one can be downloaded too - a failed read-back keeps its archive on
  disk rather than deleting the only copy - but it is never restorable and
  never counts as the last verified backup
- a download is never gated on verification. If an archive with that name is
  on disk, the authenticated download route serves it, whatever the run's
  status, the switch or the checksums say: still running, read-back failed,
  never read back, or proven. `Backup::isDownloadable()` decides this from the
  file alone; verification is reported beside the archive, not used as the key
  to it. Restoring is the only gated step, and it needs a verified archive
- a long-running queue worker must read `backup.verify` fresh for each job:
  `Setting` remembers values for the life of the process, so `CreateBackupJob`
  calls `Setting::forget()` before reading the switch
- `php artisan crm:restore-check` stays the read-only check: it verifies every
  archive and prints the record counts plus the manual procedure for a restore
  done by hand

Backups can also be taken on a schedule, and are switched off until they are
switched on. `BACKUP_SCHEDULE=true` plus `BACKUP_SCHEDULE_TYPE` and
`BACKUP_SCHEDULE_CRON` puts the same verified `crm:backup` command on the
scheduler, which needs `php artisan schedule:run` running from cron (or
`php artisan schedule:work` while developing). `php artisan schedule:list` shows
what is configured. `BACKUP_SCHEDULE_PRUNE` removes nothing at all until
`BACKUP_KEEP` is given a number.

## Scanning the server for planted code

`crm:malware-scan` reads a hosting account for files that should not be there —
a web shell, `eval` of a decoded blob, PHP hidden inside an image, a script in an
upload folder, a script git has never seen — and changes nothing at all.
`--quarantine --confirm` is the second step: it moves what it found into
`storage/app/quarantine`, outside the web root, and writes each file's original
path, checksum and the rule that caught it into a manifest beside it.
`crm:quarantine --restore=<id>` puts one back byte for byte, `--list` shows what
is held, and only `--purge=<id> --confirm` actually deletes a copy. A scan never
deletes anything, and a Medium finding is never moved: the threshold is
`security.quarantine_severity`, High by default, because a pattern that might be
the office's own old code is a question for a person. Every file is re-read
immediately before it is held, so a finding that no longer matches the file on
disk leaves that file where it is.

It is usable on a shared account for two reasons. It skips what nobody here
wrote — `vendor`, `node_modules`, build output, caches, backups and the
quarantine itself — and it asks git about everything else: a pattern in a
tracked, unmodified file is reported at Medium ("read this"), while the same
pattern in a file the repository has never seen is reported as what it looks
like. An untracked script is a finding of its own, and also Medium: an
uncommitted file is how new work starts, so that rule never moves anything — a
shell dropped in the same folder is caught by what is inside it. `.env` files are
never read at all. Symlinks are never followed, and a directory it could not open is
reported rather than passed over, so a scan is only clean when it says so.

`--root` (repeatable) chooses what to scan; empty means the folder above this
application, which on a hosting account is every site that account owns.
`--min-severity=medium` widens the report, `--include-vendor` reads dependencies
too, and `--json` writes the run to `storage/app/security`. The exit code is 1
when anything at or above `--min-severity` was found, whether or not it was
moved, so a scheduled run can raise an alarm on it. Tests are in
`tests/Feature/MalwareScanTest.php`.

The same scan has a screen: **Settings → Data → Security scan**
(`/settings/security-scan`, `settings.manage`), which runs it in the request that
asks for it and lists every finding with the rule that caught it. The two share
one implementation rather than two: `MalwareScanner::defaultRoots()` decides
what "the whole account" means, `FindingQuarantine` decides what may be moved and
re-reads each file before it is, and every run — from the screen or from the
command line — records itself through `ScanArchive` into
`storage/app/security/latest.json`, which is the report the screen shows. So a
nightly cron scan is the one an administrator reads in the morning. The screen
offers holding the actionable findings and nothing else: no purge, because the
only deletion in this feature stays on the command line where it needs
`--confirm`. Tests are in `tests/Feature/SecurityScanScreenTest.php`.

## Restoring from an address instead of a sign-in

Two addresses can put a backup back with no account. Both are registered outside
the web group (`routes/recovery.php`), so they start no session, ask for no CSRF
token and never share a signed-in user, and neither is linked from anywhere in
the application. They differ in how long they live:

- **The one-time recovery link**, `/recovery/{RECOVERY_TOKEN}`, for an
  installation whose users table cannot be read. It is a key in configuration,
  and it closes itself after the first restore that works. It may proceed
  without capturing the state it replaces, because an installation broken enough
to need it may not be able to write a backup at all.
- **The restore link**, `/restore/{token}`, for planned work — a deploy being
  moved to production, a migration tried against real data, a database that has
  to go back more than once in an afternoon. It is closed until an Admin or
  Super Admin opens it on **Settings → Backup**, it carries an expiry chosen
  when it was opened (a week at the ceiling) **or none at all**, and it can be
  given a new address or closed in one click. It stays usable while it is open,
  which is the whole difference between the two.

A link opened with `permanent: true` — or the same thing, a window of `hours: 0`,
which is what the screen's **Does not expire** choice posts — never expires on
its own: it is the installation owner's standing way back into their own
database, for an evening when nothing can be signed into and nobody can be asked
to arm a link first.
`RestoreLink::armPermanent()` writes `expires_at: null` and `permanent: true`,
and `hasExpired()` reads that flag rather than the missing date — a state file
with no expiry that is *not* marked permanent still counts as closed, so a
truncated file can never become an open door. Everything else about it is
unchanged: the same `settings.manage` gate to arm it, the same verified full
backup before anything is written, the same one-click **Close the link**, the
same use log. Treat the permanent address as the owner's key rather than a
convenience — one address, renewed the moment it has been shared.

It is renewed, never extended. **Renew address** on the same screen (the route
keeps its `restore-link.rotate` name) calls `RestoreLink::renew()`, which mints a
new token and leaves the terms alone: `permanent: true` and `expires_at: null`
stay as they were, so a renewal can never quietly put a clock on the owner's way
back into their own database. A timed link renewed the same way keeps the window
it was armed with — renewal moves the address, it does not top up the clock. Each
renewal records `renewed_at`, `renewed_by_name` and a `renewals` count, which the
screen shows beside the address so "this key was replaced on Tuesday" is
answerable. The previous address stops answering on the next request.

The restore link's state is a file, not a row: `storage/app/restore-link/link.json`
holds the token, the window and the closed marker, and `uses.json` holds every
attempt, refused ones included. A restore replaces the database, so anything
recorded there — the expiry, the fact that the link was closed — would be undone
by the thing it describes. That is also why its address is managed on the Backup
screen rather than in `.env`: `RESTORE_LINK_ENABLED=false` is the only switch
left to configuration, and it closes the feature outright.

Every use follows the same order as any other restore, and refuses to go on
without it: the archive is read back and checksummed (a damaged one restores
nothing), a **full verified backup** of the current state is taken (if that
fails, nothing is written), the archive is replaced over the database, the
migrations newer than it are applied, and the copy taken beforehand is recorded
again on the other side so the way back is visible on the screen. The link then
stays open — until its expiry, or until somebody closes a permanent one.
`UNVERIFIED` archives can be chosen
from the list, but they are proven by that read-back before anything is written;
the database's own record of verification is shown beside them, not used as the
gate. An unarmed link, a wrong address and a switched-off feature all answer
404; an expired or closed one answers 410 and says which. Tests are in
`tests/Feature/RestoreLinkTest.php`.

Treat an open restore link as a password: close it when the work is done, and
give it a new address if it has been pasted into chat or a ticket.

## Builder accounts and the clients under them

A building company that sends work our way is a customer of type `Builder`
(`App\Enums\CustomerType`), and the households and businesses it sends are
customers too, linked with `customers.builder_id`. One axis, one table: the
builder is the account we deal with, the client is the roof we work on, and both
are billing parties with their own sites, jobs and tickets.

- `CustomerType::isBuilder()` and `canBelongToBuilder()` decide which way the
  link may point. Only a builder holds clients, and a builder is never somebody
  else's client — `StoreCustomerRequest` refuses that and drops `builder_id`
  when the record is being saved as a builder, so the field cannot arrive from a
  form that never showed it.
- The link lives on the **client**: `customers.builder_id` is nullable and
  `nullOnDelete`, because archiving a builder must never take the households
  with it. Detaching is therefore authorised against the client, not the
  builder (`CustomerController::attachClient` authorises both, which is what
  stops somebody who may edit the building company from rewriting a household's
  record by posting to the builder's page).
- A builder's page lists its clients in its own panel, with **New client**
  (the customer form opened as `?builder_id=`) and **Assign a client** (an
  existing customer, found through the same `lookups.customers` type-ahead the
  job form uses). A client's page carries the note *Under <builder>* and the
  same line appears in the customer list, so whose client a job belongs to is
  never a question.
- Work is raised against the client: the builder's client list links each row to
  a new job for that client, so the site, the grid details and the warranty all
  follow the roof rather than the building company.
- Nothing else changes for a builder: it is still a customer in the list, on the
  dashboard counts, and in search. `Options::builders()` is the picker list, and
  tests are in `tests/Feature/BuilderCustomerTest.php`.
- `App\Services\Customer\BuilderMatcher` reads a company name and scores it as a
  building company — `SIGNALS` are the words that say so, `NOT_BUILDERS` are the
  trustees, investment vehicles, shopfronts and trades that must never be
  mistaken for one, and a blocked name is never a candidate however it scores.
  `crm:find-builders` is read-only and lists what it found with the words that
  matched; `--commit` takes a verified database backup, converts only the rows it
  listed (re-reading each before writing), and writes each one's previous type to
  a report so `--revert` puts back exactly those rows while leaving a type
  somebody has chosen since the run alone. Tests are in
  `tests/Feature/BuilderDetectionTest.php`.
- The job sheet made one customer per line, so a builder that sent fifteen
  installs arrived as fifteen customers. `crm:merge-customers <survivor> --from=…`
  folds duplicates into one, repointing **every** column that names a customer
  (`MergeCustomersCommand::REFERENCES` — jobs, sites, tickets, documents,
  quotations, invoices, ownership history, portal sign-ins and the rest), so no
  row is left attached to an archived record. A merge archives the duplicates
  rather than deleting them, notes on each one where its work went, and writes a
  report naming every moved row and its original owner, so `--revert` puts each
  row back where it came from and restores the records and the survivor's
  previous type. It takes a verified database backup first, and does nothing at
  all without `--commit`. Tests are in `tests/Feature/CustomerMergeTest.php`.

## Ticket descriptions

A ticket's description is written in the rich text editor on the ticket form and
stored as a small subset of HTML. It is sanitized twice: on the way in, in
`StoreTicketRequest` and `UpdateTicketRequest` (before validation, because
`validated()` reads the validator's own data), and again on the way out in
`App\Support\Html`, which the presenter uses. The same column holds text customers
sent in through the SMS ingest and text imported from the old support desk, so
nothing stored is ever rendered as markup without passing through `Html` first —
and only `TicketPresenter`'s `body_html` goes anywhere near
`dangerouslySetInnerHTML` — the user manual's page is the one other place, and
it renders a file from this repository rather than anything a person typed (see
below). A value with no tags in it is treated as plain text and
escaped with its line breaks kept, which is what the 761 imported descriptions
are.

"Active" tickets — the count on the support list — are the ones somebody here can
move today: open, in progress, or waiting on the install crew. Resolved, closed
and cancelled are finished, and a ticket waiting on the customer is parked until
they answer. See `TicketStatus::isActive()`.

## The user manual

`docs/user-manual.md` is the staff manual: the whole system screen by screen,
with the guided walkthroughs and their screenshots. It is the only copy —
`/settings/manual` renders that file and `/settings/manual/download` builds the
PDF from it (`/manual` and `/manual/download` redirect to them) — so a screen
and the paragraph describing it belong in the same commit, and a second copy of
the words is never written anywhere.

It sits under **Settings → Help**, and `/settings/manual` carries no permission
of its own: every signed-in member of staff reads it, so somebody who cannot
open the Vault still learns what the Vault is and who to ask. Only the staff
guard stands in the way — a customer portal account is refused.

Keeping it current is enforced rather than remembered. `tests/Feature/ManualTest.php`
fails when an enum label in Appendix B — a ticket status, category or outcome
code — exists in `App\Enums` but not in the document, and when a screenshot the
Markdown references is missing from `docs/screenshots`. So a new ticket status
is not finished until its sentence is written.

`App\Support\Manual` converts the Markdown with commonmark (`html_input` strip,
no unsafe links), gives each chapter the anchor its own contents list already
uses, and rewrites `screenshots/*.png` to the authorised image route. The
screenshots are PNGs under `docs/screenshots`, never `public/`,
because a CRM screen carries customers' names and addresses;
`ManualController::image` serves one at a time to a signed-in member of staff
and checks the name against a strict pattern. The PDF reads the same files and
embeds them, so a download travels on its own.

The page is the second place that renders trusted markup with
`dangerouslySetInnerHTML`. It is safe for the opposite reason a ticket is not:
the input is a file in this repository, never anything a person typed, so it is
trusted at the source rather than sanitised at the edge. A ticket description
still goes through `App\Support\Html`; the manual does not.

`tools/capture-manual-screenshots.mjs` re-takes the pictures against a running
copy of the app — `npm install --no-save puppeteer-core`, then
`MANUAL_EMAIL=... MANUAL_PASSWORD=... node tools/capture-manual-screenshots.mjs`.
It needs a temporary staff account, so remove that account and its sign-in
history afterwards. Re-take them when a screen's layout changes; the words
matter more than the pictures.

## What a ticket is about, and the office's conversation on it

Status says how far the work has got; `tickets.category` says what kind of work
it is, and the two answer different questions. `App\Enums\TicketCategory` is the
list — warranty claim, technical fault, battery fault, installation issue,
enquiry, paperwork and compliance, other — chosen because those are the faults a
solar business actually sees, not a generic helpdesk taxonomy. The column is
nullable on purpose: a ticket nobody has looked at has not been categorised, and
the screens say so rather than showing a guess as if a person had chosen it.

`TicketCategory::guess()` reads the wording for a starting point. The SMS ingest
uses it on the way in, and `2026_10_26_110000_add_category_to_tickets_table.php`
used it once to categorise the historical tickets where the wording was plain,
leaving the rest for a person. A repeated SMS delivery never replaces a category
somebody chose.

## One conversation, one outcome

A ticket has **one feed**, not a comment box beside a discussion box beside a
note field. Everything said about a ticket is an entry in it, and every entry
wears what it is:

- **Customer reply** — something the customer said or was told. Stored as
  `ticket_comments` with `type = customer_reply`, markup-ready and sanitized
  through `Html` like any other description.
- **Internal note** — the office talking to itself. It is *also* the staff inbox
  thread (`message_threads.ticket_id`, unique, one per ticket), so it counts as
  unread in the inbox and a reply written in the inbox appears on the ticket.
  One row, two places: never a copy.
- **Change** — authorless: a status move, recorded when the ticket moves.
- **Imported** entries keep the identity, author and timestamp they arrived with
  and say so, because the old desk did not record whether a note was customer-
  facing or internal. Ambiguous ones stay `note`, which reads as staff-only.

`App\Services\Tickets\TicketActivity` assembles that feed. It reads the comments
and the discussion thread, sorts them together on the timestamp each entry
carries, and returns the counts the filter chips use (`customer`, `internal`,
`changes`). Discussion messages are only read for somebody who may open the
inbox; they are never half-shown. `TicketPresenter::comments()` still exists
because tests and other readers use that shape, but the ticket page renders
`activity`.

The composer offers exactly one choice before the words: internal note, or
customer reply. It posts to `tickets.discussion.store` or `tickets.comments.store`
as appropriate, so the store stays the one that owns each kind of entry. Either
can carry one photo, screenshot or PDF — a message keeps its own
`attachment_path`, served through `messages.attachment`, which requires being on
that conversation.

`App\Services\Messaging\TicketDiscussion` owns the private half. The first
message opens the thread and pulls in everybody holding the ticket plus whoever
logged it — staff accounts that can open the inbox, never a customer's portal
sign-in. Somebody assigned later is added by the next message, and nobody is ever
removed, so a colleague taken off the ticket still has what was said. Opening the
ticket counts as reading it. Posting needs both the ticket policy and
`messages.view`; a person without the inbox gets no internal option at all rather
than a composer that refuses.

When a ticket moves status, `TicketStatusController` and
`TicketController::update` write the move into the discussion as an authorless
entry — from which status, to which, by whom, and the **note** that came with it.
Neither ever opens a thread on its own: a status change is not a conversation, so
a ticket nobody needed to discuss stays undiscussed.

## The outcome is not a comment

The resolution is the answer the business is left holding, so it is a record on
the ticket rather than one more line of chat: `tickets.resolution` (the words),
`resolution_code` (`App\Enums\TicketResolution` — repaired on site, parts
replaced, manufacturer warranty, settings corrected, install corrected, no fault
found, advice given, duplicate, other), `resolved_by`, and the existing
`resolved_at` / `closed_at`. The ticket page shows it in its own card; the feed
carries a single line pointing at that card and does not copy the paragraph.

Finishing a ticket — resolving or closing it, from the status panel or the edit
form — needs both the words and the kind of outcome. Both are asked for at once
so nobody fixes one field only to be sent back for the other. Tickets finished
before this existed keep their words and have a blank code, which every screen
says plainly instead of guessing. Reopening clears `resolved_at` and
`resolved_by`; the words stay, because what happened on site is still true.

The note on a status move is a *separate field* from the resolution: it says why
it moved and what happens next, lands in the conversation, and is never presented
as the outcome.

## The shared ticket status board

`/ticket-status` is the queue the crews read without a CRM account: one status at
a time, narrowed to the queues a link was issued for, with the suburb, the crew
on the ticket and how long it has sat. Staff reach the same board with their own
account and see every queue their ticket access covers. It used to be read-only;
it now carries exactly three actions, and each has a switch.

- **A note.** From staff it is written through `TicketDiscussion`, so it is the
  same row the staff inbox shows. From a crew link there is no staff account to
  author it, so `App\Services\Tickets\BoardNotes` writes an internal note on the
  ticket with the crew's name snapshotted against it — no account is invented,
  and the office reads it in the ticket's own feed.
- **Ready for review.** A crew or a colleague says the work is done, in words,
  and that becomes a `ticket_completion_requests` row in `pending`. The ticket
  does **not** move: a claim is a question, and a second submission updates the
  claim already waiting rather than stacking another one.
- **Completed.** Admin and Super Admin only, and only against a pending claim.
  `App\Services\Tickets\TicketCompletionReview` writes the outcome: the crew's
  words become `tickets.resolution` unless the reviewer rewrote them, the kind of
  outcome is the reviewer's (`resolution_code`), `resolved_by` is the reviewer,
  and the status becomes `Resolved` — closing stays a separate step. Sending it
  back leaves the ticket where it is and records why, which the crew reads on the
  board.

A reviewer can never be a link: a shared board is not a login. Nor can any viewer
act on a ticket outside the queues their access covers, whatever URL they post to.

The board is also the one public address the coming-soon holding page does not
hold: `ShowComingSoon` lets `/ticket-status` through because a crew has no
account here to sign in with, so the credential the office issued is the door,
not the veil. Nothing else about it changes — a visitor without a link still
lands on the sign-in screen, and every other public address is still held.

What the board may do is the office's decision, not the crew's: three switches on
**Settings → Status link**, stored by `App\Support\TicketBoardSettings` as
`ticket_board.notes`, `ticket_board.review` and `ticket_board.complete`, and on
by default. Switching one off removes the button and makes the endpoint refuse
the request — the switch is not a hidden button. Only Admin and Super Admin may
move them, deliberately narrower than `settings.manage`, because these decide
whether a crew can move a support ticket at all. Tests are in
`tests/Feature/TicketCompletionReviewTest.php`.

## Passwords

User passwords are stored through the `hashed` cast on `App\Models\User`, so
assign a plain string and Laravel hashes it. Change a password by updating the
user — never by editing `.env`, seeding, or rebuilding the database. The owner's
account is `config('solar.super_admin.email')`, with the `Super Admin` role.

## Signing in with Google

The staff sign-in screen offers a second door beside the passkey and the
password: **Continue with Google**. It is a second way into an account that
already exists, never a way to make one. Google answers who the person is; the
application answers whether they work here, so an address that matches no staff
account, one that belongs to a customer's portal sign-in (`users.customer_id`
set), and one the office has deactivated are all refused, and nothing is
created. There is still no sign-up link.

`users.google_id` holds Google's own subject id rather than the email address,
so a change of work address does not lose the link: the first successful
sign-in fills it by matching the address on the staff account an administrator
already created, and it is never re-pointed on an account that already holds
one. A second factor still applies — somebody who has turned one on answers it
after Google has vouched for them, because the factor belongs to the account
and not to the password that was avoided. Refusals are written to the sign-in
history (`login_events`), and a success goes through the `web` guard like any
other sign-in, so `last_login_at` is stamped by the same listener.

The client is configuration, not a screen: `GOOGLE_CLIENT_ID` and
`GOOGLE_CLIENT_SECRET` from the Google Cloud console (OAuth client ID, type
"Web application") with `<APP_URL>/auth/google/callback` in its authorised
redirect URIs. Without both values the button is not rendered and both routes
answer 404. `GOOGLE_REDIRECT_URI` overrides the callback address for an
installation behind a proxy — it has to match the console character for
character — and `GOOGLE_ALLOWED_DOMAINS` (comma-separated) narrows sign-in to
the business's own Workspace addresses. `/auth/google` and
`/auth/google/callback` sit outside the CRM's `auth` group, guarded by `guest`,
and Socialite's state parameter is what ties the callback to the session that
started the trip. Tests are in `tests/Feature/GoogleLoginTest.php`.

## The credentials vault

`vault_entries` is the company's own logins for the manufacturer portals —
Solis Cloud, Sungrow iSolarCloud, Fronius, Tesla, and the rest — the ones the
office signs into to watch a customer's system and the ones an installer is
handed to do the same. It is not a place for a customer's password.

The password column uses the `encrypted` cast, so what sits in the database is
ciphertext tied to this installation's `APP_KEY`. A dump on its own therefore
gives nothing away, which is why one can be taken and verified for a backup; a
dump *and* the app key is the whole vault, so the key stays out of the
repository and out of anything shared.

A database copied between installations carries passwords sealed with the key
of the installation that typed them. The other installation can list the entry
— `has_password` reads the column, not the value — but reveal answers 409 with
the reason until that key is listed in `APP_PREVIOUS_KEYS` there, or the
password is typed in again on that installation, which re-seals it under its
own key. Local and live are two installations: matching `APP_KEY` is not
required, but the key that wrote a row must be available where the row is
read. Never copy the live database over local without carrying the live key,
and never copy local over live without carrying the local key.

Only admin and operations open it: `vault.view` and `vault.manage`, whose
defaults are in `App\Support\Permissions`, are held by `Admin` and `Operations
Manager` and by nobody else, and `App\Policies\VaultEntryPolicy` is what the
routes and the request object ask. Sales, finance and the technical support
tiers get a 403 rather than a screen with the passwords greyed out; the sidebar
hides the entry from them as well. An installation that already holds data gets
the permissions from `php artisan crm:grant-vault-permissions`, which only ever
adds — do not re-run `RolePermissionSeeder` on a live database.

A password never travels in a page payload. `VaultPresenter` leaves it out and
says `has_password` instead, so the list, a browser cache and an Inertia history
entry can hold only names, logins and organisation codes. Reading one is
`POST vault/{id}/reveal`, which is a POST because it also writes the audit line —
`last_revealed_at`, `last_revealed_by` and a `reveal_count` — and answers
`no-store`, so a prefetch, a link or a back button cannot fetch it again. The
entry shows who last looked and when. Leaving the password blank on the edit
form keeps the stored one; it never erases a login because a field was not
filled in. An entry that falls out of use is retired (`is_active` false) or
removed with a soft delete, never hard deleted: it may be the only record of
which account a system from three years ago was monitored under.

The old support desk held the same list, and it is still on this server:

    php artisan vault:import-legacy --from-database=sms_portable_20260618_145206
    php artisan vault:import-legacy --from-database=sms_portable_20260618_145206 --commit

By default its passwords are not imported, and the column is not even selected:
they are ciphertext under the previous installation's key, which this
application cannot read, so copying them would only move an unusable secret
into a second database. The 26 entries then arrive with their portal name,
address, login, organisation code and notes, and every one says on screen that
no password has been recorded yet, because that gap is the work left to do.

That column is not a password but a Laravel payload — base64 of a small JSON
document holding the IV, the ciphertext and a MAC — so it can be read only with
the old installation's own `APP_KEY`. Hold that key and the words come across
too:

    php artisan vault:import-legacy --from-database=sms_portable_20260618_145206 \
        --legacy-key="base64:…" --commit

`App\Services\Vault\LegacyVaultDecrypter` opens each payload with that key and
the import writes the result through the model's `encrypted` cast, so what lands
in `vault_entries` is ciphertext under *this* key and nothing prints the plain
text. Payloads that will not open — the wrong key, or a row sealed under a third
one — are counted as "ciphertext that would not open" and left blank rather than
guessed at. Without the key there is no way back: the ciphertext is AES with an
HMAC over it, and an installation that has lost its key has lost those
passwords, which then have to be reset in each portal and typed in once.

A commit takes and verifies its own database backup first. The import matches on
`legacy_id` (`sms:<id>`), so running it again updates what it wrote and never
overwrites a password or a note somebody has typed here since — the old copy
only ever fills a gap.

## The install schedule import

Regenerate the CSV from the workbook — never from the database:

    python tools/parse_install_schedule.py "C:/Users/techs/Downloads/Install Schedule.xlsx"

Then import it. Dry run first; `--commit` writes, `--fresh` replaces the previous
run of the same file:

    php artisan crm:import-install-schedule
    php artisan crm:import-install-schedule --commit

Reports land in `storage/app/import/`: `install-schedule.csv`, `dropped.csv` and
`report.json`. A row is only kept when it has a name, a phone number and an
address; `GRC Project` and `Form Responses 2` are excluded. Clients with several
installs are merged into one customer, and their installs become one job each.

## The ticket import

`tickets:import` brings tickets and their conversations across from an older
system, from a SQL dump. Always look before leaping:

    php artisan tickets:import "path/to/dump.sql" --dry-run
    php artisan tickets:import "path/to/dump.sql"

A real run takes and verifies its own database backup first and refuses to start
without one, so it can never be the thing that leaves the database unprotected.

It is additive and idempotent. Tickets are stamped with the identifier they had
in the old system, and comments with their own, so running it twice updates what
it already wrote instead of making a second copy. It never deletes, and it never
creates a customer, a job or a user to make a relationship fit: a link is only
made from an explicit mapping CSV (`--map-customers`, `--map-jobs`, `--map-users`)
or an exact match on a business identifier, and anything else is listed in the
report for a person to map later.

Other options worth knowing: `--batch-size`, `--limit` for a trial run,
`--resume` to continue an interrupted run from its checkpoint, and
`--preserve-ids` to reuse the original ids where they are still free. The report
is written to `storage/app/import/tickets-import-<execution>.json`, and the
checkpoint to `tickets-import-state.json` beside it.

Note that the supplied `tickets-.sql` export contains table structures only —
its data rows were not selected when it was dumped, so it reports zero tickets to
import. The old system's database, however, is still on this server, which makes
it the better source: read the rows where they are rather than exporting them to
a file first.

    php artisan tickets:import --from-database=sms_portable_20260618_145206         --map-customers=storage/app/import/map-customers.csv --dry-run
    php artisan tickets:import --from-database=sms_portable_20260618_145206         --map-customers=storage/app/import/map-customers.csv

`--from-database` reuses this application's own MySQL credentials with only the
database name changed, never prints them, and refuses to point at this
application's own database. It reads the conversation out of all three of the old
comment tables and namespaces their ids by table, because those tables number
their rows independently. Add `--legacy-connection` if the old system is not
reachable on the default connection.

The 762 tickets and 2,495 comments from `sms_portable_20260618_145206` are
already in, stamped on the `sms` source. Customers were linked on an exact phone
number, which covered 617 tickets; the rest are listed for a person to map in
`storage/app/import/unmatched-customers.csv`, with the old system's staff in
`unmatched-staff.csv` and its installers in `unmatched-installers.csv`. Once real
accounts exist for those people, re-run with `--map-users` to fill the links in —
the import is idempotent, so a second run updates what it already wrote rather
than importing anything twice.

## The installer register

`installers` is the register of electrical and solar subcontractors the business
sends work to, and it is a compliance record rather than a contact list. A row
holds what an Australian crew needs before it can be booked: the business's
electrical contractor licence (an REC in Victoria, EC in Western Australia, a
contractor licence elsewhere) and the individual electrician's own licence, both
with expiry dates; Solar Accreditation Australia accreditation, which replaced
Clean Energy Council accreditation in 2024, plus the legacy CEC number; the
public liability certificate of currency with the cover it names, and
professional indemnity; and the site tickets — White Card, working at heights,
ARCtick refrigerant handling, forklift and EWP. Every credential keeps its
expiry, because an expired licence is what makes a crew unusable on a given day,
and the list treats one expired credential as `expired` however current the rest
are.

A crew is kept, not deleted, when it is no longer used: `is_active` is false for
the rows under an "installers we do not use any more" heading, and removing one
soft deletes it, so a job from last year can still name who did the work.

A support ticket's `installer_id` points at this register, not at `users`: the
crew who did the install is who has to answer for it, and a crew is not a
sign-in. `Options::installers()` is the ticket picker (current crews first, then
the retired ones), and `Installer::forName()` is how a job's free-text installer
name becomes a crew — an exact match on the person or the business, never a
near miss. The staff member holding the ticket is `assignee_id`, which is what
the list's "Assigned to me" scope and the finance module still mean by
installer.

A ticket logged in the CRM starts out assigned to the person who logged it:
`TicketController::store` fills the assignee set with the creator when the form
names nobody, and the create screen's picker starts on them so the assignment is
visible and can be handed on before saving. Only an explicit choice overrides
it — a ticket nobody owns is one nobody chases. The default is ownership rather
than the assign permission, so somebody who may log a ticket but not hand it out
still holds their own.

`crm:import-installers` reads the register from a workbook, a link-shared
workbook, or a service-account Google Sheet. Unlike a job sheet it reads **every
tab at once** and takes each tab's name as the state (`Brisbane` and
`Regional QLD` are both QLD), because the register is one list spread over a tab
per state:

    php artisan crm:import-installers "auto Import Installers.xlsx"
    php artisan crm:import-installers --workbook-url=<link> --commit

A row only needs a name, so a crew whose licence has not been typed in yet still
appears — the gap is the point. Bare "Expiry Date" columns are attached to the
credential whose number sits to their left, which is how the sheets are laid out.
Running the same sheet again updates what it wrote, matching on the contractor
licence, then the email, then the name within the state. A commit takes and
verifies its own database backup first, and every row is stamped
`Google Sheet <spreadsheet id>` (or the file name) as its source.

## Warranty and inspection reports

`inspections` is the module a crew fills in on site after an install, for a
handover, a warranty claim, a defect recheck or an audit. The checklist is
`App\Support\InspectionChecklist`, and it follows the standards an Australian
installation is inspected against: **AS/NZS 5033:2021** for the array and DC
wiring, **AS/NZS 4777.2:2020** for the inverter and its grid connection,
**AS/NZS 5139:2019** for battery storage, and **AS/NZS 3000** for earthing and
the electrical tests, with the labels, handover and warranty paperwork that a
manufacturer asks for on a claim. It records observations and readings, not a
certificate of compliance — the job's own `inspection_status` and electrical
certificate remain separate, and `App\Enums\InspectionStatus` is the job's
inspection state, not the report's (`App\Enums\InspectionReportStatus`).

A report is opened against a job from **Inspections → Request an inspection**, or
from the job's own panel, which mints `WIR-0001` and a 48-character token. The
crew answers through `/inspection/{token}` with no account: the token is the key,
the link lasts 30 days, the office can extend or close it, and a submitted report
is closed for good — a second visit is a second report with its own reference.
Drafts save themselves while the crew works, so a phone that loses signal does
not lose the answers.

A submission is accepted only when it is a record worth keeping: every checklist
item answered (the battery section only for hybrid and battery systems), the
readings and the test instrument recorded, at least three photos, a signature,
the declaration ticked, and the outcome agreeing with the defects — a pass has
none, a conditional or fail has at least one, and a fail needs a major or
critical defect behind it. Those rules live in
`App\Http\Requests\Inspection\SubmitInspectionRequest`; `App\Support\InspectionAnswers`
is the one place the posted document is stripped down to the fields the checklist
defines, and the columns the list filters on (outcome, inspected date, defect
count) are read out of that document at submission.

Photos, PDFs and Word documents attach as they are taken. Files live on the
private disk under `inspection-reports/{id}`, are served only through the token
or the staff policy, and are capped at 24 photos, 12 documents and 20 MB each.
`Download pack` builds a zip of the attachments and a printable `report.html`
under the private disk's `tmp/inspection-packs` (`storage/app/private`), and
deletes it after sending. The
emails — the link to the crew, and a submitted report to the office — go through
the settings in **Settings → Email**; a failed submission email is logged rather
than shown, so the crew's work is never lost to an SMTP problem.

A report can be removed in **any** state — an empty link, one that has run out, or
one a crew has already submitted — because a report raised against the wrong job
would otherwise sit in the list forever. `InspectionReportPolicy::delete` asks
only for `inspections.manage`, and `destroy` deletes the attachments from the
private disk before the row. The screen confirms by hand and says which kind of
report it is; there is no undo but the backup.

### The standing link a crew opens on site

Everything above is a report the office asked for: it hangs off a job, and the
crew waits for the link. `App\Support\InspectionIntakeLink` is the second door —
one standing address, `/warranty-inspection/{token}`, that the business opens
from **Inspections** and a crew uses at the property when nobody in the office
knew about the visit. The per-report process is untouched; this is a way in, not
a second kind of report. It is not a session and not a login either: the token is
the whole credential, so the address is only shown to somebody holding
`inspections.manage`, **New address** retires every copy of the old one, and
closing forgets the address rather than pausing it (settings keys
`inspection_intake.enabled` and `inspection_intake.token`). Reports opened this
way are marked `source = self_service` (`App\Enums\InspectionSource`).

The crew types the customer's name, phone, street address, suburb, state,
postcode and their own name, and then confirms which client it is.
`App\Services\InspectionIntake` does the matching: the street is compared without
its unit prefix or its street-type suffix, the phone on its last nine digits
(`PhoneNumber::key`), the name as a substring, and only a site scoring enough on
the address or the phone is offered — with the reason it was offered, because the
crew is the one standing in front of the meter. "None of these is the right
place" is a first-class answer, which is why `inspection_reports.job_id` is
nullable: a report for an address this CRM holds no client for still opens,
carrying `customer_name`, `customer_phone` and the `site_*` snapshot exactly as
typed. `customer_id` and `site_id` are filled when one was confirmed, and
`job_id` becomes the most recent job at that address, so the report lands on the
job card the office already watches. Nothing is written into `data` at intake:
what the crew has not said yet stays unsaid.

The office closes the loop from the report. The detail page offers the same
address matches — and a weaker name-only match, because a name is better than an
empty afternoon — and `POST inspections/{report}/attach` links one, accepting
only a site from that candidate list so the endpoint cannot point a report at an
arbitrary customer. The typed snapshot is never overwritten by the link; it is
what the visit was recorded against. Both public inspection addresses are let
through `ShowComingSoon` for the same reason the status board is — a crew on a
roof has no account for the veil to protect — and a closed or stale token is
answered by the controller with a 404. Tests are in
`tests/Feature/InspectionIntakeTest.php`.

The permissions are `inspections.view`, `inspections.create` and
`inspections.manage` (send, extend, close, review, delete an unanswered link, and
open, replace or close the standing on-site link). An
installation that already holds data gets them from
`php artisan crm:grant-inspection-permissions`, which only ever adds, following
the default role matrix in `App\Support\Permissions`; do not re-run
`RolePermissionSeeder` on a live database, because it syncs every role back to
the defaults. Tests are in `tests/Feature/InspectionReportTest.php`.

## Reading a Google Sheet

Three sheets can be connected at once — the client list, the job sheet and the
installer register — each with its own link, switches, cadence and report on
**Settings → Sheet import**, so connecting one never disturbs another. The job
sheet keeps the settings names it has always had; clients and installers are
stored separately underneath, and the installer register is read as the whole
workbook (`export?format=xlsx`) rather than one tab's CSV.

A spreadsheet that is private to the business is read with a Google **service
account**, not anybody's sign-in, so a scheduled run does not stop working when
a token expires. Create the account in the Google Cloud console, enable the
Sheets API on the project, download its JSON key to somewhere outside the
repository and the public web root, and share the spreadsheet with the
account's address as a **Viewer**. Then:

    GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
    GOOGLE_SHEET_ID=1sAQVfLZjKnWuDtFvbNs61RAisbUXU4iY_nY2NtVmmQg
    GOOGLE_SHEET_GID=1895975164

If a service account is not wanted, the sheet can instead be shared by link and
read from its CSV export. **Settings → Sheet import** is the way to set that up:
paste the ordinary link out of the browser's address bar (the `/edit` one, with
`#gid=` on the end), press **Connect and check**, and the screen turns it into
the export URL, reads the sheet at once, and shows what it found before anything
is written. The link is only saved once it has been read successfully, and the
same screen owns the schedule — read-only until **Import new rows automatically**
is switched on — so keeping a sheet in sync never means editing `.env`. Pressing
**Import** there runs exactly what `crm:import-jobs --csv-url=<url> --commit`
runs, including the verified backup first; the clients card runs
`crm:import-clients` instead, which writes customers and nothing else.

Sharing by link makes the sheet readable by anyone holding the link, so it is a
deliberate choice about that data, not a shortcut. Both routes stamp the same
marker, so `--fresh` treats them as one source. `GOOGLE_SHEET_URL` and the
`SHEET_IMPORT_*` values above remain as the fallback for an installation that
configures this in `.env` instead; the screen's settings take precedence.

The gid is the number at the end of the sheet's URL. `crm:import-jobs` reads the
sheet through the same column detection, duplicate rules, report and
backup-before-commit as an uploaded workbook, because the sheet is handed to the
importer through the same interface:

    php artisan crm:import-jobs --google --list
    php artisan crm:import-jobs --google
    php artisan crm:import-jobs --google --commit

Without `--commit` it writes only its report, to `storage/app/import`. Everything
a run creates is stamped `Google Sheet <spreadsheet id>` as its marker, so
`--fresh` replaces exactly that run and never a row it did not create. Nothing is
ever written back to the sheet.

A row is written against the client who is already here rather than a second copy
of them. `CustomerIndex` resolves the customer in this order: the site at that
address, then the phone number, then the email, then the name. The name is the
last resort, because a number changes and a record typed three years ago may
carry an old one — and it answers only when exactly one customer in the same
postcode (or, failing that, the same suburb) holds it. Two households called
Smith are not one household: a name shared by several customers makes the row its
own record, and the preview says which account it was written against and what
proved it. A **builder** is matched on the business name alone, since its billing
address is its office rather than the roof the sheet is about, so a job sheet
naming a building company lands on the builder's account instead of making a
second copy of it. `crm:import-clients` matches the same way and never changes a
builder's type, because that type is what holds its clients.

Scheduled runs are off until they are switched on — from Settings → Sheet
import, or with `SHEET_IMPORT_SCHEDULE=true` — and they only read until
importing is switched on as well (`SHEET_IMPORT_COMMIT=true`) — the safe order is
to watch the report first. The scheduled run overwrites
`storage/app/import/google-sheet.json` each time and needs `php artisan
schedule:run` from cron, like the scheduled backups.

## Releasing by parts, and the version number

`App\Support\Release` is the switchboard for a gradual handover, and
**Settings → Release** (`/settings/release`, Admin and Super Admin by role) is
the screen. Each part is on unless it has been switched off, so this feature
never removes a screen an installation already had; **Full release** wins over
the individual switches and remembers them for when it is turned back off.

A part that is off is absent, not greyed out. `EnforceRelease` (registered in
`bootstrap/app.php`, before `ShowComingSoon`) maps a route name to its part
through `Release::ROUTES` and answers 404 — so every route in the part's file,
including one added later, is covered by the prefix rather than by a list of
URLs. The places that aggregate across parts leave a disabled part out rather
than failing: `LookupController::search` and the type-ahead endpoints, the
dashboard's job metrics, and the customer/job pages' job, ticket and inspection
panels. The sidebar, the New job button, the dashboard and the settings tabs
read the shared `release` Inertia prop, which is built by the same class the
middleware asks, so a hidden link and a 404 cannot disagree.

When a new part is added: add it to `Release::parts()`, add its route-name
prefix to `Release::ROUTES`, add `part` to its sidebar items, and gate any
cross-module count or panel it appears in. `tests/Feature/ReleaseTest.php` holds
the line.

The version is `VERSION` (validated semver) plus the newest entry in
`docs/releases.md`. `php artisan crm:version` reads both; `--patch`, `--minor`
and `--major` with `--note="…"` write both. `--install-hook` puts a local
pre-commit hook in `.git/hooks/pre-commit` that bumps the patch number and
stages the two files unless the commit already stages `VERSION`, or carries a
version that has been written but not yet committed (a deliberate minor or
major bump). The hook is per checkout and is never committed; run the command
on a fresh clone. The number is shared to the frontend as `version` and
shown at the foot of the sidebar.
