Migration Manager
The Migration Manager moves a customer's Desktop (Nutritionist Pro VB6) database into their online account. It is being built directly into nexgen V3, so there is no separate Windows tool. The entry point is a public, server-rendered page on the paid app:
https://app.nutritionistpro.com/migration
The page is URL only; nothing in the app links to it. It is not available on the trial site, which answers 404.
How the page is gated
The page asks the customer to log in, then walks them through four stage tabs.
- Log in to your online account. Anyone can see this line. A signed-in user sees their name here, and the Login link brings them back to this page after they sign in.
- Upload. Shown to every signed-in user. The upload step is live when the environment has an uploads bucket (see below); without one the tab says the upload is turned off.
- Analyse. Runs over the uploaded file in the background and reports what the desktop database holds.
- Migrate and Verify. Placeholders until those stages are built.
There is no feature flag on the page any more; signing in is the only gate. A subscription is not needed to reach the page, which is what lets a migration beta customer use it before they buy.
The upload accepts .mdb files only, up to 8 GB. The network edition's SQL Anywhere .db file is refused at upload, and every state of the page tells network edition users to contact support@axxya.com, because that database cannot be migrated online. The browser sends the file straight to S3 in 16 MB parts using URLs the backend presigns; the file never passes through the Laravel container, which is what keeps it clear of the 60 second ALB idle timeout. A closed tab can be resumed from the same browser, because the finished parts are remembered in the browser's local storage.
Creating an account for a migration beta customer
A customer joining the migration beta often has no online account yet. Support creates one with a single command, from the customer's email alone. The account has no subscription: the customer can sign in and use the Migration Manager, and Support can test their upload and analysis, but the application itself stays closed until a subscription is added.
What you need from the customer:
- Their email address. It becomes the login and the account email.
- Optionally their name and company name. Both default from the email when left out.
Preview first. Nothing is written until you drop --dry-run:
copilot svc exec -a nexgen-v3 -e prod -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan staff:create-migration-account customer@example.com --name=\\\"Test User\\\" --company=\\\"Test Company\\\" --dry-run\"'"
Then apply it:
copilot svc exec -a nexgen-v3 -e prod -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan staff:create-migration-account customer@example.com --name=\\\"Test User\\\" --company=\\\"Test Company\\\"\"'"
Run these from the nexgen_v3 repo root so Copilot finds the copilot/ directory. The same command with -e staging creates the account on staging. The quoting matters: the inner \" and \\\" are what let a name with a space through three shells. For a local stack the command is simply docker compose exec nexgen-backend php artisan staff:create-migration-account customer@example.com --name="Test User".
--dry-run: reports every row it would write and touches nothing.
--name=: the customer's full name. Defaults to the part of the email before the @.
--company=: the company name shown as the account name. Defaults to the email.
The command writes three rows and nothing else: the client (the account), the user who owns it, and the client-admin role, which is what lets the owner buy a subscription and manage users later. It creates no order, no seat, no contract, no Chargebee customer and no Freshsales record, and it sends no email. The first purchase from the shop creates the Chargebee customer.
The account is created already verified. There is no activation email and nothing for the customer to click: the user row is written in the verified state (is_activated = 0; the value 1 is what trial registration uses to mean "waiting for the activation link") and no activation token exists. The customer signs in straight away.
The output ends with what to send the customer: their email, the temporary password, and the Migration Manager link. Send those by whatever channel Support uses; the app does not email them.
The new account signs in with the password temppassword, the same one staff:grant-customer-access uses. Device verification is switched off in every environment, so anyone who knows the customer's email can sign in until the customer changes it, and nothing forces a change. Ask the customer to change it from the Change Password entry in the page's user menu, or to use Forgot Password on the login page, as soon as they sign in.
What the customer sees
- Signing in from the Migration Manager's Login link returns them to the page, signed in, with the stage tabs offered. The user menu in the header has Profile, Change Password and Log Out, but no "Back to Application", because there is no licence yet.
- Signing in from the normal login page lands on the manage dashboard, which says "Subscription expired" and offers "Purchase a new subscription". That is expected for an account with no subscription; the Migration Manager is still one URL away.
- The application (recipes, labels, reports) is closed until a subscription is active. Any attempt to open it bounces to the manage dashboard.
Adding a subscription later
Either of these works, and neither disturbs the account or its uploads:
- The customer signs in and buys from the shop. Guest checkout refuses an email that already has an account, so they must be signed in first. The purchase creates the Chargebee customer and the order, and the next login opens the application.
- Support gives a complimentary period with
staff:grant-customer-access, for example while the migration is being checked. See Giving a customer complimentary access.
What the command refuses
Each of these exits with an error and writes nothing:
- A deleted user with that email. The old row is never recreated beside the new one.
- An invalid email, or one longer than 60 characters.
- The trial site.
- A database with no client-admin role.
An email that already has a live account is not an error. The command reports the user and client ids, writes nothing, and the customer signs in with their own password or uses Forgot Password. So running it twice is harmless.
The command warns when the client-admin role has no auth_item_child rows, which happens only on a freshly seeded local database. Production and staging link the role to its tasks, and a login whose role resolves to no operation is refused with "No Permissions attached to this user".
Checking an account
From the bastion, read-only:
SET @email = 'customer@example.com';
SELECT u.id, u.email, u.active, u.is_admin, u.client_id, c.name, c.email AS client_email, c.is_active, c.shopper_id
FROM users u
JOIN clients c ON c.id = u.client_id
WHERE u.email = @email AND u.deleted_at IS NULL;
SELECT a.auth_id, i.name AS role
FROM auth_assignment a
JOIN auth_item i ON i.id = a.auth_id
WHERE a.userid = (SELECT id FROM users WHERE email = @email);
SELECT COUNT(*) AS orders
FROM orders_subscriptions
WHERE userid = (SELECT client_id FROM users WHERE email = @email);
A working migration account has the same lowercased email on the user and the client, active = 1, is_admin = 0, is_active = 1, a NULL shopper_id, the client-admin role and no orders. If the user's email and the client's email differ, the login treats the user as a sub-user and refuses them; the command always writes them the same.
There is no hand-written SQL for creating an account. A new account needs a hashed password, which only the app can produce, so scale the backend up and use the command.
Where the uploads go
Each environment has one private bucket, created by the environment addon copilot/environments/addons/migration-uploads.yml:
nexgen-v3-staging-migration-uploads
nexgen-v3-test-migration-uploads
nexgen-v3-prod-migration-uploads
Every object lives under the owning account's prefix, and the object name repeats the account id, the upload time, the account name and the original file name:
<client_id>/<client_id>_<YYYYMMDD-HHMMSS>_<account name>_<original name>
Every user of an account sees all of that account's uploads on the page, and nobody can see or delete another account's files. The bucket blocks all public access, denies plain HTTP, encrypts at rest, keeps old versions for 90 days, and abandons incomplete multipart uploads after 7 days so a closed browser does not leave billed parts behind.
Only two roles can reach the bucket:
- The
nexgen-backendtask role, which presigns the browser's part uploads and lists and deletes an account's own files. This is attached by the service addoncopilot/nexgen-backend/addons/migration-uploads-access.yml, which also hands the bucket name to the container asMigrationUploadsBucketName. - The
bastiontask role, which can list, read and delete objects. This is added bycopilot/bastion/overrides/cfn.patches.yml. The bastion cannot write to the bucket; only the app writes files.
Nobody holds static keys for the bucket. Both roles sign with their task credentials.
Checking the bucket from the bastion
The bastion image has the AWS CLI installed, and copilot svc exec runs a command inside it. Run these from the nexgen_v3 repo root so Copilot finds the copilot/ directory.
List everything in the production bucket, with sizes and a total:
copilot svc exec --app nexgen-v3 --env prod --name bastion --command "aws s3 ls s3://nexgen-v3-prod-migration-uploads/ --recursive --human-readable --summarize"
The same for staging:
copilot svc exec --app nexgen-v3 --env staging --name bastion --command "aws s3 ls s3://nexgen-v3-staging-migration-uploads/ --recursive --human-readable --summarize"
List one account's uploads only, by its client id:
copilot svc exec --app nexgen-v3 --env prod --name bastion --command "aws s3 ls s3://nexgen-v3-prod-migration-uploads/1234/ --human-readable"
Show the multipart uploads still in progress. An entry here is a file a browser is still sending, or one that was abandoned and has not yet hit the 7 day cleanup:
copilot svc exec --app nexgen-v3 --env prod --name bastion --command "aws s3api list-multipart-uploads --bucket nexgen-v3-prod-migration-uploads"
Confirm the bucket exists and read its lifecycle rules:
copilot svc exec --app nexgen-v3 --env prod --name bastion --command "aws s3api get-bucket-lifecycle-configuration --bucket nexgen-v3-prod-migration-uploads"
Copy one upload to the bastion for inspection. The bastion task has 2 GB of memory and only ephemeral disk, so copy to /tmp and delete the file when done:
copilot svc exec --app nexgen-v3 --env prod --name bastion --command "aws s3 cp s3://nexgen-v3-prod-migration-uploads/1234/1234_20261014-153000_Test_Account_data.mdb /tmp/"
To read a file on your own machine instead, use your own AWS credentials for the account; your IAM user needs s3:GetObject on the bucket, which the addon does not grant by default:
aws s3 cp s3://nexgen-v3-prod-migration-uploads/1234/1234_20261014-153000_Test_Account_data.mdb .
If the command fails
AccessDeniedons3 lsmeans the bastion is running an image deployed before the bucket policy patch was added. Redeploy it withcopilot svc deploy --name bastion --env prod.NoSuchBucketmeans the environment addon has not been deployed to that environment yet. See the rollout order below.- A
nexgen-backendorbastiondeploy that rolls back withThe following resource(s) failed to create: [AddonsStack]means the same thing: the service addon'sFn::ImportValuefound noMigrationUploadsBucket*export for that environment. Confirm withaws cloudformation list-exports --query "Exports[?contains(Name,'MigrationUploads')].Name", run the environment deploy, then redeploy the service. The running service is untouched by the rollback. copilot svc execexits 0 whatever the remote command did, so read the output rather than the exit code when scripting against the bastion.
First rollout to an environment
The bucket, the backend policy and the bastion policy are three CloudFormation stacks, and the second and third import exports from the first. Deploy them in this order, once per environment:
copilot env deploy --name prod
copilot svc deploy --name nexgen-backend --env prod
copilot svc deploy --name bastion --env prod
The environment deploy prints the stack changes and asks for confirmation; check that the only additions are the bucket and its policy. Confirm that the backend picked up the bucket name by opening /migration while signed in; if the bucket name is empty the page says the upload step is turned off.
Local development
The compose stack includes RustFS as an S3 stand-in on port 9000 (console on 9001) with a bucket named by MIGRATION_UPLOADS_BUCKET in the root .env. The s3-init job creates the bucket and its CORS rule; without the CORS rule a presigned PUT looks like a failed upload in the browser while curl reports 200. Worktree environments add 100 per copy to the port.
The tests are tests/Feature/MigrationPageTest.php, tests/Feature/MigrationUploadsTest.php, tests/Feature/MigrationAnalysisTest.php and tests/Feature/MigrationAccountCommandTest.php in the backend, and e2e/nexgen/migration-page.spec.ts, e2e/nexgen/migration-upload.spec.ts, e2e/nexgen/migration-analyse.spec.ts and e2e/nexgen/migration-account.spec.ts in Playwright. The upload spec generates a 20 MB file and deletes it by name afterwards; the account spec creates an account with the command and removes it by email, and runs locally only.