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.
| Layer | Import 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
| Export | Definition |
|---|---|
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_VALUES | The four values, system first |
BoardThemeType, SystemBoardThemeType, UserBoardThemeType | Unions 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.
| Function | Signature | Rule |
|---|---|---|
getSystemThemeLabel | (theme: BoardTheme, lang: string) => string | getLocalizedField(theme.labels, lang, "") |
getUserThemeLabel | (theme: BoardTheme) => string | labels.default, else the first label value, else "" |
getBoardThemeLabel | (theme: BoardTheme, lang: string) => string | Dispatches on isUserThemeType(theme.type) |
createUserThemeLabel | (title: string) => LocalizedLabels | { default: title } |
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 })
| Method | Signature | Description |
|---|---|---|
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.
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
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.
| Method | Effect |
|---|---|
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 / method | Where 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
| Factory | Result type | Contents |
|---|---|---|
entity() | BoardTheme | Full row plus the usageCount join |
entityWithOwner() | BoardThemeWithOwner | BoardTheme & { 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.
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.
| Method | Signature | Description |
|---|---|---|
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
| Error | Code | Thrown by |
|---|---|---|
NotFoundError | THEME_NOT_FOUND | ThemesService.update when the theme disappears between the ownership check and the write |
AccessDeniedError | ACCESS_DENIED | ThemesService.update, remove and listLinkedBoards on any theme but the caller's own, missing included, before anything is written or listed |
NotFoundError | NOT_FOUND | ThemeReadRepository.getById |
The HTTP API answers them as 404 THEME_NOT_FOUND and 403 ACCESS_DENIED. See Errors.
Editor boards
The anonymous public-editor store of deals, its read and write services, the minimal write pipeline, the rotation projection, the query object family and the ports.
Collections
Deal collections, a teacher's private ordered sets of boards with a public link, served as Governed reads and refused for mutation once degraded.