Bridge Training

Public

The deal view served to the public player and the services that read deals, shared sessions and shared collections without a ServiceContext.

The public module is the read side of the public player: a flat DealView without owner, themes or permissions, and the services that serve single deals, "next deal" picks and the deal payloads behind a shared session or collection link. It also holds the one anonymous write, the public editor's deal creation. The player app, its SSR host and the API routes under the public prefix consume it.

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

No ServiceContext

None of the methods on this page take a ServiceContext. They serve readers who have no account: the public player opened from a code, a shared session link, the anonymous editor. The credential is the code itself. The visibility gate for shared containers is applied by the owning repository's public read (getByCode for sessions, a publiclyVisible() query for collections), which finds nothing when the link is disabled or outside its window, and the deal payloads never carry userId. This is the deliberate exception to the rule described in Service Context.

Reads that need a viewer (bookmarks, private boards, votes) are on Boards, with a ctx.

Domain

DealView

DealView = EditorDealView | AppDealView, discriminated on source. The two differ only in the comment shape carried by auction and play: editor deals are anonymous and language-unknown, so their per-move comments are plain RichText; app deals come from an account with a known language, so their comments are LocalizedField<RichText>. Narrow on source for typed comments, or keep treating comment as unknown and use the isRichText and isLocalizedField guards.

Prop

Type

FieldRule
idBoard identity for app deals, null for editor deals. Optional: only the read paths that anchor telemetry carry it.
userIdOwner's account id for app deals, null for editor deals. Lets the API resolve the ads policy at the wire boundary; stripped from every public payload.
denominationAuthored trump for trump-input modes, where no auction carries it. Absent elsewhere and on legacy editor deals.
resultCarried verbatim: a numeric string, a sentinel such as MAKE_CONTRACT or PAR, or a number on legacy editor boards. Resolve at the consumer boundary.
levelDifficulty band 1 to 4, null for editor deals.
supportedLanguagesAuthor-supported languages; empty means language-agnostic.

The module's index.ts exports the three types only. The hand sorting used by the services is internal.

Application

DealService

createDealService(repository: DealReadRepository): DealService

MethodSignatureDescription
getByCode(code: string) => Promise<DealView | null>findMany(DealQuery.empty().byCode(code).first(), DealProjections.view()), from the unified deals view. Each suit of cards is re-sorted by rank; source, auction and play pass through untouched.
searchByCode(code: string, ctx: ServiceContext) => Promise<SearchByCodeResult | null>The search of a signed-in caller: the same read under DealProjections.summary(), scoped by visibleTo(ctx.userId), so it finds editor deals and the app deals the account may read. A rich result carries only the account's own userThemes and inspirations. kind: "tiny" for editor rows, kind: "rich" for app rows, with enough metadata for the import-by-code preview.
searchByCodes(codes: readonly string[]) => Promise<SearchByCodeResult[]>findMany(DealQuery.empty().byCodes(codes), DealProjections.summary()): every row the codes name, editor and app alike, in one read; an empty list reads nothing. For batch reads such as the strict-schema audit of the operations CLI.
searchPublicByCode(code: string) => Promise<SearchByCodeResult | null>The search of a caller with no account: the same read scoped by publiclyVisible(), so a private or shared app deal answers null. A rich result comes back without userThemes or inspirations, which no such caller owns, and with what is public about the deal: its system themes and the vote totals of every account.
pickNext(criteria: PickNextPublicDealCriteria) => Promise<CodedDealView | null>findMany(DealQuery.nextPick(criteria), DealProjections.view()), the first row of the ranked pool. When the pool is empty and excludeCodes was non-empty, retries once without exclusions. Null only when the unrestricted pool is empty too.
public.ts
// The player reads take no ServiceContext: a deal code is the whole credential.
export function buildDealService(repository: DealReadRepository): DealService {
  return createDealService(repository);
}

export async function openDeal(
  deals: DealService,
  code: string
): Promise<DealView | null> {
  const deal = await deals.getByCode(code);
  // deal.cards is re-sorted suit by suit, deal.source narrows the comment shape
  if (deal?.source === "app") {
    deal.auction; // Auction<LocalizedField<RichText>> | null
  }
  return deal;
}

export function findDealToImport(
  deals: DealService,
  code: string,
  ctx: ServiceContext
): Promise<SearchByCodeResult | null> {
  // only the deals ctx.userId may read, with its own user themes alone
  return deals.searchByCode(code, ctx);
}

