Sessions
Group sessions, created or expected from a weekly recurrence, their permanent code and public link, completeness, linked collections, and the GroupSessionsService.
The sessions module models what a group does on a given day: a named session carrying an ordered list of boards. A session can be created (persisted, with a permanent public code) or expected (virtual, derived from the group's weekly recurrence and not yet persisted). The teacher app's calendar, the session detail page, the public session page served by the player and the PDF export all read from here.
Import from @workspace/training/domain/sessions and @workspace/training/application/sessions.
Domain
Session types
GroupSession = CreatedGroupSession | ExpectedGroupSession, discriminated on status. Session is an alias of CreatedGroupSession.
Prop
Type
boards is the deduped union of the boards reached through linkedCollections (in collection position order, then the collection's own order) and the ad-hoc boards. A board present in a linked collection and ad-hoc appears once, at its collection's position. Each entry is a CollectionBoardEntry from Boards: available with the full board, or an unavailable tombstone with the id only when the owner unshared it.
Prop
Type
Prop
Type
Occurrence key
OccurrenceKey is a string {groupId}:{YYYY-MM-DD} identifying one weekly slot of a group. A created session and the expected occurrence it fulfils share the same key; this is how the two lists are merged.
| Function | Signature | Description |
|---|---|---|
buildOccurrenceKey | (groupId: string, localDate: string) => OccurrenceKey | Local date, not an instant. |
Visibility and public link
Every created session has a permanent code, assigned at creation and never updated. Only sessions whose visibility is enabled, and whose availability window includes now, are served at the public URL.
Prop
Type
Prop
Type
PublicContent is an alias of RichText from @workspace/rich-text: the teacher composes the public page in one editor, with magic-variable slots resolved at render time. PublicContentPatch is PublicContent | null, the value both public-link ports write, where null clears the content.
| Export | Definition |
|---|---|
PUBLIC_CONTENT_SLOT_NAMES | { sessionTitle: "session_title", sessionDate: "session_date", publicLink: "public_link", boardsList: "boards_list", downloadSection: "download_section" } |
PublicContentSlotName | Union of the five slot values |
| Function | Signature | Description |
|---|---|---|
textToParagraphs | (text: string) => RichBlockNode[] | Blank-line separated paragraphs; single newlines become line breaks. Used for i18n template strings. |
isLegacyPublicContent | (value: unknown) => value is { intro: string; closing: string } | Detects the pre-rich-text shape. |
migrateLegacyPublicContent | (legacy) => RichText | Intro paragraphs, boards_list slot, download_section slot, closing paragraphs. |
parseStoredPublicContent | (raw: string | null | undefined, hooks?: StoredPublicContentHooks) => RichText | null | Parses the stored JSON and holds it to the same validateRichText rule as a write, so only well-formed content within the bounds is served. Legacy rows are migrated on read and onLegacyMigration fires once; anything that fails validation gives null and onInvalid receives the reason (invalid_json or the rich-text violation). |
PUBLIC_CONTENT_SLOT_NAMES.boardsList; // => "boards_list"
textToParagraphs("Hello\nthere\n\nSee you"); // => two paragraphs, a line-break inside the first
// Legacy `{ intro, closing }` rows are migrated on read; invalid content gives null with a reason.
export function readPublicContent(raw: string | null, sessionId: string) {
return parseStoredPublicContent(raw, {
onLegacyMigration: () => {
console.info("legacy public content migrated", sessionId);
},
onInvalid: (reason) => {
console.warn("invalid public content", sessionId, reason);
},
});
}Session code
| Export | Definition |
|---|---|
SESSION_CODE_LENGTH | 8 |
generateSessionCode() | A random 8-character code from the shared short-code alphabet (56 characters, no visually ambiguous ones). Uniqueness is enforced by the database. |
generateSessionCode(); // => "K7MDP2XQ" (8 characters, unambiguous alphabet)
SESSION_CODE_LENGTH; // => 8Board id helpers
| Function | Signature | Description |
|---|---|---|
getCollectionBoardIds | (session: CreatedGroupSession) => string[] | Ids reached through linked collections, deduped, in collection position order. |
getAdHocBoardIds | (session: CreatedGroupSession) => string[] | session.boards minus the collection ids, order preserved. |
export function splitBoards(session: CreatedGroupSession) {
const fromCollections = getCollectionBoardIds(session); // deduped, collection position order
const adHoc = getAdHocBoardIds(session); // session.boards minus the collection ones
return { fromCollections, adHoc };
}Recurring sessions
Pure date arithmetic on local dates (YYYY-MM-DD), no clock.
| Export | Definition |
|---|---|
RecurringGroup | Pick<Group, "id" | "name" | "color" | "startDate" | "endDate" | "isWeekly" | "isArchived"> |
Prop
Type
| Function | Signature | Description |
|---|---|---|
computeExpectedOccurrences | (params: ComputeOccurrencesParams) => ExpectedGroupSession[] | One occurrence per week where the group's day (the weekday of startDate) falls in [from, to], clamped by the group's startDate, endDate and expectedFrom. Weekly, non-archived groups only. Sorted by date. |
The merge of created and expected occurrences is internal to the domain; call GroupSessionsService.getOccurrences from application code.
export function expectedThisAutumn(groups: Group[]) {
// Only weekly, non-archived groups produce occurrences; the window is also
// clamped by each group's startDate / endDate.
return computeExpectedOccurrences({
groups,
from: "2026-09-01",
to: "2026-12-31",
}); // => ExpectedGroupSession[] sorted by date
}
buildOccurrenceKey("group-1", "2026-09-21"); // => "group-1:2026-09-21"Completeness
The calendar colours a session by how its board count compares with the previous session of the same group.
| Export | Definition |
|---|---|
SessionCompleteness | "complete" | "incomplete" | "empty" | "expected" |
| Function | Signature | Description |
|---|---|---|
buildPreviousBoardCountMap | (sessions: CreatedGroupSession[]) => Map<string, number | undefined> | Occurrence key to the board count of the previous created session in the same group, by date. First sessions map to undefined. |
getSessionCompleteness | (session: GroupSession, previousBoardCount: number | undefined) => SessionCompleteness | expected when not created, empty at zero boards, incomplete when strictly fewer boards than the previous session, complete otherwise. A group's first session is never incomplete. |
export function completenessByKey(
sessions: CreatedGroupSession[],
merged: GroupSession[]
): Map<string, string> {
const previous = buildPreviousBoardCountMap(sessions);
const result = new Map<string, string>();
for (const session of merged) {
result.set(
session.occurrenceKey,
getSessionCompleteness(session, previous.get(session.occurrenceKey))
);
}
return result; // "expected" | "empty" | "incomplete" | "complete"
}Projections
Prop
Type
Prop
Type
Prop
Type
Domain errors
DuplicateBoardInSessionError extends AppError with code DUPLICATE_BOARD_IN_SESSION, isExpected: true and a boardIds: string[] field listing the offenders. The duplicate guard that throws it is internal: it rejects a payload that repeats an id, then a payload that contains an id already on the session. The same guard protects collections.
Application
GroupSessionsService
createGroupSessionsService(deps: GroupSessionsServiceDeps): GroupSessionsService, where the deps are { sessions: GroupSessionsRepository; sessionsRead: GroupSessionsReadRepository; sessionBoards: GroupSessionBoardsRepository; sessionCollections: GroupSessionCollectionsRepository; collectionsRead: DealCollectionsReadRepository; groupsRead: GroupsReadRepository; boardsRead: BoardReadRepository; entitlements: EntitlementsGate; uow: GroupSessionsUnitOfWork }.
create and update write through uow.run: the session row and its board and collection links commit or roll back together. GroupSessionsTx is { sessions; sessionBoards; sessionCollections }, bound to the transaction; createDrizzleGroupSessionsUnitOfWork(db: OwnerConnection) implements it.
Every method composes the canonical ports: a GroupSessionQuery or a GroupSessionCriteria, a projection or an aggregation, and the two junction repositories for the links. The service hydrates the detail() projection (board ids) into CreatedGroupSession entities (board entries) with one board read per session, scoped to the boards ctx.userId may read (visibleTo): ids the caller cannot read become unavailable tombstones, inside linked collections too. The ServiceContext comes last everywhere.
Access. A session belongs to the owner of its group, and only that account reads or changes it. The service decides, on the owner's predicate (ownerId), before reading the session itself:
- a session id: the caller's own session goes through; any other id, another account's or a missing one, throws
AccessDeniedError, so the answer does not reveal whether the session exists; - a group id (
create,listByGroup,listScheduledDates): the same rule, on the caller's groups; - a collection id (
create,update,linkCollection): the same rule, on the caller's collections; - a board id (
create,addBoards, and the idsupdateandupdateBoardOrderadd): it must be a board the caller may read, elseInvalidBoardReferenceError. Boards already on the session stay, readable or not.
Each refusal comes before any write.
export function buildGroupSessions(repositories: {
sessions: GroupSessionsRepository;
sessionsRead: GroupSessionsReadRepository;
sessionBoards: GroupSessionBoardsRepository;
sessionCollections: GroupSessionCollectionsRepository;
collectionsRead: DealCollectionsReadRepository;
groupsRead: GroupsReadRepository;
boardsRead: BoardReadRepository;
uow: GroupSessionsUnitOfWork; // a session and its links commit together
}): GroupSessionsService {
return createGroupSessionsService({
...repositories,
entitlements: allowAllEntitlements,
});
}| Method | Signature | Description |
|---|---|---|
getOccurrences | (params: { from: string; to?: string; expectedFrom?: string }, ctx) => Promise<GroupSession[]> | Created sessions of the caller in the window (byOwner(ctx.userId).between(from, to), detail()) merged with the expected occurrences of the caller's groups. to defaults to from plus 365 days. Created wins over expected on the same key; same-date ties list created first. |
getById | (sessionId: string, ctx) => Promise<CreatedGroupSession> | Throws AccessDeniedError. |
list | (ctx) => Promise<GroupSessionPreview[]> | The caller's sessions by date, for quick-select menus: byOwner(ctx.userId), preview(). |
listByGroup | (groupId: string, ctx) => Promise<GroupSessionSummary[]> | By date: byGroup(groupId).byOwner(ctx.userId), summary(). Throws AccessDeniedError. |
listScheduledDates | (groupId: string, ctx) => Promise<string[]> | The distinct UTC days of the group's sessions, ascending: the calendarDays aggregation. Throws AccessDeniedError. |
create | (groupId: string, dto: CreateGroupSessionDTO, ctx) => Promise<CreatedGroupSession> | Checks the group, the boards and the collections, generates the permanent code, writes the row, then appends the boards and the collections in payload order. Throws NotFoundError(GROUP_NOT_FOUND | DEAL_COLLECTION_NOT_FOUND), AccessDeniedError, InvalidBoardReferenceError, ValidationError on a blank name. |
update | (sessionId: string, dto: UpdateGroupSessionDTO, ctx) => Promise<CreatedGroupSession> | Checks the boards it adds and the collections, patches the provided columns, then replaceAll on the board links and on the collection links when boardIds or collectionIds is given. Throws NotFoundError(SESSION_NOT_FOUND | DEAL_COLLECTION_NOT_FOUND), AccessDeniedError, InvalidBoardReferenceError, ValidationError on a blank name, past the rich-text bounds or on a window closing before it opens. |
remove | (sessionId: string, ctx) => Promise<void> | The links go with the row. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError. |
addBoards | (sessionId: string, boardIds: string[], ctx) => Promise<number> | Rejects duplicates, then checks every id resolves to a readable board, then appends them through the board links' writeMany. Returns the rows inserted. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError, DuplicateBoardInSessionError, InvalidBoardReferenceError. |
updateBoardOrder | (sessionId: string, boardIds: string[], ctx) => Promise<void> | Checks the boards not yet on the session, then replaceAll on the board links. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError, InvalidBoardReferenceError. |
removeBoard | (sessionId: string, boardId: string, ctx) => Promise<void> | Permissive: no error when the board is absent. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError. |
getPublicLink | (sessionId: string, ctx) => Promise<PublicLink | null> | Throws AccessDeniedError. |
setPublicVisibility | (sessionId: string, visibility: Partial<SessionVisibility>, ctx) => Promise<void> | Only the provided fields change; explicit null clears a window bound. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError. |
updatePublicContent | (sessionId: string, content: PublicContent | null, ctx) => Promise<void> | null clears the content. Throws ValidationError past the rich-text bounds, NotFoundError(SESSION_NOT_FOUND), AccessDeniedError. |
linkCollection | (sessionId: string, collectionId: string, ctx) => Promise<void> | Checks the session, then the collections-per-session limit, then the collection, then appends after the already linked ones. Throws LimitReachedError, NotFoundError(SESSION_NOT_FOUND | DEAL_COLLECTION_NOT_FOUND), AccessDeniedError, ConflictError(COLLECTION_ALREADY_LINKED). |
unlinkCollection | (sessionId: string, collectionId: string, ctx) => Promise<void> | Idempotent. Throws NotFoundError(SESSION_NOT_FOUND), AccessDeniedError. |
export function calendar(
service: GroupSessionsService,
ctx: ServiceContext
): Promise<GroupSession[]> {
// `to` defaults to from + 365 days; created sessions win over expected ones
// sharing the same occurrenceKey.
return service.getOccurrences(
{ from: "2026-09-01", to: "2026-12-31", expectedFrom: "2026-10-08" },
ctx
);
}export async function scheduleSession(
service: GroupSessionsService,
groupId: string,
boardIds: string[],
ctx: ServiceContext
): Promise<CreatedGroupSession> {
const session = await service.create(
groupId,
{ name: "Week 3: overcalls", date: "2026-09-21", boardIds },
ctx
);
await service.setPublicVisibility(
session.id,
{ enabled: true, availableTo: "2026-09-28T00:00:00Z" },
ctx
);
return session;
}export async function addBoards(
service: GroupSessionsService,
sessionId: string,
boardIds: string[],
ctx: ServiceContext
): Promise<number> {
try {
return await service.addBoards(sessionId, boardIds, ctx);
} catch (error) {
if (isAppError(error)) {
error.code; // "SESSION_NOT_FOUND" | "DUPLICATE_BOARD_IN_SESSION" | "INVALID_BOARD_REFERENCE"
}
throw error;
}
}export async function attachCollection(
service: GroupSessionsService,
sessionId: string,
collectionId: string,
ctx: ServiceContext
): Promise<void> {
await service.linkCollection(sessionId, collectionId, ctx); // ConflictError(COLLECTION_ALREADY_LINKED) when already linked
await service.unlinkCollection(sessionId, collectionId, ctx); // idempotent
await service.unlinkCollection(sessionId, collectionId, ctx); // resolves too
}DTOs
Prop
Type
Prop
Type
collectionIds on update has replace semantics: ids removed are unlinked, ids added are linked at their index. Omit it to leave the links untouched. The code is never updatable.
Prop
Type
Query objects
The generics are described on Queries. The session family binds them as follows.
GroupSessionQuery (read side), over QueryParams<GroupSessionWhere, GroupSessionSort>.
| Method | Effect |
|---|---|
GroupSessionQuery.empty() | Every session, whoever owns it |
.withId(id) / .withIds(ids) | id equality / membership |
.byGroup(groupId) | groupId equality |
.byOwner(ownerId) | Sessions of the groups ownerId owns |
.withCode(code) | code equality |
.between(from, to) | Sessions dated on a UTC day from from to to, both inclusive (YYYY-MM-DD) |
.publiclyVisible() | Public link enabled and now within its window |
.withPagination(p) / .withSorting(s) | Pagination and GroupSessionSort; without a sort, sessions come back by date |
.withFilters(where) | Raw GroupSessionWhere merge, escape hatch |
GroupSessionCriteria (write side and aggregates): byId, byIds, byGroup, where and .and(where). Refuses an empty where.
GroupSessionWhere: id, groupId, ownerId and code, each an Equality<string>; dateRange, a GroupSessionDateRange { from, to } of inclusive days; publiclyVisible, a boolean naming the visibility predicate. ownerId maps to the owner of the session's group, so it scopes writes too.
GroupSessionProjections
| Factory | Result type | Contents |
|---|---|---|
detail() | RawCreatedGroupSession | The session with its group, the union of its board ids and its linked collections, boards still by id |
summary() | GroupSessionSummary | The row plus its board count |
preview() | GroupSessionPreview | The group's name and colour plus the board count |
publicPage() | PublicSessionData | Author name and the codes of the non-orphaned boards of the union, in order |
Board counts and the detail() board ids are the deduped union of the linked collections' boards and the ad-hoc ones, as CreatedGroupSession.boards describes.
Prop
Type
RawCreatedGroupSession is Omit<CreatedGroupSession, "boards" | "linkedCollections"> & { boardIds: string[]; linkedCollections: RawLinkedCollection[] }.
GroupSessionAggregations: count(criteria), the number of matching sessions; calendarDays(criteria), the distinct UTC days (YYYY-MM-DD) the matching sessions fall on, ascending. GroupSessionSort: { column: "date" \| "name"; desc: boolean }, whitelisted by GROUP_SESSION_SORT_COLUMNS. GroupSessionPatch: name, date, the three visibility columns and publicContent; only the provided fields change, and an empty patch writes nothing.
export function sessionsOfMonth(
sessionsRead: GroupSessionsReadRepository,
ownerId: string
) {
// Both days inclusive; the detail keeps boards by id for the service to hydrate.
return sessionsRead.findMany(
GroupSessionQuery.empty().byOwner(ownerId).between("2026-09-01", "2026-09-30"),
GroupSessionProjections.detail()
); // => RawCreatedGroupSession[], by date
}Ports
GroupSessionsReadRepository is ReadRepository<GroupSessionQuery>: getById(id, projection), findMany(query, projection), findManyPaginated(query, projection), aggregate(aggregation), plus the public-link read getPublicLink(sessionId) => Promise<PublicLink | null>, null only when the session does not exist. getById throws NotFoundError(SESSION_NOT_FOUND) when no row matches.
GroupSessionsRepository is WriteRepository<GroupSessionCriteria, GroupSessionPatch, PersistableGroupSessionWrite> plus writeOne(input) => Promise<{ sessionId: string }> and the public-link write updatePublicContent(sessionId, 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 both link tables.
getPublicLink and updatePublicContent are the public-link pair the deal collections ports carry too, implemented once in the database package.
Prop
Type
The session links junctions
A session's ad-hoc boards and its linked collections are two ordered links, each its own repository, as Junction tables prescribes. Each link carries a 0-based position; reads come back by session, then by position. Writes lock the session row and run in one transaction inside the repository.
GroupSessionBoardsRepository
| Method | Signature | Description |
|---|---|---|
findMany | <T>(query: GroupSessionBoardsQuery, projection: Projection<T>) => Promise<T[]> | GroupSessionBoardsProjections.links() returns GroupSessionBoardLink rows { sessionId, boardId, position }. |
writeMany | (links: readonly GroupSessionBoardWrite[]) => Promise<{ linked: number }> | Appends each board after its session's last one, in input order. A board already in the session violates the unique index and nothing is written. |
delete | (criteria: GroupSessionBoardsCriteria) => Promise<{ deletedCount: number }> | Removes the links the criteria scopes. |
replaceAll | (sessionId: string, orderedBoardIds: readonly string[]) => Promise<void> | Replaces one session's whole ordered list; [] clears it. |
GroupSessionCollectionsRepository has the same four methods over GroupSessionCollectionsQuery, GroupSessionCollectionsCriteria and GroupSessionCollectionLink rows { sessionId, collectionId, position }, with replaceAll(sessionId, orderedCollectionIds). Its writeMany turns the unique index into ConflictError(COLLECTION_ALREADY_LINKED), so a link that races past the service's check still answers the typed conflict. A link that survives a replaceAll keeps its added_at.
Both queries take .bySession(id), .bySessions(ids) and the child's .byBoard(id) / .byBoards(ids) or .byCollection(id) / .byCollections(ids); both criteria have bySession, the child's byBoard or byCollection, where and .and(where). GroupSessionBoardWrite is { sessionId: string; boardId: string }, GroupSessionCollectionWrite { sessionId: string; collectionId: string }.
SessionDealsReader
Read-side port used by telemetry and usage analytics: the deals of one session, in session order, each once. Its 1-based position is the "deal N" shown next to a session-scoped metric. Bespoke by design: two parallel statements over the session, its group's owner, both junctions, the linked collections' boards and the board codes, where the canonical ports would chain four round trips.
| Method | Signature | Description |
|---|---|---|
listForOwner | (ownerUserId: string, sessionId: string) => Promise<ScopedDeal[]> | Empty when the session is unknown, belongs to someone else, or has no deal. The three cases are indistinguishable by design. |
ScopedDeal is { code: string; position: number } from @workspace/training/application/shared.
Errors
| Error | Code | Thrown by |
|---|---|---|
AccessDeniedError | ACCESS_DENIED | Every method given a session, group or collection id that is not the caller's, missing included. |
NotFoundError | SESSION_NOT_FOUND | GroupSessionsReadRepository.getById. |
DuplicateBoardInSessionError | DUPLICATE_BOARD_IN_SESSION | addBoards. |
InvalidBoardReferenceError | INVALID_BOARD_REFERENCE | addBoards, from Boards. |
ConflictError | COLLECTION_ALREADY_LINKED | linkCollection, GroupSessionCollectionsRepository.writeMany. |
LimitReachedError | LIMIT_REACHED | linkCollection, past the collections per session. |
Test data: makeCreatedGroupSession, makeRawCreatedGroupSession, makeRawLinkedCollection, makeGroupSessionSummary, makeGroupSessionPreview, makePublicSessionData, makeExpectedGroupSession, makePublicLink, makePublicContent and makeSessionVisibility in Factories. In-memory fakes in Testing: createFakeGroupSessionsRepositories, createFakeGroupSessionBoardsRepository and createFakeGroupSessionCollectionsRepository.
export function sessionFixture(faker: Faker): CreatedGroupSession {
return makeCreatedGroupSession(faker, { boardCount: 4 });
}Groups
Teaching groups with their weekly recurrence, their students and their session previews, with the GroupsService, its canonical repositories and the group students junction.
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.