Appearance
Client login during onboarding turns a validated invite into a client account, relationship, and first onboarding task list.
The invitation link proves the client is allowed to start. Keycloak proves the person behind the device. CoachMe then owns the business transition: creating or loading the client user, linking the auth identity, consuming the invitation, creating the coach-client relationship, and starting the assigned onboarding workflow.
V1 baseline decision
client onboarding uses the same login providers as coach auth: email/password, Google, and Apple, plus Telegram as a conditional federated provider pending the OIDC spike. WhatsApp is not a login provider. Invite contact binding is strict for every provider. A client can have only one active or onboarding coach relationship, web onboarding is browser fallback only, and a coach can become a client only in their own self-coaching workspace.
Tags: NestJS · Keycloak OIDC · Drizzle + PostgreSQL · Client Mobile Onboarding
Client Login And Registration Flows
The client cannot self-register directly into a coach relationship. The app must carry a server-validated invitation context through provider login and revalidate it at the point of account linking.
New Client From Invite
- Client opens a valid invitation link and sees a safe preview of coach, workspace, and onboarding expectations.
- Client chooses an enabled provider such as email/password, Google, Apple, or Telegram (Telegram conditional pending the OIDC spike).
- Client app runs Keycloak authorization code with PKCE and receives an access token.
- Client calls
POST /api/v1/auth/client-onboarding-registrationwith the access token, invitation token, device context, locale, timezone, and terms versions. - API verifies the OIDC token, revalidates the invitation, enforces strict invited email or phone binding, creates
User,AuthIdentity,ClientProfile,CoachClientRelationship, and onboarding task records in one transaction. - API marks the invitation
CONSUMED, emits onboarding events, and returns a client session context pointing to the required profile step or first onboarding task.
Existing Client Opens Another Invite
- Client opens a valid invitation and logs in with an already linked provider identity.
- API resolves the provider identity to an existing active
UserwithCLIENTrole. - API checks whether this user already has an active or onboarding coach-client relationship.
- If the relationship already exists for the same invitation or workspace, API returns the existing onboarding/session context idempotently.
- If the user already has a different active or onboarding coach relationship, API rejects the invite with
CLIENT_ALREADY_HAS_ACTIVE_COACH. - If the resolved user is a coach, API allows client role attachment only for a self-coaching invitation where the coach user, client user, and owning workspace policy all match.
Returning During Onboarding
- Client opens the app later and authenticates with Keycloak or refreshes a stored session.
- Client calls
GET /api/v1/auth/client-me. - API returns active relationship, onboarding status, pending tasks, feature flags, and allowed client surfaces.
- Client lands on the next incomplete onboarding task, questionnaire, profile step, or Today view if onboarding is complete.
Previously Registered Client Reinstalls Or Clears App Data
- Client may lose all local mobile state by deleting the app, reinstalling it, clearing app data, or moving to a new device.
- App must list the available client login providers before requiring an invitation token.
- After provider login, the app calls
GET /api/v1/auth/client-mewithout an invitation token. - If the provider identity resolves to an existing CoachMe client user, API allows login and returns the current client session context.
- If that client has an active or onboarding relationship, the response includes that relationship and pending tasks; if not, the user is still logged in but sees an invite/connect-coach empty state.
- If the provider identity is not found in CoachMe, app can then show invitation-required recovery copy; it must not block the provider login attempt before checking the server identity.
Failure And Recovery
- Expired, revoked, consumed-by-other-user, suspended-workspace, or contact-mismatch invitations return specific reason codes.
- Client app shows recovery copy: request a new link, switch account, contact coach, or restart provider login.
- API writes audit events for failed linking attempts without consuming the invitation.
- Repeated suspicious attempts trigger rate limiting and optional coach/admin alerts.
Idempotent registration
Client onboarding registration is idempotent by provider identity plus invitation. Network retries should return the same client session context after successful relationship creation. Returning login is a separate resume path: a previously registered client can choose any enabled provider, authenticate, and recover server-side onboarding state through /auth/client-me when that provider identity exists in CoachMe, even when local app data is gone. The user account can log in immediately after creation; the coach-client relationship becomes ACTIVE after the required baseline profile is complete.
Domain Model
IdentityAccess owns users, auth identities, sessions, and invite consumption. CoachClientManagement owns the relationship and client-facing profile created from the validated invitation.
Aggregates & Entities
User: application identity with roles, status, primary contact, terms acceptance, and lifecycle state.AuthIdentity: Keycloak/provider subject linked to the user, unique by provider, issuer, and subject.ClientProfile: client-facing baseline profile, locale, timezone, onboarding state, and privacy preferences.ClientInvitation: invite consumed during account-linking transaction.CoachClientRelationship: relationship between workspace/coach and client user, initiallyONBOARDINGand activated after the required baseline profile is complete.OnboardingWorkflowInstance: generated task list from the invitation's onboarding template.AppSession: client app telemetry and revocation bridge.
Value Objects & Rules
VerifiedProviderIdentity: normalized claims, provider reference, verified email/phone flags, and issuer metadata.InvitationBinding: proves that the token, invite target, provider claims, and relationship scope are compatible.ClientRole: app role that allows client surfaces. A user can also haveCOACHrole only for the self-coaching policy.OnboardingStatus:NOT_STARTED,IN_PROGRESS,COMPLETED,BLOCKED.- Client user creation requires both a verified provider identity and a valid invitation.
- Invitation binding is strict for all invite channels: the provider identity must prove a verified email or phone matching the invitation target.
- Invitation consumption and relationship creation must be atomic.
- A client can have only one active or onboarding coach relationship in Public V1.
- Dual-role accounts are allowed only for self-coaching: a coach can attach
CLIENTrole to their own user account for their own workspace, not to join another coach as a client.
| Model | Owned By | Important Methods | Emits |
|---|---|---|---|
User | IdentityAccess | registerClient(), attachIdentity(), acceptTerms(), suspend() | ClientRegistered, ClientLoggedIn, UserSuspended |
ClientProfile | CoachClientManagement | createForUser(), updateBaseline(), completeOnboardingStep() | ClientProfileCreated, ClientOnboardingProgressed |
CoachClientRelationship | CoachClientManagement | startFromInvitation(), activate(), pause(), end() | CoachClientRelationshipStarted |
OnboardingWorkflowInstance | Forms / CoachClientManagement | createFromTemplate(), completeTask(), skipOptionalTask() | ClientOnboardingStarted, OnboardingTaskCompleted |
Table Structure
The onboarding login flow uses IdentityAccess tables from coach auth plus client and relationship tables from CoachClientManagement.
DDD boundary rule
this flow coordinates multiple bounded contexts. IdentityAccess owns User, AuthIdentity, and ClientInvitation; CoachClientManagement owns ClientProfile and CoachClientRelationship; Onboarding owns workflow state. Cross-context links should be IDs plus application-service orchestration/read-model checks, not navigable aggregate references.
| Table | Purpose | Important Columns | Constraints & Indexes |
|---|---|---|---|
users | Application identity for the client. | id, primary_email, primary_phone, display_name, status, roles text[], created_at, updated_at, deleted_at | Unique lower email and normalized phone when present; role contains CLIENT, with optional COACH only for self-coaching; login excludes suspended/pending-deletion/deleted users. |
auth_identities | Maps provider identities to CoachMe users. | id, user_id, provider, provider_subject, issuer, email_verified, phone_verified, raw_claims jsonb, last_login_at, unlinked_at | Unique active (provider, issuer, provider_subject); raw claims minimized for support/debug. |
client_profiles | Client baseline profile and onboarding state. | id, user_id, display_name, timezone, locale, onboarding_status, privacy_settings jsonb, created_at, deleted_at | Unique active user_id; index on onboarding_status; soft delete only if user-facing recovery is needed. |
coach_client_relationships | Permission and business relationship between a coach workspace and client user. | id, workspace_id, coach_user_id, client_user_id, invitation_id, status, started_at, ended_at, metadata jsonb | Unique active client_user_id where ended_at is null to enforce one active/onboarding coach relationship; unique active (workspace_id, client_user_id); indexes on client_user_id, coach_user_id, and status. |
onboarding_workflow_instances | Assigned onboarding flow generated after relationship creation. | id, relationship_id, template_id, status, started_at, completed_at, current_task_id | Unique active workflow per relationship; index (relationship_id, status). |
onboarding_tasks | Concrete tasks such as profile step, questionnaire, measurements, photos, terms, or plan review. | id, workflow_instance_id, task_type, status, sort_order, due_at, completed_at, payload jsonb | Index (workflow_instance_id, sort_order); index (status, due_at) for reminders. |
app_sessions | Client session telemetry and revocation tracking. | id, user_id, keycloak_session_id, client_app, device_id_hash, ip_hash, last_seen_at, revoked_at | Index (user_id, last_seen_at desc); client app must be mobile-client or supported web fallback. |
Lifecycle Policy
Use explicit relationship and workflow states instead of generic deletion for onboarding progress.
Registration Lifecycle
- Invitation remains
ACTIVEduring preview and provider login. - Invitation becomes
CONSUMEDonly after the user, identity, relationship, and onboarding workflow are committed. - Relationship starts as
ONBOARDINGafter account creation and becomesACTIVEafter the required baseline profile is complete. Remaining questionnaire or coaching tasks can stay pending without blocking activation. - Onboarding workflow tasks are retained as history even after completion.
- Failed registration attempts never consume the invite and are recorded as audit events.
Deletion And Retention
usersandclient_profilesfollow account lifecycle deletion/anonymization policy.coach_client_relationshipsusestatusandended_at; relationship history is permission and coaching audit context.onboarding_workflow_instancesandonboarding_tasksare hidden by relationship status, not deleted during normal workflows.- Deleting or clearing the client app must only remove local session/cache state; server-side client identity, relationship, and onboarding progress remain recoverable after provider login.
- Auth, invitation, session, and audit rows use retention/anonymization jobs rather than app soft delete.
Resumable onboarding
The onboarding flow should be resumable. Deleting local mobile state must not delete server onboarding progress.
API Contracts
Registration endpoints require a valid Keycloak token and a still-valid invitation. Session endpoints require a resolved client user and relationship context.
| Endpoint | Use Case | Request | Response |
|---|---|---|---|
POST /api/v1/auth/client-onboarding-registration | Complete client account creation/linking from validated invitation after provider login. | Bearer access token plus invitationToken, validationId, displayName, timezone, locale, termsVersion, privacyVersion, clientApp, deviceId. | user, clientProfile, relationship, onboarding, roles, nextRoute, session. |
GET /api/v1/auth/client-me | Resolve client app session context on cold start, returning onboarding session, reinstall, cleared app data, or new device. | Bearer access token. | User identity, the single active or onboarding relationship when present, onboarding status, pending tasks, permissions, feature flags, or invite-required empty state. |
POST /api/v1/auth/client-session/heartbeat | Update client app session telemetry without extending Keycloak token lifetime. | clientApp, deviceId, appVersion, pushTokenId. | lastSeenAt, sessionStatus, relationshipStatus. |
GET /api/v1/client-onboarding/tasks | Load concrete onboarding tasks after registration or returning login. | Bearer access token; relationship is inferred from the user's single active or onboarding client relationship. | Ordered tasks, current task, optional questionnaire assignment, due dates, and completion state. |
POST /api/v1/client-onboarding/tasks/{taskId}/complete | Complete a non-form onboarding task such as profile confirmation or intro step. | Task-specific payload and idempotency key. | Updated task, workflow status, next task, and next route. |
Key Code Snippets
These snippets are implementation sketches for the planned TypeScript monorepo. Keep final code in the owning bounded context and keep transaction boundaries explicit.
Drizzle schema shape
apps/api/src/coach-client-management/db/schema.ts
ts
export const clientProfiles = pgTable("client_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
// External identity references. Do not import/navigate User, Workspace, or Invitation aggregates here.
userId: uuid("user_id").notNull(),
displayName: text("display_name").notNull(),
timezone: text("timezone").notNull(),
locale: text("locale").notNull().default("en"),
onboardingStatus: text("onboarding_status").notNull().default("NOT_STARTED"),
privacySettings: jsonb("privacy_settings").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
}, (table) => ({
userUnique: uniqueIndex("client_profiles_user_active_uq")
.on(table.userId)
.where(sql`${table.deletedAt} is null`),
onboardingIdx: index("client_profiles_onboarding_idx").on(table.onboardingStatus),
}));
export const coachClientRelationships = pgTable("coach_client_relationships", {
id: uuid("id").primaryKey().defaultRandom(),
workspaceId: uuid("workspace_id").notNull(),
coachUserId: uuid("coach_user_id").notNull(),
clientUserId: uuid("client_user_id").notNull(),
invitationId: uuid("invitation_id").notNull(),
status: text("status").notNull().default("ONBOARDING"),
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
endedAt: timestamp("ended_at", { withTimezone: true }),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
}, (table) => ({
activeClientWorkspaceUnique: uniqueIndex("coach_client_relationships_active_uq")
.on(table.workspaceId, table.clientUserId)
.where(sql`${table.endedAt} is null`),
oneActiveClientRelationshipUnique: uniqueIndex("coach_client_relationships_one_active_client_uq")
.on(table.clientUserId)
.where(sql`${table.endedAt} is null`),
clientIdx: index("coach_client_relationships_client_idx").on(table.clientUserId),
coachIdx: index("coach_client_relationships_coach_idx").on(table.coachUserId),
}));Application service transaction
apps/api/src/identity-access/use-cases/register-client-from-invite.ts
ts
@Injectable()
export class RegisterClientFromInviteUseCase {
constructor(
private readonly tokenVerifier: OidcTokenVerifier,
private readonly invitations: ClientInvitationRepository,
private readonly users: UserRepository,
private readonly relationships: CoachClientRelationshipRepository,
private readonly onboarding: OnboardingWorkflowService,
private readonly sessions: AppSessionRepository,
private readonly clock: Clock,
private readonly tx: TransactionRunner,
) {}
async execute(input: RegisterClientFromInviteInput, accessToken: AccessToken): Promise<ClientSessionDto> {
const identity = await this.tokenVerifier.verify(accessToken);
return this.tx.run(async () => {
const invitation = await this.invitations.lockByToken(input.invitationToken);
invitation.assertCanBeConsumedBy(identity, this.clock.now());
const user = await this.users.findByProviderRef(identity.ref)
?? User.registerClient(input, identity);
const existing = await this.relationships.findByInvitation(invitation.id, user.id);
if (existing) return this.sessions.clientContext(user.id, existing.id);
await this.relationships.assertCanStartMvpRelationship(user.id, invitation);
const relationship = CoachClientRelationship.startFromInvitation(invitation, user.id);
const workflow = await this.onboarding.createFromInvitation(invitation, relationship.id);
invitation.consume(user.id);
await this.users.save(user);
await this.relationships.save(relationship);
await this.onboarding.save(workflow);
await this.invitations.save(invitation);
await this.users.saveEventsToOutbox(user);
return this.sessions.createClientContext(user.id, relationship.id, input.device);
});
}
}Controller and guard boundary
apps/api/src/identity-access/http/client-auth.controller.ts
ts
@Controller("/api/v1/auth")
export class ClientAuthController {
constructor(
private readonly registerFromInvite: RegisterClientFromInviteUseCase,
private readonly clientSessionQuery: ClientSessionContextQuery,
) {}
@Post("client-onboarding-registration")
@UseGuards(KeycloakBearerGuard)
async register(
@Body() body: RegisterClientFromInviteDto,
@CurrentToken() token: AccessToken,
) {
return this.registerFromInvite.execute(body, token);
}
@Get("client-me")
@UseGuards(KeycloakBearerGuard)
async me(@CurrentIdentity() identity: VerifiedProviderIdentity) {
return this.clientSessionQuery.forProviderIdentity(identity.ref);
}
}Client mobile handoff
apps/mobile-client/src/features/onboarding/register-from-invite.ts
ts
export async function completeClientRegistrationFromInvite(input: ClientOnboardingRegistrationForm) {
const inviteContext = invitationStore.requireValidationContext();
const token = await authClient.getAccessToken({ allowedRole: "CLIENT" });
const session = await api.auth.registerClientFromInvite({
headers: { Authorization: `Bearer ${token}` },
body: {
invitationToken: inviteContext.token,
validationId: inviteContext.validationId,
displayName: input.displayName.trim(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locale: await localeStore.getPreferredLocale(),
termsVersion: CURRENT_TERMS_VERSION,
privacyVersion: CURRENT_PRIVACY_VERSION,
clientApp: "mobile-client",
deviceId: await deviceIdentity.getStableId(),
},
});
clientSessionStore.set(session);
invitationStore.clearValidationContext();
navigation.replace(session.nextRoute);
}Events & Background Jobs
Onboarding side effects start after the account and relationship transaction commits.
Domain / Integration Events
ClientRegistered: emitted when a new client user and profile are created.ClientLoggedIn: emitted for session telemetry and suspicious login detection.ClientInvitationConsumed: emitted when an invite is successfully linked to a client.CoachClientRelationshipStarted: emitted after relationship creation.ClientOnboardingStarted: emitted after workflow/task generation.TermsAccepted: emitted when client legal versions are accepted.
Jobs
- Generate onboarding tasks from the invite's onboarding template.
- Send coach notification that the invited client started onboarding.
- Schedule reminders for incomplete questionnaire/profile/measurement tasks.
- Sync Keycloak role mapping if app roles and realm roles diverge.
- Run suspicious login checks using provider, device hash, IP hash, and failed invitation attempts.
Permissions & Access Rules
Provider login is necessary but never sufficient for client onboarding access. Relationship context is the permission boundary.
- Client onboarding registration requires both a valid Keycloak token and a consumable invitation.
- Only users with
CLIENTrole andACTIVEstatus can enter client mobile surfaces. - Client APIs require an active or onboarding relationship; identity alone cannot access coach-scoped data.
- Coach-only sessions are rejected from client onboarding endpoints with an app/role mismatch error.
- If an existing provider identity belongs to a coach-only user, the API may attach
CLIENTrole only for self-coaching in that coach's own workspace. - Invitation contact binding is strict for every channel and rejects a provider identity that does not prove a matching verified email or phone.
- A client user can have only one active or onboarding coach-client relationship. New invites from another coach are rejected until the existing relationship is ended.
- Suspended users, suspended workspaces, revoked invites, and ended relationships receive explicit 403/409 reason codes.
Web And Mobile Client Behavior
The client app owns the main UX, while web fallback should keep recovery and deep-link routing clear.
Client Mobile
- For first-time onboarding registration, start only after invitation validation has stored temporary validation context.
- For returning clients, list the same enabled providers as coach auth: email/password, Google, and Apple, plus Telegram where the OIDC spike is proven.
- Use Expo AuthSession for Keycloak PKCE and platform secure storage for refresh material.
- After registration, clear invite context and land on the next onboarding task.
- On cold start, reinstall, cleared app data, or new device, allow provider login and call
/auth/client-mebefore hydrating onboarding or Today screens. - If
/auth/client-meresolves an existing client account, allow login; when it also resolves an onboarding relationship, resume the server task state without asking for the original invitation link again.
Web Fallback
- Support opening the invite preview in browser when the app is not installed.
- Route to app store or open the mobile app. V1 baseline web must not complete onboarding registration in browser.
- Never expose the raw invitation token in analytics events or error reporting.
- Use localized invalid-link and switch-account recovery states.
Coach Surfaces Impact
- Coach sees invited client transition from invited to onboarding after successful registration.
- Coach task inbox can receive “client started onboarding” and “onboarding overdue” tasks.
- No coach profile is created by this flow.
- Shared auth package should support app-specific allowed roles and session contexts.
Test Scenarios & Resolved Decisions
Client auth testing must cover invitation revalidation, idempotency, relationship permissions, role boundaries, and onboarding resume behavior.
Required Tests
- Domain: client registration requires verified provider identity and valid invitation.
- Domain: invite consumption and relationship creation happen atomically.
- Domain: relationship starts as
ONBOARDINGand activates after the required baseline profile is complete. - API integration: repeated registration with same provider identity and invitation is idempotent.
- API integration: expired/revoked/consumed-by-other-user invitation fails without creating user or relationship.
- API integration: existing client with an active or onboarding relationship cannot accept another coach invite.
- API integration: coach-only user can attach client role only for a self-coaching invitation in their own workspace.
- API integration: client onboarding provider discovery returns email/password, Google, and Apple, plus Telegram where the OIDC spike is proven.
- Permission: coach-only sessions cannot access client onboarding endpoints.
- Permission: strict contact binding rejects provider identities whose verified email or phone does not match the invitation target.
- Contract: client mobile receives the same
ClientSessionDtoafter registration and returning login. - API integration: previously registered client can reinstall or clear app data, choose any enabled provider already linked to their CoachMe identity, and recover client context through
/auth/client-mewithout an invitation token. - Contract: browser fallback can preview and deep link only; it cannot complete V1 baseline onboarding registration.
- E2E: client validates invite, logs in with provider, lands on questionnaire/profile task, closes app, reopens, and resumes the correct task.
- Security: invalid contact binding and suspicious retries are audited and rate-limited.
Resolved V1 Baseline Decisions
- V1 baseline allows one active or onboarding coach relationship per client user.
- Dual-role accounts are allowed only for self-coaching: a coach can be their own client in their own workspace.
- Client onboarding uses the same V1 baseline login providers as coach auth: email/password, Google, and Apple, with Telegram conditional pending the OIDC spike. WhatsApp is not a login provider.
- Contact binding is strict for all invites, including manual copy links.
- The user account can log in after account creation, but the coach-client relationship becomes
ACTIVEafter the required baseline profile is complete. Remaining questionnaire or optional onboarding tasks can stay pending. - V1 baseline onboarding registration is mobile-only. Web supports invite preview, recovery, app-store routing, and app deep-link fallback only.