Skip to content

Patient Onboarding & Legacy Account Linking ​

Status: Part A is built (2026-09-23 — see A → Built). Part B was revised on 2026-09-24 for the two platforms running in parallel — the revised design is authoritative, and the subsections after it are the first design, kept where they still hold and marked where they do not. The revision's round trip is built in the repo (2026-09-24, B → Built); the sync that brings data across is next. What shipped before A is in What is already live. Decisions the owner has made are marked Decided; the open ones are named where they sit.

Two pieces of work, designed together because the second lives inside the first:

  • A. One onboarding — the screen a signed-in person meets before they are a patient at the clinic, in the /welcome style, replacing /onboard.
  • B. Linking a legacy account — how a patient of the old platform (the Strapi app, restartix-admin-api) gets their history onto their new-platform account, once that history has been imported.

B's data import is Phase 3 of the platform-completion plan and waits for its gates. A and B's mechanism can be built before it.

Principles ​

  1. Clerk authenticates; nothing else does. Decided. No custom login is built — credential storage, brute-force protection, resets and bot defence are exactly where a health platform gets breached. Sign-in methods: email code, Google, Facebook; no passwords.
  2. A login is only a login. Decided. Clerk collects no name or profile fields, and nothing is read from the Clerk account — not even a Google display name. The person's name lives in patient_profiles.name and is asked for by our onboarding.
  3. Legacy never vouches for who someone is here. Decided. It may only vouch that a person already signed in through Clerk also owns legacy account #N. That is the lesson of the hand-off removed on 2026-09-23: the legacy app asserted identity by an email it never verified, and a legacy patient could rewrite their own email and arrive signed in as someone else.
  4. Link by legacy account id, never by email. Decided. An email is a hint that a link may exist; it is never the key.
  5. The /welcome look is the base. Decided. The owner likes it and dislikes the current /onboard form; the unified flow is built on app/welcome/*'s card, scaffold and layout. Its beta notice stays for now.
  6. Nothing is created until the last step. Decided. Signing in creates a Clerk user and nothing on the platform. Every onboarding screen only collects; the final submit links an imported profile when one can be linked, and creates a new profile only when none can. See why.
  7. The software resolves what it can; staff help where the patient can't.Decided. With ~20k legacy patients, anything the software could settle but hands to a person becomes a queue nobody clears. So every case the patient can resolve themselves is designed to resolve without staff — and what is genuinely beyond them (a wrong email on the legacy record, no access to the old account) goes to the clinic, which is what staff are for.

What is already live ​

  • Custom domains sign in through Clerk satellites — custom-domains.md → Patient sign-in. Sign-in and sign-up happen on the clinic's {slug}.portal.restartix.pro; the patient returns to the clinic's domain signed in.
  • /welcome is the one onboarding — every host, every way in (A, built 2026-09-23). It calls POST /v1/me/patient-profile then POST /v1/portal/onboard; /onboard is a redirect to it.
  • Profile-claiming invitations exist. Staff "Invite to create an account" creates an invitation carrying PatientProfileID; at the invitee's first sign-in, invites.BindForPrincipal matches it by the Clerk-verified email (FindAllOpenForEmail) and stamps patient_profiles.human_id, so the person takes over a record that already carries their history. The same stamp is how a family member claims their own profile (P7).
  • Bringing a legacy account over — the round trip through the legacy platform's login, built 2026-09-24 in the repo (B, below; not deployed).
  • The legacy side (restartix-admin-api, on its staging): PUT /my-account writes only self-editable fields (dcac62e); /legacy-handoff answers a round trip with an Ed25519 token once its key is set, and otherwise sends patients to the new platform's address with no token. The "move account" page is a WordPress template (restartix-wordpress).

A. One onboarding ​

Who arrives, and what each needs ​

A signed-in human reaches onboarding when /me says they are not a patient at this clinic ((patient)/layout.tsx redirects). Today two screens split the cases; the unified flow handles all of them:

CaseBefore 2026-09-23What the flow does
Ordinary self-sign-up/welcome on a custom domain, /onboard on a subdomainName, phone, date of birth → required documents → join
Already has a profile (patient at another clinic)/onboard step 2Ask only what the profile lacks; this clinic's required documents
Pending staff invitation at this clinic/onboard with a bannerSay who invited them; join consumes the invite server-side
Arrived through a share link (/join/{code})/onboard with a banner; the code rides in a cookieSame, plus share_link_code on the join call
Clinic does not take sign-ups (portal_self_signup_enabled = false)/onboard explainsExplain, offer the clinic's contact, sign-out
Staff member or superadmin/onboard explainsExplain they are staff here, sign-out
Legacy patient with an imported, unlinked profile—The migration step (B), after the documents

Steps ​

Decided (owner, 2026-09-23), in this order, one thing to a screen:

  1. Welcome — the greeting, what the platform is for, and the beta notice. It asks for nothing.
  2. About you — name, phone and date of birth, all required. A clinic needs all three before any appointment, and this is the one moment a patient is guaranteed to pass through. A human who already has a profile (a patient at another clinic) is asked only for what their profile lacks. The phone takes a country prefix and the date is three selects (day, month, year) — most patients are elderly, and a native date picker opens on this month and asks them to page back decades.
  3. The documents — only the required documents, each on its own screen with its text, so a patient at least sees every one even if they do not read it all. They come in two sets from two different parties, and a screen before each set says whose they are: first the RestartiX platform's, then the clinic's. Terms are agreed to ("Sunt de acord"); a privacy notice is information, not an agreement, so it is acknowledged ("Am luat la cunoștință"). Terms come before the notice in each set. A double-click cannot skip a document: the second click lands on the next screen's button, in the same place, and is ignored. Optional purposes (marketing, analytics, the clinical opt-ins) are not asked here; each is asked where its feature needs it.
  4. Your old account — the migration step, only when B has something to offer (see B → where it sits in onboarding).
  5. Done → an order or a claim the patient left to sign up, if one is waiting; otherwise /dashboard.

Nothing is created until the last step ​

patient_profiles.human_id is UNIQUE: a login owns one profile. If step 1 created the profile, every migrating patient would own a new, empty profile by the time step 3 found their imported one — two profiles for one person. So the screens collect and the final submit decides:

  • Bringing a legacy account over — create the profile from step 1 and record the proven legacy account against it, in one transaction, then the consents, then join. If a bulk run imported that account as a profile of its own, stamp human_id on that one instead and fill only what it lacks (legacy has no date of birth).
  • Not — create the profile from step 1, record the consents, join.

Backend ​

The flow calls what /onboard calls, so every path provisions the same shape — profile, consents, the patients row, and a subscription only when an invitation or share link pins a tier (onboarding never auto-subscribes — decisions.md). The one addition is the link itself (B), which runs first when the submit links:

  • POST /v1/me/patient-profile — creates the portable profile + platform-scope consents; idempotent (returns the existing profile).
  • POST /v1/portal/onboard — org-scope consents + the patients row + subscription; consumes a pending invite; takes share_link_code.

The removed hand-off's one-shot ProvisionForClaim is not coming back: it skipped invitation consumption, share-link redemption and their tier pins, and one endpoint per shape is how the shapes drift.

Routing ​

  • (patient)/layout.tsx sends a not-yet-patient to /welcome on every host. /onboard is a one-line redirect to it, kept for links that still name it.
  • A sign-up with nowhere to return to lands on /welcome; a sign-in on /, where an order or a claim left half-done picks up again. Both are Clerk fallbacks on the ClerkProvider (app/layout.tsx), and the satellite proxy uses the same defaults. Never force: a redirect_url must win, because it carries the satellite's return trip and the page a patient left to sign up (a booking, an order).
  • /join/{code} and invitation emails land in the flow with their context.
  • Public routes stay as they are; the flow requires a signed-in human.

Built 2026-09-23 ​

  • The flow — apps/portal/app/welcome/: page.tsx picks the screen (staff notice, sign-ups-closed notice, or the flow) and works out which of name / phone / date of birth the profile lacks; welcome-client.tsx orders the steps — welcome-card.tsx, about-you-step.tsx, then document-steps.tsx (the screen before each set, and one per document), all drawn with step.tsx; actions.ts is the one write.
  • Where a patient goes after joining — lib/pending-resume.ts, shared with the home page: an order, then a claim, then /dashboard. /onboard used to finish on /, which is where those resumed; the first cut of /welcome went straight to the dashboard and would have stranded a buyer's order.
  • Where a new account goes after sign-up — /welcome directly. Clerk's default was /, one extra screen; and a redirect_url carried from the sign-in page (a page the patient had been sent away from) is still honoured, which is how a sign-up once landed on /forms on its way to /welcome.
  • The API gate — portalonboarding.Onboard admits an accepted invitation or this clinic's active share link when self-sign-up is off. Before, the invitation was looked up only after the gate, so an invited patient at a closed clinic could not join.
  • Required fields and minors, enforced by the API — creating a profile requires name, phone and date of birth; joining refuses a profile lacking any (409 profile_incomplete); under 18 is refused (403 minor_self_signup) with the reason: the documents are contract-basis.
  • Invitation emails — accepting a patient invitation landed on /onboarding, a route that never existed. It lands on /welcome now.
  • Removed — /onboard's two-step form, its consent block and the onboard message namespace.

B. Linking a legacy account ​

Revised 2026-09-24 — both platforms run in parallel ​

Decided (owner, 2026-09-24). The old platform keeps running beside the new one for a while, so the import cannot be one run that happens before anyone links: patients must be able to bring their account over before any bulk import, and to bring over again whatever they did on the old platform since. Everything becomes one repeatable operation per legacy account — sync consumer #N — and every path uses it.

Proof: logging in on the old platform. Decided. The only self-service proof:

  1. Signed in on the new platform, the patient chooses Bring my account from the old platform.
  2. The platform records a random state against this login (Redis, a few minutes) and sends them to the old platform with it and an allow-listed return address.
  3. They log in there — most are already signed in through their automatic-login link — and confirm.
  4. The old platform sends them back with its Ed25519 token, now carrying the state (and aud, a two-minute exp, a single-use jti).
  5. The platform accepts it only on an authenticated request from the login that owns that state. A link planted in somebody else's browser, or replayed, is refused — a login that never asked cannot receive one. The verified proof is then held server-side against that login until it is used.

The old app's own Move to the new platform button starts the same flow from the other side: it sends the patient to the new platform's start page (sign up or sign in there), which makes the round trip at once — one click, since they are already signed in on the old platform.

The round trip never interrupts onboarding midway, because leaving the page would lose what the patient had typed. It happens before onboarding (arriving from the old app's button) or after it (from their account). Onboarding's last step shows an account that is already proven — "We found it: {name}" — and joining records the link and queues the first sync.

  • Email is a hint, never proof. A verified login address matching an old account may prompt "you seem to have an account on the old platform"; the link still takes the old-platform login. The email + phone check is dropped.
  • The staff invitation stays the fallback for someone who cannot log in on the old platform.
  • Prerequisite, and a hard one. The proof is only as strong as the old platform's login. Its consumer routes that act on ANY consumer id — change-password above all, which also stores the new password in plain text — must be closed first; otherwise an attacker resets a victim's old password, logs in as them there, and links the victim's history to their own login. Checked 2026-09-24 (owner, Strapi admin on apiv2.restartix.ro): neither the Public nor the Consumer role — the one a signed-in patient holds — may call changePassword, checkPassword, createUser, createOrUpdateSubscription, or the core find / findOne / update / delete; on users-permissions User the Consumer role has me only. The callers are staff logins (restartix-admin, restartix-leo-dashboard), and no browser bundle carries a static API token. So the hole is closed where it matters — but by a checkbox in the database, not by code: ticking one of those for the Consumer role reopens it silently.

Sync: one operation, in the background, triggered by hand.

  • Triggers — decided: manual only for now. Linking queues the first sync; afterwards the patient's Check for updates from the old platform queues another, and staff may queue one for a patient. No automatic runs — none at sign-in, no nightly job. A bulk run (the same sync over every account) is a later decision, most likely at switch-off.
  • In the background. A request only records a sync job and returns; a worker in the API runs it — the in-process dispatcher the render and notify queues already use — so nothing depends on the patient keeping a page open. The page shows the job's status; completion is announced in the Portal's bell.
  • Idempotent and resumable. A map records which old row became which new row (legacy_record_map: consumer, old table + id, new table + id, a hash of what was copied). A run copies only what the map lacks, or what changed where the rules allow an update; a run that dies midway is finished by the next. "Check for updates" can say what is new before bringing it over.
  • Rules. One-way: old to new, nothing goes back. History (completed sessions, progress) is append-only — added if missing, never changed or deleted. Editable data (profile fields) only fills blanks, so anything edited on the new platform wins. Subscriptions get a rule of their own with the data mapping (most likely: the old platform's state wins while it runs).
  • Where it writes: into the patient's own profile. With no bulk import there is no imported copy to claim, so the fold is needed only if a bulk run later creates a record for somebody who then signs up separately.
  • What comes across — open, settled when the work gets there. It needs a survey of the old database; programmes, progress and subscriptions are the known candidates.

The export API. Decided. The platform never reads the old database. restartix-admin-api gains a service-authenticated export of one consumer's data, with a since for incremental runs. How the two services authenticate to each other is part of that design (for instance, the platform signing its requests with its own key).

Consent. Counsel's sign-off on the legal texts still gates the first real patient's data being synced — self-service does not move that gate.

Tables. legacy_account_links stays (consumer ↔ profile, how and when linked; the last sync comes with the sync). legacy_record_map and a sync-jobs table are new. None adds a column to a domain table; all of them live for the parallel period and go when the old platform is switched off. legacy_account_links.email was dropped in the build (2026-09-24): nothing matches against it any more, and a hint by email, if it ever comes, asks the export API rather than keeping a copy.

From the first slice (2026-09-23): kept — core/legacylinktoken (gained the state claim), legacy_account_links, linking inside the profile step (still the path for claiming bulk-imported records after switch-off), the onboarding step. Replaced — the swap on arrival and its legacy-link-claim cookie (every token now returns to a login that asked for it), and the email + phone proof. Both are gone from the code.

The data ​

One table is legacy-aware, because linking and syncing need a lookup the domain tables cannot give — "which profile is legacy consumer #N". It is a relation, not an account — no credential, nothing signs anyone in — so it is named for what it holds (migration 000053):

text
legacy_account_links
  consumer_id         the legacy consumer's id — UNIQUE
  organization_id     → organizations(id)               -- RLS
  patient_profile_id  → patient_profiles(id), UNIQUE
  linked_at           NULL only on a bulk-imported row nobody has claimed
  link_method         'legacy_link' | 'staff_invite' | 'email_invite'

No domain table gains a column for legacy data. One legacy account belongs to one person here, and one person brings over one account — the two UNIQUE constraints. patients.consumer_id predates this and is the clinic's own free-form external id; linking does not write the legacy id there.

Self-service linking writes a row pointing at the patient's own profile. A bulk run (not decided) would instead import a legacy patient nobody has linked as a profile with no login (patient_profiles.human_id IS NULL) plus a patients row — the state of a family member added by a caregiver — with an unlinked row here. Linking then claims that profile: stamp human_id on it, set linked_at and link_method, audit it — the same stamp as a P7 claim.

Lifecycle. The table lives for the parallel period and goes when the legacy platform is switched off. Dropping it strands nobody: linked profiles are ordinary profiles. Row-level sync bookkeeping — which legacy row became which new row — is legacy_record_map, the sync's table.

Proof — four ways to show "that profile is me" ​

Superseded 2026-09-24 by the revised design: the old-platform login is the one self-service proof, tied to the session that asked; the email match below becomes a hint, and proof 2's swap on arrival goes. Staff invitation (3) stands.

  1. Email match, plus a second check. The human's email is Clerk-verified (Google, Facebook or an email code). If exactly one unlinked imported profile carries that email, onboarding offers it and asks one thing only the patient knows.
    • Legacy stores a phone per patient and no date of birth, so the check is the phone — the one typed in onboarding step 1 — compared after both sides go through the platform's phone normaliser.
    • Nothing from the legacy record is shown or pre-filled before the check passes: pre-filling the phone would hand over the answer, and showing the name would tell a mistyped mailbox whose account it has.
    • Why a second check at all: legacy emails were typed by staff and never verified. A typo would hand someone else's medical history to whoever owns the mistyped mailbox.
    • consumer.email is unique on the legacy side, so an email matches at most one imported profile. A failed phone check offers the legacy link (proof 2) instead; staff only if that fails too.
  2. The "Move to the new platform" link. For patients whose new login uses a different email, or none (Facebook accounts registered by phone give no email). From inside legacy they follow a link; they sign in through Clerk here; the link's token then proves they own legacy account #N.
    • Legacy (/legacy-handoff) mints {consumer_id, iss, aud, iat, exp: 120s, jti} signed with an Ed25519 key pair — decided. Legacy holds the private key (a Railway secret) and signs with Node's built-in crypto (the jsonwebtoken package does not do EdDSA); the platform holds only the public key and verifies with Go's crypto/ed25519, so nothing on the platform can forge a link. A kid header names the key, so it can be rotated. Never the old hand-off secret: that meant "sign this person in" and must never mean anything here.
    • Swapped on arrival — decided (owner, 2026-09-23). The token lives two minutes and creating a login takes longer, so checking it only after sign-in would fail nearly every new patient. The link lands on the Portal's /legacy?t=…, which verifies it at once (signature, expiry, issuer/audience, jti reserved single-use in Redis) and swaps it for a random one-hour claim the API keeps, held by the Portal in the HttpOnly legacy-link-claim cookie. The claim links nothing by itself: only the authenticated profile step presents it, and that is the only place an account links. Nothing ever signs anyone in.
    • Verified by core/legacylinktoken (stdlib only). The old core/handofftoken and its HS256 shape were deleted, not extended.
  3. Staff invitation — already built. The clinic invites the patient to the imported profile; the invite binds at their first sign-in with that email. The path for people who ring the clinic.
  4. Email invitations in bulk — the same invite, sent by the platform to every legacy email at import. Blocked on patients receiving email at all (platform-completion → Patients receive NO email).

Where it sits in onboarding ​

Revised 2026-09-24: the proof happens before onboarding or after it, never midway; the last step shows an account already proven. See the revised design.

Step 3 of the flow, and only when there is something to show:

  • an email match exists and the phone from step 1 matches → "We found your account from the old platform — bring it over?";
  • the person arrived through the legacy link → the token links it before the step renders, and the step confirms it;
  • neither → the step does not appear. A plain "I had an account on the old platform" affordance points them at the clinic or the legacy link — it must not become an email-lookup form.

Consents given on the legacy platform were given to different texts and are never carried across; the step-2 acceptance is recorded on the linked profile.

Edge cases ​

  • The human already has a profile — they joined fresh and link later: the legacy button after onboarding, a new-platform patient whose legacy account was imported afterwards. Onboarding itself never causes this (see above); the late case is resolved automatically, by the fold.
  • A staff login is never linked to a legacy patient: every entry point refuses staff and superadmins (legacy_account_staff).
  • Dependants on the legacy side (accounts under a company or a family) — unknown until the legacy data is surveyed; the P7 model is the target.
  • The legacy account is already linked — the UNIQUE constraint answers; the patient sees "already linked", and the clinic can see to whom.

The fold ​

Narrowed 2026-09-24: with the sync writing into the patient's own profile, the fold is needed only when a bulk run has created a record for somebody who then signed up separately.

Decided (owner, 2026-09-23): the late case resolves automatically.

  • The person's own profile is empty — no appointments, forms, sessions or documents yet. Retire it (release human_id, soft-delete) and link the imported profile. Nothing is lost because nothing was there.
  • Their own profile carries data. Fold the imported profile INTO it: move everything attached to the imported profile onto the person's profile, then retire the imported one. Their own field values win; blanks are filled from the imported profile.

Everything, not only what the import wrote. Once the import runs, the clinic sees imported patients and can add appointments, forms and notes to them before they link. Those records belong to the same person, so the fold moves all of them — which is also why no per-row "came from legacy" marker is needed: the fold never has to tell imported records from the clinic's.

Complete by construction. The fold takes its list of tables from the database's own foreign keys to patient_profiles and patients, not from a hand-kept list, and a test fails when a table references either and the fold has no rule for it — so a table added next year cannot be silently left behind on a retired profile. Moves are audited row by row.

Collisions, the only places two rows cannot both survive:

  • One patients row per clinic. The person's existing row stays; the imported profile's records re-point to it.
  • Two subscriptions at the clinic (the default tier from joining, a paid one from legacy). The richer one stays active and the other ends — the exact rule is set when the import defines what a legacy subscription becomes.
  • Any other unique constraint the foreign-key walk meets (one live enrollment per programme, for instance) needs an explicit rule, and the coverage test is what surfaces it.

Security and compliance ​

  • Tied to the login that asked. The state is recorded against one login at one clinic and spent by the first token presented for it; a token from a round trip this login never started — planted, replayed, carried from another login — fails exactly like a forgery (legacy_link_invalid).
  • Nothing is looked up by email, so there is nothing to enumerate: the only way to name a legacy account is to be signed in as it on the legacy platform.
  • The return address is allow-listed on the legacy side (LEGACY_LINK_RETURN_ORIGINS, and the path must be /legacy/return), so the legacy app is not an open redirect and hands a token to no other host. The API also checks the shape of the return address before starting.
  • The proof is only as strong as the legacy login. Checked 2026-09-24: its password routes are closed to patients (see the prerequisite above). Found while building: the repo copy of the legacy login page printed its ?redirect= parameter raw into a <script>. The live page did not — it had been fixed on the server (esc_url_raw, same-host check, wp_json_encode) and the repo was stale; the owner copied it back 2026-09-24. That version still let ?redirect=http:evil.com through (no host, so it passed as relative, and a browser on the https site reads it as http://evil.com) and crashed on ?redirect[]=; both are tightened in the repo. An open redirect, not a way to the session token — deploy it with the "move account" template.
  • A shared computer can be signed in on the legacy platform as somebody else. The legacy "move account" page names the account signed in there and offers "not my account" (sign out, sign in again). That page is the one defence: the new platform's last step no longer offers "This isn't my account" (removed 2026-09-29 as confusing), so a proof, once made, is brought over by that step's only action. Settings → account still offers "not mine" for a proof left pending.
  • Audit every link, with the proof used: CREATE on legacy_account_link for a self-service link, two UPDATE rows when a bulk-imported profile is claimed.
  • Controller. The legacy data belongs to the clinic that ran the legacy platform, and linking is offered on that clinic's portal only (LEGACY_LINK_ORGANIZATION_ID); it moves the data within one controller and grants no new party access. Syncing real patients' data is gated on counsel's sign-off of the legal texts.

Built 2026-09-24 — the round trip (repo only) ​

Replaces the first slice of 2026-09-23 (the swap on arrival, the email match and its phone check are gone from the code).

  • The token — core/legacylinktoken: {consumer_id, state, iss, aud, iat, exp ≤ 10 min, jti}, EdDSA, kid; state required.
  • The API — domain/legacylinks, every route on the Portal's self-service tier (authenticated, clinic from X-Organization-ID): GET /v1/portal/legacy-account (offered here? linked? proven?), POST …/start (records state → {login, clinic} in Redis for 15 minutes; returns the legacy page with state and return), POST …/proof (verifies the token, spends the state, checks it is this login's at this clinic, reserves the jti, holds {consumer_id, clinic} for the login for an hour), DELETE …/proof ("not mine"), POST …/link (a patient already joined). A login still joining links through POST /v1/me/patient-profile with legacy_account: {consumer_id} — the account must be the one the held proof names. Joining a clinic that takes no sign-ups is admitted for a login with a legacy link there.
  • The table — legacy_account_links (000053, edited in place: email and email_match dropped; local only).
  • The Portal — /legacy (starts the round trip; signed out, it sends the patient to create an account first), /legacy/return (redeems the token, then /welcome or the settings page), /legacy/ready (explains and offers a retry), the first welcome screen's "bring my account" link (before anything is typed), the last step with the proven account and "This isn't my account", and a settings card for patients already joined.
  • The legacy side — restartix-admin-api /legacy-handoff: without state it sends the button to the new platform's /legacy (its home when unconfigured); with state + return it mints for the signed-in consumer and returns the allow-listed return address. restartix-wordpress: the Platform - Move account template + assets/js/account-move.js, and the login-redirect fix.
  • Local testing — node --env-file=infra/scripts/.env.local infra/scripts/legacy-link.mjs serve stands in for the legacy page at localhost:9400; set LEGACY_LINK_START_URL to it and LEGACY_LINK_ORGANIZATION_ID to the demo clinic. infra/scripts/seed-local/legacy-patient.sql seeds a bulk-imported account to test the claim path.

Not built yet: the sync (export API, legacy_record_map, sync jobs, "check for updates"), which is what makes a link bring any data across; the fold; a staff view of the link; the bulk run.

Turning it on (production) ​

Tested first against a test clinic in production, so the real clinic's patients see nothing until it is switched over; the old app's "move" button has its own switch (LEGACY_LINK_BUTTON) for the same reason.

  1. Deploy restartix-wordpress (the tightened login redirect and the new template), and create the "move account" page on the legacy WordPress site with the Platform - Move account template — done 2026-09-24: https://restartix.ro/programe/muta-contul-restartix, the value of legacy_link_start_url. Upload only the template, account-move.js and the login template: the repo's functions.php and sdk.js are stale against the server (sdk.js there is a dev build pointing at localhost:1337). If the SDK's API root differs from https://apiv2.restartix.ro/api, define RESTARTIX_API_ROOT in wp-config.php.
  2. node infra/scripts/legacy-link.mjs keygen on the owner's machine — the private key must never pass through anyone else.
  3. Deploy api and portal with linking still off, then run the migrations runner (it uses the api image's :latest, so 000053 can only run after that deploy).
  4. Terraform: legacy_link_public_keys, legacy_link_start_url (the page from step 1) and legacy_link_organization_id — the test clinic — then apply and deploy api (a task-definition change reaches the service only through a deploy).
  5. The legacy app (Railway): merge and deploy its staging work, then set LEGACY_LINK_PRIVATE_JWK, LEGACY_LINK_KID and LEGACY_LINK_RETURN_ORIGINS (the test clinic's portal host). Leave LEGACY_LINK_BUTTON unset.
  6. Test from the test clinic's portal with an old-platform account.
  7. Go live: legacy_link_organization_id → the real clinic, apply, deploy api; LEGACY_LINK_RETURN_ORIGINS → every host the real clinic's portal answers on; then LEGACY_LINK_BUTTON=on.
  8. Rotation: add the new public key beside the old one, switch the legacy app, then remove the old one.

Built 2026-10-07 — copying subscriptions ​

Decided (owner, 2026-10-07). Subscriptions come across first, ahead of any history, because the first 7-day trials (offer "Lansare 7 zile") ended while patients who had bought lifetime access on the old platform were using them.

  • The mapping lives on the old platform: products.platform_offer_id (restartix-admin-api 51c0c1c) names the access offer a legacy PRODUCT becomes here. The owner fills it by hand; a product left empty is skipped.
  • The export — two read-only routes on restartix-admin-api, GET /api/platform-export/consumers/:id/subscriptions and GET /api/platform-export/subscriptions-by-email?email=, authenticated by a Strapi API token. That token is a Custom token granting only those two actions; a Full-access one would expose every consumer row, plaintext generated_password included.
  • The connection is platform config, not a clinic integration (decided 2026-10-07): the token is RestartiX's own access to its own old system, and only one clinic ever ran it. LEGACY_EXPORT_URL + LEGACY_EXPORT_TOKEN (Secrets Manager restartix/production/legacy-export, wired behind enable_legacy_export), bound to the clinic in LEGACY_LINK_ORGANIZATION_ID like the link itself. Every other clinic is told it is not available and never sees the button (GET /v1/legacy-subscriptions/availability). Client: internal/integration/legacyexport/. It goes when the old platform does.
  • Copying = applying the mapped offer through ProvisionOffer, with the legacy dates rather than the offer's term (ProvisionInput.Window; an expiry 50+ years out is lifetime). On the trial's tier this extends the trial; a purchased subscription may replace another tier, a granted one may not.
  • Idempotent through the ledger: a copy is an access_offer_fulfillments row with trigger_type = 'legacy_subscription' and trigger_ref = the legacy subscription id. A second copy is a no-op, and a partial unique index binds one legacy subscription to one patient. Expired and unmapped subscriptions are reported, never copied. Revoking access here does not let a later sync bring it back.
  • Staff path — "Copy from old platform" on the patient's Subscription tab (GET /v1/patients/{patientId}/legacy-subscriptions?email=, then POST …/copy, both patient_subscriptions.manage). Staff confirm the person by name and phone. Access only: the account is NOT linked, because a link later brings history across and an email is not proof of identity.
  • Patient path — POST /v1/me/legacy-subscriptions/sync copies the linked account's subscriptions. Linking runs it once (best effort), and the Settings card offers "Check for my subscriptions". Linking itself is still parked in the Portal (LEGACY_LINKING_OPEN = false).
  • Terraform: remove the hand-off secrets (HANDOFF_SECRET, HANDOFF_REENTRY_SECRET) from the api task definition and Secrets Manager, and the prompt in infra/scripts/populate-production-secrets.sh. The API no longer reads either. The order is forced by how deploys work — the deploy action clones the family's latest task-definition revision, the ECS service ignores Terraform's revision, and a task definition naming a deleted (or pending-deletion) secret cannot start tasks; the execution role's permission to read a secret also disappears in the same apply that removes it from the task definition:
    1. Terraform: remove the two entries from the api block in compute.tf.
    2. Deploy api straight after, and confirm it is healthy — until then a relaunch of the old revision would fail on the lost permission.
    3. Terraform: remove the containers from main.tf (the restartix/production/handoff-secret entry and the whole handoff_reentry_secret block).
    4. With step 3: the README line and the populate-production-secrets.sh section. (services/api/.env.example no longer mentions either.) Staging has no hand-off wiring.
  • Legacy: merge dcac62e to master; then delete LEGACY_HANDOFF_SECRET / LEGACY_HANDOFF_URL from Railway. The Strapi roles were checked 2026-09-24: neither Public nor the patient role may call /consumer/:id/change-password, check-password, create-user or subscription — but nothing in the code stops it, so ticking one for the Consumer role reopens it.
  • Clerk settings (recommended, not confirmed applied): session maximum lifetime 1 year with a 30-day inactivity timeout; passkeys off (Clerk advises against them with satellites); a custom email-sending domain, since an email code is now everyone's way back in.
  • Test on an iPhone. Safari can cap cookies set by a host that CNAMEs to a third party at 7 days, and clerk.<domain> does. If iPhone patients are signed out weekly, Clerk's proxy option is the fix.
  • Satellite setup is manual per domain (Clerk dashboard + the clinic's CNAME, then Verify). Automating it — POST /v1/domains on the Clerk Backend API when a domain is added, with the second CNAME in the add-domain dialog — is the next step once a second clinic brings a domain.
  • Local testing of the satellite flow needs demo.localhost:9200 added as a satellite on the Clerk development instance.

Decided 2026-09-23 ​

  • Onboarding asks name, phone and date of birth (required), then the required platform and clinic documents, then the migration step.
  • Nothing is created until the last step; signing in creates only the Clerk user.
  • The legacy key is the consumer id.
  • The late two-profile case is resolved automatically by the fold; staff help only where the patient cannot resolve something themselves.
  • No domain table gains a column for legacy data. One linking table, legacy_account_links, kept for a linking window the owner closes (not until the last account links — some never will); a row's legacy email is cleared when it links. Row-level import bookkeeping stays in the migration tooling's staging.id_map. (2026-09-24: the table now lives for the parallel period, its email column is gone, and sync bookkeeping is legacy_record_map — see the revised design.)
  • The legacy link is signed with an Ed25519 key pair; the platform holds only the public key.
  • The legacy link's token is swapped on arrival for a one-hour claim held in an HttpOnly cookie — superseded 2026-09-24 by the round trip.
  • The linking table is legacy_account_links — a relation, not an account — in its own migration, 000053.

Decided 2026-09-24 ​

  • Both platforms run in parallel for a while. The import becomes one repeatable, idempotent sync per legacy account, used by every path.
  • The proof is logging in on the old platform, in a round trip tied to the new-platform session that started it. Email is a hint only; the email + phone check is dropped. Staff invitation is the fallback.
  • The old platform exposes an export API; the platform never reads its database.
  • Sync runs in the background as a job, and only when triggered by hand — by linking, by the patient's "check for updates", or by staff. No automatic runs for now.
  • What comes across is settled later, when the work gets there.
  • The beta notice stays for now.