Anaya Care Docs

Identity, Access & Tenancy

Part of the Anaya Care product wiki. See 00-overview.md.

Purpose

This module covers who can log in, what they can do, and which tenant's data they can see. It spans:

  • Authentication — password/phone/magic-link/Google/Apple login, JWT access tokens, rotating refresh tokens, logout denylisting (apps/backend/src/auth/).
  • Identity — the User entity plus role-specific profile entities, signup and invitation flows, identity verification (email codes, SMS, government ID), and account lifecycle (separation, deletion) (apps/backend/src/users/).
  • Authorization — a granular, business-configurable permission system (BusinessPermission in @anaya/shared) enforced by PermissionsGuard, with role-based fallbacks via RolesGuard.
  • Tenancy — every business-scoped document carries a business ref; scoping is enforced by a CLS-based Mongoose plugin, not by guards (apps/backend/src/common/plugins/business-scope.plugin.ts).
  • Business administration — the Business tenant entity, owner signup/onboarding, custom roles (BusinessRole), and platform-wide pricing (BaseRate/CityRate).

Entities & Data Model

User (users collection)

apps/backend/src/users/entities/user.entity.ts

FieldTypeNotes
firstName, lastName, preferredName, fullNamestringfullName is a virtual (lines 50–55)
roleUserRole enumdefault Representative (line 69)
customRoleObjectId → BusinessRoleonly set when role === Custom, else null (lines 72–81)
businessObjectId → Businesstenant-scoping field; the auto-scoping plugin keys on it (lines 83–91)
businessAssignmentStatusBusinessAssignmentStatusPending/Active/Suspended/Terminated/Resigned (packages/shared/src/enums/domain-status.ts:109–115)
email, username, phonestringsparse-unique via partial-filter indexes (lines 295–309); empty strings normalized to null in a pre('save') hook (lines 281–290)
passwordstring, select: falsebcrypt hash; invited users get the literal placeholder 'default-password' (auth/constants/password.constants.ts:14)
emailVerified, phoneVerifiedDate | nullverification timestamps
isActive, isOnboardedbooleanboth default false; isActive set true at signup/login
inviteStatus[], inviteEmailId, inviteEmailStatus[]arraysinvite lifecycle + Resend email webhook history (lines 127–160)
verificationStatusUserVerificationStatusPENDING_EMAIL/PENDING_PHONE/PENDING_ID/REJECTED/ACTIVE (domain-status.ts:33–39)
governmentIdVerificationObjectId → GovernmentIdVerificationlines 231–236
googleId/appleId (+ …LinkedAt)stringOAuth links, unique partial indexes (lines 310–320)
tokenVersionnumberbumping it invalidates all JWTs (checked in JwtStrategy, line 54 of jwt.strategy.ts)
passwordResetAttempts, passwordResetLockedUntilnumber/Datelockout fields (lines 238–242)
deletedAt, deletionRequestedAt, deletionReasonaccount-deletion request workflow (lines 250–258)
separationEffectiveDate/Reason/Type/InitiatedBy/InitiatedAtcare-provider termination/resignation tracking (lines 260–274)
careProvider, medicalProfessionalObjectId refspointers to role profile docs (lines 183–195)
Preferencestimezone, timeFormat, dateFormat, weekStart, expoPushTokens[], birthday, unReadNotificationCount

Key indexes: {business, role}, {verificationStatus}, {separationEffectiveDate, businessAssignmentStatus} (cron), {deletedAt} (lines 322–335).

Business (businesses collection)

apps/backend/src/business/entities/business.entity.ts

FieldTypeNotes
name, slugstringslug globally unique (line 38)
statusBusinessStatusactive/inactive/suspended, default active
ownerObjectId → Userset during owner signup
onboardingStatus, onboardingCompletedAtBusinessOnboardingStatuspending/complete; default Complete (line 75) — but owner signup explicitly creates with Pending (users.service.ts:2774)
Onboarding survey fieldsphone, address, careTypes[], yearsInOperation, clientCountRange, caregiverCountRange, currentSchedulingMethod, priorityFeatures[], matchingCriteria[], operationalChallenges[], successGoal, referralSource (lines 81–143, mapped to onboarding "Steps 1–7")
baseRolePermissionOverridesRecord<UserRole, BusinessPermission[]> | nullper-business override of default role permissions (lines 145–153)
settingsRecord<string, any>untyped settings bag (line 48)

