Bridge Training

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.

The collections module models a deal collection: a private, ordered set of boards owned by a teacher, decoupled from group sessions. A collection has the same public-link parity as a session (permanent code, availability window, rich-text public content) and can be linked to sessions or materialised into one. The teacher app and the API consume the service; the public player reads collections through the public module.

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

Domain

DealCollection

Prop

Type

visibility is a SessionVisibility and publicContent a PublicContent, both from the sessions module.

Projections

Prop

Type

DealCollectionDetail is DealCollection & { boards: CollectionBoardEntry[] }, the detail the service composes from the collection row and its board links: each link hydrated into an available entry with the full board, or an unavailable tombstone when ctx.userId may no longer read it (the board query's visibleTo), for instance once its owner unshared it.

Governed

Governed<T> = T & { availability: StockAvailability }

A collection as the service serves it: the persisted row plus its stock availability, computed against the owner's collections_per_user_max ceiling. The repository never knows the ceiling; the service adds it. StockAvailability is { status: "active" | "grace" | "degraded"; publicUntil: Date | null } from Entitlements.

The module has no constants, guards or pure functions of its own.

Application

DealCollectionsService

createDealCollectionsService(deps: DealCollectionsServiceDeps): DealCollectionsService, where the deps are { collections: DealCollectionsRepository; collectionsRead: DealCollectionsReadRepository; collectionBoards: DealCollectionBoardsRepository; boardsRead: BoardReadRepository; overflow: StockOverflowResolver; entitlements: EntitlementsGate; uow: DealCollectionsUnitOfWork }.

create, update and the creation branch of ensureForOwner write through uow.run: the collection row and its board links commit or roll back together. DealCollectionsTx is { collections; collectionBoards }, bound to the transaction; createDrizzleDealCollectionsUnitOfWork(db: OwnerConnection) implements it.

Every read carries the collection's availability. Every mutation but deletion is refused with ResourceDegradedError on a collection above the owner's ceiling, during the public grace period too: deleting is one of the two ways out of the overflow, the other being a higher plan. The rule itself is documented in Entitlements.

Every method on a collection id but listByOwner and ensureForOwner first loads the collection among the caller's own: any other id, another owner's or a missing one, gives AccessDeniedError, so the answer does not reveal whether the collection exists. Reads and mutations alike: nothing of another owner's collection is read past that check. The ServiceContext comes last.

ExportDefinition
COLLECTIONS_STOCK_LIMIT"collections_per_user_max", the stock ceiling collections are ranked against
DEAL_COLLECTION_RESOURCE"deal_collection", the resource name a degraded refusal carries
MethodSignatureDescription
list(includeArchived: boolean | undefined, ctx) => Promise<Governed<DealCollectionSummary>[]>The caller's collections with board count.
getById(collectionId: string, ctx) => Promise<Governed<DealCollection>>Governed against the owner's ceiling. Throws AccessDeniedError.
getDetail(collectionId: string, ctx) => Promise<Governed<DealCollectionDetail>>The row plus its board links, hydrated with ctx.userId as viewer. Throws AccessDeniedError.
create(dto: CreateDealCollectionDTO, ctx) => Promise<string>Owned by ctx.userId. Every board id must be one the caller may read, checked before any write. Returns the new id. Throws InvalidBoardReferenceError.
update(collectionId: string, dto: UpdateDealCollectionDTO, ctx) => Promise<void>Board ids not yet in the collection must be readable by the caller; boards already there stay, readable or not. Throws AccessDeniedError, ResourceDegradedError, InvalidBoardReferenceError.
updateBoardOrder(collectionId: string, boardIds: string[], ctx) => Promise<void>replaceAll on the board links. Same refusals and board rule as update.
toggleArchive(collectionId: string, ctx) => Promise<boolean>Returns the new archive status. Same refusals as update.
remove(collectionId: string, ctx) => Promise<void>A single delete, no cascade. Throws AccessDeniedError. Allowed on a degraded collection.
addBoards(collectionId: string, boardIds: string[], ctx) => Promise<number>Ownership checked first, then the plan's boards per collection, then degradation; then an empty payload returns 0, then duplicates and readability are validated, then the boards are appended through the links' writeMany. Throws AccessDeniedError, ResourceDegradedError, DuplicateBoardInSessionError, InvalidBoardReferenceError.
getPublicLink(collectionId: string, ctx) => Promise<PublicLink>Code, visibility and public content, read from the collection row. Throws AccessDeniedError.
setPublicVisibility(collectionId: string, visibility: Partial<SessionVisibility>, ctx) => Promise<void>Same refusals as update.
updatePublicContent(collectionId: string, content: PublicContent | null, ctx) => Promise<void>Same refusals as update.
listByOwner(ownerId: string, includeArchived: boolean | undefined, ctx) => Promise<Governed<DealCollectionSummary>[]>list for an explicit owner: the admin import reads another owner's collections.
ensureForOwner(ownerId: string, spec: EnsureDealCollectionSpec, ctx) => Promise<EnsureDealCollectionResult>Idempotent: the owner's first collection, archived included, whose trimmed name equals spec.name receives the boards it lacks; otherwise a collection is created with all of them. No entitlement check, the import writes on the owner's behalf. Returns { id, createdNow, added }. Throws ValidationError on a blank name.

AccountDealCollectionsService is DealCollectionsService without listByOwner and ensureForOwner. The API types its dealCollectionsService with it, so a request on behalf of an account cannot reach the back-office methods that act for another owner.

collections.ts
export function buildCollections(
  collections: DealCollectionsRepository,
  collectionsRead: DealCollectionsReadRepository,
  collectionBoards: DealCollectionBoardsRepository,
  boardsRead: BoardReadRepository,
  overflow: StockOverflowResolver,
  uow: DealCollectionsUnitOfWork // a collection and its board links commit together
): DealCollectionsService {
  return createDealCollectionsService({
    collections,
    collectionsRead,
    collectionBoards,
    boardsRead,
    overflow,
    entitlements: allowAllEntitlements,
    uow,
  });
}

COLLECTIONS_STOCK_LIMIT; // => "collections_per_user_max"
DEAL_COLLECTION_RESOURCE; // => "deal_collection"
collections.ts
export async function listCollections(
  service: DealCollectionsService,
  ctx: ServiceContext
): Promise<Governed<DealCollectionSummary>[]> {
  const collections = await service.list(false, ctx);
  for (const collection of collections) {
    collection.availability.status; // "active" | "grace" | "degraded"
    collection.availability.publicUntil; // Date while in grace, null otherwise
  }
  return collections;
}
collections.ts
export async function createFromBoards(
  service: DealCollectionsService,
  boardIds: string[],
  ctx: ServiceContext
): Promise<string> {
  const collectionId = await service.create(
    { name: "Overcalls", boardIds: [] },
    ctx
  );
  // Validates readability of every id and rejects duplicates.
  await service.addBoards(collectionId, boardIds, ctx);
  await service.setPublicVisibility(collectionId, { enabled: true }, ctx);
  return collectionId;
}
collections.ts
export async function rename(
  service: DealCollectionsService,
  collectionId: string,
  name: string,
  ctx: ServiceContext
): Promise<"renamed" | "degraded"> {
  try {
    await service.update(collectionId, { name }, ctx);
    return "renamed";
  } catch (error) {
    if (isAppError(error) && error.code === "RESOURCE_DEGRADED") {
      return "degraded"; // the owner deletes it, or moves to a higher plan
    }
    throw error;
  }
}
collections.ts
export async function boardsOf(
  service: DealCollectionsService,
  collectionId: string,
  ctx: ServiceContext
) {
  const detail = await service.getDetail(collectionId, ctx); // NotFoundError when unknown
  return detail.boards.map((entry) =>
    entry.status === "available" ? entry.board.data.id : `${entry.id} (unavailable)`
  );
}

DTOs

Prop

Type

Prop

Type

The code is permanent and not updatable.

Query objects

The generics are described on Queries. The collection family binds them as follows.

DealCollectionQuery (read side), over QueryParams<DealCollectionWhere, DealCollectionSort>.

MethodEffect
DealCollectionQuery.empty()Every collection the connection can see, archived included
.withId(id) / .withIds(ids)id equality / membership
.byOwner(ownerId)ownerId equality
.withCode(code)code equality
.withoutArchived()isArchived false
.publiclyVisible()Public link enabled and now within its window
.withPagination(p) / .withSorting(s)Pagination and DealCollectionSort; without a sort, collections come back by name
.withFilters(where)Raw DealCollectionWhere merge, escape hatch

DealCollectionCriteria (write side and aggregates): byId, byIds, byOwner, where and .and(where). Refuses an empty where.

DealCollectionWhere: id, ownerId and code, each an Equality<string>; isArchived, an Equality<boolean>; publiclyVisible, a boolean naming the visibility predicate. ownerId maps to the owning account.

DealCollectionProjections

FactoryResult typeContents
entity()DealCollectionThe row
summary()DealCollectionSummaryThe row plus its board count, without public content
creationOrder()OwnedResourceRefId and creation instant: the stock the ceiling ranks
publicPage()PublicCollectionRecordThe public page: author name and the codes of the non-orphaned boards, in order, plus the owner and creation instant so the public service can rank it

PublicCollectionData is an alias of PublicSessionData, so the public viewer reuses the session renderer; PublicCollectionRecord is PublicCollectionData & { ownerId: string; createdAt: Date }.

DealCollectionAggregations: count(criteria?). DealCollectionSort: { column: "name" \| "createdAt"; desc: boolean }, whitelisted by DEAL_COLLECTION_SORT_COLUMNS. DealCollectionPatch: name, isArchived, the three visibility columns and publicContent; every update bumps updatedAt.

Ports

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

DealCollectionsRepository is WriteRepository<DealCollectionCriteria, DealCollectionPatch, PersistableDealCollectionWrite> plus writeOne(input) => Promise<{ collectionId: string }> and the public-link write updatePublicContent(collectionId, content: PublicContentPatch) => Promise<void>, which replaces the public content alone and bumps updatedAt. update, updateMany and delete report the affected ids; a delete cascades to the board links.

updatePublicContent is the public-link write the group sessions ports carry too, implemented once in the database package; the collection's public link is read from its entity.

Prop

Type

The collection boards junction

The ordered link between a collection and its boards is its own repository, as Junction tables prescribes. Each link carries a 0-based position; reads come back by collection, then by position.

DealCollectionBoardsRepository

MethodSignatureDescription
findMany<T>(query: DealCollectionBoardsQuery, projection: Projection<T>) => Promise<T[]>DealCollectionBoardsProjections.links() returns DealCollectionBoardLink rows { collectionId, boardId, position }.
writeMany(links: readonly DealCollectionBoardWrite[]) => Promise<{ linked: number }>Appends each board after its collection's last one, in input order. A board already in the collection violates the unique index and nothing is written.
delete(criteria: DealCollectionBoardsCriteria) => Promise<{ deletedCount: number }>Removes the links the criteria scopes.
replaceAll(collectionId: string, orderedBoardIds: readonly string[]) => Promise<void>Replaces one collection's whole ordered list; [] clears it. The collection row lock and the transaction stay inside the repository.

DealCollectionBoardsQuery.empty() takes .byCollection(id), .byCollections(ids), .byBoard(id) and .byBoards(ids). DealCollectionBoardsCriteria has byCollection, byBoard, where and .and(where). DealCollectionBoardsWhere is { collectionId?: Equality<string>; boardId?: Equality<string> }. DealCollectionBoardWrite is { collectionId: string; boardId: string }.

CollectionDealsReader

The collection counterpart of the sessions module's SessionDealsReader: the deals of one collection in collection order, with 1-based position. Bespoke by design: one statement joins the links, the collection's owner and the board codes, where the canonical ports would take three round trips (the collection, its links, its boards) and the publicPage() projection drops the orphaned boards a usage read still counts.

MethodSignatureDescription
listForOwner(ownerUserId: string, collectionId: string) => Promise<ScopedDeal[]>Empty when the collection is unknown, belongs to someone else, or holds no deal; the caller cannot and must not tell the cases apart.

Errors

ErrorCodeThrown by
NotFoundErrorDEAL_COLLECTION_NOT_FOUNDDealCollectionsReadRepository.getById on an unknown id.
AccessDeniedErrorACCESS_DENIEDEvery method on a collection id that is not the caller's, missing included.
ResourceDegradedErrorRESOURCE_DEGRADEDEvery mutation but remove on a collection in grace or degraded. Carries resource: "deal_collection" and resourceId.
DuplicateBoardInSessionErrorDUPLICATE_BOARD_IN_SESSIONaddBoards.
InvalidBoardReferenceErrorINVALID_BOARD_REFERENCEcreate, update, updateBoardOrder, addBoards.

Test data: makeDealCollectionSummary in Factories. In-memory fakes in Testing: createFakeDealCollectionsRepositories and createFakeDealCollectionBoardsRepository.