ApiDealService is DealService without searchByCodes. The API types its public deals with it, so a request cannot reach the batch read that spans every account.

public.ts
export function anotherDealLikeThis(deals: DealService, seen: string[]) {
  // excludeCodes is dropped on a second try when the pool is empty;
  // teachingMode never is.
  return deals.pickNext({
    levels: [2],
    language: "fr",
    excludeCodes: seen,
    teachingMode: "auction",
  });
}

PublicDealsService

createPublicDealsService(deps: { dealRepo: DealReadRepository; sessionsRead: GroupSessionsReadRepository; collectionsRead: DealCollectionsReadRepository }): PublicDealsService

Backs the deal payload of a shared link. The owning container's public read is the visibility gate; the deal repository is the data source. The boundary mapper drops userId and re-sorts each suit, so consumers that do not re-sort at render time, like the PDF diagram, get canonical rank order.

PublicDealView is Omit<DealView, "userId"> & { code: string }.

MethodSignatureDescription
getForSessionCode(code: string) => Promise<PublicDealView[]>The deals of a public session's boards, in session order, through GroupSessionQuery.withCode(code).publiclyVisible() and the publicPage() projection. [] when the code is unknown, the link disabled, or now outside the window. Codes the deal read does not return are dropped silently.
getForCollectionCode(code: string) => Promise<PublicDealView[]>Mirror call on the collections read repository, through DealCollectionQuery.withCode(code).publiclyVisible() and the publicPage() projection; the sessions read repository is not consulted.
getForBoardCodes(codes: string[]) => Promise<PublicDealView[]>findMany(DealQuery.empty().byCodes(codes), DealProjections.view()), put back in the order of codes, no visibility gate. [] for an empty list without touching the repository.
public.ts
export function buildPublicDeals(
  dealRepo: DealReadRepository,
  sessionsRead: GroupSessionsReadRepository,
  collectionsRead: DealCollectionsReadRepository
): PublicDealsService {
  return createPublicDealsService({ dealRepo, sessionsRead, collectionsRead });
}

export function dealsOfSharedSession(
  publicDeals: PublicDealsService,
  sessionCode: string
): Promise<PublicDealView[]> {
  // [] when the code is unknown, the link is disabled or outside its window;
  // rows come back in session order, without userId.
  return publicDeals.getForSessionCode(sessionCode);
}

PublicCollectionsService

createPublicCollectionsService(deps: { collectionsRead: DealCollectionsReadRepository; overflow: StockOverflowResolver; publicDeals: Pick<PublicDealsService, "getForBoardCodes"> }): PublicCollectionsService

The unauthenticated read of a collection by code with the owner's stock ceiling applied. A collection above the ceiling keeps its public page during a grace period, with a notice, then closes. The closed page is an explicit unavailability, not an unknown code, so the viewer can say so. See Entitlements for the rule.

TypeShape
PublicCollectionAvailability{ status: "active" | "grace" | "degraded"; publicUntil: string | null }, publicUntil an ISO instant while in grace
PublicCollectionViewPublicSessionData & { availability: PublicCollectionAvailability }
MethodSignatureDescription
getByCode(code: string) => Promise<PublicCollectionView | null>Null when the code is unknown, the link disabled or outside its window. Once degraded, boards is [] and publicContent is null.
getDeals(code: string) => Promise<PublicDealView[]>The deals of the boards the page shows; [] once degraded or when getByCode is null.
public.ts
export function buildPublicCollections(
  collectionsRead: DealCollectionsReadRepository,
  overflow: StockOverflowResolver,
  publicDeals: PublicDealsService
): PublicCollectionsService {
  return createPublicCollectionsService({
    collectionsRead,
    overflow,
    publicDeals,
  });
}

export async function sharedCollectionPage(
  publicCollections: PublicCollectionsService,
  code: string
) {
  const view = await publicCollections.getByCode(code);
  if (!view) {
    return null; // unknown code, disabled link or outside the window
  }
  view.availability.status; // "active" | "grace" | "degraded"
  view.availability.publicUntil; // ISO instant while in grace
  view.boards; // [] once degraded, publicContent null too
  const deals = await publicCollections.getDeals(code); // [] once degraded
  return { view, deals };
}

PublicSessionsService

