Appearance
Client invitation link validation proves a client is allowed to start onboarding before any account is created or linked.
CoachMe-owned invitation records gate client registration. Keycloak can prove identity later, but the app must first prove that the invitation token is valid, active, unexpired, unconsumed, unreclaimed, scoped to the coach workspace, and safe to show to the person opening it.
V1 baseline decision
invitations expire after 7 days by default. Manual copy ships first, followed by email, WhatsApp, then SMS. Clients must register with a verified email or verified phone matching the invitation target; the other contact method is optional.
Telegram cannot deliver a cold invitation
Telegram bots cannot initiate contact and cannot address a phone number, so Telegram is not an initial invitation-delivery channel. It is a candidate only for notifications and codes after a client has started and linked the CoachMe bot. See the Integration Register for the decision and its open evidence.
Tags: NestJS · Opaque Invite Tokens · Drizzle + PostgreSQL · Client Mobile Onboarding
Invitation Validation Flows
The link validation flow is public enough to be opened before login, but strict enough to avoid leaking sensitive coach, workspace, or client data.
Coach Creates Invite
- Coach starts adding a client from web or coach mobile and enters at least one invitation target: email or phone.
- API verifies the coach has active membership in the workspace and permission to invite clients.
- API creates a
ClientInvitationwith statusACTIVE, defaultexpires_at7 days from creation, allowed contact hash, role intent, onboarding template, and one-time token hash. - API emits
ClientInvitationCreatedand prepares the selected delivery channel. V1 baseline channel rollout order is manual copy, email, WhatsApp, then SMS. Delivery channels are message transports only; they never authenticate anyone and never relax target binding. - Coach sees delivery status and can revoke or resend without changing the original audit trail. Resend reuses the existing token unless the coach explicitly chooses to regenerate it.
Client Opens Link
- Client opens
/invite/{token}from mobile deep link, universal link, or web fallback. - Client app calls
GET /api/v1/client-invitations/validate?token=...without requiring an app session. - API hashes the token, finds the active invite, checks expiration, revocation, consumption, workspace status, and target binding policy.
- API returns a safe preview: coach display name, workspace display name, onboarding step count or generic step labels, locale hints, expiration timestamp, and accepted auth providers.
- Client continues to provider login/registration using an invitation validation context, not by trusting the URL alone.
Invalid Or Expired Link
- API returns a reason code such as
INVITE_EXPIRED,INVITE_REVOKED,INVITE_CONSUMED, orINVITE_NOT_FOUND. - Client shows a non-sensitive recovery screen with contact support or request-new-link CTA.
- API writes
ClientInvitationValidationFailedaudit metadata with IP hash, user agent hash, and reason code for abuse review.
Validated Invite Continues Registration
- Client authenticates with Keycloak through PKCE using an allowed provider.
- Client calls the future onboarding registration endpoint with access token plus invite token.
- API revalidates the invitation in the same transaction that creates or loads the client user and coach-client relationship.
- API verifies that the provider identity proves a verified email or phone matching the invitation target. If the invite has both targets, matching either verified contact is sufficient and the other remains optional.
- Invite is marked
CONSUMEDonly after relationship creation succeeds.
Validation and consumption
Validation is not consumption. A link can be validated multiple times for preview and retry, but it is consumed exactly once during client account linking. Manual-copy links are still contact-bound; they are not bearer-only registration grants.
Domain Model
IdentityAccess owns invitation integrity. CoachClientManagement owns the eventual coach-client relationship created after validated onboarding registration.
Aggregates & Entities
ClientInvitation: aggregate root for one invitation lifecycle, token hash, target contact, workspace scope, delivery state, and status transitions.InvitationDeliveryAttempt: delivery history for manual copy, email, WhatsApp, or SMS events.InvitationValidationAttempt: security telemetry for successful and failed validation attempts.OnboardingContext: read model combining invite identity references, coach/workspace preview data, and assigned onboarding template preview.WorkspaceIdandUserId: external identity references used to prove the inviter can create client invitations through workspace policy/read models.
Value Objects & Rules
InvitationToken: high-entropy opaque token shown once; onlytoken_hashis stored.InvitationStatus:ACTIVE,EXPIRED,CONSUMED,REVOKED.InviteTarget: normalized email or phone with salted hash fields for privacy-safe matching.InviteExpiry: default 7-day V1 baseline window, extendable by resend policy without changing token semantics unless explicitly regenerated.- One active invite per workspace and target contact unless the earlier invite is revoked or consumed.
- Invitation consumption requires a verified provider email or phone matching one target hash. The unverified or unmatched contact method can be collected later.
- Validation never creates a user, profile, relationship, or Keycloak identity by itself.
| Model | Owned By | Important Methods | Emits |
|---|---|---|---|
ClientInvitation | IdentityAccess | create(), validatePreview(), consume(), revoke(), markExpired() | ClientInvitationCreated, ClientInvitationConsumed, ClientInvitationRevoked |
InvitationDeliveryAttempt | IdentityAccess | recordQueued(), recordSent(), recordFailed() | ClientInvitationDeliveryFailed |
InvitationValidationAttempt | IdentityAccess | recordSuccess(), recordFailure(), flagSuspicious() | ClientInvitationValidationFailed |
Table Structure
PostgreSQL stores app-owned invitation records, delivery history, and validation audit. Tokens are never stored in plaintext.
DDD boundary rule
ClientInvitation stores external IDs such as workspace_id, invited_by_user_id, consumed_by_user_id, and revoked_by_user_id as identity references. IdentityAccess should validate them through workspace/user policies or read models; the invitation aggregate should not navigate or mutate CoachWorkspace, WorkspaceMembership, or User aggregates outside its boundary.
| Table | Purpose | Important Columns | Constraints & Indexes |
|---|---|---|---|
client_invitations | Invitation lifecycle and validation source of truth. | id, workspace_id, invited_by_user_id, target_email_hash, target_phone_hash, token_hash, status, expires_at, consumed_at, consumed_by_user_id, revoked_at, revoked_by_user_id, onboarding_template_id, metadata jsonb, created_at, updated_at | Unique active token_hash; partial unique active target per workspace; indexes on (workspace_id, status), expires_at, and invited_by_user_id. |
invitation_delivery_attempts | Append-only delivery queue and provider result history. | id, invitation_id, channel, provider, destination_hash, status, provider_message_id, error_code, attempted_at | Index (invitation_id, attempted_at desc); index (status, attempted_at) for retry workers; raw provider metadata retained for 30 days. |
invitation_validation_attempts | Security telemetry for link opens, previews, failures, and abuse monitoring. | id, invitation_id, token_hash_prefix, result, reason_code, ip_hash, user_agent_hash, client_app, created_at | Index (invitation_id, created_at desc); index (reason_code, created_at); retained for 30 days before purge or anonymization. |
outbox_events | Transactional event handoff for notifications, analytics, and onboarding workflow setup. | id, aggregate_type, aggregate_id, event_type, payload jsonb, status, available_at | Index (status, available_at); consumed by BullMQ worker with retry policy. |
Lifecycle Policy
Invitation state changes are explicit because they drive security, onboarding recovery, and support behavior.
Status Transitions
ACTIVEis the only status that can validate successfully.EXPIREDcan be computed fromexpires_ator materialized by a scheduled job for reporting.CONSUMEDis set in the same transaction that links the client account to the coach workspace.REVOKEDis set by coach/admin action and should immediately invalidate the token.- Resend reuses the active token by default. Token regeneration happens only after explicit coach action and immediately invalidates previous links.
- Expired or revoked invites can be reissued by creating a new invite and preserving the old audit trail.
Deletion And Retention
client_invitationsare not soft-deleted in normal app flows; the status history is the lifecycle record.- Delivery and validation attempts are append-only security records with 30-day retention/anonymization, not user-facing deletion.
- PII contact values should be hashed for matching; plaintext delivery destinations should live only in the notification provider payload if required.
- Provider message IDs, provider error payloads, IP hashes, and user-agent hashes are purged or anonymized after 30 days. Aggregate delivery counts can remain without provider identifiers.
- Support/admin queries can include inactive invitations; normal client validation only sees active eligible links.
Invitation lifecycle states
Do not use generic deleted_at for invitations. Revoked, expired, and consumed have different product meanings and should remain queryable.
API Contracts
Creation and management endpoints require coach authentication. Public validation endpoints accept only opaque tokens and return safe preview data.
| Endpoint | Use Case | Request | Response |
|---|---|---|---|
POST /api/v1/workspaces/{workspaceId}/client-invitations | Coach creates a client onboarding invitation. | targetEmail or targetPhone, onboardingTemplateId, locale, optional expiresInDays defaulting to 7, optional message and deliveryChannel. V1 baseline delivery channel order is manual copy, email, WhatsApp, then SMS. | invitationId, status, expiresAt, masked target, delivery status, optional manual inviteUrl. |
GET /api/v1/client-invitations/validate | Public link preview before client registration. | Query token, optional clientApp, locale. | valid, validationId, safe coach/workspace preview, generic onboarding expectations, provider options, or reason code. |
POST /api/v1/client-invitations/{invitationId}/revoke | Coach/admin revokes an active invite. | reason. | status, revokedAt, revokedBy. |
POST /api/v1/client-invitations/{invitationId}/resend | Retry delivery through selected channel. | deliveryChannel, optional message, optional regenerateToken that defaults to false and requires explicit coach confirmation. | deliveryAttemptId, status, expiresAt. |
GET /api/v1/workspaces/{workspaceId}/client-invitations | Coach views invitation list and statuses. | Filters: status, target, createdAfter, createdBefore. | Paginated invitation summaries with masked target and delivery state. |
Key Code Snippets
These snippets are implementation sketches for the planned TypeScript monorepo. Final code should keep validation rules in application/domain services, not directly in controllers.
Drizzle schema shape
apps/api/src/identity-access/db/schema.ts
ts
export const invitationStatus = pgEnum("invitation_status", [
"ACTIVE",
"EXPIRED",
"CONSUMED",
"REVOKED",
]);
export const clientInvitations = pgTable("client_invitations", {
id: uuid("id").primaryKey().defaultRandom(),
// External identity references. Do not import/navigate Workspace or User aggregates here.
workspaceId: uuid("workspace_id").notNull(),
invitedByUserId: uuid("invited_by_user_id").notNull(),
targetEmailHash: text("target_email_hash"),
targetPhoneHash: text("target_phone_hash"),
tokenHash: text("token_hash").notNull(),
status: invitationStatus("status").notNull().default("ACTIVE"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
consumedAt: timestamp("consumed_at", { withTimezone: true }),
consumedByUserId: uuid("consumed_by_user_id"),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByUserId: uuid("revoked_by_user_id"),
onboardingTemplateId: uuid("onboarding_template_id"),
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
tokenUnique: uniqueIndex("client_invitations_token_hash_uq").on(table.tokenHash),
workspaceStatusIdx: index("client_invitations_workspace_status_idx").on(table.workspaceId, table.status),
expiresIdx: index("client_invitations_expires_idx").on(table.expiresAt),
activeEmailTargetUnique: uniqueIndex("client_invitations_active_email_target_uq")
.on(table.workspaceId, table.targetEmailHash)
.where(sql`${table.status} = 'ACTIVE' and ${table.targetEmailHash} is not null`),
activePhoneTargetUnique: uniqueIndex("client_invitations_active_phone_target_uq")
.on(table.workspaceId, table.targetPhoneHash)
.where(sql`${table.status} = 'ACTIVE' and ${table.targetPhoneHash} is not null`),
}));Domain aggregate sketch
packages/domain/src/identity-access/client-invitation.ts
ts
export class ClientInvitation extends AggregateRoot<InvitationId> {
private constructor(private props: ClientInvitationProps) {
super(props.id);
}
static create(command: CreateClientInvitationCommand, token: InvitationToken): ClientInvitation {
if (!command.targetEmail && !command.targetPhone) {
throw new DomainError("INVITATION_TARGET_REQUIRED");
}
const invitation = new ClientInvitation({
id: InvitationId.create(),
workspaceId: command.workspaceId,
invitedByUserId: command.invitedByUserId,
targetEmailHash: command.targetEmail?.hashValue,
targetPhoneHash: command.targetPhone?.hashValue,
tokenHash: token.hash,
status: "ACTIVE",
expiresAt: command.expiresAt,
onboardingTemplateId: command.onboardingTemplateId,
});
invitation.addEvent(new ClientInvitationCreated({
invitationId: invitation.id.value,
workspaceId: command.workspaceId.value,
occurredAt: new Date(),
}));
return invitation;
}
validatePreview(now: Date): InvitationPreviewEligibility {
if (this.props.status !== "ACTIVE") return { valid: false, reason: `INVITE_${this.props.status}` };
if (this.props.expiresAt <= now) return { valid: false, reason: "INVITE_EXPIRED" };
return { valid: true };
}
}NestJS validation use case
apps/api/src/identity-access/use-cases/validate-client-invitation.ts
ts
@Injectable()
export class ValidateClientInvitationUseCase {
constructor(
private readonly invitations: ClientInvitationRepository,
private readonly workspaceQuery: WorkspacePreviewQuery,
private readonly attempts: InvitationValidationAttemptRepository,
private readonly tokenHasher: InvitationTokenHasher,
private readonly clock: Clock,
) {}
async execute(input: ValidateClientInvitationInput): Promise<InvitationValidationDto> {
const tokenHash = this.tokenHasher.hash(input.token);
const invitation = await this.invitations.findByTokenHash(tokenHash);
if (!invitation) {
await this.attempts.recordFailure({ tokenHashPrefix: tokenHash.slice(0, 12), reason: "INVITE_NOT_FOUND", input });
return { valid: false, reasonCode: "INVITE_NOT_FOUND" };
}
const eligibility = invitation.validatePreview(this.clock.now());
if (!eligibility.valid) {
await this.attempts.recordFailure({ invitationId: invitation.id, reason: eligibility.reason, input });
return { valid: false, invitationId: invitation.id.value, reasonCode: eligibility.reason };
}
await this.attempts.recordSuccess({ invitationId: invitation.id, input });
return this.workspaceQuery.safeInvitationPreview(invitation.id, input.locale);
}
}Controller boundary
apps/api/src/identity-access/http/client-invitations.controller.ts
ts
@Controller("/api/v1")
export class ClientInvitationsController {
constructor(
private readonly createInvitation: CreateClientInvitationUseCase,
private readonly validateInvitation: ValidateClientInvitationUseCase,
private readonly revokeInvitation: RevokeClientInvitationUseCase,
) {}
@Post("workspaces/:workspaceId/client-invitations")
@UseGuards(KeycloakBearerGuard, WorkspaceCoachGuard)
async create(@Param("workspaceId") workspaceId: string, @Body() body: CreateClientInvitationDto) {
return this.createInvitation.execute({ workspaceId, ...body });
}
@Get("client-invitations/validate")
async validate(@Query() query: ValidateClientInvitationDto, @RequestMeta() meta: RequestMeta) {
return this.validateInvitation.execute({ ...query, meta });
}
@Post("client-invitations/:invitationId/revoke")
@UseGuards(KeycloakBearerGuard, WorkspaceCoachGuard)
async revoke(@Param("invitationId") invitationId: string, @Body() body: RevokeInvitationDto) {
return this.revokeInvitation.execute({ invitationId, reason: body.reason });
}
}Client mobile validation call
apps/mobile-client/src/features/invitations/validate-invite.ts
ts
export async function validateInvitationLink(token: string) {
const result = await api.clientInvitations.validate({
query: {
token,
clientApp: "mobile-client",
locale: await localeStore.getPreferredLocale(),
},
});
if (!result.valid) {
return navigation.replace("InviteProblem", { reasonCode: result.reasonCode });
}
invitationStore.setValidationContext({
token,
validationId: result.validationId,
workspace: result.workspace,
coach: result.coach,
onboardingSteps: result.onboardingSteps,
allowedProviders: result.allowedProviders,
});
return navigation.replace("InvitePreview");
}Events & Background Jobs
Invitation side effects use the outbox so link creation and delivery never partially succeed.
Domain / Integration Events
ClientInvitationCreated: emitted after an active invitation is stored.ClientInvitationDeliveryQueued: emitted when a channel-specific delivery attempt is queued.ClientInvitationValidationFailed: emitted for suspicious or repeated invalid opens.ClientInvitationConsumed: emitted after account linking and coach-client relationship creation.ClientInvitationRevoked: emitted when coach or admin invalidates a link.
Jobs
- Deliver invitations through configured notification channels and record provider result.
- Expire active invitations after
expires_atfor reporting and coach UI consistency. - Rate-limit repeated validation failures by IP hash, token prefix, and workspace target.
- Send reminder nudges before expiration when the coach enabled reminders.
- Purge or anonymize validation-attempt hashes and raw delivery provider metadata after 30 days.
Permissions & Access Rules
Invitation links bridge unauthenticated and authenticated flows, so they need stricter scope boundaries than normal logged-in APIs.
- Only active coaches with an active workspace membership can create, resend, revoke, or list invitations for that workspace.
- Public validation returns safe preview data only; it must not expose target email/phone, internal IDs beyond harmless references, coach private notes, or client records.
- Pre-login preview is intentionally minimal in every V1 baseline market: coach display name, workspace display name, generic onboarding expectations, locale hints, expiration, and allowed providers. Market configuration may hide more, but should not expose more before login.
- Validation allows repeated preview but should be rate-limited and audited.
- Registration must revalidate the token server-side; a previously cached client-side validation cannot authorize account linking.
- Registration must prove either the invited email or invited phone through the selected Keycloak provider before consuming the invite.
- Consumed, expired, revoked, suspended-workspace, and deleted-workspace invitations cannot proceed to onboarding.
- A provider login alone never creates a coach-client relationship. The relationship must come from a valid consumed invitation or future admin-created relationship workflow.
Web And Mobile Client Behavior
Every surface that can open an invite should produce the same validation outcome while adapting the UX for deep links and fallback browsers.
Coach Web
- Create invitation from client list or onboarding workflow setup.
- Show masked target, status, expiration, delivery attempts, resend, revoke, and copy-link actions.
- Warn before regenerating a token because old links immediately stop working.
- Default invite expiration is 7 days; resending does not regenerate the token unless the coach explicitly confirms regeneration.
- Filter invites by active, expired, consumed, and revoked.
Coach Mobile
- Support lightweight invite creation and resend from the coach mobile client list.
- Use the same API contracts as coach web.
- Ship manual share-sheet/copy first, then use email, WhatsApp, and SMS as each delivery integration becomes available.
- Show enough status for the coach to know whether the client started onboarding.
Client Mobile
- Handle universal links and app links for
/invite/{token}. - Fallback web page should route to app store, open app, or continue in browser when supported.
- Store validation context only temporarily until registration/linking succeeds.
- During registration, choose a provider that verifies the invited email or phone; unmatched secondary contact details can be added after onboarding starts.
- Show localized invalid-link states with clear next steps, not raw technical errors.
Test Scenarios & Resolved Decisions
Invitation testing should focus on token safety, status transitions, privacy, idempotency, and the handoff into client onboarding registration.
Required Tests
- Domain: invitation requires at least one target contact and creates active status with hashed token only.
- Domain: default invitation expiration is 7 days.
- Domain: active unexpired invitation validates; expired, consumed, and revoked invitations fail with correct reason codes.
- API integration: public validation returns safe preview data and never leaks target contact or private coach data.
- API integration: repeated validation does not consume the invitation.
- API integration: resend reuses the existing token unless
regenerateTokenis explicitly confirmed. - API integration: client registration revalidates and consumes the invitation in the same transaction as relationship creation.
- API integration: client registration requires a verified provider email or phone matching the invitation target.
- Security: invalid token attempts are audited and rate-limited by token prefix and IP hash.
- Security: validation attempts and raw delivery provider metadata are purged or anonymized after 30 days.
- Contract: web fallback and client mobile receive the same reason codes and preview DTO shape.
- E2E: coach creates invite, client opens deep link, validates, authenticates, and proceeds into onboarding tasks.
Resolved V1 Baseline Decisions
- Default invitation expiration window for V1 baseline is 7 days.
- Resend reuses the existing token by default; regeneration requires explicit coach action.
- Delivery channels ship in this order: manual copy, email, WhatsApp, SMS. Telegram is excluded from initial delivery because its bots require user-initiated contact.
- Delivery channels are message transports only. A channel never authenticates the recipient, never creates or links an account, and never relaxes target binding. Whoever opens the link still has to verify the invited contact.
- Target contact binding is mandatory for registration, including manually copied links. The user must verify either invited email or invited phone; the other contact method is optional.
- Pre-login preview is market-minimal: coach display name, workspace display name, generic onboarding expectations, locale hints, expiration, and allowed providers. Market rules may hide more, but V1 baseline should not expose target contact, private coach contact details, client data, internal IDs, or notes before login.
- Validation attempts and raw delivery provider metadata are retained for 30 days before purge or anonymization.