mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
157 lines
9.7 KiB
Markdown
157 lines
9.7 KiB
Markdown
# migrate-demo-edge01.sh
|
|
|
|
Copies **exactly one** user — the demo account
|
|
`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01
|
|
Postgres to the **separate** edge01 demo Postgres. No other user's rows are
|
|
transferred under any circumstance.
|
|
|
|
> Review the script first, then run it deliberately. It performs destructive,
|
|
> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice.
|
|
> It never writes to apps01 (source is read-only).
|
|
|
|
## How it connects
|
|
|
|
- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss`
|
|
(stable), psql `healthlog`/`healthlog`.
|
|
- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate
|
|
Coolify resource whose container name is **not hardcoded** — the script
|
|
resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts
|
|
if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`).
|
|
The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked
|
|
Postgres resource, not the docs/landing DB.
|
|
- **Edge demo user id is resolved at runtime** by `username='demo'` (it may
|
|
differ from the apps01 id). The wipe phase is scoped to that resolved id; the
|
|
load then brings the apps01 row in, so afterwards the edge01 demo user
|
|
carries the apps01 canonical id.
|
|
|
|
Each table is transferred with a scoped, server-side `\copy ... TO STDOUT`
|
|
piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists
|
|
on both ends** so secret columns can be NULLed and column order is pinned.
|
|
|
|
## What moves (FK-parent-first, 20 tables)
|
|
|
|
| # | Table | Scope predicate |
|
|
| --- | ----------------------------- | --------------------------------------------------------------------------------- |
|
|
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
|
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
|
| 3 | `cycle_profiles` | `user_id = demo` |
|
|
| 4 | `menstrual_cycles` | `user_id = demo` |
|
|
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
|
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
|
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
|
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
|
| 9 | `medications` | `user_id = demo` |
|
|
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
|
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
|
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
|
| 13 | `medication_inventory_items` | `user_id = demo` |
|
|
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
|
| 15 | `medication_side_effects` | `user_id = demo` |
|
|
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
|
| 17 | `personal_records` | `user_id = demo` |
|
|
| 18 | `user_achievements` | `user_id = demo` |
|
|
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
|
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
|
|
|
## Secret / credential columns NULLed on the `users` row
|
|
|
|
`codex_access_token_encrypted`, `codex_refresh_token_encrypted`,
|
|
`codex_token_expires_at`, `codex_connected_at` (and `codex_connection_status`
|
|
reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
|
|
`ai_local_key_encrypted`, `ai_openai_key_encrypted`,
|
|
`withings_client_id_encrypted`, `withings_client_secret_encrypted`,
|
|
`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`,
|
|
`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`,
|
|
`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`,
|
|
`mood_log_api_key_encrypted`, `mood_log_webhook_secret`,
|
|
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
|
|
|
|
Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`,
|
|
`role='ADMIN'`.
|
|
|
|
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
|
|
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
|
|
display / preference / layout JSON (`thresholds_json`,
|
|
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
|
|
`source_priority_json`, `doctor_report_prefs_json`, `notification_prefs`,
|
|
`healthkit_config_json`, unit prefs, `height_cm`, `gender`, `timezone`,
|
|
`locale`, `date_of_birth`, `display_name`, onboarding state, research-mode
|
|
acknowledgement).
|
|
|
|
## What is excluded — and why
|
|
|
|
- **Auth / device / push** (sessions, passkeys, auth_challenges, api_tokens,
|
|
refresh_tokens, devices, push_subscriptions, push_attempts,
|
|
notification_channels, notification_preferences) — per-environment, never
|
|
portable.
|
|
- **Integration / OAuth** (withings/whoop/fitbit connections, all
|
|
`*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions +
|
|
reminder messages, clinician_share_links, provider_health) — tied to live
|
|
credentials.
|
|
- **Encrypted-at-rest payloads we can't decrypt on edge01:**
|
|
`insight_narratives` and all `coach_*` (coach_conversations, coach_messages,
|
|
coach_facts, coach_usage). The edge01 instance does not hold the prod
|
|
encryption key, so the AES-256-GCM ciphertext would be unreadable. The same
|
|
reason drives NULLing `cycle_day_logs.sensitive_encrypted` /
|
|
`notes_encrypted` while still carrying that table's plaintext cycle fields.
|
|
The plaintext AI assessment text is preserved through the `insights.%`
|
|
`audit_logs` rows (the text lives in the `details` JSON) instead.
|
|
- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups,
|
|
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
|
|
tables (measurement_rollups, mood_entry_rollups,
|
|
medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these
|
|
rebuild on edge01.
|
|
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
|
|
- **Instance / global:** app_settings; global catalogues (mood_tags,
|
|
cycle_symptoms) — these pre-exist on edge01 and the carried link rows
|
|
reference the global ids only.
|
|
|
|
## Encryption note
|
|
|
|
The edge01 demo instance is assumed **not** to share the apps01 encryption
|
|
key. Every column whose value is AES-256-GCM ciphertext under the prod key is
|
|
either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped
|
|
entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying
|
|
that ciphertext would produce undecryptable rows that the app's fail-closed
|
|
loader would reject.
|
|
|
|
## role = ADMIN + DEMO_MODE expectations
|
|
|
|
The demo user is loaded with `role='ADMIN'` so the admin surfaces are
|
|
viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on
|
|
edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in
|
|
demo mode, so an admin demo session can browse admin pages but cannot change
|
|
anything. If DEMO_MODE were ever off on edge01, this admin demo account would
|
|
be a write-capable admin. Keep DEMO_MODE enabled.
|
|
|
|
## Safety mechanisms built into the script
|
|
|
|
- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call.
|
|
- **Pre-flight guard (apps01):** for each carried set, a query that must
|
|
return `0` foreign-owned rows — aborts on any non-zero. Includes a check
|
|
that the demo owns **0 custom mood_tags** (so every carried
|
|
`mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus
|
|
a defensive "non-global tag" link count.
|
|
- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to
|
|
the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to
|
|
proceed if `username='demo'` on edge01 resolves to the operator id.
|
|
- **Post-load verification (edge01):** per-table demo row counts; a hard
|
|
assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero
|
|
rows; a check that user_id-bearing carried tables hold only the demo id; and
|
|
a confirmation the demo user exists with `role=ADMIN`.
|
|
|
|
## Caveats / things to confirm before running
|
|
|
|
- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
|
|
tables on edge01, not just what this run inserted. On a clean
|
|
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
|
|
accounts, treat a non-zero value as a warning to inspect, not an automatic
|
|
failure (the script prints it as a WARNING, not an abort).
|
|
- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script
|
|
verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the
|
|
demo Postgres resource uses different ones).
|
|
- The script assumes edge01's schema is at the same (or a compatible) Prisma
|
|
migration level as apps01. A column present on apps01 but missing on edge01
|
|
fails the `\copy` loudly rather than silently corrupting data — run
|
|
migrations on edge01 first if the two have drifted.
|