createPublicSessionsService(deps: { sessionsRead: GroupSessionsReadRepository; publicDeals: Pick<PublicDealsService, "getForSessionCode"> }): PublicSessionsService

The unauthenticated read of a group session by code, the session counterpart of PublicCollectionsService. Sessions have no stock ceiling, so the visibility filter is the whole gate: a GroupSessionQuery.withCode(code).publiclyVisible() read with the publicPage() projection.

MethodSignatureDescription
getByCode(code: string) => Promise<PublicSessionData | null>Null when the code is unknown, the link disabled or outside its window.
getDeals(code: string) => Promise<PublicDealView[]>PublicDealsService.getForSessionCode: the deals in session order, [] when getByCode is null.
public.ts
export async function sharedSessionPage(
  sessionsRead: GroupSessionsReadRepository,
  publicDeals: PublicDealsService,
  code: string
) {
  const publicSessions: PublicSessionsService = createPublicSessionsService({
    sessionsRead,
    publicDeals,
  });
  const session = await publicSessions.getByCode(code);
  if (!session) {
    return null; // unknown code, disabled link or outside the window
  }
  const deals = await publicSessions.getDeals(code); // session order, without userId
  return { session, deals };
}

Input shapes

PickNextPublicDealCriteria is { levels: readonly ClassifiedLevel[]; language: string; excludeCodes: readonly string[]; teachingMode: TeachingMode | null }. levels and language rank, they never filter; empty means no preference. excludeCodes and teachingMode are hard constraints, and only excludeCodes is dropped on the fallback retry: the caller asked for another deal of this kind, and a foreign mode is a wrong answer. null means no teaching-mode constraint.

Query objects

Generics are on Queries. The unified deals view is keyed by code and has no id of its own: editor rows carry none, app rows carry their board's.

DealQuery: DealQueryParams = QueryParams<DealWhere, DealSort>.

MethodEffect
DealQuery.empty()Every deal of the view, app and editor
DealQuery.nextPick(criteria)The next-deal draw: pickable(), excludingCodes, withTeachingMode, sorted by the criteria's levels and language, first()
.byCode(code) / .byCodes(codes)code equality / membership
.pickable()App deals neither trashed nor orphaned, whatever their sharing
.publiclyVisible()Editor deals and public app deals, what a caller with no account may read
.visibleTo(accountId)Editor deals and the app deals the account may read, under the board rule of BoardWhere.visibleTo; summaries keep only the account's own user themes
.excludingCodes(codes)code.notIn; an empty list is a no-op
.withTeachingMode(mode)One teaching mode; null is a no-op
.first()A one-row window
.withPagination(p) / .withSorting(s) / .withFilters(where)Pagination, DealSort, raw DealWhere merge

DealWhere: { code?: Equality<string> & { notIn? }; source?: Equality<"app" \| "editor">; deletedAt?: Nullability; orphanedAt?: Nullability; teachingMode?: Equality<TeachingMode>; publiclyVisible?: true; visibleTo?: string }.

DealSort: { rank: "nextPick"; levels; language }. Closest level first, unclassified deals last; then the requested language over language-agnostic deals over other languages; then deals with a review over bare ones; ties break at random, so a one-row window is a draw. A sorted query must be scoped by pickable(): the ranking reads the board index, which holds app deals only.

DealProjections

FactoryResult typeContents
view()CodedDealViewDealView & { code }, from the view's narrow columns only
summary()SearchByCodeResulttiny for editor rows, rich for app rows (owner, themes, votes, auction system), review normalised

Ports

DealReadRepository is ReadRepository<DealQuery>: getById(code, projection), findMany, findManyPaginated, aggregate. Public, no user context. getById takes the code and throws NotFoundError(DEAL_NOT_FOUND); aggregate supports no kind yet. There is no write side: deals are written through Boards and Editor boards.

A query sorted by a DealSort runs in two steps: the ranking reads scalar columns of the board index alone, then the chosen codes are hydrated from the deals view in rank order. Sorting the view directly would build its whole jsonb projection for every candidate before keeping one.

Testing

createFakeDealReadRepository(seed?: FakeDealRow[]) from @workspace/training/testing is an in-memory DealReadRepository. See Testing.

Errors

The public services never throw NotFoundError: an unknown or closed code is a null or an empty array, so the player can render the right page without an exception path.

Test data: makeDealView (app source) and makeEditorDealView in Factories.