Bridge Training

Groups

Teaching groups with their weekly recurrence, their students and their session previews, with the GroupsService, its canonical repositories and the group students junction.

The groups module models a class: a named set of students that meets on a schedule and receives sessions. The weekly flag and the start and end dates drive the expected occurrences on the calendar. The teacher app and the API consume it; the sessions module reads groups through the same read port to compute occurrences.

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

Domain

Group

Prop

Type

defaultSessionTime is a wall-clock time of day (HH:mm or HH:mm:ss) used as the default for new sessions, null when the group has no recurring time. owner is the Owner projection of the teacher account. studentCount, sessionCount, nextSession and nextExpectedDate are computed on read: the withDetails projection fills the two counts, the entity projection leaves them at zero.

SessionPreview

The lightweight session shown on a group card.

Prop

Type

The module has no constants, guards or pure functions. The recurrence arithmetic lives in sessions and takes a RecurringGroup projection of Group.

Application

GroupsService

createGroupsService(deps: GroupsServiceDeps): GroupsService, where GroupsServiceDeps is { groups: GroupsRepository; groupsRead: GroupsReadRepository; groupStudents: GroupStudentsRepository; studentsRead: StudentsReadRepository; entitlements: EntitlementsGate }.

The ServiceContext comes last on every method. The two list methods and create use ctx.userId. The id-scoped methods check ownership first: they count the caller's group (and, for addStudent, the caller's student), throw AccessDeniedError when the caller has no such row, whether it belongs to another account or does not exist, so the answer does not reveal which, and only then read or write it. Each method composes a query object, a criteria or a projection and calls one primitive of the ports below.

MethodSignatureDescription
list(ctx) => Promise<Group[]>The caller's groups by name: findMany(GroupQuery.empty().byOwner(ctx.userId), GroupProjections.entity()).
listWithDetails(ctx) => Promise<GroupWithDetails[]>The same groups through GroupProjections.withDetails(), archived included. Filter client-side.
getById(groupId: string, ctx) => Promise<Group>The caller's group.
create(dto: CreateGroupDTO, ctx) => Promise<Group>Owned by ctx.userId. Applies the defaults (color blue, not weekly, no end date), writes one row and re-reads it. Throws LimitReachedError past the plan's group ceiling.
update(groupId: string, dto: UpdateGroupDTO, ctx) => Promise<Group>Updates through GroupCriteria.byId, scoped to the owner. Throws NotFoundError(GROUP_NOT_FOUND) only when the group disappears between the check and the write.
remove(groupId: string, ctx) => Promise<void>Deletes through GroupCriteria.byId, scoped to the owner; the student links go with the group. Throws NotFoundError(GROUP_NOT_FOUND) only when the group disappears between the check and the write.
addStudent(groupId: string, studentId: number, ctx) => Promise<void>Links one of the caller's students to one of the caller's groups; adding an existing link is a no-op. Throws AccessDeniedError when either is not the caller's and LimitReachedError past the plan's students per group.
removeStudent(groupId: string, studentId: number, ctx) => Promise<void>Deletes the link on the junction of one of the caller's groups.
groups.ts
export function listGroupsForCards(
  deps: GroupsServiceDeps,
  ctx: ServiceContext
): Promise<GroupWithDetails[]> {
  const groups = createGroupsService(deps);
  return groups.listWithDetails(ctx); // archived included; filter client-side
}
groups.ts
export function createWeeklyGroup(
  deps: GroupsServiceDeps,
  ctx: ServiceContext
): Promise<Group> {
  const groups = createGroupsService(deps);
  return groups.create(
    {
      name: "Monday beginners",
      startDate: "2026-09-07",
      isWeekly: true,
      defaultSessionTime: "18:30",
      color: "#1d4ed8",
    },
    ctx
  );
}
groups.ts
export async function archiveGroup(
  deps: GroupsServiceDeps,
  groupId: string,
  ctx: ServiceContext
): Promise<Group | null> {
  const groups = createGroupsService(deps);
  try {
    return await groups.update(groupId, { isArchived: true }, ctx);
  } catch (error) {
    if (isAppError(error) && error.code === "GROUP_NOT_FOUND") {
      return null;
    }
    throw error;
  }
}
groups.ts
export async function updateRoster(
  deps: GroupsServiceDeps,
  groupId: string,
  ctx: ServiceContext
): Promise<void> {
  const groups = createGroupsService(deps);
  await groups.addStudent(groupId, 42, ctx); // a link on the junction
  await groups.removeStudent(groupId, 42, ctx);
}