BusinessRole (business_roles collection) — custom roles

apps/backend/src/business/entities/business-role.entity.ts

FieldNotes
nameunique per business ({business, name} unique index, line 59)
permissions[]array of BusinessPermission values
description, colorUI metadata
businessrequired tenant ref

Created by owners on the Roles & Permissions settings page, or auto-created by migration 031, which converted every legacy Care Manager user to role: Custom + a "Care Manager" BusinessRole (apps/backend/src/migrations/031-migrate-care-manager-to-custom-role.ts). The CareManager role no longer exists in UserRole.

Profile entities

EntityCollectionLinked byKey fieldsSource
CareProviderProfilecare_provider_profilesuserId → Userabout, skills[] (refs), workExperiences[], education[], licenses[] (embedded, with files[] → LockCare), averageRating, isAcceptingJobsusers/entities/care-provider-profile.entity.ts
RepresentativeProfilerepresentative_profilesuser → User, client → Client (both required)relationshipType, relationshipDescription, decisionAuthorities[], notes, isActive; unique {user, client} among active rows (lines 65–68)users/entities/representative-profile.entity.ts
MedicalProfessionalProfilemedical_professional_profilesuserId → Userrole (MedicalProfessionalRole), speciality, subSpeciality, upin, npiusers/entities/medical-professional-profile.entity.ts

Naming history: FamilyProfile was renamed to RepresentativeProfile by migration 025, which renamed the family_profiles collection, flipped users.role 'Family''Representative', and rewrote Family-referencing values across care proposals, reviews, requests, notifications, and priorityFeatures (apps/backend/src/migrations/025-family-to-representative-rename.ts, header comment). RepresentativeProfile is the single source of truth for representative↔client links — the legacy User.families[] / Client.families[] arrays were removed in migration 023.

None of the three profile entities has a business field — they are tenant-scoped only transitively through their User (see Tenancy below).

BaseRate / CityRate (base_rates, city_rates collections)

apps/backend/src/base-rates/entities/base-rate.entity.ts, city-rate.entity.ts

EntityFields
BaseRatehourlyRate, overtimeRate, liveInRate, vasRates {MD, CT, HA, AF, CB, CM}
CityRatestate, city, rates {hourlyRate, overtimeRate, liveInRate, vasRates}, isSameAsBaseRate

Neither entity has a business field, so the auto-scoping plugin does not apply — rates are platform-global and shared across all tenants, even though the API is gated by the business-level permissions BASE_RATES_VIEW/BASE_RATES_MANAGE (base-rates/base-rates.controller.ts:26–79).

Auth support entities (apps/backend/src/auth/entities/)

  • RefreshToken — SHA-256 token hash, userId, familyId (rotation family), tokenVersion, userAgent, expiresAt (refresh-token.entity.ts, used by refresh-token.service.ts).
  • VerificationToken — password-reset tokens.
  • EmailVerificationCode — 6-digit code records with attempt counters.
  • GovernmentIdVerification — status PENDING/APPROVED/REJECTED/EXPIRED + document type (domain-status.ts:44–49).
  • AuthAuditLog — security audit events (e.g., CROSS_BUSINESS_ACCESS logged when a SuperAdmin reads a business in business/platform.controller.ts:34–45).

(BASE_RATE/CITY_RATE have no FK to BUSINESS — they are platform-global.)

Workflows & State Machines

Login & sessions

Login methods (auth/auth.controller.ts):

  • POST /auth/password — username or email + password, bcrypt compare (auth.service.ts:284–321). Blocked while invite is still pending (line 297).
  • POST /auth/phone/send-code + POST /auth/phone — SMS OTP via the SMS sender service (Twilio Verify) (auth.service.ts:323–367).
  • POST /auth/magic-link + GET /auth/magic-link — emailed JWT link; verifying it also accepts a pending invite and marks email/phone verified (auth.service.ts:251–282).
  • POST /auth/google, POST /auth/apple (+ link/unlink endpoints) — OAuth sign-in (auth.controller.ts:671–771).

