Bridge Training

Students

The students a teacher enrols in groups, with the StudentsService and its canonical repositories.

The students module holds the people a teacher teaches. A student belongs to one teacher account and can be placed in several groups. The teacher app lists, creates and archives students; the API exposes the same operations.

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

Domain

Prop

Type

Student ids are numbers, unlike most ids in the package. userId is the owning teacher account.

StudentWithGroupCount is the list projection: Student & { groupCount: number }.

The module has no constants, guards or pure functions.

Application

StudentsService

createStudentsService(deps: StudentsServiceDeps): StudentsService, where StudentsServiceDeps is { students: StudentsRepository; studentsRead: StudentsReadRepository; groupStudents: GroupStudentsRepository; groupsRead: GroupsReadRepository; entitlements: EntitlementsGate }.

Every method takes the ServiceContext last. List and create are scoped to ctx.userId. The id-scoped methods check ownership first: they count the caller's student (or group, for listByGroup), throw AccessDeniedError when the caller has no such row, whether it belongs to another account or does not exist, so sequential ids reveal nothing, 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(includeArchived: boolean | undefined, ctx) => Promise<StudentWithGroupCount[]>The caller's students by last then first name, through StudentProjections.withGroupCount(). Archived students are left out unless includeArchived is true.
getById(studentId: number, ctx) => Promise<Student>The caller's student.
listByGroup(groupId: string, includeArchived: boolean | undefined, ctx) => Promise<Student[]>Checks the group is the caller's, reads its links on the group students junction, then the caller's students among them with StudentQuery.byOwner(...).withIds. Same ordering and archive rule as list.
create(dto: CreateStudentDTO, ctx) => Promise<Student>Owned by ctx.userId; email defaults to an empty string, phone to null. Throws LimitReachedError past the plan's student ceiling.
update(studentId: number, dto: UpdateStudentDTO, ctx) => Promise<Student>Updates through StudentCriteria.byId, scoped to the owner. Throws NotFoundError(NOT_FOUND) only when the student disappears between the check and the write.
remove(studentId: number, ctx) => Promise<void>Deletes through StudentCriteria.byId, scoped to the owner. The group links go with the student.
students.ts
export function listActiveStudents(
  deps: StudentsServiceDeps,
  ctx: ServiceContext
): Promise<StudentWithGroupCount[]> {
  const students = createStudentsService(deps);
  return students.list(false, ctx); // owned by ctx.userId, archived excluded
}
students.ts
export async function enrol(
  deps: StudentsServiceDeps,
  ctx: ServiceContext
): Promise<Student> {
  const students = createStudentsService(deps);
  const created = await students.create(
    { firstName: "Ada", lastName: "Lovelace", email: "ada@example.com" },
    ctx
  );
  // Throws NotFoundError(NOT_FOUND) when the id is unknown.
  return students.update(created.id, { phone: "+33 6 00 00 00 00" }, ctx);
}

DTOs

Prop

Type

Prop

Type

Archiving is an update with isArchived: true; there is no dedicated method.

Query objects

The generics are described on Queries. The student family binds them with numeric ids.

StudentQuery (read side), over QueryParams<StudentWhere, StudentSort>.

MethodEffect
StudentQuery.empty()Every student the connection can see, archived included
.withId(id) / .withIds(ids)id equality / membership
.byOwner(ownerId)ownerId equality
.withArchived(include)Keeps archived students only when include is true
.withPagination(p) / .withSorting(s)Pagination and StudentSort; without a sort, students come back by last then first name
.withFilters(where)Raw StudentWhere merge, escape hatch

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

StudentWhere: id (Equality<number>), ownerId (Equality<string>) and isArchived (Equality<boolean>).

StudentProjections: entity() returns Student; withGroupCount() returns StudentWithGroupCount, counting the student's group links.

StudentAggregations: count(criteria?). StudentSort: { column: "lastName" \| "createdAt"; desc: boolean }, whitelisted by STUDENT_SORT_COLUMNS. StudentPatch: the mutable columns of UpdateStudentDTO.

students.ts
// Inside a service: numeric ids, archived students left out.
export function activeStudentsAmong(
  studentsRead: StudentsReadRepository,
  studentIds: number[]
): Promise<Student[]> {
  return studentsRead.findMany(
    StudentQuery.empty().withIds(studentIds).withArchived(false),
    StudentProjections.entity()
  );
}

Ports

StudentsReadRepository is ReadRepository<StudentQuery, undefined, number>: the third type argument keys getById by the serial student id. getById throws NotFoundError(NOT_FOUND) when no row matches.

StudentsRepository is WriteRepository<StudentCriteria, StudentPatch, PersistableStudentWrite, undefined, StudentUpdateResult, StudentDeleteResult> plus writeOne(input: PersistableStudentWrite) => Promise<{ studentId: number }>. The two results carry numeric ids: { updatedIds: number[] } and { deletedIds: number[] }.

Prop

Type

Group membership is not on these ports: it lives on GroupStudentsRepository, documented with groups.

Errors

ErrorCodeThrown by
AccessDeniedErrorACCESS_DENIEDEvery id-scoped method, on a student or group that is not the caller's, missing included.
NotFoundErrorNOT_FOUNDupdate when the student disappears between the check and the write. The generic code is used, not a student-specific one.

Test data: makeStudent and makeStudentWithGroupCount in Factories. In-memory fakes in Testing: createFakeStudentsRepositories, fed by createFakeGroupStudentsRepository for group counts.

students.ts
export function studentFixture(faker: Faker): Student {
  return makeStudent(faker, { isArchived: false });
}