Nexgen V3 permissions
There is a skill in the repo with a lot of details about how permissions work in V3.
Staff access to the app
Every Axxya staff login (is_admin = 1) can use the whole app on the paid and trial sites: Food Labeling, Diet Analysis, and Menu Creation, every region except India, and every language of those regions. No subscription is needed and access does not expire. Nothing has to be granted; creating the login is enough.
Creating a staff login
Open Admin > Axxya Staff (/admin/staff) in the environment where the person needs access and create their login. Staff created there have is_admin = 1. Saving the login also gives the person a client account of their own, named Axxya Staff - <full name>, and their region and language rows.
What staff see
- After logging in, staff land in the app like a customer, not on the admin dashboard.
- The user menu (the name at the top right) has an Admin item that opens the admin dashboard (
/admin/dashboard) on the same login. - The Manage Subscriptions, Manage Accounts, Logged in Devices, and trial Subscribe links are hidden for staff. The account management pages under
/app/manageand the shop checkout still refuse staff logins. - On the trial site, staff have no trial countdown and no trial create limits.
Staff logins made before this change
Older staff logins have no client account. Each one is fixed the next time that person logs in, or when their login is saved in Admin > Axxya Staff: the app gives them a client account and the missing region and language rows during that login. Nothing has to be run by hand. Anyone who is already signed in when the change is deployed needs to log in again.
A staff member who already has a subscription keeps it. An existing client account is reused, never replaced, and their orders are never changed. Their access no longer depends on those orders, so an expired or frozen order does not take anything away. A subscription of their own is still managed from Admin > Axxya Clients, since the account management pages refuse staff logins.
If a staff login points at a client account that no longer exists, the app leaves it alone and writes a warning to the Laravel log (StaffAccess: client not ensured for staff user). The person can still use the app. Fix the client by hand.
Removing staff access
Delete the login in Admin > Axxya Staff. The staff pages have no way to turn a staff login into a customer login; that means setting users.is_admin to 0 in the database by hand. A login with is_admin = 0 gets only what its own subscriptions grant from its next login onward.
The code is nexgen_v3/nexgen/backend/app/Support/StaffAccess.php. Anything new that decides module, region, or language access must check it too.
Granting staff an internal subscription
Staff no longer need this command to use the app; see "Staff access to the app" above. Use it only when a staff account has to hold real subscription rows, for example to look exactly like a paying customer.
staff:grant-online-access gives an existing user internal subscriptions to Nutritionist Pro Online, without a payment or Chargebee subscription. It covers Food Labeling, Diet Analysis, and Menu Creation, plus all configured regions except India and their languages.
The Artisan command requires an existing user, identified by email or numeric user ID. It does not create the login, set a password, set is_admin, or assign master-admin. It adds the client-admin role and creates a client account if the user has none. If the user's client account no longer exists, it stops with an error and writes nothing. Existing staff admin access stays in place.
Staging and production have separate databases. A login in production may not exist in staging. The commands below use staging; change -e staging to -e prod for production. Use the paid nexgen-backend service.
Preview and grant access
Replace STAFF_EMAIL_OR_ID with the existing user's email or numeric ID. Run the preview first. The wrapper runs Artisan as www-data, the application user.
copilot svc exec -a nexgen-v3 -e staging -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan staff:grant-online-access STAFF_EMAIL_OR_ID --dry-run\"'"
Drop --dry-run to apply it, and the same wrapper works for --until and --revoke:
copilot svc exec -a nexgen-v3 -e staging -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan staff:grant-online-access STAFF_EMAIL_OR_ID\"'"
--dry-run: reports every row it would write and touches nothing.
--years=N: subscription length, default 1 year from when the command runs. An existing later expiry is kept, so a plain re-run cannot shorten someone's access.
--until=YYYY-MM-DD: exact expiry, stored at the start of that date. Overrides --years and can shorten access.
--revoke: deactivates the internal subscriptions and the user's seats. It removes client-admin unless the client has another active, non-staff subscription. The login and is_admin status remain, so revoking product access does not remove staff administration access.
--dry-run combines with any of the others, so --until=2027-01-31 --dry-run and --revoke --dry-run both preview without writing. And --revoke refuses to run alongside --until (exit 1), since one turns access off and the other sets an expiry; it silently ignores --years.
The user must log out and back in after a grant, expiry change, or revocation so the session loads the current roles and permissions.
If the command says "No user found"
The command could not find an undeleted user matching the email or ID in the selected environment. It stops before making changes, with or without --dry-run.
Check the environment in the Copilot command, then check the email in that environment's Axxya Staff list. If the login is missing, create it there and repeat the dry run. If the user was deleted, their soft-deleted record is excluded from the lookup. Switching from staging to production requires a new Copilot session or a command using -e prod; changing the local checkout or branch does not switch the remote database.
Giving a customer complimentary access
Use this to give a customer free access for a fixed period, for example a complimentary month, or a trial of every module for someone who has no account yet. The customer gets all three modules (Food Labeling, Diet Analysis, and Menu Creation) and every launched label region. When the period ends, access stops, and the customer can buy a subscription from the shop.
Do not do this by moving the end date on an order the customer pays for. Chargebee still bills on its own schedule, and its next payment, cancellation, or reactivation writes its own date back over the change. So the free time is not free, and it does not last.
The command attaches the client to a hidden, zero-cost product on its own contract id, 9100021. It does not call Chargebee, Freshsales, or send any email. The customer's own orders are left exactly as they are.
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:grant-customer-access customer@example.com --until=2026-10-28 --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:grant-customer-access customer@example.com --until=2026-10-28\"'"
The argument is the customer's email, or a numeric user id for an account that already exists.
--dry-run: reports every row it would write and touches nothing.
--until=YYYY-MM-DD: the last day of access. The customer keeps access to the end of that day. Without it, access runs for 31 days from today.
--name=: full name for a new account. Defaults to the part of the email before the @.
--company=: company name for a new account. Defaults to the email.
--revoke: turns the grant off again. It deletes nothing and never removes the account, so re-granting is one command.
--until here means the last day, stored as 23:59:59 on that date. The two commands above store the start of the date instead.
When the email has no account
The command creates one. The customer signs in at the normal login page with their email and the password temppassword, or uses Forgot Password to have a new one emailed to them. The new user is the account owner and holds client-admin, which is what lets them buy a subscription and manage users later.
Device verification is switched off in every environment, so anyone who knows the customer's email can sign in with temppassword until the customer changes it, and nothing forces a change. Ask the customer to change it, or to use Forgot Password, as soon as they sign in.
When the email already has an account, its password and every order it holds are left alone. A customer whose subscription has lapsed gets access back through the new order.
What the command refuses
Each of these exits with an error and writes nothing:
- A user on someone else's company account. The message names the account owner's email; grant the access to the owner instead. Other users on the account see only the modules the owner has assigned them.
- A staff account, or a user with no client. Use
staff:grant-online-accessfor staff. - A deleted user, or a client that has been purged.
- An
--untildate in the past. Use--revoketo end access now. - An invalid email, or one longer than 60 characters.
- The trial site.
Extending or shortening a grant
Run the command again. Without --until, the end date only ever moves later, so a repeated run cannot cut a customer's access short. With --until, the date is set exactly, earlier or later.
Buying a subscription afterwards
This works with the existing All Modules plan. The customer signs in, then buys from the shop. Guest checkout refuses an email that already has an account, so they must be signed in first. The purchase adds a new order on the shop's own contract, and the first purchase also creates the customer in Chargebee. The complimentary order stays on its own contract id, so the two never collide.
A customer whose access has ended lands on the Subscriptions page when they sign in, which links to the shop.
Things to know
- An existing customer must log out and back in. Modules and regions are read at login.
- Cancelling in the app can still purge the account. A customer who cancels their only paid plan in the app while holding a complimentary grant is purged, because the purge does not count the complimentary order.
- Buy More Users tries to buy seats on every subscription the account holds, including the complimentary one, which does not exist in Chargebee. A customer on a complimentary grant alone is refused earlier, because they have no Chargebee customer yet.
- Freshsales. The next Chargebee webhook for the account lists the complimentary order among the account's active products.
Checking a complimentary grant
From the bastion, read-only:
SET @email = 'customer@example.com';
SET @client := (SELECT client_id FROM users WHERE email = @email);
SELECT o.id, o.status, o.subscription_status, o.in_app_status, o.until_date, o.quantity
FROM orders_subscriptions o
WHERE o.userid = @client AND o.contract_id = 9100021;
SELECT u.id, u.email, u.active, us.status AS seat
FROM users u
LEFT JOIN user_subscriptions us ON us.user_id = u.id
AND us.subscription_id = (SELECT id FROM orders_subscriptions WHERE userid = @client AND contract_id = 9100021)
WHERE u.client_id = @client AND u.deleted_at IS NULL;
A working grant is Approved / A / in_app_status = 1 with an until_date still ahead, and every active login holds a seat with status 1.
Ending a grant by hand
Only for when artisan is unavailable. This is what --revoke does. Nothing is deleted:
SET @sub := (SELECT id FROM orders_subscriptions WHERE userid = @client AND contract_id = 9100021);
UPDATE orders_subscriptions SET in_app_status = 0, status = 'In-Active', subscription_status = 'C', updated_at = NOW()
WHERE id = @sub;
UPDATE user_subscriptions SET status = 0 WHERE subscription_id = @sub;
There is no hand-written SQL for creating a grant. A new account needs a hashed password, which only the app can produce, so scale the backend up and use the command.
Granting a customer all label regions
Every launched label region now sells for one price, so a customer who paid for Food Labeling should be able to work in all four of them: USA, Canada, UK/EU, and China/HK. India has never launched and is never granted. Use this when support reports that a customer is missing a region, including the common case of an account that was sold one of the old region-specific SKUs, or one whose second region stopped working after a renewal.
Region rights are the intersection of two grants: the per-user rows and the regions carried by the customer's active orders. See Entitlement follows the purchase for the catalog side. Widening one half alone does nothing, which is why this is a command rather than a one-line update.
The command attaches the client to a hidden, zero-cost product that carries every launched region. It does not touch Chargebee, the customer's own subscription, or their billing, and it does not widen the SKU they bought, which is shared with every other customer on it. The grant expires with the Food Labeling subscription it rides on, so it cannot outlive what the customer paid for.
Preview first. Nothing is written until you drop --dry-run:
copilot svc exec -a nexgen-v3 -e staging -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan customer:grant-label-regions customer@example.com --dry-run\"'"
Then apply it:
copilot svc exec -a nexgen-v3 -e staging -n nexgen-backend --command "sh -c 'su -s /bin/sh www-data -c \"php artisan customer:grant-label-regions customer@example.com\"'"
The argument is the customer's login email, or a numeric user id. Every active login under that client is granted, and suspended or deleted logins are skipped.
--dry-run: reports every row it would write and touches nothing.
--until=YYYY-MM-DD: exact expiry date. Only needed when the client has no active Food Labeling order; without it the command refuses, so a region grant can never quietly resurrect a lapsed account.
--revoke: turns the grant off again. It deletes nothing, so re-granting is one command.
The command is additive and idempotent: a re-run on an account that already has the grant reports zero writes.
The customer must log out and back in. Regions, languages, and permissions are all resolved at login, so nothing changes in a session that is already open.
Prod often has no running nexgen-backend task, in which case copilot svc exec ... --name nexgen-backend fails with "found no running task". Either scale the service up first, or apply the same rows by hand from the bastion using the SQL below.
Checking what a customer actually has
From the bastion, read-only. orders_subscriptions.userid is the client id, not the user id.
copilot svc exec --app nexgen-v3 --env prod --name bastion
mysql -h $DB_HOST -u $DB_USERNAME -p"$DB_PASSWORD" nexgen
SET @email = 'customer@example.com';
SET @client := (SELECT client_id FROM users WHERE email = @email);
SELECT o.id, o.contract_id, o.status, o.subscription_status, o.in_app_status, o.until_date,
p.title, p.category_id, GROUP_CONCAT(pir.region_id ORDER BY pir.region_id) AS regions
FROM orders_subscriptions o
LEFT JOIN product_items pi ON pi.contract_id = o.contract_id
LEFT JOIN products p ON p.id = pi.product_id
LEFT JOIN product_item_regions pir ON pir.item_id = pi.id
WHERE o.userid = @client
GROUP BY o.id ORDER BY o.until_date DESC;
SELECT u.id, u.email, u.active,
GROUP_CONCAT(DISTINCT cur.region_id ORDER BY cur.region_id) AS user_regions
FROM users u
LEFT JOIN client_user_regions cur ON cur.user_id = u.id
WHERE u.client_id = @client AND u.deleted_at IS NULL
GROUP BY u.id;
A region shows up in the app only when it is on both sides of that pair, on an order with in_app_status = 1 and subscription_status = 'A', and the login holds a user_subscriptions seat with status = 1 on that order.
Applying the grant by hand
Only for when artisan is unavailable. Run the block in order, in one session, so the variables survive. Every statement is guarded, so re-running it writes nothing.
SET @email = 'customer@example.com';
SET @client := (SELECT client_id FROM users WHERE email = @email);
SET @until := (SELECT MAX(o.until_date) FROM orders_subscriptions o
JOIN product_items pi ON pi.contract_id = o.contract_id
JOIN products p ON p.id = pi.product_id
WHERE o.userid = @client AND o.in_app_status = 1
AND LOWER(o.subscription_status) = 'a'
AND o.contract_id <> 9100011 AND p.category_id = 1);
SELECT @client, @until; -- both must be non-null before you go on
INSERT INTO products (product_id, type, title, slug, features, benefits, description, long_description,
great_for, technical_specs, category_id, is_active, do_not_display, display_order,
constant_contact_id, meta_title, meta_keyword, meta_description, created_at, updated_at)
SELECT 1, 'web', 'Food Labels Online - All Regions (granted)', 'food-labels-online-all-regions-granted',
'', '', 'Every launched label region, granted by support. Not for sale.', '', '', '',
1, 0, 1, 0, 0, '', '', '', NOW(), NOW()
FROM dual WHERE NOT EXISTS (SELECT 1 FROM products WHERE title = 'Food Labels Online - All Regions (granted)');
SET @product := (SELECT id FROM products WHERE title = 'Food Labels Online - All Regions (granted)');
INSERT INTO product_items (product_id, contract_id, region_id, is_subscription, duration, duration_label, price, created_at, updated_at)
SELECT @product, 9100011, 1, 1, 365, '365 Days', 0.00, NOW(), NOW()
FROM dual WHERE NOT EXISTS (SELECT 1 FROM product_items WHERE contract_id = 9100011);
SET @item := (SELECT id FROM product_items WHERE contract_id = 9100011);
INSERT INTO product_item_regions (item_id, region_id)
SELECT @item, r.id FROM regions r
WHERE r.name <> 'India' AND r.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM product_item_regions x WHERE x.item_id = @item AND x.region_id = r.id);
INSERT INTO product_item_languages (item_id, language_id)
SELECT @item, l.id FROM languages l
JOIN regions r ON r.id = l.region_id AND r.name <> 'India' AND r.deleted_at IS NULL
WHERE l.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM product_item_languages x WHERE x.item_id = @item AND x.language_id = l.id);
INSERT INTO orders_subscriptions (userid, order_id, contract_id, quantity, is_subscription, contract_cost,
status, subscription_status, in_app_status, until_date, serial_key, created_at, updated_at)
SELECT @client, -1, 9100011,
(SELECT COUNT(*) FROM users WHERE client_id = @client AND active = 1 AND deleted_at IS NULL),
1, 0.00, 'Approved', 'A', 1, @until, '', NOW(), NOW()
FROM dual WHERE NOT EXISTS (SELECT 1 FROM orders_subscriptions WHERE userid = @client AND contract_id = 9100011);
SET @sub := (SELECT id FROM orders_subscriptions WHERE userid = @client AND contract_id = 9100011);
INSERT INTO client_contracts (client_id, contract_id, no_of_units, purchased_date, expiry_date, status, created_at, updated_at)
SELECT @client, @item, 1, NOW(), @until, 1, NOW(), NOW()
FROM dual WHERE NOT EXISTS (SELECT 1 FROM client_contracts WHERE client_id = @client AND contract_id = @item);
INSERT INTO user_subscriptions (user_id, subscription_id, status)
SELECT u.id, @sub, 1 FROM users u
WHERE u.client_id = @client AND u.active = 1 AND u.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM user_subscriptions us WHERE us.user_id = u.id AND us.subscription_id = @sub);
INSERT INTO client_user_regions (user_id, region_id)
SELECT u.id, r.id FROM users u
JOIN regions r ON r.name <> 'India' AND r.deleted_at IS NULL
WHERE u.client_id = @client AND u.active = 1 AND u.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM client_user_regions x WHERE x.user_id = u.id AND x.region_id = r.id);
INSERT INTO client_user_languages (user_id, lang_id)
SELECT u.id, l.id FROM users u
JOIN languages l ON l.deleted_at IS NULL
JOIN regions r ON r.id = l.region_id AND r.name <> 'India' AND r.deleted_at IS NULL
WHERE u.client_id = @client AND u.active = 1 AND u.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM client_user_languages x WHERE x.user_id = u.id AND x.lang_id = l.id);
To undo it, deactivate the granted order and its seats. Nothing is deleted, so the grant can be turned back on by setting the same columns the other way:
UPDATE orders_subscriptions SET in_app_status = 0, status = 'In-Active', subscription_status = 'C', updated_at = NOW()
WHERE userid = @client AND contract_id = 9100011;
UPDATE user_subscriptions SET status = 0 WHERE subscription_id = @sub;
Verifying
Re-run the two SELECTs above: the granted order should be Approved / A / in_app_status = 1, carrying all four regions, and every active login should hold all four client_user_regions rows and a seat on it. Then have the customer sign out and back in, open Create Label, and confirm all four region tabs appear with a language available in each. An empty language dropdown on a region means the language half of the grant did not land.
When access ends
A subscription's end date is enforced everywhere a customer can reach their data, on every request. Before this, it was applied only when the customer logged in, so an open session, the recipe import and label translation, the customer API, and the MCP server all kept working past the end date.
The rule
An order still grants access when it is switched on (in_app_status = 1) and either:
- has a change or a cancellation scheduled (
statusofCHorCS), or - has not passed its
until_date, compared to the second.
An account has access while at least one of its orders does. Axxya staff (is_admin = 1) are exempt everywhere.
The rule is held in three places by hand, one per service, and they must be changed together:
SubscriptionAccess::sweptStateinnexgen/backend/app/Support/SubscriptionAccess.php, which the login also uses.api/app/auth/entitlement.pyin the Flask API.mcp-server/src/entitlement.tsin the MCP server.
What the customer sees
- In the app: the next request after the end date signs them out with "Your subscription has expired. Please sign in to renew it." Signing in again takes them to the Subscriptions page, which links to the shop.
- Recipe import and label translation: the request is refused with "This account has no active subscription." before any AI call is made.
- Customer API:
403with "This account has no active subscription." The same key works again once the account has a subscription. - MCP server: HTTP
403carrying the same message, including in a session that was opened while the account was paid up. The same key works again once the account has a subscription.
The Flask API and the MCP server also refuse a suspended or deleted user, and they do not count a freeze order: a frozen account keeps its data and has no access. The app handles a frozen account at login, as before.
On production the app's session token does not slide, so it expires an hour after login in any case. On staging and test it slides for as long as it is used, which is where an open session could previously run on indefinitely.
A customer says access stopped
Check the orders from the bastion, read-only:
SET @email = 'customer@example.com';
SET @client := (SELECT client_id FROM users WHERE email = @email);
SELECT o.id, o.contract_id, o.status, o.subscription_status, o.in_app_status, o.until_date,
(o.until_date >= NOW()) AS has_time_left, p.title, p.type
FROM orders_subscriptions o
LEFT JOIN product_items pi ON pi.contract_id = o.contract_id
LEFT JOIN products p ON p.id = pi.product_id
WHERE o.userid = @client
ORDER BY o.until_date DESC;
The account has access when a row has in_app_status = 1 and either has_time_left = 1 or a status of CH or CS, and that row is not the freeze plan (type = 'freeze').
The usual causes, in order of likelihood:
- The Chargebee payment webhook did not arrive. A paying customer's
until_dateis moved on by that webhook at each renewal. When it is missed, the date stays in the past and the customer is now stopped mid-session instead of at their next login. Check the subscription in Chargebee, then fix the webhook delivery rather than the date. - The subscription was cancelled or frozen. That is the intended result.
- A complimentary grant ran out. Extend it with
staff:grant-customer-accessas described above.