Bridge Training

Themes

Board themes, system and user-defined, their label resolution, the ThemesService, and the theme query object family and ports.

A theme categorises boards. Two system kinds (system_bidding, system_play) carry translated labels and are shared by every account; two user kinds (user_theme, user_inspiration) belong to one teacher and carry a single untranslated label. The teacher app lists and edits themes, the deal filters select by theme id, the API exposes them, and the bulk-claim import of Boards creates user themes by slug.

LayerImport path
Domain@workspace/training/domain/themes
Application@workspace/training/application/themes

Domain

BoardTheme

Prop

Type

labels is a LocalizedLabels map (see Shared); usageCount is the number of boards linked to the theme.

Constants and guards

ExportDefinition
BOARD_THEME_TYPES{ SYSTEM_AUCTION: "system_bidding", SYSTEM_PLAY: "system_play", USER_THEME: "user_theme", USER_INSPIRATION: "user_inspiration" }
SYSTEM_BOARD_THEME_TYPE_VALUES["system_bidding", "system_play"]
USER_BOARD_THEME_TYPE_VALUES["user_theme", "user_inspiration"]
BOARD_THEME_TYPE_VALUESThe four values, system first
BoardThemeType, SystemBoardThemeType, UserBoardThemeTypeUnions of the tuples above
DEFAULT_LABEL_KEY"default", the key user labels are stored under
isUserThemeType(type)(type: BoardThemeType) => boolean, true for the two user kinds

Label resolution

System labels follow the app language; user labels ignore it, because user-created themes are not translated.

FunctionSignatureRule
getSystemThemeLabel(theme: BoardTheme, lang: string) => stringgetLocalizedField(theme.labels, lang, "")
getUserThemeLabel(theme: BoardTheme) => stringlabels.default, else the first label value, else ""
getBoardThemeLabel(theme: BoardTheme, lang: string) => stringDispatches on isUserThemeType(theme.type)
createUserThemeLabel(title: string) => LocalizedLabels{ default: title }
themes.ts
export function labelsOf(faker: Faker) {
  const system = makeBoardTheme(faker, {
    type: BOARD_THEME_TYPES.SYSTEM_AUCTION,
    labels: { en: "Stayman", fr: "Stayman (2♣)" },
  });
  getBoardThemeLabel(system, "fr"); // => "Stayman (2♣)"
  isUserThemeType(system.type); // => false

  const user = makeBoardTheme(faker, {
    type: BOARD_THEME_TYPES.USER_THEME,
    labels: createUserThemeLabel("My Tuesday drills"), // => { default: "My Tuesday drills" }
  });
  getUserThemeLabel(user); // => "My Tuesday drills"
  getBoardThemeLabel(user, "fr"); // => "My Tuesday drills": user labels ignore the language
  return { system, user };
}

Application

ThemesService

createThemesService(deps: { themes: ThemeRepository; themesRead: ThemeReadRepository; boardsRead: BoardReadRepository })

