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
| Field | Rule |
|---|---|
id | Board identity for app deals, null for editor deals. Optional: only the read paths that anchor telemetry carry it. |
userId | Owner'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. |
denomination | Authored trump for trump-input modes, where no auction carries it. Absent elsewhere and on legacy editor deals. |
result | Carried verbatim: a numeric string, a sentinel such as MAKE_CONTRACT or PAR, or a number on legacy editor boards. Resolve at the consumer boundary. |
level | Difficulty band 1 to 4, null for editor deals. |
supportedLanguages | Author-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
| Method | Signature | Description |
|---|---|---|
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. |
// 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.
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 }.
| Method | Signature | Description |
|---|---|---|
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. |
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.
| Type | Shape |
|---|---|
PublicCollectionAvailability | { status: "active" | "grace" | "degraded"; publicUntil: string | null }, publicUntil an ISO instant while in grace |
PublicCollectionView | PublicSessionData & { availability: PublicCollectionAvailability } |
| Method | Signature | Description |
|---|---|---|
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. |
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.
| Method | Signature | Description |
|---|---|---|
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. |
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>.
| Method | Effect |
|---|---|
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
| Factory | Result type | Contents |
|---|---|---|
view() | CodedDealView | DealView & { code }, from the view's narrow columns only |
summary() | SearchByCodeResult | tiny 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.
Sharing
The sharing status of a board and the sharing groups through which a teacher shares boards with chosen peers, with the SharingGroupsService.
Entitlements
What a subject may do and how much, computed from defaults and dated grants; flow quotas counted per grant, the diffusion debit, and the degraded mode of resources above a stock ceiling.