# Legacy PostgreSQL data migration (offline only)

`php artisan legacy:import /private/path/legacy-export.json` is a **dry run by
default**. It reads one local JSON object containing exactly the 62 supported
snake_case table arrays (`users` and the legacy repair-domain tables). The
command has no HTTP route, accepts no upload, never opens a PostgreSQL
connection, and rejects a file inside `public/`.

Prepare the JSON export through an approved, private process outside this
application. Store it with restrictive permissions outside the web root. An
operator can then validate it in a separate staging environment after a fresh
web installation, inspect its source counts and privacy obligations, and only
then execute:

```sh
php artisan legacy:import /private/path/legacy-export.json --apply
```

This is intentionally not a web-installer feature and does not require an
end-user to have SSH access. A deployment/staging operator performs the
private import; end users use the normal fresh web installation.

## Safety contract

* Validation checks all 62 arrays, object rows, positive preserved IDs,
  supported target keys, target-generated-column exclusion, exact decimal
  scales, ISO dates, UTC PostgreSQL timestamps (`Z` or `+00:00`), duplicate
  Clerk/email identities, and references. Unsupported tables, keys, formats,
  or unresolved references stop the import rather than being skipped.
  Export decimal values as JSON strings, not JSON floating-point values, so
  their PostgreSQL precision and MySQL scale can be verified exactly.
* `--apply` only runs against MySQL/MariaDB. It requires every domain table to
  be empty. The only permitted pre-existing user is the fresh installer local
  `super_admin`; `app_settings` and framework tables are left alone.
* An installer-admin email or `clerk_id` collision aborts. Email and Clerk
  collisions always require a separately reviewed explicit
  identity mapping; this tool never merges them. The common fresh-admin ID 1
  collision has one narrow, explicit staging-only option:

  ```sh
  php artisan legacy:import /private/path/legacy-export.json \
    --relocate-installer-admin
  php artisan legacy:import /private/path/legacy-export.json \
    --relocate-installer-admin --apply
  ```

  The first command remains a dry run and proposes the reserved destination
  ID, calculated as `max(all source user IDs, all target user IDs) + 1`. The
  `--apply` command repeats all checks in its transaction and moves only the
  single fresh local `super_admin` to that ID before inserting source users.
  Source user IDs are never changed; the administrator's email and password
  hash are not changed. Without this exact option, an ID collision aborts.
  Relocation also refuses to proceed if database or file sessions still
  reference the old admin ID. On a fresh staging target, prove there are no
  active file sessions (or invalidate them using the deployment's normal
  session procedure) before retrying. No sessions are silently repointed.
* Rows are inserted with original IDs, in dependency order, in one
  transaction. MySQL foreign-key checks stay enabled. Nullable cyclic links
  (including legacy user/store and communication/job links) are inserted
  blank and updated only after their referenced IDs exist.
* No table is dropped, truncated, or destroyed. The command reports only
  counts and structural errors; it never logs source row payloads, PII,
  credentials, or SQL binding values.

## Identity, passwords, and privacy

Legacy `clerk_id` is required and preserved. Legacy passwords and verified
email state are never trusted: each imported user receives a cryptographically
random unrecoverable password value, is marked inactive, and has
`email_verified_at` cleared. Activate/verify accounts using the normal
application recovery flow.

`customers.user_id` is a newer nullable target relationship, not a legacy
export field. The import leaves it null and never links a customer to a user
by email. The application may use an exact `clerk_id` only for a user whose
source marker is exactly `clerk`; any other legacy source marker is retained
as non-trusted imported identity data.

Supported sensitive fields already represented in the target schema (for
example contact details, employment documents, SSN-related fields, and
provider configuration fields) are retained as source data. Before migration,
the operator must perform the applicable privacy, retention, consent,
encryption-at-rest, access-control, and incident-response review. This command
does not redact, export, transmit, or print that data.

## Explicit limits

The importer is deliberately a schema-compatible JSON loader, not a generic
PostgreSQL conversion engine. It cannot automatically map renamed tables or
columns, invalid numeric/timestamp data, ID collisions, missing references,
non-UTC timestamps, or any export that differs from the documented shape.
Those cases require a reviewed mapping and a new validation/import plan; do
not modify the JSON to silently discard records.