Boards
Boards, a deal with teaching metadata: the entity family, codes and enums, the write gate, claiming editor boards, the query object family and filters, comments, analytics, bookmarks and votes, the ports and the errors.
A board is a bridge deal owned by a teacher, with teaching metadata: a short code, a name, a level, an auction system, themes, a sharing mode, a localized review and per-move comments. The game field is a Deal from @workspace/bridge whose comments are LocalizedField<RichText>. The teacher app creates, edits, lists and filters boards; the admin audits and bulk-edits them; the API serves them; the public player reads them through Public; import jobs claim anonymous drafts from Editor boards.
| Layer | Import path |
|---|---|
| Domain | @workspace/training/domain/boards |
| Application | @workspace/training/application/boards |
Every write of deal content goes through one gate (BoardsWriteService), which converges comment shapes, rotates the deal so the declarer sits South, validates it and computes the derived fields stored for filtering. BoardService.create and update enforce two more invariants before calling it: translations must be complete once a deal carries two languages, and the declarer proven by the auction must be South.
Deals and boards
Board
Prop
Type
BoardDeal is Deal<LocalizedField<RichText>, LocalizedField<RichText>, LocalizedField<RichText>>: auction comments, play comments and review are all localized rich text. See Types for the deal shape.
BoardResult is { data: Board; diagnostics: BoardDiagnostic[] }: every read returns the entity with the diagnostics the repository raised while hydrating it (field: "auction" | "play" | "cards" | "analysis", message, path).
BoardMetadata
Prop
Type
auctionThemes and playThemes are system themes, userThemes and userInspirations user themes (see Themes). sharing comes from Sharing.
Permissions, votes, ownership
Prop
Type
Permissions are computed server-side for the viewer passed as viewerUserId, so the front end never reasons about identity. They are null in public contexts.
Prop
Type
Ownership is a discriminated union: { kind: "user"; userId } or { kind: "orphaned"; orphanedAt; reason: OrphanReason }. A board becomes orphaned when its owner hard-deletes it: the row moves under the system account but keeps its id so sessions still resolve it. OrphanReason is "deal_deleted" | (string & {}), open for future triggers. owner is null for orphaned boards: branch on ownership.kind.
| Function | Signature | Rule |
|---|---|---|
makeOwnership | ({ userId, orphanedAt, orphanReason }) => Ownership | orphanedAt !== null means orphaned, reason defaults to "deal_deleted". The single entry point repositories use. |
makeOwnership({ userId: "u1", orphanedAt: null, orphanReason: null });
// => { kind: "user", userId: "u1" }
makeOwnership({ userId: "u1", orphanedAt: "2026-01-01T00:00:00Z", orphanReason: null });
// => { kind: "orphaned", orphanedAt: "2026-01-01T00:00:00Z", reason: "deal_deleted" }BoardSummary family
BoardSummary is the minimal representation from the unified deals view, enough to preview and play a deal. TinyBoardSummary is an alias: the base shape, the only thing editor-source rows can offer. RichBoardSummary is the app-only superset with metadata, themes, owner and votes, enough to render a rich preview without a second read. SearchByCodeResult discriminates them: { kind: "tiny"; board: TinyBoardSummary } or { kind: "rich"; board: RichBoardSummary }.
Prop
Type
An editor-source review with no recorded language is keyed under the BCP-47 sentinel "und"; consumers must ask for a real language before persisting it.
Prop
Type
Collection entries
CollectionBoardEntry replaces parallel boardIds and unavailableBoardIds arrays in collections and sessions: { status: "available"; board: BoardResult } or { status: "unavailable"; id } when the deal was unshared or made private by its owner.
| Function | Signature | Rule |
|---|---|---|
getBoardEntryId | (entry: CollectionBoardEntry) => string | The board id whatever the status |
getEntryBoardIds | (entries: CollectionBoardEntry[]) => string[] | Ordered id list |
projectBoardEntries (application) | (orderedBoardIds: readonly string[], readable: readonly BoardResult[]) => CollectionBoardEntry[] | Keeps the junction order; an id absent from readable becomes an unavailable tombstone |
export function entriesInOrder(
orderedIds: string[],
readable: BoardResult[]
): string[] {
const entries = projectBoardEntries(orderedIds, readable);
// entries[i] => { status: "available", board } or { status: "unavailable", id }
return getEntryBoardIds(entries); // same order as orderedIds
}Languages
A board's languages are the union of locale keys holding visible content across the review, every auction comment and every play comment. An empty result means the deal is language-agnostic; the repository treats it as matching every language filter. DealLanguageInput is the minimal structural shape (review?, auction?, play?), satisfied by any BoardDeal or partial snapshot.
| Function | Signature | Rule |
|---|---|---|
filledLocales | (field: LocalizedField<RichText> | undefined) => string[] | Locales whose rich text is non-empty after trimming |
getDealContentLanguages | (deal: DealLanguageInput) => string[] | Sorted, deduplicated union |
getDealLanguageGaps | (deal: DealLanguageInput) => DealLanguageGapsReport | { languages, gaps }; a slot only produces a gap when it has at least one filled locale and misses one of the board languages. DealLanguageGap is { category: "review" | "auction" | "play"; index?; missing } |
export function languagesOf(game: BoardDeal) {
getDealContentLanguages(game); // => ["en", "fr"] when review or comments hold both
const report = getDealLanguageGaps(game);
report.languages; // => ["en", "fr"]
report.gaps; // => [{ category: "auction", index: 2, missing: ["fr"] }]
// a slot without any filled locale is never a gap; [] languages = language-agnostic
return report;
}Sequence adapters
Converters between the flat sequence stored in the database (Sequence<TComment> = { value: string; comment?: TComment }[]) and the bridge Auction / Play. Generic over the comment type, no casts needed.
| Function | Signature |
|---|---|
sequenceToDealAuction | <T>(sequence: Sequence<T>, dealer: Player) => Auction<T> |
sequenceToDealPlay | <T>(sequence: Sequence<T>, leader?: Player) => Play<T>, leader defaults to "N" |
dealAuctionToSequence | <T>(auction: Auction<T>) => Sequence<T> |
dealPlayToSequence | <T>(play: Play<T>) => Sequence<T> |
export function roundTrip(game: BoardDeal) {
const flat = dealAuctionToSequence(game.auction); // [{ value: "1S", comment: undefined }, …]
const back = sequenceToDealAuction(flat, game.auction.first);
return back; // { first: "S", moves: [{ bid: "1S" }, …] }
}PBN export
boardsToPBN(opts: BoardsToPbnOptions): string serialises boards into one multi-deal PBN file. Boards are numbered from startBoardNumber and repeated seriesCount times; each deal is rotated so its dealer matches the standard dealer of its box number, using the injected rotater (apps hand in their localized one from @workspace/bridge/rotation). Comments are flattened to plain text in language.
Prop
Type
export function exportCollection(
boards: Board[],
rotater: Rotater, // the app's localized rotater
language: string
): string {
return boardsToPBN({
boards,
startBoardNumber: 1,
seriesCount: 2, // boards repeated twice, board numbers continue
language,
rotater,
event: "Tuesday class",
site: "https://example.test",
now: new Date(),
});
}AuctionSystem and Bookmark
Prop
Type
Prop
Type
Codes and enums
Deal codes
A deal code is the 8-character public identity of a board, used in player URLs and sharing. Generation delegates to the short-code utility of @workspace/shared (56-character alphabet without ambiguous glyphs). Uniqueness is enforced by the repository.
| Export | Definition |
|---|---|
DEAL_CODE_LENGTH | 8 |
DEAL_CODE_SEARCH_MIN_CHARS | 8: an exact search only fires once the full code is typed |
generateDealCode() | Random code from the alphabet; two identical deals get different codes |
isValidDealCode(code) | Strict check for new codes: exact length, alphabet only |
isLegacyCompatibleDealCode(code) | Lenient check for boundaries that reference existing codes: ^[A-Za-z0-9]{8}$, because legacy codes predate the alphabet |
const code = generateDealCode(); // 8 chars from the short-code alphabet
isValidDealCode(code); // => true
isValidDealCode("abcdefgh"); // => false when a char is outside the alphabet
isLegacyCompatibleDealCode("abcdefgh"); // => true: length and alphanumeric shape only
isLegacyCompatibleDealCode("abc"); // => falseLevels, scoring, vulnerability, contract level, counter status
| Export | Definition |
|---|---|
LEVEL | { BEGINNER: 1, INTERMEDIATE: 2, ADVANCED: 3, EXPERT: 4, UNSET: "UNSET" } |
LEVEL_VALUES | [1, 2, 3, 4] |
ClassifiedLevel | 1 | 2 | 3 | 4, a level explicitly set |
Level | ClassifiedLevel | "UNSET", the read-side type |
parseLevel(value) | Anything outside 1 to 4 becomes "UNSET" |
isUnsetLevel(level) | Type guard for "UNSET" |
SCORING_MODE / SCORING_MODE_VALUES | { TPP: "TPP", IMP: "IMP" }, filter-safe (no UNSET) |
parseScoring(raw: unknown) | ScoringMode, "UNSET" for anything invalid |
VULNERABILITY / VULNERABILITY_VALUES | { NONE, NS, EW, ALL }, filter-safe |
parseVulnerability(raw: unknown) | Vulnerability, "UNSET" for anything invalid |
CONTRACT_LEVEL / CONTRACT_LEVEL_VALUES | { PARTIAL: "partial", GAME: "game", SMALL_SLAM: "small_slam", GRAND_SLAM: "grand_slam" } |
parseCounterStatus(raw: unknown) | CounterStatus, "none" for anything invalid |
ContractLevel and CounterStatus are re-exported from @workspace/bridge/types.
parseLevel(3); // => 3
parseLevel(7); // => "UNSET"
isUnsetLevel(parseLevel(null)); // => true
parseScoring("IMP"); // => "IMP"
parseScoring("bogus"); // => "UNSET"
parseVulnerability("NS"); // => "NS"
parseVulnerability(undefined); // => "UNSET"
parseCounterStatus("XX"); // => "XX"
parseCounterStatus(42); // => "none"Votes
| Export | Definition |
|---|---|
VOTE_TYPES | { UP: "up", DOWN: "down" }, VoteType is the union |
VoteDeltas | { score, upvotes, downvotes } |
computeVoteDeltas(currentVote, newVoteType) | Optimistic deltas: toggle off when the vote repeats, new vote when none, switch when it flips (score moves by 2) |
invertVoteDeltas(deltas) | Negates every field, for rollback |
computeVoteDeltas(null, "up"); // => { score: 1, upvotes: 1, downvotes: 0 }
computeVoteDeltas("up", "up"); // => { score: -1, upvotes: -1, downvotes: 0 } (toggle off)
computeVoteDeltas("down", "up"); // => { score: 2, upvotes: 1, downvotes: -1 } (switch)Writing boards
BoardService
createBoardService(deps: BoardServiceDeps): BoardService, where the deps are { boards: BoardRepository; boardsRead: BoardReadRepository; themesRead: ThemeReadRepository; boardsWrite: BoardsWriteService; boardThemes: BoardThemesRepository; uow: BoardWriteUnitOfWork }.
create and update write through uow.run: the new themes and inspirations, the board and its theme links commit or roll back together. BoardWriteTx is { boards; boardsWrite; themes; boardThemes }, bound to the transaction. Everything the write needs is read before the transaction opens, so a request never holds two connections. The database package's createDrizzleBoardWriteUnitOfWork(db: OwnerConnection) implements it.
Every method takes ctx: ServiceContext last (see Service Context). The service holds the access rules, whatever connection the repositories run on:
- Reads are scoped with
visibleTo(ctx.userId): the caller's own boards, public ones, shared ones whose owner has the caller in a sharing group, and orphans still linked from one of the caller's sessions or collections. A board carries system themes and the caller's own themes, never another account's. Its score counts every account's vote. There is no admin view: an administrator reads like any other account. - Mutations (
update,softDelete,restore,hardDelete,linkThemes) first look the boards up among the caller's own: any id not found there, missing or another account's, throwsAccessDeniedErrorbefore any write, so the answer does not reveal whether the board exists. - Theme ids given to
create,updateorlinkThemesmust be system themes or the caller's own; any other id, a missing one included, throwsAccessDeniedError.
| Method | Signature | Description |
|---|---|---|
create | (input: CreateBoardInput, ctx) => Promise<BoardResult> | Generates a code, asserts translation completeness and declarer South, runs the gate as a dry run first so no theme is created when it rejects, creates newThemes and newInspirations, writes through boardsWrite.writeOne, links themeIds plus the new ones, re-reads the entity. Throws ValidationError with BOARD_TRANSLATION_INCOMPLETE, BOARD_DECLARER_NOT_SOUTH or BOARD_GATE_REJECTED; a code conflict or a persistence failure throws a plain Error. |
update | (input: UpdateBoardInput, ctx) => Promise<BoardResult> | Creates new themes first. With game, asserts the same invariants and overwrites in place through boardsWrite.overwriteMany (the stored code is kept). Without game, patches the index columns from metadata (name, boardNumber, sharing, level, auctionSystemId). themeIds are replaced only when themeIds, newThemes or newInspirations were supplied. |
softDelete | (boardId, ctx) => Promise<void> | Ownership check, then sets deletedAt under BoardCriteria.byId(id).notInTrash(); an already trashed row is an idempotent no-op. |
restore | (boardId, ctx) => Promise<void> | Ownership check, then clears deletedAt. |
hardDelete | (boardId, ctx) => Promise<void> | Ownership check, then requires the board to be in the trash, else throws BoardNotInTrashError. Then deletes with { mode: "orphan" }: the board moves to the orphan account, keeping its id, and leaves its former owner's collections and sessions; other owners' sessions and collections still play it. |
get | (boardId, ctx) => Promise<BoardResult> | getById with the entity() projection, the caller as viewer and visibleTo. Throws NotFoundError for a missing board and for one the caller may not read. |
getByIds | (boardIds: string[], ctx) => Promise<BoardResult[]> | Lifecycle "all"; result follows the input order and skips unknown or invisible ids. |
getFilteredPaginated | (params: BoardListInput, ctx) => Promise<FilteredBoardsWithPagination> | The deals page: translates the wire filters into a BoardQuery, scoped to the visible boards. |
searchByCode | (params: { code }, ctx) => Promise<BoardResult[]> | Exact code among the visible boards, lifecycle includingOrphans so old session links resolve. |
getDistinctLanguages | (ctx) => Promise<string[]> | BoardAggregations.distinctLanguages() over the visible boards. |
linkThemes | (links: BoardThemeLink[], ctx) => Promise<{ linked: number }> | Links the caller's boards to system themes or to its own themes through BoardThemesRepository.writeMany. |
export function createBoard(
deps: BoardServiceDeps,
input: CreateBoardInput,
ctx: ServiceContext
): Promise<BoardResult> {
const boards = createBoardService(deps);
// Throws ValidationError(BOARD_TRANSLATION_INCOMPLETE | BOARD_DECLARER_NOT_SOUTH | BOARD_GATE_REJECTED)
return boards.create(input, ctx);
}
export function newBoardInput(game: BoardDeal): CreateBoardInput {
return {
game, // declarer South, translations complete
metadata: {
name: "Stayman after 1NT",
auctionSystem: "sef",
sharing: "private",
level: LEVEL.INTERMEDIATE,
},
newThemes: [{ title: "Stayman" }], // created before the board, then linked
};
}export async function trashThenPurge(
deps: BoardServiceDeps,
boardId: string,
ctx: ServiceContext
): Promise<void> {
const boards = createBoardService(deps);
await boards.softDelete(boardId, ctx); // idempotent; AccessDeniedError unless ctx.userId owns it, missing included
await boards.restore(boardId, ctx); // back in the explorer
await boards.softDelete(boardId, ctx);
await boards.hardDelete(boardId, ctx); // BoardNotInTrashError without the softDelete above
}DTOs
CreateBoardInput is Omit<CreateBoardDTO, "code" | ComputedFields>: the caller never supplies the code nor the derived fields (partnership, contractLevel, counterStatus, opener, flattenedAuction, cardCounts, supportedLanguages). UpdateBoardInput is Omit<UpdateBoardDTO, ComputedFields>.
Prop
Type
Prop
Type
Prop
Type
NewThemeDTO is { title: string }: a user theme or inspiration created inline, stored under the "default" label key.
BoardsWriteService
The write gate. createBoardsWriteService(deps: { boards: BoardRepository; boardsRead: BoardReadRepository; clock?: () => Date }). Every method takes opts: WriteOpts = { commentLocale: string | null }: the BCP-47 locale that plain-string and non-localized RichText comments are wrapped at. With null, any such comment rejects the write.
The gate accepts a RawBoardData (an EditorBoardData whose move comments may be string, RichText or LocalizedField<RichText>) and, in order:
- Rejects a code that is not a valid deal code, unless it equals the
grandfatheredCodebyte for byte (invalid_code). Overwrites pass the stored code as grandfathered, so a legacy code stays writable as long as it is kept unchanged; a changed code iscode_mismatch. - Canonicalises the deal shape for the teaching mode's auction input, resolves the declarer (a complete auction overrides the declared one and
report.declarerCorrectedrecords it), and derives the rotation count to South. - Rejects when unlocalized comments are present and
commentLocaleisnull(comments_present_without_locale). - Converts comments: strings are rotated (player names) and parsed from PBN text,
RichTextis wrapped at the locale, localized fields pass through.report.commentsConvertedcounts them. - Rotates the deal structurally so the declarer sits South. When no final comment was supplied, the rotated
reviewbecomes the final comment at the locale. - Validates:
missing_declarer,missing_player,cards_incomplete(malformed_handsorinvalid_total_<n>: 52 cards, or an equal multiple of 4 per seat),auction_first_invalid, andplay_order_invalidfrom the engine's chronological trick replay (card_not_owned,revoke,invalid_play). No repair. - Computes
ValidatedDerivedFields(denomination,contractLevel,opener,partnership,counterStatus,flattenedAuction,cardCounts,supportedLanguages) and stamps the persistable write withgatedAt(fromclock) andgatedVersion.
| Method | Signature | Description |
|---|---|---|
writeOne | (input: BoardsWriteInput, opts) => Promise<BoardsWriteOutcome> | Gate, then boards.writeOne. A repository throw becomes persistence_failed with the message. |
writeMany | (inputs: BoardsWriteInput[], opts) => Promise<BoardsWriteOutcome[]> | One outcome per input in order; only gated inputs reach boards.writeMany; board ids and failures are mapped back by code. |
overwriteMany | (items: { boardId; input }[], opts) => Promise<BoardsWriteOutcome[]> | Loads the stored codes, rejects a changed code with code_mismatch, gates with the stored code grandfathered, then boards.overwriteMany. persisted outcomes carry the existing boardId. |
checkMany | (inputs, opts) => GateCheckOutcome[] | Synchronous gate only, no write: { code, ok: true } or { code, ok: false, rejections }. Used by dry runs. |
gateToPersistable | (input, opts) => GatePersistOutcome | Gate only, returning { ok: true, persistable } or { ok: false, rejections }. |
diffMany | (items: { boardId; input }[], opts) => Promise<BoardDiffOutcome[]> | Gates and compares against the stored Board in domain form: identical, differs or gate_rejected. The comparator is conservative: any uncertainty resolves to differs. |
BoardsWriteOutcome.status | Fields |
|---|---|
persisted | boardId, report: BoardsWriteReport |
gate_rejected | rejections: GateRejection[] |
already_exists | none |
persistence_failed | detail? |
Prop
Type
Prop
Type
GateRejection is { reason: GateRejectionReason; detail?: string } with GateRejectionReason one of missing_declarer, missing_player, cards_incomplete, play_order_invalid, comments_present_without_locale, auction_first_invalid, invalid_code, code_mismatch. RawBoardData, RawAuctionMove, RawPlayMove and ValidatedDerivedFields are exported as types; the gate function itself is internal to the package.
export async function importOne(
boards: BoardRepository,
boardsRead: BoardReadRepository,
input: BoardsWriteInput // raw deal + overrides, code generated by the caller
) {
const writer = createBoardsWriteService({ boards, boardsRead });
const check = writer.checkMany([input], { commentLocale: "fr" });
if (!check[0]?.ok) {
return check[0]?.rejections; // [{ reason: "cards_incomplete", detail: "invalid_total_48" }]
}
const outcome = await writer.writeOne(input, { commentLocale: "fr" });
switch (outcome.status) {
case "persisted":
return outcome.report; // { rotated, rotationCount, commentsConverted, declarerCorrected? }
case "gate_rejected":
return outcome.rejections;
case "already_exists":
case "persistence_failed":
return outcome.status;
}
}Derived fields
computeClaimDerivedFields(source: ClaimDerivableSource): ClaimDerivedFields computes cardCounts, partnership, opener, contractLevel, counterStatus and flattenedAuction from cards, contract, player and auction. It throws ValidationError with VALIDATION_ERROR when player is missing. The gate computes its own derived fields; this helper serves callers that need them without a write.
Claiming
Claiming turns an anonymous editor board into an owned board. The editor record's data is the gate's raw input, the editor code is grandfathered, and commentLocale gives the editor's plain comments their language.
BoardClaimService
createBoardClaimService(deps: BoardClaimServiceDeps) with { editorBoardsRead, boardsRead, boardsWrite }.
| Method | Signature | Description |
|---|---|---|
plan | (input: ClaimInput) => Promise<ClaimPlan> | Read-only. editor_missing, already_exists (with boardId) or will_create, the last two carrying the editor source and the overrides. Never writes. |
apply | (input: ClaimInput) => Promise<ClaimResult> | not_found, already_exists or created with the new boardId. A gate rejection or a persistence failure throws a plain Error naming the code and the reasons. |
parseOverrides | (raw: string) => ClaimOverrides | Parses a JSON object, keeps string values under name, boardNumber, scoring, sharing, complement, drops anything else. Throws on invalid JSON or a non-object. Also exported as a standalone function. |
Prop
Type
Prop
Type
export async function claimEditorBoard(
deps: BoardClaimServiceDeps,
code: string,
userId: string
) {
const claim = createBoardClaimService(deps);
const plan = await claim.plan({ code, userId });
if (plan.status === "already_exists") {
return plan.boardId;
}
const result = await claim.apply({
code,
userId,
overrides: claim.parseOverrides('{"name":"Lesson 3","sharing":"shared"}'),
commentLocale: "fr", // locale the editor's plain comments are wrapped at
});
return result.status === "created" ? result.boardId : null;
}BoardBulkClaimService
createBoardBulkClaimService(deps: BoardBulkClaimServiceDeps). It takes editorBoardsRead, boardsRead and boardsWrite like the claim, boardThemes (writeMany, for the theme links), accounts (AccountsService.resolveIdsByEmails, to resolve owner and share emails), themes and themesRead (user themes by slug), sharingGroups (the SharingGroupsService, for ensureWithMembers) and getThemeLocale(). Two callers: the admin xlsx and csv claim, and pnpm cli --actor <accountId> import-xlsx-metadata, whose dry run is plan and whose --apply is apply, both under the operator's context.
| Method | Signature | Description |
|---|---|---|
plan | (rows: BulkClaimRow[], opts: { commentLocale? }, ctx: ServiceContext) => Promise<BulkClaimPlan> | Resolves every row to a BulkClaimRowStatus, runs the gate on writable rows (will_insert, will_replace) so the dry run mirrors the write, and diffs already-claimed rows: one whose content would change is promoted to will_update, an identical one stays skip_already_claimed. |
apply | (plan: BulkClaimPlan, opts: BulkClaimApplyOptions, ctx: ServiceContext) => Promise<BulkClaimStats> | Re-resolves from current database state (the plan is a hint), creates one "Imported sharing" group per owner with the resolved share members, creates missing user themes by slug (locale from getThemeLocale), writes inserts in batches of batchSize (default 100) through boardsWrite.writeMany, links themes, overwrites will_replace rows through overwriteMany, and reports every gate rejection and persistence failure in the stats. onProgress(processed) fires after each batch. |
BulkClaimRowStatus | Meaning |
|---|---|
will_insert | New claim |
will_update | Already claimed, content differs (plan only; apply does not overwrite it unless the row sets replace) |
will_replace | Already claimed and the row opted into replace |
skip_editor_missing | No editor board for the code |
skip_owner_missing | ownerEmail unknown |
skip_duplicate | Code repeated in the input |
skip_already_claimed | Already claimed and identical |
skip_gate_rejected | Content rejected by the gate, gateRejections set |
Prop
Type
Prop
Type
Row helpers are exported for the import tooling.
| Export | Definition |
|---|---|
IMPORTED_SHARING_GROUP_NAME | "Imported sharing" |
difficultyToLevel(value) | easy 1, medium 2, hard 3, very hard 4, case-insensitive; null otherwise |
slugifyThemeLabel(label) | Lower-case, diacritics stripped, non-alphanumerics collapsed to - |
buildBulkClaimWriteInput(row, ownerId, source) | The BoardsWriteInput of a row: sharing from row.sharing, else "shared" when shareEmails is non-empty, else "private"; code grandfathered |
export async function bulkClaim(
deps: BoardBulkClaimServiceDeps,
rows: BulkClaimRow[],
ctx: ServiceContext // the acting admin
) {
const bulk = createBoardBulkClaimService(deps);
const plan = await bulk.plan(rows, { commentLocale: "fr" }, ctx);
plan.rows.map((r) => r.status); // "will_insert" | "will_update" | "skip_already_claimed" | …
plan.stats.gateRejections; // [{ code, reasons }]
const stats = await bulk.apply(
plan,
{
commentLocale: "fr",
batchSize: 100,
onProgress: (n) => console.log(`${n} rows processed`),
},
ctx
);
return stats.inserted;
}PbnImportService
createPbnImportService(deps: PbnImportServiceDeps) turns spreadsheet rows and already-parsed PBN files into owned boards. It composes services only: accounts (resolveIdsByEmails), themes (checkSystemThemeIds, ensureOwnerThemes), sharingGroups (ensureWithMembers), dealCollections (listByOwner, ensureForOwner) and boardsWrite (writeMany), plus the boardThemes junction (writeMany): the importer links boards it writes for many owners to their own themes, which the caller-scoped BoardService.linkThemes would refuse. PBN parsing and the deal-to-source mapping stay with the caller: each PbnImportFile carries a parsed first deal, an empty file or a parse error.
| Method | Signature | Behaviour |
|---|---|---|
plan | (input: PbnImportInput, ctx) => Promise<PbnImportPlan> | Dry run, never writes. Matches each row to its owner and its file (refs compared case-insensitively, .pbn implied), gives every creatable row a fresh deal code and its BoardsWriteInput, counts the statuses, lists unreferenced files and unknown owner emails, reports invalid system-theme ids and the theme or inspiration labels too long for a theme title, and plans one collection per owner and name (create or add_to_existing). |
apply | (input: PbnImportInput, ctx) => Promise<PbnImportReport> | Resolves the rows afresh, then throws before any write: InvalidThemeTitlesError if a theme or inspiration label is too long for a theme title, UnknownSystemThemesError if a system-theme id is unknown or of the wrong kind. Otherwise writes, in order: the owners' user themes by slug, one "Imported sharing" group per owner with the resolved share members, the boards, the theme links (user and system), and the collections, which reuse an existing one of the same name and only add missing boards. |
PbnImportRowStatus | Meaning |
|---|---|
will_create | Owner and file resolved, first deal parsed |
skip_duplicate_pbn_ref | An earlier row already uses this file |
skip_owner_missing | The owner email names no account |
skip_pbn_missing | No uploaded file matches the ref |
skip_pbn_parse_error | The file did not parse; parseError carries the message |
skip_no_deals | The file parsed but holds no deal |
Prop
Type
Prop
Type
normalizePbnRef(ref) is the file key both sides use: trimmed, lower-cased, .pbn appended when missing.
export async function importPbn(
deps: PbnImportServiceDeps,
rows: PbnImportRow[],
files: PbnImportFile[],
ctx: ServiceContext
) {
const importer = createPbnImportService(deps);
const plan = await importer.plan({ rows, files, language: "fr" }, ctx);
plan.stats.willCreate;
plan.collections; // [{ name, ownerEmail, status: "create" | "add_to_existing", … }]
if (plan.invalidThemeTitles.length > 0) {
return null; // apply would throw InvalidThemeTitlesError
}
if (plan.invalidSystemThemeIds.length > 0) {
return null; // apply would throw UnknownSystemThemesError
}
const report = await importer.apply({ rows, files, language: "fr" }, ctx);
return report.inserted;
}Queries and filters
The generics (QueryObject, Criteria, Projection, Aggregation, ReadRepository, WriteRepository, pagination) are described on Queries. This section lists the board bindings.
BoardQuery
Immutable query object for reads. BoardQueryParams extends QueryParams<BoardWhere, BoardSort> with two decorations: viewerUserId (permissions and avatars, never a filter) and lifecycleMode. Visibility is a filter of its own, where.visibleTo.
LifecycleMode | Rows |
|---|---|
active (default) | Not trashed and not orphaned: UI lists |
includingOrphans | Not trashed; orphans stay reachable so historical session links resolve |
onlyTrashed | Trash view |
all | No constraint: id-batch lookups (sessions, collections, claim), admin |
| Method | Where produced |
|---|---|
BoardQuery.empty() | Every board the viewer can see |
.ownedBy(userId) / .notOwnedBy(userId) | userId eq / ne |
.visibleTo(accountId) | visibleTo: the boards the account may read, and only its own user themes on them |
.bookmarkedBy(userId) | Join on bookmarks |
.withSharing(sharing) | sharing eq |
.exposedInCatalog() | public and the active lifecycle: the deals the author catalog exposes |
.withId(id) / .withIds(ids) | id eq / in |
.withCode(code) / .withCodes(codes) | code eq / in |
.byAuthors(authorIds) | authorIds in |
.withLevels(levels) | level in |
.withLanguages(languages) | supportedLanguages.overlapsOrEmpty: language-agnostic boards match |
.withScoring(modes) / .withVulnerabilities(values) / .withPlayers(players) / .withContractLevels(levels) / .withCounters(statuses) / .withPedagogy(modes) | Membership on the matching column |
.withCompetitive(values) | isCompetitive eq; both values or none is a no-op |
.withSides(sides) | partnership.inFromSides, mapped by the repository |
.withTrumpSuit({ suits?, includeNT?, includeTrump? }) | denomination.matchTrump |
.withThemeGroups(groups) | themeIds.existsAnyOf: one EXISTS per group, OR inside a group, AND across groups |
.withBiddingSequence(sequence) | biddingSequence.matches prefix match |
.withCommentLocales(locales) / .withCommentMatchesAny(patterns) | comment.hasAnyLocale / comment.matchesAny; empty lists are no-ops; patterns must be escaped by the caller |
.withLifecycle(mode) / .onlyTrashed() / .includingOrphans() | Lifecycle decoration |
.withPagination(p) / .withSorting(s) / .asViewer(userId) | Read concerns |
.withFilters(where) | Raw BoardWhere merge |
.overrideWith(other) | Merge another query, second wins on conflicts |
.toParams() | The frozen BoardQueryParams |
export function recentSharedBoards(
boardsRead: BoardReadRepository,
viewerUserId: string
): Promise<BoardResult[]> {
const query = BoardQuery.empty()
.notOwnedBy(viewerUserId)
.withSharing("public")
.withLevels([1, 2])
.withLanguages(["fr"]) // language-agnostic boards match too
.withTrumpSuit({ suits: ["H", "S"], includeNT: false })
.withSorting({ column: "createdAt", desc: true })
.withPagination({ page: 1, pageSize: 20 })
.asViewer(viewerUserId); // permissions and avatars, never a filter
return boardsRead.findMany(query, BoardProjections.entity());
}
export async function codesOf(
boardsRead: BoardReadRepository,
ids: string[]
): Promise<string[]> {
const refs = await boardsRead.findMany(
BoardQuery.empty().withIds(ids).withLifecycle("all"),
BoardProjections.ids()
);
return refs.map((r) => r.code);
}BoardWhere
The leaf filter shape returned by toParams().where and consumed by the repository. Fields are AND-combined; names mirror domain concepts, not columns. mergeWhere(a, b) shallow-merges two fragments.
Prop
Type
BoardCriteria
Filter-only query object for update, delete and scoped aggregations. Inherits the empty-where refusal from Criteria.
| Static / method | Where produced |
|---|---|
BoardCriteria.byId(id) / .byIds(ids) | id eq / in |
BoardCriteria.byCode(code) / .byCodes(codes) | code eq / in |
BoardCriteria.where(where) | Any BoardWhere |
.and(where) | AND an extra fragment |
.notInTrash() / .inTrash() | deletedAt is null / not null |
BoardUpdatePatch
The payload of BoardRepository.update. index patches the index columns, payload the JSONB payload columns, themeIds replaces the theme junction ([] clears it, undefined leaves it), recomputeAuctionSearch asks the repository to recompute the auction search tokens from { auction, dealer }.
Prop
Type
Prop
Type
export async function renameBoards(
boards: BoardRepository,
ids: string[],
name: string
): Promise<number> {
const { updatedIds } = await boards.update(
BoardCriteria.byIds(ids).notInTrash(),
{ index: { name } }
);
return updatedIds.length;
}
export function clearThemes(boards: BoardRepository, boardId: string) {
return boards.update(BoardCriteria.byId(boardId), { themeIds: [] });
}BoardSort
{ column: BoardSortColumn; desc: boolean } with BOARD_SORT_COLUMNS = ["level", "name", "boardNumber", "createdAt", "updatedAt", "deletedAt", "publishedAt"] as the trust boundary for sort input. publishedAt is the first time the board became public (boards_index.published_at, written by the repository on every path that sets sharing, never cleared); its nulls sort last in both directions. votes is absent on purpose: vote counts live in another table.
BoardProjections
| Factory | Result type | Contents |
|---|---|---|
entity() | BoardResult | Full hydration: index, payload, themes, owner, votes, diagnostics |
ids() | BoardIdsRow | { id, code } for existence checks and references |
finalComment() | BoardFinalCommentRow | { boardId, comment } for the admin comment search |
BoardAggregations
Each factory returns an Aggregation<T>; criteria scopes the rows, default scope excludes trashed and orphan rows.
| Factory | Result type |
|---|---|
distinctLanguages(criteria?) | string[] |
count(criteria?) | number |
groupBy(field: EnumAuditField, criteria?) | GroupCount[], field one of scoring, sharing, dealer, declarer, vuln, level, contractStrain |
topValues(field: FreeFormAuditField, limit, criteria?) | TopValuesResult = { items, totalDistinct, otherCount }, field boardNumber or name |
dateHistogram(field: DateAuditField, "month", criteria?) | DateHistogramBucket[] = { period, count }[], field createdAt or updatedAt |
themeUsage(limit) / sharingGroupUsage(limit) | UsageResult = { items, totalDistinct } |
commentsStats(criteria?) | CommentsStats |
Prop
Type
Legacy filters
BoardFilters and BoardFiltersQuery are the filter value objects of the advanced search: a query is a list of groups combined with OR, each group AND-combining its fields. BiddingSequenceFilter is Bid[][], one array of alternatives per position. BoardListFilters / BoardListInput are the wire shape of the deals page, translated into a BoardQuery by the service. BoardSearchByCodeInput is { code }; FilteredBoardsWithPagination is the list response.
Prop
Type
Prop
Type
Limits protect query plans and keep the UI readable. BOARD_FILTERS_LIMITS aggregates them and BoardFiltersLimits is its type.
| Constant | Value | Meaning |
|---|---|---|
MAX_FILTER_GROUPS | 3 | OR groups per query |
MAX_PAGE_SIZE | 100 | Results per request |
MAX_OFFSET | 1000 | Deepest offset served |
MAX_SYSTEM_THEMES | 10 | System themes per group |
MAX_USER_CUSTOM_THEMES | 5 | User themes per group |
MAX_SEQUENCES_PER_GROUP | 20 | Bidding sequences per group after padding |
MAX_SEQUENCE_LENGTH | 12 | Bids per sequence including padding |
MAX_FILTER_SEQUENCE_BIDS | 8 | Bids a user may enter |
MAX_PASS_PADDING | 3 | Leading Pass variants |
| Function | Signature | Rule |
|---|---|---|
cleanFiltersQuery | (query: BoardFiltersQuery) => BoardFiltersQuery | Keeps the first MAX_FILTER_GROUPS groups, cleans each (drops empty arrays and unknown values, truncates to the limits, keeps at most 8 criteria per group), removes empty groups, expands bidding sequences into their padded variants |
generatePaddedSequences | (sequence: BiddingSequenceFilter) => BiddingSequenceFilter[] | A sequence starting with Pass is returned alone; otherwise variants with 0 to 3 leading Pass bids, within MAX_SEQUENCE_LENGTH |
isEmptyQuery | (query: BoardFiltersQuery) => boolean | No group or only empty groups |
const query: BoardFiltersQuery = [
{ levels: [1, 2], biddingSequence: [["1NT"], ["Pass"], ["2C"]] },
{}, // empty group, dropped
];
cleanFiltersQuery(query);
// => 4 groups: the sequence is expanded with 0 to 3 leading Pass bids
generatePaddedSequences([["Pass"], ["1S"]]); // => [[["Pass"], ["1S"]]]: already starts with PassassertBoardsAccessible
assertBoardsAccessible(boardReadRepository: BoardReadRepository | undefined, boardIds: string[], accountId: string): Promise<void> checks that every id resolves to a board the account may read (visibleTo(accountId), lifecycle all, ids() projection) before a session or collection mutation and throws InvalidBoardReferenceError with the missing ids. It is skipped when no repository is injected or the list is empty.
Comments
BoardsCommentsService
Admin bulk surface for the final per-locale board comment. createBoardsCommentsService(deps: { boards: BoardRepository; boardsRead: BoardReadRepository }).
| Method | Signature | Description |
|---|---|---|
listByLocaleAndPattern | (opts: ListByLocaleAndPatternOptions) => Promise<BoardFinalCommentRow[]> | { boardId, comment } rows under an optional locales and matchesAny prefilter, optional ownerId, limit default 5000, createdAt ascending, lifecycle includingOrphans. CommentsRewriteService runs the per-locale rich-text matching afterwards. |
updateComments | (rows: { boardId; comment: LocalizedField<RichText> }[]) => Promise<{ updated: number }> | One updateMany call, one transaction; rows whose id matches nothing contribute no count. Empty input returns { updated: 0 } without a call. |
Comment rewrite rules
domain/boards holds the pure rules of the bulk search and replace over that comment. A CommentRule is { find, locale, action, replace? }:
findis the literal*(every comment), a regex when it holds a regex metacharacter (isRegexFind), else a literal matched anywhere in the plain text.localeis*(every locale of the comment) or a supported locale code.actionis one ofCOMMENT_RULE_ACTION_VALUES:searchonly reports the match,replacesubstitutesreplacefor every occurrence,delete_blockremoves every occurrence,appendaddsreplace(markdown, with\nand\tescapes) as new blocks,delete_commentdrops the locale.
Rules run in order on each text leaf of the matched locale. A locale is will_update when its plain text changes or it is dropped, no_change when the rewrite keeps the text, matched for search-only rules; a row takes the strongest status of its locales. A literal rule narrows the repository scan with an escaped ilike pattern and its locale; any regex, wildcard find or wildcard locale lifts that part of the prefilter.
CommentsRewriteService
The plan/apply use case over BoardsCommentsService. createCommentsRewriteService(deps: { boardsComments: BoardsCommentsService; accounts: Pick<AccountsService, "resolveIdsByEmails"> }).
| Method | Signature | Description |
|---|---|---|
plan | (input: CommentsRewriteInput, ctx) => Promise<CommentsRewritePlan> | Validates and compiles the rules, resolves the optional owner email (trimmed, lower-cased), scans at most COMMENT_REWRITE_SCAN_LIMIT rows and returns the matched rows, the rewritten comments (pending) and the stats; truncated once the scan hits the limit. An unknown owner returns ownerResolved: false and empty stats without scanning. |
apply | (plan: CommentsRewritePlan, ctx) => Promise<CommentsRewriteReport> | Writes plan.pending through updateComments in one batch and returns the stats with updated. |
export async function replaceInComments(
boards: BoardRepository,
boardsRead: BoardReadRepository,
accounts: AccountsService,
ctx: ServiceContext
) {
const rewrite = createCommentsRewriteService({
boardsComments: createBoardsCommentsService({ boards, boardsRead }),
accounts,
});
const plan = await rewrite.plan(
{
ownerEmail: "teacher@example.com",
rules: [
{ find: "Stayman", locale: "fr", action: "replace", replace: "2♣" },
],
},
ctx
);
// plan.rows => [{ boardId, rowStatus, locales }], plan.stats => { scanned, matched, willUpdate, … }
return rewrite.apply(plan, ctx); // => { ...stats, updated }
}Analytics
BoardsAnalyticsService
The admin audit surface, composed over BoardReadRepository.aggregate and BoardAggregations. createBoardsAnalyticsService(deps: { boardsRead: BoardReadRepository }).
| Method | Signature |
|---|---|
groupByEnum | (field: EnumAuditField) => Promise<GroupCount[]> |
topValues | (field: FreeFormAuditField, limit = 20) => Promise<TopValuesResult> |
dateHistogram | (field: DateAuditField, bucket: "month") => Promise<DateHistogramBucket[]> |
commentsStats | () => Promise<CommentsStats> |
themeUsage | (limit = 20) => Promise<UsageResult> |
sharingGroupUsage | (limit = 20) => Promise<UsageResult> |
export async function auditSnapshot(boardsRead: BoardReadRepository) {
const analytics = createBoardsAnalyticsService({ boardsRead });
const byLevel = await analytics.groupByEnum("level"); // [{ value, count }]
const names = await analytics.topValues("name", 10); // { items, totalDistinct, otherCount }
const months = await analytics.dateHistogram("createdAt", "month");
const comments = await analytics.commentsStats();
return { byLevel, names, months, comments };
}Bookmarks and votes
BookmarksService and VotingService
createBookmarksService(deps: { bookmarks: BookmarksRepository; boardsRead: BoardReadRepository }) and createVotingService(deps: { votes: VotingRepository; boardsRead: BoardReadRepository }). Both take a ServiceContext last on every method and scope the repository call to ctx.userId. Adding a bookmark or setting a vote first checks the board is one the caller may read (BoardQuery.visibleTo), and refuses a missing or another account's private board with the same AccessDeniedError; removing one's own row needs no check.
| Service | Method | Signature | Description |
|---|---|---|---|
BookmarksService | list | (ctx) => Promise<BookmarkIds> | The caller's bookmarked board IDs. |
add / remove | (boardId: string, ctx) => Promise<void> | add throws AccessDeniedError on a board the caller may not read; remove ignores a missing bookmark. | |
exists | (boardId: string, ctx) => Promise<boolean> | ||
toggle | (boardId: string, ctx) => Promise<boolean> | Removes the bookmark when it exists, adds it otherwise under the same check as add. Returns true when added. | |
VotingService | list | (ctx) => Promise<UserVote[]> | The caller's votes. |
setVote | (boardId: string, voteType: VoteType, ctx) => Promise<void> | Replaces any previous vote; throws AccessDeniedError on a board the caller may not read. | |
removeVote | (boardId: string, ctx) => Promise<void> | Ignores a missing vote. |
export function toggleBookmark(
bookmarks: BookmarksRepository,
boardsRead: BoardReadRepository,
boardId: string,
ctx: ServiceContext
): Promise<boolean> {
return createBookmarksService({ bookmarks, boardsRead }).toggle(boardId, ctx); // true when added
}SetVoteDTO = { boardId; voteType } is the vote input, and computeVoteDeltas drives the optimistic update on the client. The in-memory fakes createFakeBookmarksRepository() and createFakeVotingRepository() from @workspace/training/testing back the services' tests with the tables' semantics: one bookmark and one vote per (user, board).
Auction systems
AuctionSystemsService
createAuctionSystemsService(deps: { auctionSystems: AuctionSystemsRepository }): AuctionSystemsService. The catalogue is global reference data, read the same by every caller, so no method takes a ServiceContext. The API exposes it as auctionSystemsService in the authenticated context.
| Method | Signature | Description |
|---|---|---|
list | () => Promise<AuctionSystem[]> | Every auction system, ordered by sortOrder. |
Ports
BoardRepository
Command side. WriteRepository<BoardCriteria, BoardUpdatePatch, PersistableBoardWrite, BoardDeleteOptions, BoardUpdateResult, BoardDeleteResult> plus the inserts. Theme links live on BoardThemesRepository.
| Method | Signature | Description |
|---|---|---|
writeOne | (input: PersistableBoardWrite) => Promise<{ boardId }> | Single insert. Throws on failure. |
writeMany | (inputs: PersistableBoardWrite[]) => Promise<BoardsWriteManyResult> | Bulk insert; inserts: { code, boardId }[], failures: { code, reason }[]. |
overwriteMany | (rows: { boardId; input: PersistableBoardWrite }[]) => Promise<BoardsWriteManyResult> | In-place overwrite keeping ids; inserts lists the overwritten pairs. |
update | (criteria: BoardCriteria, patch: BoardUpdatePatch) => Promise<{ updatedIds }> | Filtered update. |
updateMany | (rows: { criteria; patch }[]) => Promise<{ updatedIds }> | One transaction. |
delete | (criteria: BoardCriteria, opts: { mode: "orphan" }) => Promise<{ deletedIds }> | Orphan transfer in one transaction: the boards move to the orphan account, out of the trash, and their former owners' collection and session links are dropped. "orphan" is the only mode. |
Prop
Type
BoardData is the owned-deal payload shape: an EditorBoardData whose auction and play comments are LocalizedField<RichText>. SupportedLanguage and BoardResult are re-exported from this port for convenience.
BoardThemesRepository
The board_themes junction, named after both sides. Reads take a BoardThemesQuery (empty().byBoard(id), .byBoards(ids), .byTheme(id), .byThemes(ids)) and the BoardThemesProjections.links() projection, which returns BoardThemeLink = { boardId; themeId } rows.
| Method | Signature | Description |
|---|---|---|
findMany | (query: BoardThemesQuery, projection: Projection<T>) => Promise<T[]> | Filtered read of the link set. |
writeMany | (links: BoardThemeLink[]) => Promise<{ linked: number }> | Bulk insert. Throws on failure. |
delete | (criteria: BoardThemesCriteria) => Promise<{ deletedCount: number }> | Filtered delete; BoardThemesCriteria.byBoard(s) / .byTheme(s). |
replaceAll | (boardId: string, themeIds: readonly string[]) => Promise<void> | Replaces one board's whole theme set; [] clears it. Lock and transaction inside the implementation. |
BoardReadRepository
ReadRepository<BoardQuery, BoardGetByIdOptions>: getById(id, projection, opts?), findMany(query, projection), findManyPaginated(query, projection), aggregate(aggregation). getById throws NotFoundError when no row matches. BoardGetByIdOptions is { viewerUserId?; visibleTo? }: the viewer drives permissions and avatars, visibleTo applies the rule of BoardWhere.visibleTo, so an invisible board is not found.
BookmarksRepository and VotingRepository
The board_bookmarks and board_votes user ↔ board links. Reads take a BookmarkQuery or a VoteQuery (empty().byUser(id), .byBoard(id)) and the BookmarkProjections.links() or VoteProjections.votes() projection, which return BookmarkLink = { userId; boardId } and BoardVote = { userId; boardId; voteType } rows. Deletes take BookmarkCriteria or VoteCriteria (byUserAndBoard(userId, boardId), where(...)), which refuse an empty where.
| Port | Method | Signature | Description |
|---|---|---|---|
BookmarksRepository | findMany | (query: BookmarkQuery, projection: Projection<T>) => Promise<T[]> | Filtered read of the link set. |
writeOne | (link: BookmarkLink) => Promise<void> | Insert; an existing bookmark is kept, on the (user_id, board_id) unique constraint. | |
delete | (criteria: BookmarkCriteria) => Promise<{ deletedCount: number }> | Filtered delete. | |
VotingRepository | findMany | (query: VoteQuery, projection: Projection<T>) => Promise<T[]> | Filtered read of the votes. |
writeOne | (vote: BoardVote) => Promise<void> | Upsert on the (board_id, user_id) pair, replacing the vote type. | |
delete | (criteria: VoteCriteria) => Promise<{ deletedCount: number }> | Filtered delete. |
AuctionSystemsRepository
Pick<ReadRepository<AuctionSystemQuery>, "findMany">: the catalogue has no write path in the product. AuctionSystemQuery.empty() reads the whole catalogue, withFilters narrows it by id or identifier; AuctionSystemProjections.entity() returns AuctionSystem rows ordered by sortOrder.
createFakeAuctionSystemsRepository(seed?) from @workspace/training/testing is its in-memory fake.
Testing
makeBoard(faker, options), makeBoardDeal(faker, overrides), makeBoardSummary, makeBookmark, makeAuctionSystem, makeDealCode and makePersistableBoardWrite come from @workspace/training/factories. makeBoard takes compact hands, auction and dealer strings plus partial metadata and board overrides.
export function fixture(faker: Faker): Board {
return makeBoard(faker, {
hands: "AKQ2.KQ3.A54.K32 / JT98.JT9.KQJ.JT9",
auction: "1NT pass 3NT AP",
metadata: { sharing: "public", level: 2 },
});
}Errors
| Error | Code | Thrown by |
|---|---|---|
ValidationError | BOARD_TRANSLATION_INCOMPLETE | BoardService.create / update when a deal with two or more languages has a slot missing a translation; context: { languages, gaps } |
ValidationError | BOARD_DECLARER_NOT_SOUTH | BoardService.create / update when the auction (or the declared field when the auction cannot prove it) gives a non-South declarer |
ValidationError | BOARD_GATE_REJECTED | BoardService.create / update when the gate rejects; context: { rejections } |
ValidationError | VALIDATION_ERROR | computeClaimDerivedFields without player |
BoardNotInTrashError | BOARD_NOT_IN_TRASH | BoardService.hardDelete on a board that was not soft-deleted first; carries boardId |
InvalidBoardReferenceError | INVALID_BOARD_REFERENCE | assertBoardsAccessible when ids are missing or not visible to the account; carries boardIds |
AccessDeniedError | ACCESS_DENIED | BoardService mutations on a board the caller does not own, missing included, and theme ids that are neither system themes nor the caller's own |
InvalidThemeTitlesError | INVALID_THEME_TITLES | PbnImportService.apply when a theme or inspiration label is longer than THEME_TITLE_MAX_LENGTH; carries titles |
UnknownSystemThemesError | UNKNOWN_SYSTEM_THEMES | PbnImportService.apply when a row names an unknown or wrong-kind system theme; carries ids |
NotFoundError | NOT_FOUND | Read repositories' getById, hence get |
InvalidCommentRuleError | none, a DisplayableError | CommentsRewriteService.plan on an empty find, an unknown locale, a replace or append without a value, a bad regex, or a wildcard-locale rule with a regex or wildcard find and no owner email |
CommentRewriteOwnerNotFoundError | none, a DisplayableError | CommentsRewriteService.apply when the plan's owner email names no account |
Claim and write services return rejections as data (BoardsWriteOutcome, ClaimPlan, BulkClaimStats) rather than throwing; BoardClaimService.apply and BoardService.create escalate a gate rejection to a plain Error or a ValidationError respectively. See Errors for the base classes.
Sessions
Group sessions, created or expected from a weekly recurrence, their permanent code and public link, completeness, linked collections, and the GroupSessionsService.
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.