Bridge Training

Accounts

Accounts (person or organisation), public handles, owners, user roles, preferences, onboarding completeness, and the AccountsService fed by the identity provider.

The accounts module models the people and organisations that own everything else in the product: boards, groups, sessions, collections. It is consumed by the API (the account behind every authenticated request), the admin back-office (roles, search, pagination, impersonation audit) and the auth adapter, which pushes identity snapshots from the identity provider into the store.

Import from @workspace/training/domain/accounts and @workspace/training/application/accounts.

Domain

Account

Account is a discriminated union on type: Person or Organisation. The base fields are shared.

Prop

Type

Prop

Type

Two fields carry rules worth knowing:

  • avatarTrusted: an admin reviewed and approved the avatar. Peers only see avatarUrl once it is true. The gating happens at the DTO boundary, not in this module.
  • onboardedAt: when the account first completed onboarding. null means never onboarded. A set value with isAccountOnboarded false means a newly required field is missing and a catch-up dialogue is due.
  • signupSource: the app the account was created from, fixed at creation and never changed. It records the origin of the account; rules read its audience instead, see Audience and space.
  • handle and handleLocked: the public identifier of the account and its lock; see Public handle.
  • certified: certified by us from the back-office, the blue badge of the author catalog; see Catalog. The gold premium badge is read from the running grants and never stored.

Public handle

The handle is the readable, stable address of an account (/authors/ffb), used by the author page instead of the technical id. It is optional: an account without one has no author page, and publishing to the catalogue requires one.

  • Unique across the app, regardless of case. The handle is stored lower-cased (normalizeAccountHandle trims and lower-cases it), so FFB and ffb are the same handle and two accounts cannot hold them.
  • Format. Groups of [a-z0-9] joined by single hyphens, 3 to 30 characters: no leading or trailing hyphen, no doubled hyphen. The table checks the same format.
  • Reserved words. RESERVED_ACCOUNT_HANDLES lists the handles no account may take: route segments (admin, api, new, me, settings, …) and names that would pass for the platform. The list lives in the domain only.
  • Set once, then locked. Setting the handle turns handleLocked on. The service refuses any later change, even to the same handle, until an admin turns the lock off (unlockHandle, the back-office Unlock action); the owner can then set it once more, and it locks again. The profile update never writes the handle.
ExportDefinition
ACCOUNT_HANDLE_MIN_LENGTH, ACCOUNT_HANDLE_MAX_LENGTH3, 30
ACCOUNT_HANDLE_PATTERN/^[a-z0-9]+(?:-[a-z0-9]+)*$/
RESERVED_ACCOUNT_HANDLESReadonlySet<string> of the reserved handles
ACCOUNT_HANDLE_ISSUES, AccountHandleIssue"too_short" | "too_long" | "invalid_format" | "reserved"
FunctionSignatureDescription
normalizeAccountHandle(raw: string) => stringTrimmed and lower-cased: the stored form.
findAccountHandleIssue(handle: string) => AccountHandleIssue | nullThe first rule a normalized handle breaks, in the order of ACCOUNT_HANDLE_ISSUES, or null. Uniqueness is not checked.
isValidAccountHandle(handle: string) => booleanNo issue.
suggestAccountHandle(displayName: string) => string | nullA well-formed handle drawn from a display name, accents and punctuation dropped (Fédération Française de Bridge → federation-francaise-de-bridge), cut at 30 characters; null when none comes out or it is reserved. Availability is not checked.

Signup source

ExportDefinition
SIGNUP_SOURCES["app", "learn"]: the teacher app and the learner space
SignupSource(typeof SIGNUP_SOURCES)[number]
isSignupSource(value)Type guard over unknown

Audience and space

The audience says who the account is for, teachers or learners, and the space is the app that opens its content to it. Rules and screens read these two, never signupSource: each app shows its content to the accounts whose space it is and the others a screen pointing to their app; the API does not filter on the space.

