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
/welcomestyle, 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
- 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.
- 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.nameand is asked for by our onboarding. - 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.
- Link by legacy account id, never by email. Decided. An email is a hint that a link may exist; it is never the key.
- The
/welcomelook is the base. Decided. The owner likes it and dislikes the current/onboardform; the unified flow is built onapp/welcome/*'s card, scaffold and layout. Its beta notice stays for now. - 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.
- 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. /welcomeis the one onboarding — every host, every way in (A, built 2026-09-23). It callsPOST /v1/me/patient-profilethenPOST /v1/portal/onboard;/onboardis 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.BindForPrincipalmatches it by the Clerk-verified email (FindAllOpenForEmail) and stampspatient_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 itsstaging):PUT /my-accountwrites only self-editable fields (dcac62e);/legacy-handoffanswers 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:
| Case | Before 2026-09-23 | What the flow does |
|---|---|---|
| Ordinary self-sign-up | /welcome on a custom domain, /onboard on a subdomain | Name, phone, date of birth → required documents → join |
| Already has a profile (patient at another clinic) | /onboard step 2 | Ask only what the profile lacks; this clinic's required documents |
| Pending staff invitation at this clinic | /onboard with a banner | Say 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 cookie | Same, plus share_link_code on the join call |
Clinic does not take sign-ups (portal_self_signup_enabled = false) | /onboard explains | Explain, offer the clinic's contact, sign-out |
| Staff member or superadmin | /onboard explains | Explain 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:
- Welcome — the greeting, what the platform is for, and the beta notice. It asks for nothing.
- 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.
- 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.
- Your old account — the migration step, only when B has something to offer (see B → where it sits in onboarding).
- 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_idon 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 + thepatientsrow + subscription; consumes a pending invite; takesshare_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.tsxsends a not-yet-patient to/welcomeon every host./onboardis 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 theClerkProvider(app/layout.tsx), and the satellite proxy uses the same defaults. Never force: aredirect_urlmust 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.tsxpicks 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.tsxorders the steps —welcome-card.tsx,about-you-step.tsx, thendocument-steps.tsx(the screen before each set, and one per document), all drawn withstep.tsx;actions.tsis 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./onboardused to finish on/, which is where those resumed; the first cut of/welcomewent straight to the dashboard and would have stranded a buyer's order. - Where a new account goes after sign-up —
/welcomedirectly. Clerk's default was/, one extra screen; and aredirect_urlcarried 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/formson its way to/welcome. - The API gate —
portalonboarding.Onboardadmits 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 (403minor_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/welcomenow. - Removed —
/onboard's two-step form, its consent block and theonboardmessage 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:
- Signed in on the new platform, the patient chooses Bring my account from the old platform.
- The platform records a random
stateagainst this login (Redis, a few minutes) and sends them to the old platform with it and an allow-listed return address. - They log in there — most are already signed in through their automatic-login link — and confirm.
- The old platform sends them back with its Ed25519 token, now carrying the
state(andaud, a two-minuteexp, a single-usejti). - 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-passwordabove 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 onapiv2.restartix.ro): neither the Public nor the Consumer role — the one a signed-in patient holds — may callchangePassword,checkPassword,createUser,createOrUpdateSubscription, or the corefind/findOne/update/delete; on users-permissionsUserthe Consumer role hasmeonly. 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):
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.
- 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.emailis 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.
- 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-incrypto(thejsonwebtokenpackage does not do EdDSA); the platform holds only the public key and verifies with Go'scrypto/ed25519, so nothing on the platform can forge a link. Akidheader 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,jtireserved single-use in Redis) and swaps it for a random one-hour claim the API keeps, held by the Portal in the HttpOnlylegacy-link-claimcookie. 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 oldcore/handofftokenand its HS256 shape were deleted, not extended.
- Legacy (
- 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.
- 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
patientsrow 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
stateis 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.comthrough (no host, so it passed as relative, and a browser on the https site reads it ashttp://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:
CREATEonlegacy_account_linkfor a self-service link, twoUPDATErows 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;staterequired. - The API —
domain/legacylinks, every route on the Portal's self-service tier (authenticated, clinic fromX-Organization-ID):GET /v1/portal/legacy-account(offered here? linked? proven?),POST …/start(recordsstate→ {login, clinic} in Redis for 15 minutes; returns the legacy page withstateandreturn),POST …/proof(verifies the token, spends thestate, checks it is this login's at this clinic, reserves thejti, 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 throughPOST /v1/me/patient-profilewithlegacy_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:emailandemail_matchdropped; 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/welcomeor 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: withoutstateit sends the button to the new platform's/legacy(its home when unconfigured); withstate+returnit mints for the signed-in consumer and returns the allow-listed return address.restartix-wordpress: thePlatform - Move accounttemplate +assets/js/account-move.js, and the login-redirect fix. - Local testing —
node --env-file=infra/scripts/.env.local infra/scripts/legacy-link.mjs servestands in for the legacy page atlocalhost:9400; setLEGACY_LINK_START_URLto it andLEGACY_LINK_ORGANIZATION_IDto the demo clinic.infra/scripts/seed-local/legacy-patient.sqlseeds 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.
- Deploy
restartix-wordpress(the tightened login redirect and the new template), and create the "move account" page on the legacy WordPress site with thePlatform - Move accounttemplate — done 2026-09-24:https://restartix.ro/programe/muta-contul-restartix, the value oflegacy_link_start_url. Upload only the template,account-move.jsand the login template: the repo'sfunctions.phpandsdk.jsare stale against the server (sdk.jsthere is a dev build pointing atlocalhost:1337). If the SDK's API root differs fromhttps://apiv2.restartix.ro/api, defineRESTARTIX_API_ROOTinwp-config.php. node infra/scripts/legacy-link.mjs keygenon the owner's machine — the private key must never pass through anyone else.- Deploy
apiandportalwith linking still off, then run the migrations runner (it uses the api image's:latest, so000053can only run after that deploy). - Terraform:
legacy_link_public_keys,legacy_link_start_url(the page from step 1) andlegacy_link_organization_id— the test clinic — then apply and deployapi(a task-definition change reaches the service only through a deploy). - The legacy app (Railway): merge and deploy its
stagingwork, then setLEGACY_LINK_PRIVATE_JWK,LEGACY_LINK_KIDandLEGACY_LINK_RETURN_ORIGINS(the test clinic's portal host). LeaveLEGACY_LINK_BUTTONunset. - Test from the test clinic's portal with an old-platform account.
- Go live:
legacy_link_organization_id→ the real clinic, apply, deployapi;LEGACY_LINK_RETURN_ORIGINS→ every host the real clinic's portal answers on; thenLEGACY_LINK_BUTTON=on. - 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-api51c0c1c) 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/subscriptionsandGET /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, plaintextgenerated_passwordincluded. - 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 Managerrestartix/production/legacy-export, wired behindenable_legacy_export), bound to the clinic inLEGACY_LINK_ORGANIZATION_IDlike 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_fulfillmentsrow withtrigger_type = 'legacy_subscription'andtrigger_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=, thenPOST …/copy, bothpatient_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/synccopies 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).
Related loose ends from 2026-09-23
- Terraform: remove the hand-off secrets (
HANDOFF_SECRET,HANDOFF_REENTRY_SECRET) from the api task definition and Secrets Manager, and the prompt ininfra/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:- Terraform: remove the two entries from the api block in
compute.tf. - Deploy
apistraight after, and confirm it is healthy — until then a relaunch of the old revision would fail on the lost permission. - Terraform: remove the containers from
main.tf(therestartix/production/handoff-secretentry and the wholehandoff_reentry_secretblock). - With step 3: the README line and the
populate-production-secrets.shsection. (services/api/.env.exampleno longer mentions either.) Staging has no hand-off wiring.
- Terraform: remove the two entries from the api block in
- Legacy: merge
dcac62etomaster; then deleteLEGACY_HANDOFF_SECRET/LEGACY_HANDOFF_URLfrom Railway. The Strapi roles were checked 2026-09-24: neither Public nor the patient role may call/consumer/:id/change-password,check-password,create-userorsubscription— 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/domainson 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:9200added 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'sstaging.id_map. (2026-09-24: the table now lives for the parallel period, itsemailcolumn is gone, and sync bookkeeping islegacy_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.