MethodSignatureDescription
list(types: BoardThemeType | BoardThemeType[] | undefined, ctx) => Promise<BoardTheme[]>Themes of the given types; undefined means every type. Only system themes and the themes ctx.userId owns come back, through ThemeQuery.asViewer: another account's theme never does.
create(dto: CreateThemeDTO, ctx) => Promise<BoardTheme>Writes a theme owned by ctx.userId (slug: null, kind dto.type ?? "user_theme") and re-reads it with the entity() projection.
update(themeId: string, dto: UpdateThemeDTO, ctx) => Promise<BoardTheme>Replaces the labels of a theme ctx.userId owns. Throws AccessDeniedError when it is not one of the caller's themes (a system theme, another account's or a missing one, alike so the answer does not reveal whether it exists), before anything is written; NotFoundError with code THEME_NOT_FOUND only when the theme disappears before the write.
remove(themeId: string, ctx) => Promise<number>Deletes a theme ctx.userId owns and returns boardsAffected, the number of board links removed. Same refusals as update.
listLinkedBoards(themeId: string, ctx) => Promise<LinkedBoard[]>The caller's active boards carrying a theme they own, projected to LinkedBoard. Same refusals as update.
listSystem(ctx) => Promise<SystemTheme[]>Every system theme with the slug it was seeded under, ordered by id.
listSystemPage(pagination: PaginationParams, ctx) => Promise<PaginatedItems<BoardTheme>>One page of system themes, newest first.
listUserPage(pagination: PaginationParams, ctx) => Promise<PaginatedItems<BoardThemeWithOwner>>One page of every owner's user themes and inspirations, newest first, with their owner.
checkSystemThemeIds(candidates: SystemThemeIdCandidates, ctx) => Promise<SystemThemeIdsCheck>Keeps the auction and play candidates that exist under that system kind; every other id lands in invalidIds, deduplicated and sorted.
ensureOwnerThemes(requests: ThemeEnsureRequest[], locale: string, ctx) => Promise<EnsuredThemes>Resolves each owner's slugs to theme ids and creates the missing ones with one label under locale; the first request for a slug wins. Every title to create goes through assertThemeTitle before the first write. The bulk claim reconciles its themes through the same function.

AccountThemesService is ThemesService without listSystemPage and listUserPage. The API types its themesService with it, so a request on behalf of an account cannot reach the back-office pages across every owner.

themes.ts
type ThemesDeps = {
  themes: ThemeRepository;
  themesRead: ThemeReadRepository;
  boardsRead: BoardReadRepository;
};

export async function manageThemes(deps: ThemesDeps, ctx: ServiceContext) {
  const themes = createThemesService(deps);

  const mine = await themes.list(["user_theme", "user_inspiration"], ctx); // scoped to ctx.userId
  const systemOnly = await themes.list("system_bidding", ctx); // never owner-scoped

  const created = await themes.create(
    { labels: createUserThemeLabel("Squeeze"), type: "user_theme" },
    ctx
  );
  await themes.update(created.id, { labels: { default: "Simple squeeze" } }, ctx); // NotFoundError if absent
  const linked = await themes.listLinkedBoards(created.id, ctx); // [{ boardId, boardName, contract, level, teaching }]
  const boardsAffected = await themes.remove(created.id, ctx); // junction rows removed

  return { mine, systemOnly, linked, boardsAffected };
}

DTOs and view types

Prop

Type

Prop

Type

LinkedBoard is the response shape of listLinkedBoards; level is null when the board level is UNSET.

Prop

Type

SystemThemes and UserThemes are the groupings the teacher app renders; the package only defines the shapes.

Prop

Type

Prop

Type

themes.ts
export function groupForUi(all: BoardTheme[]): {
  system: SystemThemes;
  user: UserThemes;
} {
  return {
    system: {
      auction: all.filter((t) => t.type === "system_bidding"),
      cardPlay: all.filter((t) => t.type === "system_play"),
    },
    user: {
      userDefined: all.filter((t) => t.type === "user_theme"),
      inspirations: all.filter((t) => t.type === "user_inspiration"),
    },
  };
}

Query objects

The generics (QueryObject, Criteria, Projection, Aggregation, pagination) are described on Queries. The theme family binds them as follows.

ThemeQuery (read side). ThemeQueryParams extends QueryParams<ThemeWhere, ThemeSort> with viewerUserId. When viewerUserId is set, the repository ANDs type is a system type OR ownerId = viewerUserId to every read, whatever the other filters: a viewer never reads another account's theme. Without a viewer the read is unscoped, which only the operator reads (back-office pages, PBN import) use.

MethodEffect
ThemeQuery.empty()Every theme, or every theme the viewer can see once asViewer is set
.withId(id) / .withIds(ids)id equality / membership
.withTypes(types)One type, several, or undefined and [] as no-ops
.byOwner(ownerId)ownerId equality; no-op when undefined
.bySlug(slug) / .bySlugs(slugs)Identifier slug equality / membership
.withPagination(p) / .withSorting(s)Pagination and ThemeSort
.asViewer(userId)Scopes the read to system themes and userId's own; undefined clears it
.withFilters(where)Raw ThemeWhere merge, escape hatch
.toParams()The frozen ThemeQueryParams