For now the audience follows from the signup source, in audienceOf only. When the two may differ (a learner who becomes a teacher), an audience stored on the account replaces this derivation and the callers stay the same.

ExportDefinition
ACCOUNT_AUDIENCE, AccountAudienceTEACHER: "teacher", LEARNER: "learner": the audiences of the plan grids
ACCOUNT_SPACE, AccountSpaceAPP: "app", LEARN: "learn": the teacher app and the learner space
FunctionSignatureDescription
audienceOf(account: Pick<Account, "signupSource">) => AccountAudienceapp → teacher, learn → learner.
spaceOf(account: Pick<Account, "signupSource">) => AccountSpaceThe space of the account's audience: teacher → app, learner → learn.
isInSpace(account: Pick<Account, "signupSource">, space: AccountSpace) => booleanWhether space is the account's space: isInSpace(account, ACCOUNT_SPACE.LEARN).

Owner

Owner is the public projection of an account attached to a resource (a group, a board). It never carries the email or the role.

Prop

Type

Prop

Type

User role

ExportDefinition
USER_ROLE{ ADMIN: "admin", TRANSLATOR: "translator", USER: "user" }
USER_ROLE_VALUES["admin", "translator", "user"]
UserRoleUnion of the three literals
DEFAULT_USER_ROLE"user"
isUserRole(value: unknown): value is UserRoleGuard against the list above

Roles are read from the database row, never from identity-provider claims. Authorization stays in the consuming app; see Service Context.

Preferences

AccountPreferences is the per-account UI state persisted server-side. Each member is optional and nullable, and null means an explicit reset.

Prop

Type

TypeValues or shape
ThemePreference"light" | "dark" | "system"
ExplorerViewMode"grid" | "table"
GroupsViewMode"table" | "grid"
SessionsViewMode"list" | "grid"
DealsExplorerPreferencesviewMode, columnVisibility: Record<string, boolean>, columnOrder: string[]
GroupsListPreferences, SessionsListPreferencesviewMode

The column is jsonb and is not migrated when a member is dropped: a shareTemplates key still stored on older accounts is stripped when the wire schema parses the preferences, and disappears from the row on the account's next preferences write.

Onboarding

The onboarding rules live in one place so the onboarding redirect, the catch-up dialogue and the database backfill agree.

ExportDefinition
AccountField"name" | "country" | "accountLanguage" | "appLanguage" | "auctionSystem"
MissingFieldsRecord<AccountField, boolean>
FunctionSignatureDescription
computeMissingFields(account: Account | null | undefined) => MissingFieldsOne boolean per required field. name means first and last name for a person, organisation name for an organisation. A missing account reports nothing missing.
hasMissingFields(missing: MissingFields) => booleanAny flag set.
isAccountOnboarded(account: Account | null | undefined) => booleanEvery required field present. False for a missing account.

onboardedAt does not participate: it records that onboarding happened once, not that the account is complete today.

Names

FunctionSignatureDescription
computeDisplayName(input: ComputeDisplayNameInput) => stringPerson: capitalised firstName lastName. Organisation: trimmed name. Empty: the email prefix, so the column is never blank. Capitalisation applies after a space, a hyphen or an apostrophe.
computeSignatureName(account: Account) => stringThe name that signs content sent to others. Person: capitalised first name, else displayName. Organisation: displayName.
accounts.ts
computeDisplayName({
  type: "person",
  firstName: "ada",
  lastName: "d'arc",
  email: "ada@example.com",
}); // => "Ada D'Arc"

computeDisplayName({
  type: "person",
  firstName: null,
  lastName: null,
  email: "ada@example.com",
}); // => "ada" (email prefix fallback)