Token model (auth/services/token.service.ts, refresh-token.service.ts):

  • Access token: JWT, 15 minutes (SESSION.ACCESS_TOKEN_EXPIRY_SECONDS, packages/shared/src/constants/common.ts:481). Payload: sub/userId, username, role, businessId, tokenVersion (token.service.ts:31–42). Extracted from Authorization: Bearer or a ?token= query param (for PDF streaming) (jwt.strategy.ts:25–30).
  • Refresh token: opaque 96-hex-char random string, 30 days, stored as SHA-256 hash in Mongo with a familyId for rotation/theft detection; a 30-second grace window tolerates concurrent refreshes (refresh-token.service.ts:24–41). POST /auth/refresh is @Public() + rate-limited (30/min) and rotates the pair (auth.controller.ts:157–163, auth.service.ts:415–468).
  • Logout (POST /auth/logout): revokes the refresh token and adds the access token to a Redis denylist keyed by sub:exp until natural expiry (token.service.ts:79–105).
  • Per-request validation (jwt.strategy.ts:36–90): rejects denylisted tokens, deleted users, tokenVersion mismatches, and users whose businessAssignmentStatus is Terminated/Resigned. It re-reads the user from the DB every request and resolves the effective permissions array fresh via PermissionService so role/permission changes apply immediately, not after token expiry.

Signup

  • POST /auth/signup (no guard — open) creates the user, auto-logs-in, and returns role-based requiredSteps/nextStep (auth.controller.ts:257–313):
    • Care Provider: email verification (if email given) → phone verification (required) → government ID → admin review (PENDING_ID) → ACTIVE (auth.controller.ts:378–418).
    • Representative: email verification required, phone/Government ID optional.
  • Business assignment at public signup (users.service.ts:170–204): invite flows inherit the inviter's business from CLS; self-signup Care Providers get business: null (independent); all other public signups fall back to the first active business by createdAt — a platform-wide default-tenant behavior.
  • POST /auth/signup/owner (rate-limited 5/15 min) creates a Business (status Active, onboarding Pending) plus an Owner user, and signs them in (auth.controller.ts:315–332, users.service.ts:2729–2820).
  • POST /auth/signup/save-progress / GET /auth/signup/progress — resumable signup state in Redis with a 24-hour TTL (auth.controller.ts:334–370).
  • Email verification codes: 10-minute expiry, max 5 check attempts, max 3 resends, 60-second resend cooldown (auth.service.ts:147–160).
  • Password reset: 1-hour token; account lockout after 5 failed attempts for 30 minutes (auth.service.ts:1112, 1217–1224); admin unlock via POST /auth/admin/unlock-account.

Invitation flow

users.service.ts:1840–2070, auth.controller.ts:1440–1481:

  1. Admin invites by email or phone (POST /users/invite-by-email|invite-by-phone, gated by USERS_INVITE). The user is created immediately with inviteStatus: [INVITED], businessAssignmentStatus: PENDING, and the placeholder password 'default-password'.
  2. A 7-day magic-link JWT is emailed/SMS'd. Mobile-only roles (CareProvider, Representative) get a link to the web /invite page (which redirects to the app); other roles get /accept-invite (users.service.ts:1962–1965).
  3. If the email/SMS send fails outright the user record is deleted (rollback); if SMS is merely unconfigured the record is kept for re-invite (users.service.ts:1894–1913).
  4. Accepting (POST /auth/accept-invite from mobile, or magic-link verification on web) flips inviteStatus to ACCEPTED and signs the user in; POST /auth/set-initial-password (JWT-guarded) replaces the placeholder password.
  5. POST /users/invite-super-admin creates a platform SuperAdmin (no business) via the same pattern; the route itself has no @RequirePermissions and is checked in the service/controller logic (users.controller.ts:331–339).

Profile setup (per role)

profile-setup/profile-setup.service.ts:39–74 — steps and required subset:

RoleAll stepsRequired for completion
CareProviderabout, skills, education, work_experience, licensesabout, skills
MedicalProfessionalprofessional_role, specialty, credentialsall three
Representativenonenone — "admin handles linking" (lines 335–341)
Owner / Admin / SuperAdmin / System / Customnonenone

POST /profile-setup/complete-step upserts the role profile document and sets User.isOnboarded once all required steps are done (lines 156–237). GET /profile-setup/status also "auto-fixes" a stale isOnboarded flag (lines 132–138).

