All notable changes to this app are recorded here. Newest entries on top.
/account/domains, the Tracking Portal URL in Settings now shows that domain (e.g. https://tracking.acmecleaners.com/track) instead of the platform fallback (mypancho.com/p/.../drycleanpro/track). Falls back to the platform URL if no custom domain is connected. Same change applies anywhere the tracking link is displayed — share buttons, settings, etc./settings directly — the page is admin-only. Same gate applies to PropertyPro, LogisticsRoute, RealtyManager, and CRMDesk._promote_user_demos_if_plan_active($userId) in extensions/billing/api/apps.php runs UPDATE app_installs SET type='premium', status='active', expires_at=NULL, deactivated_at=NULL, purge_after=NULL WHERE user_id = ? AND (type='demo' OR status IN ('demo','expired','cancelled')) AND app_id IN (SELECT id FROM apps WHERE tier='premium') whenever has_active_plan($userId) is true.has_app_access() in core/lib/app-context.php calls the helper before reading the install row when the requested app is tier='premium' — opening the app post-upgrade self-heals.core/views/{desktop,mobile}/marketplace.php calls the helper at the top of the page render — the marketplace tile clears its "Free trial" badge without needing to open the app first.apps/demo endpoint no longer re-seeds when an install row already exists. Previously the has_active_plan short-circuit always ran _seed_app_for_user(), which collided with rows the demo seed had already inserted and surfaced E_APP_SEED_FAILED ("We hit a snag installing X"). The endpoint now checks for an existing app_installs.id first and skips the seed if found, leaving the demo's data intact.apps/activate instead of apps/demo when there's already a demo install for that app — the right endpoint for a no-re-seed flip — gated by client-side DEMO_SUBS.indexOf(appId) !== -1.core/lib/auth.php _enforce_manual_verification_lifecycle(): early-return for users with any active tenant_memberships row. They were vouched for by the inviting tenant; the call-to-verify ceremony (warning at day 14, lockout at day 21) shouldn't apply.core/index.php _dispatch_auth_api('login') redirect block: (1) the last_app_id check now also accepts an active tenant_memberships row as valid access, and (2) when a fresh login has no last_app_id AND the user has exactly one active membership AND zero installs, the redirect deep-links to /app/{thatApp} instead of /.core/views/{desktop,mobile}/marketplace.php: $activeSubs now appends tenant-membership app_ids after the app_installs loop, so the My Apps pill counts tenant apps and tenant tiles render with the active styling.has_app_access() itself to globally accept tenant memberships — the targeted login-redirect fix above is sufficient and avoids touching billing / demo expiry / private-app gates.core/index.php _route_app(): the per-tenant role from tenant_memberships is now lifted into $tenantRole during the membership-fallback resolution and exposed downstream as $userRole (was hard-coded to 'admin'). The router now also honours each route's roles array in app.json — when present, non-platform-admin users whose role isn't in the allowed list get a 403. Platform owners/staff still bypass for support/debugging. This unblocks correct staff/customer nav rendering (the layout reads $appConfig['navigation'][$userRole]) and makes existing roles: ["admin"] declarations on routes like /reports, /audit, /my-orders actually load-bearing.roles: ["admin"] to the /settings route in apps/{drycleanpro,propertypro,logisticsroute,realtymanager,crmdesk}/app.json./join landing for authenticated users: the view now POSTs to /api/v1/join immediately on load and redirects on success (link-preview crawlers stay unauthenticated, so they can't consume codes by accident). Unauthenticated visitors still see the "Create account / Sign in" card.Invalid CSRF token when clicking the join confirmation. The /join view now reads $csrfToken from the standard _route_view() injection and sends it as X-CSRF-Token on the POST.Unknown app action because extensions/billing/api/apps.php's top-level dispatcher was running when other API files (like core/admin/api/join.php) included it for its helper functions. The earlier guard only checked isset($action) && isset($user), but those are populated by _route_api regardless of which API file is the actual dispatch target. Tightened the guard to also require $category === 'apps' so the dispatcher only fires for /api/apps/* routes; helper-only includes leave the function definitions intact and skip the switch entirely./upgrade — they now land directly inside the tenant's app as a staff/customer member.?return= flow: core/views/{desktop,mobile}/signup.php — the OTP-success handler now uses safeReturnUrl() || data.redirect || '/apps' so the post-signup destination respects the ?return=/join?... query param. Previously the IIFE-style override at script init was being clobbered by the inline handler that ran later.core/lib/app-context.php — the require_tenant_member() owner-bootstrap self-heal now requires an active app_installs row before assuming the viewer owns the app. Without this gate, any non-member visiting /app/{appId} would trip the userId === tenantId check and get a wrongly-bootstrapped "owner of their own tenant" tenant_memberships row, which then blocked access via the wrong subscription path.extensions/billing/api/apps.php was being included for its helper functions but its top-level switch ($action) dispatcher was running too, emitting an HTML warning when $action was undefined; added an if (!isset($action) || !isset($user)) return; guard right before the dispatcher. (2) require_tenant_admin() was rejecting the tenant owner because their platform.db.tenant_memberships row had never been seeded; extended require_tenant_member() self-heal so when the viewer is the tenant owner (their UUID matches the tenant_id) it lazily calls _per_user_app_bootstrap_owner() and retries before throwing 403._per_user_app_bootstrap_member() helper in extensions/billing/api/apps.php mirrors _per_user_app_bootstrap_owner() for non-owner joiners. Wired into core/admin/api/join.php (broadcast + phone-invite paths), core/admin/api/invites.php (accept), and the canonical members/decide action./join landing route + view in core/index.php and core/views/join.php resolves invite links. Click required (no auto-POST) to defend against link-preview crawlers.core/views/{desktop,mobile}/{login,signup}.php now honor a ?return= query param (same-origin only) so post-auth flows from /join bounce back correctly.require_tenant_member() in core/lib/app-context.php now self-heals a missing per-app tenant_members row when the platform tenant_memberships row says the caller is an active member. Fixes legacy installs where the owner pre-dates the bootstrap hook.core/lib/tenant-members-api.php and the Settings card UI to a shared partial at core/views/partials/staff-invites-card.php. apps/drycleanpro/api/members.php is now a one-line shim that requires the shared handler. To opt another per-user app into the same surface (PropertyPro, LogisticsRoute, RealtyManager, CRMDesk, etc.): drop the same one-line members.php shim into the app's api/ folder, add a members entry to its app.json api_routes, and require __DIR__ . '/../../../../core/views/partials/staff-invites-card.php' from its settings sections partial after setting $canManageStaff.apps/drycleanpro/views/members.php and the /members route from app.json. The shared handler still exposes index, decide, revoke, and cancel-invite actions for any future power-user surface that wants them./video/drycleanpro. Multi-video apps get a playlist alongside the player./p/0/… — those links now resolve correctly. The "Copy tracking portal link" in Settings is also fixed (used to build /p//drycleanpro/track because of an undefined variable).customer_attachments table + apps/drycleanpro/api/customer-attachments.php (upload / list / update / delete) mirrored on order_attachments. Files land in uploads/drycleanpro/customers/{customer_id}/.apps/drycleanpro/api/audit.php and apps/drycleanpro/views/audit.php. The audit list joins audit_log.user_id to tenant_members.pancho_user_id for display_name. Accepts ?customer_id=X to narrow to a single customer's history.customers/activity endpoint added for the per-customer timeline (substring-matches audit_log.details against customer name + ticket_numbers, same technique PropertyPro uses).seed_with_currency() {{OWNER_UUID}} placeholder still covers demo seeds./my-orders view for authenticated customers and /members admin view (admin-only) for managing staff + customers.users table with tenant_members(pancho_user_id, role, ...) anchored to the platform Pancho UUID.created_by_user_id, changed_by_user_id, recorded_by_user_id, uploaded_by_user_id, cancelled_by_user_id, customer_notes.created_by_user_id) switched from INTEGER to TEXT UUID.customers.pancho_user_id TEXT UNIQUE (nullable) — walk-in customers keep working; when they sign into Pancho with the matching phone the record auto-links.tenant_invites table for pre-registering staff/customers by phone.