computeDisplayName({
  type: "organisation",
  organisationName: " Bridge Club de Lyon ",
  email: "club@example.com",
}); // => "Bridge Club de Lyon"
accounts.ts
export function onboardingState(account: Account) {
  const missing = computeMissingFields(account);
  // => { name: false, country: true, accountLanguage: false, appLanguage: false, auctionSystem: false }
  const onboarded = isAccountOnboarded(account); // => false while any field is missing
  const signature = computeSignatureName(account); // => "Ada" for a person with a first name
  return { missing, onboarded, signature };
}

User search result

Prop

Type

Application

AccountsService

createAccountsService(deps: AccountsServiceDeps): AccountsService

DependencyTypeRole
accountsAccountsRepositoryAccount writes.
accountsReadAccountsReadRepositoryAccount reads.
eventBusDomainEventBusReceives account:created and account:updated.

The service is the one place that emits account:created and account:updated on the event bus port, and the only entry point to accounts: the API, the admin back-office, the CLI and the database scripts read and write accounts through it, never through the repository. getOwn and update take a ServiceContext to refuse a foreign account, search takes one to name its viewer, and the directory reads (getManyByIds, listPaginated, resolveIdsByEmails) take the caller's and ignore it: they serve back-office and import surfaces whose authorization the consuming app decides. Elsewhere the account id is the subject itself, resolved by the caller from the verified session or from the identity provider.