Business onboarding (Owner)

  • Owner signup leaves Business.onboardingStatus = pending; the web proxy forces Owners to /setup-business until complete (apps/web/proxy.ts:296–313).
  • PATCH /businesses/:id/onboarding saves survey steps; POST /businesses/:id/onboarding/complete finishes; POST /businesses/:id/onboarding/invite-team bulk-invites teammates by email with placeholder names '—' (business/business.controller.ts:87–139). All onboarding routes are @Roles(SuperAdmin, Owner) with an assertOwnership check that Owners only touch their own business (lines 35–42).

User status lifecycle

(UserVerificationStatus, domain-status.ts:33–39; transitions implemented across auth.controller.ts:372–418 and the verification endpoints.)

Separately, businessAssignmentStatus tracks employment: Pending (invited) → ActiveSuspended/Terminated/Resigned. Separation is scheduled with an effective date via POST /users/:id/separate, reversible via :id/reinstate and DELETE :id/pending-separation (gated by USERS_DEACTIVATE, users.controller.ts:633–676); a cron processes due separations (users/care-provider-separation-cron.service.ts). Terminated/Resigned users are rejected at JWT validation (jwt.strategy.ts:11–14, 58–65). Account deletion is a request → admin execute/cancel queue (users.controller.ts:463–631, gated by USERS_DELETE).

Business Rules & Constraints

Roles

  • UserRole (single source of truth, packages/shared/src/enums/user-role.ts:9–23): SuperAdmin, System (platform roles, no business), Owner, Admin, CareProvider, Representative, MedicalProfessional, Custom, Other.
  • The legacy "SuperAdmin → Admin → CareManager → CareProvider → Family → MedicalProfessional" hierarchy from older docs no longer matches the code: Family was renamed Representative (migration 025) and CareManager was migrated to a Custom BusinessRole (migration 031). Owner was introduced as a distinct business-owner role.
  • Role groups: ADMIN_ROLES = [Owner, Admin, SuperAdmin, System], OWNER_ROLES, CAREGIVER_ROLES, MOBILE_ONLY_ROLES = [CareProvider, Representative], BUSINESS_ROLES, PLATFORM_ROLES (user-role.ts:39–108). The role-group helpers are marked @deprecated in favor of permission checks (lines 47–59).
  • PERMISSION_BYPASS_ROLES = [SuperAdmin, System, Owner] always have all permissions and cannot be overridden (user-role.ts:250–258).
  • CONFIGURABLE_BASE_ROLES = [Admin, CareProvider, Representative, MedicalProfessional] — the only roles whose defaults a business can override (user-role.ts:114–119).

Permissions

  • BusinessPermission (packages/shared/src/enums/business-permission.ts) defines ~150 granular permissions as module:action or scoped module:action:{own|assigned|all}. Scope is hierarchical — :all:assigned:own — implemented by hasScopedPermission() / hasAnyScopedPermission() / getHeldScope() (lines 281–394).
  • Default sets per configurable role live in DEFAULT_ROLE_PERMISSIONS (packages/shared/src/constants/default-role-permissions.ts); convention: Admin → :all, CareProvider → :assigned, Representative → :own, MedicalProfessional → :assigned (file header comment).
  • Per-business overrides live in Business.baseRolePermissionOverrides (key = role string), managed via PUT/DELETE /business-roles/permissions/base/:role (gated by ROLES_CONFIGURE, business/business-roles.controller.ts:78–101).
  • Custom roles: BusinessRole documents with arbitrary permission sets; users with role: Custom get exactly BusinessRole.permissions (auth/services/permission.service.ts:58–65).
  • Resolution order (permission.service.ts:34–84): bypass roles → ALL; no businessId[] (non-business users have no permissions); Custom → BusinessRole.permissions; base role → business override if present, else defaults.
  • PERMISSION_GROUPS and SCOPED_PERMISSION_GROUPS (packages/shared/src/constants/permission-groups.ts) are UI metadata for the web Roles & Permissions matrix — groups like Initial Assessments, Care Proposals, Users, Business Administration, etc., with scoped families rendered as None/Own/Assigned/All selectors.
  • PermissionService.getUserIdsWithPermission() fans out notifications to every user (base, Owner, or custom role) whose effective set satisfies a permission (permission.service.ts:119–171).