ThemeCriteria (write side and aggregates). Refuses an empty where like every Criteria.

Static / methodWhere produced
ThemeCriteria.byId(id) / .byIds(ids){ id: { eq } } / { id: { in } }
ThemeCriteria.bySlug(slug) / .bySlugs(slugs){ slug: { eq } } / { slug: { in } }
ThemeCriteria.byOwner(ownerId){ ownerId: { eq } }
ThemeCriteria.where(where)Any ThemeWhere
.and(where)AND an extra fragment

ThemeWhere: id, ownerId, slug and type, each an Equality<T> operator. ownerId maps to the owning account, slug to the theme identifier.

ThemeProjections

FactoryResult typeContents
entity()BoardThemeFull row plus the usageCount join
entityWithOwner()BoardThemeWithOwnerBoardTheme & { ownerId: string | null }, null for system themes
ids()ThemeIdsRow{ id, slug } for slug maps and existence checks

ThemeAggregations: count(criteria?) returning ThemeAggregation<number>. ThemeAggregation<TResult = unknown> is Aggregation<TResult> plus an optional criteria: ThemeCriteria, the type repositories switch on.

ThemeSort: { column: "label" \| "createdAt"; desc: boolean }, with THEME_SORT_COLUMNS as the whitelist. label is locale-dependent and sorted by the implementation on a label key.

ThemeUpdatePatch: { labels?: LocalizedLabels }, the only mutable column.

themes.ts
export async function themeIdsBySlug(
  themesRead: ThemeReadRepository,
  ownerId: string,
  slugs: string[]
): Promise<Map<string, string>> {
  const rows = await themesRead.findMany(
    ThemeQuery.empty().byOwner(ownerId).bySlugs(slugs),
    ThemeProjections.ids() // { id, slug }
  );
  return new Map(rows.flatMap((r) => (r.slug ? [[r.slug, r.id]] : [])));
}

export function deleteUserThemes(themes: ThemeRepository, ownerId: string) {
  return themes.delete(
    ThemeCriteria.byOwner(ownerId).and({ type: { eq: "user_theme" } })
  ); // => { deletedIds, boardsAffected }
}

Ports

ThemeReadRepository is ReadRepository<ThemeQuery>: getById(id, projection), findMany(query, projection), findManyPaginated(query, projection), aggregate(aggregation). getById throws NotFoundError when no row matches.

ThemeRepository is WriteRepository<ThemeCriteria, ThemeUpdatePatch, PersistableThemeWrite, undefined, ThemeUpdateResult, ThemeDeleteResult> plus the two inserts.

MethodSignatureDescription
writeOne(input: PersistableThemeWrite) => Promise<{ themeId: string }>Single insert. Throws on failure.
writeMany(inputs: PersistableThemeWrite[]) => Promise<ThemesWriteManyResult>Bulk insert with per-row inserts / failures keyed by slug.
update(criteria: ThemeCriteria, patch: ThemeUpdatePatch) => Promise<{ updatedIds }>Filtered update.
updateMany(rows: { criteria; patch }[]) => Promise<{ updatedIds }>One transaction, heterogeneous rows.
delete(criteria: ThemeCriteria) => Promise<ThemeDeleteResult>Returns deletedIds and boardsAffected, the number of board links the deletion removed (surfaced by the HTTP API as deletedCount).

Prop

Type

Errors

ErrorCodeThrown by
NotFoundErrorTHEME_NOT_FOUNDThemesService.update when the theme disappears between the ownership check and the write
AccessDeniedErrorACCESS_DENIEDThemesService.update, remove and listLinkedBoards on any theme but the caller's own, missing included, before anything is written or listed
NotFoundErrorNOT_FOUNDThemeReadRepository.getById

The HTTP API answers them as 404 THEME_NOT_FOUND and 403 ACCESS_DENIED. See Errors.