MethodSignatureDescription
ensureAccount(accountId: string, identityProvider: IdentityProvider) => Promise<Account>Returns the existing account. A missing one is created from the identity the provider holds, through the same upsert as the user.created snapshot, so both paths write the same row; when the provider holds no signup source, nothing is created and ConflictError(ACCOUNT_SIGNUP_SOURCE_MISSING) is thrown.
getById(accountId: string) => Promise<Account | null>
getManyByIds(accountIds: string[], ctx: ServiceContext) => Promise<Account[]>The accounts carrying these ids, in one read; an unknown id is left out.
listPaginated(params: AccountsPageParams, ctx: ServiceContext) => Promise<PaginatedAccounts>One page of every account, for the back-office directory. page defaults to 1, pageSize to 20, capped at 100.
search(term: string, options: AccountSearchOptions, ctx: ServiceContext) => Promise<Account[]>Accounts whose email or display name contains the term, at most twenty. The viewer, ctx.userId, is left out unless options.includeSelf.
resolveIdsByEmails(emails: string[], ctx: ServiceContext) => Promise<Map<string, string>>Lower-cased email to account id, for the emails that name an account as stored; for admin and import orchestrations.
getOwn(accountId: string, ctx: ServiceContext) => Promise<Account | null>The caller's own account: throws AccessDeniedError when accountId is not ctx.userId, before any read.
getByIdentityProviderUserId(identityProviderUserId: string) => Promise<Account | null>Lookup by the provider's id, the session sub and the webhook payload id.
findForIdentity(identityProviderUserId: string, email: string) => Promise<Account | null>The account attached to this identity, else the detached one carrying the email.
upsertFromIdentitySnapshot(dto: CreatingIdentitySnapshot) => Promise<Account>Insert or update from a snapshot that names the signup source. A detached account with the same email is re-attached. The role and the signup source are written on creation only. Updates the matching account, else inserts one: emits account:updated when the update matched a row, account:created after the insert. Two concurrent first snapshots of one identity cannot both insert: the second meets ACCOUNT_ALREADY_EXISTS and returns the account the first inserted.
updateFromIdentitySnapshot(dto: IdentitySnapshot) => Promise<Account | null>Update the existing account of a snapshot, never creating one. Emits account:updated; null when no account matches.
detachIdentity(identityProviderUserId: string) => Promise<Account | null>The provider deleted the user: deactivate and drop the link, keep the row and what it owns. Emits account:updated when a row was found.
update(accountId: string, dto: UpdateAccountDTO, ctx: ServiceContext) => Promise<Account>Profile update of the caller's own account: throws AccessDeniedError when accountId is not ctx.userId. Throws ValidationError(ORGANISATION_NAME_REQUIRED) when an organisation DTO has a blank name. Switching type clears the other type's names. Throws NotFoundError(ACCOUNT_NOT_FOUND) for an unknown account. Emits nothing.
updateRole(accountId: string, role: UserRole) => Promise<Account>Emits account:updated. Throws NotFoundError(ACCOUNT_NOT_FOUND) for an unknown account.
setCertified(accountId: string, certified: boolean, ctx: ServiceContext) => Promise<Account>Back-office: certifies the account or withdraws the certification; emits account:updated. Admin authorization is the caller's. Throws NotFoundError(ACCOUNT_NOT_FOUND) for an unknown account.
completeOnboarding(accountId: string, dto: UpdateAccountDTO) => Promise<Account>Applies the form values and marks the account onboarded, keeping the first onboarding time on a resubmit. Same validation as update.
checkHandleAvailability(handle: string, ctx: ServiceContext) => Promise<AccountHandleAvailability>Whether the caller could take the handle, normalized: { handle, available: true }, or { handle, available: false, reason } with reason a broken rule (AccountHandleIssue) or "taken" when another account carries it. The caller's own handle counts as available.
setHandle(accountId: string, handle: string, ctx: ServiceContext) => Promise<Account>Sets the caller's handle, normalized, and locks it; emits account:updated. Throws AccessDeniedError for a foreign account, ValidationError(ACCOUNT_HANDLE_INVALID) with context.issue for a malformed or reserved handle, ConflictError(ACCOUNT_HANDLE_LOCKED) once locked and ConflictError(ACCOUNT_HANDLE_TAKEN) when another account carries it. The write is conditioned on the lock being off, so two concurrent sets cannot both win, and the table's unique constraint settles a race on the same handle.
unlockHandle(accountId: string, ctx: ServiceContext) => Promise<Account>Back-office: turns the lock off, keeping the handle until the owner sets a new one. Emits account:updated. Admin authorization is the caller's. Throws NotFoundError(ACCOUNT_NOT_FOUND) for an unknown account.
accounts.ts
// The adapter in packages/database implements both ports; an event bus (mitt
// in the apps) receives account:created / account:updated.
export function buildAccounts(
  accounts: AccountsRepository,
  accountsRead: AccountsReadRepository,
  eventBus: DomainEventBus
): AccountsService {
  return createAccountsService({ accounts, accountsRead, eventBus });
}
accounts.ts
// Called once per authenticated request: no ServiceContext, the account id
// comes from the verified session. The identity provider (auth adapter) gives
// the user as its creation event would; without a signup source, no account is created.
export function currentAccount(
  accounts: AccountsService,
  userId: string,
  identityProvider: IdentityProvider
): Promise<Account> {
  return accounts.ensureAccount(userId, identityProvider);
}
accounts.ts
export function completeOnboarding(
  accounts: AccountsService,
  accountId: string
): Promise<Account> {
  return accounts.completeOnboarding(accountId, {
    type: "person",
    firstName: "Ada",
    lastName: "Lovelace",
    country: "FR",
    accountLanguage: "fr",
    appLanguage: "fr",
    auctionSystem: "sef",
  });
}

export function updateOrganisation(
  accounts: AccountsService,
  accountId: string
): Promise<Account> {
  // Throws AccessDeniedError on another account, ValidationError(ORGANISATION_NAME_REQUIRED) on a blank name.
  return accounts.update(
    accountId,
    { type: "organisation", organisationName: "Bridge Club de Lyon" },
    { userId: accountId }
  );
}
accounts.ts
export function promote(
  accounts: AccountsService,
  accountId: string
): Promise<Account> {
  return accounts.updateRole(accountId, USER_ROLE.ADMIN);
}

isUserRole("translator"); // => true
isUserRole("owner"); // => false

ImpersonationService

createImpersonationService(deps: { impersonationLog: ImpersonationLogRepository }): ImpersonationService

Writes the audit trail of admin impersonation. Who may impersonate whom is decided by the consuming app before anything is recorded: the API checks the admin role and refuses an admin target, then records. The API exposes the service as impersonationService in the authenticated context.