Guard wiring — verified enforcement mechanics

Registered global guards, in execution order (apps/backend/src/app.module.ts:287–298): BusinessGuardPermissionsGuardIdleTimeoutGuard. There is no global authentication guardJwtAuthGuard (Passport jwt) is bound per-controller or per-route only.

Consequences, verified in code:

  1. Routes are unauthenticated by default. A route is only protected if its controller/handler binds JwtAuthGuard (or AuthGuard('jwt')). The @Public() decorator merely tells JwtAuthGuard and the global guards to skip (auth/guards/jwt-auth.guard.ts:12–23).
  2. Global guards run before controller-bound guards (NestJS ordering), so when the global BusinessGuard/PermissionsGuard/IdleTimeoutGuard instances execute, req.user is not yet populated — and all three explicitly return true when request.user is falsy (business.guard.ts:56–57, permissions.guard.ts:66–71, idle-timeout.guard.ts:43–44). The PermissionsGuard doc comment states this design explicitly: the global instance "defers when req.user is not yet populated… The controller-level instance then performs the actual permission check after JwtAuthGuard has authenticated the user" (permissions.guard.ts:23–27).
  3. Therefore @RequirePermissions(...) is only enforced where the controller also binds PermissionsGuard after JwtAuthGuard — e.g. @UseGuards(JwtAuthGuard, PermissionsGuard). 24 controllers do this correctly (users, auth admin endpoints, base-rates, business-roles, trainings, job-postings, blog, announcements, wound-care, incident-reports, health-metrics, requests, knowledge-base, statistics, platform-logs, platform-health, home-inventories, essential-needs, daily-motivations, feature-requests, ai-settings).
  4. 22 controllers use @RequirePermissions but never bind PermissionsGuard, so their permission requirements are decorative — never checked at runtime (the global instance passes pre-auth; no local instance runs post-auth). Verified by diffing controllers that reference RequirePermissions vs. those that reference PermissionsGuard: care-proposals/care-proposal-reviews|care-proposal-versions|care-proposals.controller.ts, chat/chat.controller.ts, client-engagements/controllers/shift-engagement-assignment.controller.ts, client-meals/controllers/client-meals|shift-meal-assignment.controller.ts, clients/controllers/business-calendar|client-care-provider-tasks|client-engagement-activities|client-schedules|client-task-submissions|clients|representative-tasks|shift-summary|task-submission-comments|task-templates.controller.ts, health-observations/health-observations.controller.ts, initial-assessments/initial-assessments.controller.ts, lock-care/lock-care.controller.ts, posts/posts.controller.ts, shifts/shifts.controller.ts. Example: clients.controller.ts binds only @UseGuards(JwtAuthGuard) at class level (line 92) while methods carry @RequirePermissions(CLIENTS_MANAGE) etc. (lines 132+). These routes are authenticated but not permission-checked; effective access control is whatever the service layer does.
  5. BusinessGuard (business-must-be-Active check) and IdleTimeoutGuard (HIPAA 15-minute idle timeout) are bound only globally — no controller binds them — so by the same ordering both no-op on every authenticated route (req.user is always undefined when they run). The active-business enforcement and idle-timeout enforcement described in their comments cannot be observed to execute for HTTP requests. The idle-activity timestamp is also only written inside the guard (idle-timeout.guard.ts:63–68), so it is never recorded either.
  6. PermissionsGuard semantics when it does run: no decorator → pass (backward compatible); bypass roles → pass; otherwise the user needs at least one of the listed permissions (OR logic), scope-aware, sourced from req.user.permissions resolved by JwtStrategy (permissions.guard.ts:58–98). Denials throw a typed 403 PERMISSION_DENIED.
  7. RolesGuard + @Roles(...) provides coarse role checks where bound (e.g. BusinessController/PlatformController are @Roles(SuperAdmin) class-wide with per-route relaxation to Owner plus an assertOwnership tenancy check — business.controller.ts:26–42).

Tenancy enforcement