DTOs

Prop

Type

When id is provided the server uses it as the primary key (client-generated UUID); otherwise the database generates one.

Prop

Type

Prop

Type

GroupStudentsDTO is the wire shape for the roster endpoints; the service takes the studentIds array directly.

Query objects

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

GroupQuery (read side), over QueryParams<GroupWhere, GroupSort>.

MethodEffect
GroupQuery.empty()Every group the connection can see
.withId(id) / .withIds(ids)id equality / membership
.byOwner(ownerId)ownerId equality
.withPagination(p) / .withSorting(s)Pagination and GroupSort; without a sort, groups come back by name
.withFilters(where)Raw GroupWhere merge, escape hatch

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

GroupWhere: id and ownerId, each an Equality<string>. ownerId maps to the owning account.

GroupProjections

FactoryResult typeContents
entity()GroupThe row; studentCount and sessionCount are zero
withDetails()GroupWithDetailsThe row plus its student and session counts

GroupWithDetails is Group & { studentCount: number; sessionCount: number }. Both counts already exist on Group, so the type adds nothing structurally; it names the projection that fills them.

GroupAggregations: count(criteria?). GroupSort: { column: "name" \| "createdAt"; desc: boolean }, whitelisted by GROUP_SORT_COLUMNS. GroupPatch: the mutable columns of UpdateGroupDTO.

groups.ts
// Inside a service: the projection drives the row type, no annotation needed.
export function ownedGroupsWithCounts(
  groupsRead: GroupsReadRepository,
  ownerId: string
): Promise<GroupWithDetails[]> {
  return groupsRead.findMany(
    GroupQuery.empty().byOwner(ownerId),
    GroupProjections.withDetails()
  );
}

Ports

GroupsReadRepository is ReadRepository<GroupQuery>: getById(id, projection), findMany(query, projection), findManyPaginated(query, projection), aggregate(aggregation). getById throws NotFoundError(GROUP_NOT_FOUND) when no row matches. The sessions service depends on this port to compute occurrences.

GroupsRepository is WriteRepository<GroupCriteria, GroupPatch, PersistableGroupWrite> plus writeOne(input: PersistableGroupWrite) => Promise<{ groupId: string }>. update, updateMany and delete report the affected ids.

Prop

Type

The group students junction

The link between a group and its students is its own repository, as Junction tables prescribes. The students module reads a group's roster through it.

GroupStudentsRepository

MethodSignatureDescription
findMany<T>(query: GroupStudentsQuery, projection: Projection<T>) => Promise<T[]>GroupStudentsProjections.links() returns GroupStudentLink pairs { groupId, studentId }.
writeMany(links: GroupStudentLink[]) => Promise<{ linked: number }>Inserts the links; an existing link is kept and not counted.
delete(criteria: GroupStudentsCriteria) => Promise<{ deletedCount: number }>Removes the links the criteria scopes.

GroupStudentsQuery.empty() takes .byGroup(id), .byGroups(ids), .byStudent(id) and .byStudents(ids). GroupStudentsCriteria has byGroup, byStudent, where and .and(where). GroupStudentsWhere is { groupId?: Equality<string>; studentId?: Equality<number> }.

Errors

ErrorCodeThrown by
AccessDeniedErrorACCESS_DENIEDEvery id-scoped method, on a group or student that is not the caller's, missing included.
NotFoundErrorGROUP_NOT_FOUNDupdate, remove when the group disappears between the check and the write.

Test data: makeGroup and makeSessionPreview in Factories. In-memory fakes in Testing: createFakeGroupsRepositories and createFakeGroupStudentsRepository.

groups.ts
export function groupFixture(faker: Faker): Group {
  return makeGroup(faker, { isWeekly: true, isArchived: false });
}