MethodSignatureDescription
record(event: ImpersonationEvent) => Promise<void>Appends one start or stop event to the log.

ImpersonationEvent is a union on action. Both members name the impersonation, the admin and the target; each keeps only the session it happens in, and an optional field left out stays out of the entry.

EventFields besides impersonationId, adminUserId, targetUserId
ImpersonationStarted (action: "start")reason?, adminSessionId?: why, and the admin's session it starts from
ImpersonationStopped (action: "stop")targetSessionId?: the session opened as the target that ends it

applyAccountIdentitySnapshot

applyAccountIdentitySnapshot(accounts: AccountsService, snapshot: IdentitySnapshot, ctx?: ApplyIdentitySnapshotContext): Promise<void>

The transport-agnostic entry for identity events. Provider parsing stays in the auth adapter; this function only applies the staleness guard, then writes through the service: upsertFromIdentitySnapshot when the snapshot carries a signupSource, updateFromIdentitySnapshot otherwise. Without a signup source a snapshot only updates an existing account: the app the user opens creates it, naming itself through ensureAccount.

The guard: the function looks up the account the snapshot would write to (attached, or detached with the same email). When that account has an updatedAt newer than snapshot.updatedAt, the write is skipped and a debug line is logged with ctx.eventType. An out-of-order or replayed event therefore never clobbers fresher data, and replaying the same snapshot twice converges to the same state.

Prop

Type

role is applied on creation only; an existing account's role is never overwritten by a snapshot. ApplyIdentitySnapshotContext holds one optional eventType: string used for logging.

accounts.ts
// From the auth adapter's webhook handler or a batch sync. The snapshot is
// skipped when it is older than the account it would overwrite, and only
// creates the account when it carries a signupSource.
export function onIdentityEvent(
  accounts: AccountsService,
  snapshot: IdentitySnapshot
): Promise<void> {
  return applyAccountIdentitySnapshot(accounts, snapshot, {
    eventType: "user.updated",
  });
}

DTOs

UpdateAccountDTO is a union on type, so a person update cannot carry an organisation name and vice versa. auctionSystem is the system identifier string, not the full object.

FieldTypeOn
type"person" | "organisation"discriminant
firstName, lastNamestring | nullperson
organisationNamestringorganisation
country, accountLanguage, appLanguage, auctionSystem, avatarUrlstring | nullboth
preferencesAccountPreferences | nullboth
showEmailPubliclyboolean | nullboth

Every field but type is optional.

CreatingIdentitySnapshot is IdentitySnapshot & { signupSource: SignupSource }: the only snapshot shape allowed to create an account. The webhook, the API's lazy creation, the admin's first request and the CLI sync all hand the service an IdentitySnapshot.

IdentityProvider is the provider read directly, implemented by the auth adapter: getIdentity(identityProviderUserId) returns the user as the provider holds it now, signup source and role included, null for a user without a primary email, and rejects when the provider cannot be reached; listIdentities() returns every user, those without a primary email left out.

TypeShape
AccountsPageParams{ page?: number; pageSize?: number }
AccountSearchOptions{ includeSelf: boolean }
AccountHandleAvailability{ handle; available: true } | { handle; available: false; reason: AccountHandleUnavailableReason }
AccountHandleUnavailableReasonAccountHandleIssue | "taken"
PaginatedAccounts{ accounts: Account[]; pagination: PaginationMeta }

Ports

Accounts bind the canonical surface of Queries. The ports name the identity provider's id identityProviderUserId; only the adapter knows which column stores it.

