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.
| Method | Signature | Description |
|---|---|---|
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. |
export function listActiveStudents(
deps: StudentsServiceDeps,
ctx: ServiceContext
): Promise<StudentWithGroupCount[]> {
const students = createStudentsService(deps);
return students.list(false, ctx); // owned by ctx.userId, archived excluded
}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>.
| Method | Effect |
|---|---|
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.
// 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
| Error | Code | Thrown by |
|---|---|---|
AccessDeniedError | ACCESS_DENIED | Every id-scoped method, on a student or group that is not the caller's, missing included. |
NotFoundError | NOT_FOUND | update 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.
export function studentFixture(faker: Faker): Student {
return makeStudent(faker, { isArchived: false });
}Accounts
Accounts (person or organisation), public handles, owners, user roles, preferences, onboarding completeness, and the AccountsService fed by the identity provider.
Groups
Teaching groups with their weekly recurrence, their students and their session previews, with the GroupsService, its canonical repositories and the group students junction.