Tenant scoping is not done by guards. The mechanism (all global):

  1. BusinessScopeInterceptor (APP_INTERCEPTOR, runs after guards so req.user exists) copies user.businessId from the JWT into req.businessId and CLS (common/interceptors/business-scope.interceptor.ts). Platform roles, @Public(), and @SkipBusinessScope() set it to null.
  2. A global Mongoose plugin (common/plugins/business-scope.plugin.ts) activates only on schemas that have a business path (line 43–46) and, when CLS has a businessId, injects {business} into find/findOne/count/update/delete filters, prepends $match to aggregates, and stamps business on saves/inserts. Callers can bypass with the query option _bypassBusinessScope (lines 52–55).
  3. When the businessId is null (SuperAdmin, public routes, cron jobs, migrations, WebSocket handlers, or any code outside the HTTP CLS context) the plugin silently does nothing (lines 109–126) — those code paths see all tenants unless they filter explicitly.
  4. Entities without a business field are never auto-scoped: BaseRate, CityRate, CareProviderProfile, RepresentativeProfile, MedicalProfessionalProfile, RefreshToken, etc. Their isolation depends entirely on service-level query shapes. (Note: ClientCarePlan does have a business field — clients/entities/client-care-plan.entity.ts:244 — so its findOne-family reads are plugin-scoped even where service filters are clientId-only; sibling modules that reported clientId-only reads are relying on this plugin.)
  5. email, username, and phone uniqueness is platform-global, not per-business (user.entity.ts:295–309) — a person cannot hold accounts in two businesses with the same email.

Surfaces (Web & Mobile)

Mobile (apps/mobile/app/)

  • Auth screens(auth)/sign-in.tsx, sign-up.tsx, phone-signin.tsx, magic-link-signin.tsx, verify-magic-link.tsx, verify-email.tsx, verify-phone.tsx, forgot-password.tsx, reset-password.tsx, government-id/ (submission), verification-pending.tsx, verification-rejected.tsx, accept-invite.tsx, invite-welcome.tsx, user-onboarding.tsx.
  • Profile setupsetup-profile/careprovider/ (about, skills, education, work-experience, licenses + add-* screens), setup-profile/medicalprofessional/, setup-profile/guide.tsx. These mirror the backend step lists exactly.
  • Onboarding(onboarding)/index.tsx.
  • Frontend-only gatinglib/route-guards.ts and lib/tab-config.ts show/hide tabs and screens by role/permission, and lib/business-permissions.ts mirrors permission checks client-side; auth/session state lives in store/auth-store.ts. These are UX gates only — server enforcement is the guard system above.

Web (apps/web/)

  • Auth pagesapp/(app)/(auth)/signin, get-started (owner signup), verify-email, reset-password, verification-pending, verification-rejected; public accept-invite, invite (mobile redirect), download-app, account-deletion.
  • Business onboardingapp/(app)/(onboarding)/setup-business.
  • User managementapp/(app)/(admin)/dashboard/users (list, [id] detail, role views), dashboard/settings/roles (custom roles & permission matrix driven by PERMISSION_GROUPS), dashboard/settings/business, dashboard/base-rates; SuperAdmin console under app/(app)/(platform)/platform.
  • Middleware (frontend-only rules)apps/web/proxy.ts: refreshes tokens in middleware, redirects unauthenticated users to /signin, routes unverified users to verification pages, forces Owners with onboardingStatus: pending to /setup-business (lines 296–313), and blocks mobile-only roles (CareProvider, Representative) from /dashboard and /onboarding, sending them to /download-app (lines 316–330). This mobile-only block exists only in web middleware — the API itself does not restrict those roles by surface.
  • Client-side permission checkslib/hooks/use-permissions.ts (usePermissions() reading session.user.permissions, bypass roles short-circuit) and lib/permissions.ts; session is stored in cookies session_user + session_permissions (lib/auth-cookies.ts:22–42).