ExportBindsNotes
AccountWhereWhereShapeid? and email? as Equality<string>, the email matched exactly as stored; handle? as Equality<string>, matched as stored (lower-cased); handleLocked? and isActive? as Equality<boolean>; identityProviderUserId? as Equality<string> & Nullability, isNull: true selecting the detached accounts; identity?: { identityProviderUserId, email }, the account attached to this identity or a detached one carrying the email; search?: string, email or display name containing the term case-insensitively.
AccountQuery, AccountQueryParamsQueryObjectempty(), withId, withIds, excluding(id) (undefined is a no-op), withEmails, withHandle, byIdentityProviderUserId, forIdentity, matching(term), withPagination, withFilters. No sorting.
AccountCriteriaCriteria<AccountWhere>byId, byIds, byIdentityProviderUserId, forIdentity, byIdWithUnlockedHandle (the guard of a handle write), where; instance and.
AccountProjections, AccountEmailRowProjection<T>entity(): the Account with its auction system resolved. emails(): { id, email }, the email as stored.
AccountAggregationsAggregation<T>count(criteria?).
AccountPatchTPatchEvery column the service writes, each optional; identityProviderUserId: null detaches, onboarded: true stamps the onboarding time unless one is set. Every update stamps updatedAt; a patched avatarUrl that differs from the stored one resets avatarTrusted; a patch carrying a type but no displayName derives the display name per row from the patched names, an absent or null one falling back to the stored one, under a row lock; a handle another account carries fails the write with ConflictError(ACCOUNT_HANDLE_TAKEN).
PersistableAccountWriteTPersistableWrite{ id, identityProviderUserId, email, displayName, signupSource } plus optional role, firstName, lastName, avatarUrl; an absent column takes its default.
AccountsReadRepositoryReadRepository<AccountQuery>getById throws NotFoundError(ACCOUNT_NOT_FOUND).
AccountsRepositoryWriteRepository<AccountCriteria, AccountPatch, PersistableAccountWrite>Plus writeOne(input) => Promise<{ accountId: string }>, which throws on a taken id (ConflictError ACCOUNT_ALREADY_EXISTS), email or identity.

No account method is bespoke. Detaching an identity and re-attaching a detached account are each one conditional update, whose criteria and patch are a single statement; the identity upsert is an update by forIdentity followed, when it matched nothing, by a writeOne, and the unique email and identity refuse the second of two concurrent inserts. Lazy creation and the identity webhook insert the same row under the same id and may race: the service catches ACCOUNT_ALREADY_EXISTS and reads back the account the other inserted, so account:created is emitted once.

createFakeAccountsRepositories(seed?, options?) from @workspace/training/testing returns { accounts, accountsRead } over one in-memory row set with the table's semantics, plus rows and identityOf(accountId) for assertions; a seeded account is attached to an identity equal to its id. options.auctionSystems lists the systems a patched auctionSystemId resolves against.

ImpersonationLogRepository

Append-only audit trail of admin impersonation, written through ImpersonationService only. It has no read side: nothing in the product reads the log back. createFakeImpersonationLogRepository() from @workspace/training/testing is its in-memory fake.

Prop

Type

MethodSignatureDescription
writeOne(input: PersistableImpersonationLogWrite) => Promise<void>Append a start or stop event.

Errors

ErrorCodeThrown by
ValidationErrorORGANISATION_NAME_REQUIREDupdate, completeOnboarding with an organisation DTO whose name is blank. Context { field: "organisationName" }.
NotFoundErrorACCOUNT_NOT_FOUNDupdate, completeOnboarding and updateRole when no account carries the id; AccountsReadRepository.getById.
ConflictErrorACCOUNT_SIGNUP_SOURCE_MISSINGensureAccount when the account does not exist and the identity provider holds no signup source for the user.
ValidationErrorACCOUNT_HANDLE_INVALIDsetHandle with a malformed or reserved handle. Context { field: "handle", issue: AccountHandleIssue }.
ConflictErrorACCOUNT_HANDLE_TAKENsetHandle, and any AccountsRepository write, when another account carries the handle.
ConflictErrorACCOUNT_HANDLE_LOCKEDsetHandle once the handle is set, until an admin unlocks it.

See Errors for the AppError family.