Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.
Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.
Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.
Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.
`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
9.6 KiB
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, containerdb-pg8wggwogo8c4gc4ks0kk4ss(stable), psqlhealthlog/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 viadocker ps | grep -iE 'postgres|^db-'and aborts if it cannot pick a single match (override withDST_DB_CONTAINER=db-xxxx). The demo app uuid isck8cs4osswg8w440gskw08w8; 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,
insurance_number_encrypted, insurer_name, insurer_ik_number.
Forced flags: telegram_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_narrativesand allcoach_*(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 NULLingcycle_day_logs.sensitive_encrypted/notes_encryptedwhile still carrying that table's plaintext cycle fields. The plaintext AI assessment text is preserved through theinsights.%audit_logsrows (the text lives in thedetailsJSON) 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 pipefailandON_ERROR_STOP=1on every psql call.- Pre-flight guard (apps01): for each carried set, a query that must
return
0foreign-owned rows — aborts on any non-zero. Includes a check that the demo owns 0 custom mood_tags (so every carriedmood_entry_tag_linkpoints 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
cmlupy4tn000001rpzx1pxvz7appears in zero rows; a check that user_id-bearing carried tables hold only the demo id; and a confirmation the demo user exists withrole=ADMIN.
Caveats / things to confirm before running
FOREIGN_HITSassertion 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 is0. 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; adjustPG_USER/PG_DBif 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
\copyloudly rather than silently corrupting data — run migrations on edge01 first if the two have drifted.