Cross-Module Dependencies

  • Every module depends on this one: JwtAuthGuard/PermissionsGuard/RolesGuard and @RequirePermissions gate all controllers; JwtStrategy injects permissions into req.user for downstream services (auth/strategy/jwt.strategy.ts:75–88).
  • Clients / Care Plans / Shifts / Assessments / Proposals (01, 02, 03, 04, 05) — consume the tenancy plugin via their business fields and rely on permission scopes (:own/:assigned/:all) for row filtering (e.g., getHeldScope, business-permission.ts:381–394). RepresentativeProfile.client is the family↔client link used in client access checks.
  • Job postings (06) — accepting an application assigns an independent CareProvider (business: null self-signup) to the hiring business (JOB_POSTINGS_APPLICATIONS_DECIDE description, permission-groups.ts:482).
  • NotificationsPermissionService.getUserIdsWithPermission() is the fan-out primitive for domain-event notifications (permission.service.ts:113–171); login triggers counter reconciliation (auth.service.ts:373–375).
  • Platform logs / audit — logins emit PLATFORM_LOG_ACTIVITY_EVENT (auth.service.ts:197–216); SuperAdmin cross-business reads write AuthAuditLog entries.
  • Email/SMS — invites and verification use the Resend-backed email sender (with webhook-driven inviteEmailStatus) and Twilio Verify.
  • Lock Care (document vault) — care-provider license files reference LockCare (care-provider-profile.entity.ts:122–127).
  • Care proposals → users: care proposals can originate users (InviteStatus.CARE_PROPOSAL_ORIGIN, domain-status.ts:124–129).

Open Questions & Gaps

  1. @RequirePermissions is unenforced on 22 controllers (full list in Business Rules §guard-wiring) because they bind only JwtAuthGuard and rely on the global PermissionsGuard, which by design passes before authentication. This includes high-sensitivity modules: clients, shifts, care proposals, initial assessments, chat, posts, lock-care, health observations. Whether service-layer checks fully compensate cannot be determined from the guard code alone — each route needs an audit.
  2. BusinessGuard never executes its check. It is global-only and skips when req.user is absent (business.guard.ts:56–57), which is always true at global-guard time. Users of inactive/suspended businesses are therefore not blocked at the API edge despite the guard's stated purpose. (JwtStrategy blocks Terminated/Resigned users, but nothing blocks an inactive business.)
  3. IdleTimeoutGuard never executes for the same reason — the HIPAA 15-minute idle timeout it documents (idle-timeout.guard.ts:16–21) is not actually enforced server-side, and the last-activity timestamp it is supposed to maintain is never written.
  4. Routes are open-by-default. With no global auth guard, any handler whose author forgets @UseGuards(JwtAuthGuard) is publicly accessible. Several users lookups are authenticated but unscoped/unpermissioned: GET /users/email/:email, GET /users/username/:username, GET /users/id/:id (any logged-in user can resolve any account, users.controller.ts:176–200), and GET /users/caregivers/global-map accepts an arbitrary businessId query with no permission decorator (lines 156–166).
  5. BaseRate/CityRate are platform-global (no business field) yet are managed through business-permission-gated endpoints — every tenant reads and writes the same rate documents. Intent (shared platform pricing vs. missing tenancy) cannot be determined from code.
  6. Default-business fallback on public signup: non-CareProvider public signups are attached to the oldest active business in the database (users.service.ts:198–204). In a true multi-tenant deployment this silently drops new Representatives/MedicalProfessionals into an arbitrary tenant.
  7. Invited users carry the shared placeholder password 'default-password' (password.constants.ts:14). Login is blocked while the invite is pending (auth.service.ts:297–300), but the window between magic-link acceptance and set-initial-password relies on that pending check alone; whether any path allows password login with the placeholder after acceptance cannot be fully determined from the inspected code.
  8. Tenancy plugin blind spots: cron jobs, migrations, WebSocket gateways, and any service using _bypassBusinessScope or .aggregate on schemas without a leading business $match operate cross-tenant by default (business-scope.plugin.ts:109–126). Profile entities (CareProviderProfile, RepresentativeProfile, MedicalProfessionalProfile) have no business field at all, so cross-tenant isolation for them is purely service-level.
  9. Business.onboardingStatus defaults to Complete in the schema (business.entity.ts:71–76) while owner signup sets Pending explicitly — businesses created through POST /businesses (SuperAdmin) skip onboarding by default. Intentional? Cannot determine from code.
  10. Stale docs/UI naming: backend CLAUDE.md still references the removed families[] arrays era, and the role hierarchy in older documentation (CareManager, Family) no longer exists; MEMORY.md references FamilyProfile which is now RepresentativeProfile (migration 025).
  11. Wiki cross-link targets 00-overview.md, 02-assessments.md etc. exist, but 07/08/10 pages referenced by numbering are absent from docs/wiki/ at the time of writing — sibling links here point only to pages that exist.

On this page