API Reference¶
This reference documents the 117 application endpoints registered by the current Express backend. It describes source-code contracts; deployment availability, database migration state and external-service credentials must be verified separately.
Base URL¶
Local development uses http://localhost:3000/api/v1. All endpoint paths below include /api/v1. Hosting configuration is described in Deployment and Hosting.
Request and Response Conventions¶
Send JSON bodies with Content-Type: application/json, except the multipart uploads described below. Path identifiers refer to existing database records. Fields described as UUIDs must contain complete UUIDs; the UUIDs in examples illustrate valid syntax and must be replaced with actual record IDs. Dates are real YYYY-MM-DD calendar dates, and session times use 24-hour HH:MM values in Africa/Johannesburg.
Most successful responses use:
{
"success": true,
"data": { "id": "11111111-1111-4111-8111-111111111111" }
}
Endpoint descriptions identify the value inside data. Examples showing selected response fields are not exhaustive schemas. Prisma dates normally serialize as ISO timestamps; excusal controllers explicitly serialize sessionDate as a date-only string. Decimal database fields can serialize as strings.
Exceptions are 204 No Content responses, draft deletion's { "success": true }, and the raw iCalendar subscription response. Deleting an avatar, calendar token, term or holiday returns 200, not 204.
Errors¶
Normal JSON errors use a string error, not a nested object:
{
"success": false,
"error": "Validation failed",
"details": [
{ "field": "sessionId", "message": "Choose the course session you will miss" }
]
}
details is optional. Zod body/query middleware formats it as field/message objects; business services may return warning objects, strings or raw validation issues instead. Authentication can include a machine-readable code inside details, such as FRESH_SIGN_IN_REQUIRED.
| Status | Meaning |
|---|---|
400 |
Body/query schema failure, malformed CSV or another explicit bad request |
401 |
Missing, invalid or stale required Auth0 token, or invalid fresh-registration proof |
403 |
Role, ownership or course permission denied, or account not active |
404 |
Missing record, missing registered local account or unknown route |
409 |
Duplicate, concurrency conflict, locked resource or protected dependent work |
413 |
Multipart file exceeds its route's size limit |
422 |
Service validation, eligibility, onboarding or workflow rule failure |
429 |
API rate limit exceeded |
500 |
Unexpected failure; production hides the underlying message |
503 |
Required email, Wits retrieval or fresh-registration verification configuration unavailable |
All /api/v1 routes share a limit of 300 requests per IP per 15-minute window. The limiter uses draft-8 RateLimit and RateLimit-Policy headers and its default text 429 response, Too many requests, please try again later., rather than the JSON error envelope.
Authentication and Permissions¶
Protected routes require Authorization: Bearer <access_token>, verified against the configured Auth0 issuer and audience using RS256. Normal protected routes also require an ACTIVE local user record and a token satisfying the account's tokenValidAfter cutoff. Identity synchronisation, closure and fresh-registration routes use token-only authentication with their own account checks. Application roles come from the local record: ADMIN, LECTURER, TUTOR and STUDENT.
The endpoint tables use these access descriptions:
- Authenticated: any active registered local user with a valid, current token.
- Manager: an Admin or a Lecturer assigned as a coordinator of the relevant course. Swap management accepts a coordinator of either involved course.
- Owner: the user owning the relevant record, with further role restrictions where stated.
Read scopes vary by resource; staff access is not universally restricted to coordinated courses. Public course reads and machine access are explained under Public API and Machine-to-Machine Access.
Authentication¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| POST | /api/v1/auth/callback |
Valid Auth0 token; service checks account state | Optional idToken for fresh registration; fetches Auth0 /userinfo |
200; synchronised user |
| POST | /api/v1/auth/register-again |
Valid Auth0 token, self | acceptFreshAccount: true, confirmationEmail, idToken |
200; fresh Student account after completed closure |
| GET | /api/v1/auth/me |
Authenticated, self | None | 200; profile, marks, availability, allocations, coordinated courses, staff request and computed onboarding |
| GET | /api/v1/auth/me/closure |
Valid current Auth0 token, existing local account | None | 200; closure status and blockers |
| POST | /api/v1/auth/me/closure |
Same | confirmationEmail |
200 if cleanup complete; 202 if pending; closure status |
| DELETE | /api/v1/auth/me |
Same | confirmationEmail; same closure operation |
200 or 202; JSON closure status, not 204 |
Identity and Onboarding¶
The callback upserts the user by Auth0 sub, updating email/name and verified-email state or creating a user with the database's default STUDENT role. It does not accept a role in the request body. An active Student with a matching, unexpired pending staff invitation receives its role and coordinator assignments only when Auth0 /userinfo reports email_verified: true. There is no separate invitation-acceptance endpoint or invitation token in the login URL.
GET /auth/me includes course relations with marks and allocations; allocations are not filtered to active status. Its computed onboarding value is:
{
"complete": false,
"missing": ["yearOfStudy", "availability", "courseMark"]
}
Students need a year of study, at least one availability window and at least one PENDING or VERIFIED mark. Other roles return complete: true for this Student onboarding calculation. Use profile updates, self-submitted marks and availability replacement. Course applications require a submitted mark for the target course; Student overflow claims require year of study and availability, but not a course mark at submission. Failing these action gates produces 422 with missing items in details.
The profile separately includes staffWelcomeRequired for Admins/Lecturers whose staffOnboardingRole does not match their current role. Staff acknowledge that welcome through /staff-access/onboarding/complete; this flag is not a general permission gate for staff API operations.
Account Closure and Fresh Registration¶
Preflight returns accountStatus, closedAt, cleanupPending, signInRemovalPending, canRegisterAgain, registrationAfter, canClose and blockers. Blockers have codes LAST_ADMIN, FUTURE_RESPONSIBILITIES or UNFINISHED_TIMESHEETS, with explanatory information and navigation links. Active dated responsibility rows naming the user as either original or responsible tutor block closure while the occurrence has not ended. Non-empty DRAFT, DISPUTED or REJECTED timesheets also prevent closure. Historical responsibility records alone do not prevent it.
For an active account, closure requires the registered email, compared after trimming and lowercasing:
{ "confirmationEmail": "tutor@example.com" }
Mismatch returns 400; blockers return 409. The transaction marks the user CLOSING, anonymises current profile fields, retires active/pending allocations, withdraws pending applications and resolves pending swaps, claims, excusals and staff access. It removes coordinator memberships, draft entries, availability, calendar credentials and notifications while retaining historical records. Repeating closure for an already non-active account is supported without repeating email confirmation.
After that commit, cleanup attempts Auth0 identity removal and avatar deletion. Auth0 404 is tolerated. Missing backend-only AUTH0_MANAGEMENT_CLIENT_ID/AUTH0_MANAGEMENT_CLIENT_SECRET, missing delete:users permission or cleanup failure leaves durable retry work and returns 202 with cleanupPending: true; local access is already blocked. Successful cleanup changes the status to CLOSED and returns 200. An Admin can explicitly retry through /users/:id/closure/retry. Provider errors are not exposed in the closure response.
Fresh registration requires completed closure and identity removal, a newly authenticated identity with the same sub, and a verified email from /userinfo. The access token's iat and ID token's auth_time must be later than the registration cutoff. The ID token is verified separately against the Auth0 issuer and AUTH0_REGISTRATION_CLIENT_ID (falling back to AUTH0_CLIENT_ID), with matching subject, RS256 and recent authentication within 15 minutes. A stale callback can return 403 with details.code: "FRESH_SIGN_IN_REQUIRED". Explicit registration requires:
{
"acceptFreshAccount": true,
"confirmationEmail": "tutor@example.com",
"idToken": "YOUR_FRESH_AUTH0_ID_TOKEN"
}
The old row remains archived and the new row starts as an active Student; previous roles, allocations and history are not restored to it. A callback with valid fresh-registration proof can also create the replacement account.
Staff Access and Invitations¶
All these routes require an active local account. Bodies use strict validation and reject unknown fields.
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| POST | /api/v1/staff-access/requests |
Student | requestedRole (LECTURER or ADMIN), trimmed reason (10–2000 characters) |
201; pending staff request |
| DELETE | /api/v1/staff-access/requests/me |
Authenticated, own pending request | None | 200; { cancelled: true } |
| POST | /api/v1/staff-access/onboarding/complete |
Admin/Lecturer, self | Current staff role; optional trimmed name (1–200 characters) |
200; { complete: true } |
| GET | /api/v1/staff-access |
Admin | None | 200; { requests, invitations }, pending records |
| PATCH | /api/v1/staff-access/requests/:id |
Admin | status (APPROVED or REJECTED), staff role, optional courseIds, trimmed reviewReason (1–2000 characters) |
200; reviewed request |
| POST | /api/v1/staff-access/invitations |
Admin | Valid email (maximum 254 characters), staff role, optional courseIds |
201; { invitation, email, loginUrl } |
| DELETE | /api/v1/staff-access/invitations/:id |
Admin | None | 200; { revoked: true } |
courseIds defaults to [], accepts at most 100 non-empty string IDs, and is deduplicated by the service. IDs must identify existing courses. Admin grants cannot include course assignments; Lecturer grants may have no assigned courses. Granting staff access requires an active Student and replaces coordinator memberships with the supplied courses.
Only one pending request per user is permitted (409 on repeat). Approval requires a verified email; reviewing one's own request is forbidden (403). Rejection still requires the role field but does not grant it or validate course assignments. Missing requests return 404; requests no longer pending return 409. Cancellation requires a pending request, otherwise 409. Welcome completion returns 409 if the submitted role no longer matches.
Example Lecturer approval:
{
"status": "APPROVED",
"role": "LECTURER",
"courseIds": ["22222222-2222-4222-8222-222222222222"],
"reviewReason": "Approved to coordinate this course."
}
Example invitation:
{
"email": "lecturer@example.com",
"role": "LECTURER",
"courseIds": ["22222222-2222-4222-8222-222222222222"]
}
Invitations are upserted by normalised email, expire after seven days and are redeemed through the verified-email callback described above. Existing Tutor/staff accounts must use Admin role changes instead (409); closed accounts cannot receive a grant. Revocation requires a pending invitation, otherwise 409. The pending list does not filter out expired invitations, but expiry prevents redemption.
Invitation creation commits the record before awaiting Brevo. Its email result reports SENT (provider acceptance), FAILED or SKIPPED; an unsuccessful email outcome still returns 201 with the saved invitation. The login URL uses the first configured frontend origin followed by /login. Staff request creation also commits first, then awaits best-effort notifications to active Admins; notification failures are logged without failing the saved request.
Users and Profiles¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/users |
Admin | Optional query role |
200; users ordered by name |
| GET | /api/v1/users/:id |
Authenticated | User ID | 200; full profile for self/staff, otherwise only name and role |
| POST | /api/v1/users/:id/closure/retry |
Admin | User ID; no required body | 200; closure status after attempted cleanup, which may remain pending |
| PATCH | /api/v1/users/:id |
Owner or Admin | Partial profile body below | 200; updated user |
| POST | /api/v1/users/:id/avatar |
Owner or Admin | Multipart avatar |
200; updated user with storage URL/key |
| DELETE | /api/v1/users/:id/avatar |
Owner or Admin | None | 200; user with cleared avatar fields |
role filtering accepts the application role names and is uppercased by the service. User detail permits Admins and Lecturers to see another user's full profile, marks, availability and allocations; other authenticated callers receive { "name": ..., "role": ... } for another user.
Profile bodies are strict: name is a trimmed non-empty string or null; yearOfStudy is integer 1–10 or null; maxHoursPerWeek is a non-negative integer. Only Admins can submit role, one of the four uppercase roles. Non-Admins submitting it receive 403; invalid profile fields produce 422. Admins cannot submit their own role change, and the last Admin cannot be demoted. Role/capacity changes revalidate active allocations transactionally.
{
"name": "Example Tutor",
"yearOfStudy": 3,
"maxHoursPerWeek": 12
}
An Admin can change another active user's role using { "role": "LECTURER" }. Coordinator assignment is a separate course operation. Actual role transitions reset staff welcome acknowledgement, revoke pending invitations and resolve pending staff requests. The last-Admin check counts active Admins. Profile/avatar changes reject closed targets. User listing includes closure state rather than filtering out closed accounts; retry returns 404 for an unknown user or 409 for an active account.
Avatar uploads accept JPEG, PNG or WebP, up to 5 MB. The backend checks both the supplied MIME type and actual file content before Supabase Storage upload. Unsupported/missing images produce 400; size-limit failures produce 413.
Tutor Pay Rates¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/users/:id/rates |
Self, Admin or authorised tutor coordinator | Tutor ID | 200; rate history, newest effective date first |
| GET | /api/v1/users/:id/rates/current |
Same | Tutor ID | 200; latest effective rate as of today, or null |
| POST | /api/v1/users/:id/rates |
Admin or authorised tutor coordinator | rate, effectiveFrom |
201; new rate |
| PATCH | /api/v1/users/:id/rates/:rateId |
Same | rate |
200; corrected rate |
A Lecturer must coordinate a course to which the tutor has an active allocation. Rates are numeric 0.01–100000 with at most two decimal places. effectiveFrom is a real date-only value; a new history entry must follow the tutor's latest existing effective date. Correction changes the existing value and marks isCorrection; it does not insert a new effective-date entry. Timesheet approval snapshots the effective rate into appliedRate.
{ "rate": 125.5, "effectiveFrom": "2027-01-01" }
Courses and Applications¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/courses |
Public; optional authentication | No implemented catalogue query filters | 200; courses ordered by code |
| GET | /api/v1/courses/:id |
Public; optional authentication | Course ID | 200; course and permitted relations |
| GET | /api/v1/courses/mine |
Authenticated | None | 200; caller's allocated/coordinated courses |
| GET | /api/v1/courses/opportunities |
Student or Tutor | None | 200; catalogue with per-course eligibility |
| GET | /api/v1/courses/:id/eligibility |
Student or Tutor | Optional query hoursPerWeek, default 2 |
200; eligibility for caller/course |
| POST | /api/v1/courses |
Admin | Course body below | 201; created course |
| PATCH | /api/v1/courses/:id |
Manager | Partial course body | 200; updated course |
| DELETE | /api/v1/courses/:id |
Admin | None | 204; subject to dependency checks |
| GET | /api/v1/courses/:id/applications |
Authenticated | Course ID | 200; managers see all applications; other callers see own |
| POST | /api/v1/courses/:id/applications |
Student or Tutor | hoursPerWeek, motivation |
201; pending application |
| PATCH | /api/v1/courses/:id/applications/:applicationId |
Manager | status, reason |
200; reviewed application |
| POST | /api/v1/courses/:id/applications/:applicationId/withdraw |
Application owner | No required body | 200; withdrawn application |
| GET | /api/v1/courses/:id/coordinators |
Manager | Course ID | 200; coordinator records with nested user |
| PUT | /api/v1/courses/:id/coordinators |
Admin | userIds array |
200; replacement coordinator records |
Course Response Shapes¶
Anonymous callers, M2M callers and recognised non-staff users receive sessions and coordinator records with user IDs/names. They do not receive coordinator emails, budgets, individual allocations or tutor marks. Course listing also exposes _count.allocations.
Recognised Admins/Lecturers receive coordinator emails and budgets; single-course detail also includes allocations and tutor marks. These staff catalogue/detail reads are not limited to coordinated courses. Management writes remain course-scoped where indicated.
For mine, Tutors receive courses with ACTIVE or PENDING allocations and myAllocation: { id, status, hoursPerWeek }. Staff receive courses linked through their coordinator membership, not every course; Students receive an empty array.
Course Bodies¶
Creation requires code (1–20 characters), name (1–255), integer year (2020–2030) and semester (1–2). Optional description is a string. Defaults are requiredTutors: 1, minMarkRequired: 50 and applicationsOpen: false; their accepted types are non-negative integer, integer 0–100 and boolean respectively. Optional budget is a non-negative number or null. Update accepts these fields partially and revalidates affected active allocations.
{
"code": "COMS3011A",
"name": "Software Design",
"year": 2026,
"semester": 2,
"requiredTutors": 4,
"minMarkRequired": 65,
"applicationsOpen": true,
"budget": 25000
}
Course deletion checks dependent allocations, timesheets, applications and draft entries and returns 409 when protected work exists; database restrictions may also prevent deletion.
Applications and Eligibility¶
Application hoursPerWeek must be integer 1–40; motivation is trimmed, 1–2000 characters. Submission checks that applications are open, no application already exists, onboarding is sufficient and the proposal is eligible. A pending mark may support submission but requires verification before approval.
{ "hoursPerWeek": 4, "motivation": "I would like to assist with this course." }
Review requires status of APPROVED or REJECTED and a trimmed non-empty reason up to 2000 characters. Approval rechecks eligibility, creates/reactivates an allocation and promotes a Student to Tutor. Withdrawal is restricted to the owner's pending application. Application listing includes applicant information and a current eligibility evaluation for pending rows.
{ "status": "APPROVED", "reason": "Verified mark and timetable meet the requirements." }
Opportunity responses use status values such as eligible, ineligible and profile_incomplete, with missing, reasons and approvalReasons; applicable results include remaining/requested hours. Closed applications or an existing application make the course opportunity unavailable. The eligibility hours query has service checks rather than a dedicated query validator.
Coordinators¶
PUT replaces the entire set atomically. userIds accepts up to 50 non-empty strings; duplicates are deduplicated. Every selected user must exist, be active and already hold LECTURER, otherwise 422. An empty array clears the set. It does not create users, issue invitations or change roles.
{ "userIds": ["11111111-1111-4111-8111-111111111111"] }
Course Sessions¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/courses/:id/sessions |
Public; optional authentication | Course ID | 200; course sessions |
| POST | /api/v1/courses/:id/sessions |
Manager | Session body | 201; session |
| PATCH | /api/v1/sessions/:id |
Manager of session's course | Partial session body | 200; updated session |
| DELETE | /api/v1/sessions/:id |
Manager of session's course | None | 204; subject to scheduling/history checks |
Create requires dayOfWeek (MONDAY through SUNDAY), startTime, endTime, and sessionType (LECTURE, TUTORIAL, LAB); venue is optional. Start must precede end. Update validates a partial body against the existing interval in service. Changes are checked against active allocations and protected dated responsibilities.
{
"dayOfWeek": "MONDAY",
"startTime": "14:00",
"endTime": "15:30",
"sessionType": "LAB",
"venue": "MSL 004"
}
Tutors, Marks and Availability¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/tutors |
Admin/Lecturer or M2M read:tutors |
No implemented query filters | 200; tutor directory |
| GET | /api/v1/tutors/:id |
Self or Admin/Lecturer | Tutor ID | 200; tutor detail |
| GET | /api/v1/tutors/marks |
Admin/Lecturer | Optional status query |
200; marks scoped to managed courses for Lecturers |
| POST | /api/v1/tutors/me/marks |
Student/Tutor, self | courseId, mark |
201; pending mark |
| POST | /api/v1/tutors/:id/marks |
Manager of supplied course | courseId, mark |
200; upserted verified mark |
| PATCH | /api/v1/tutors/marks/:markId |
Manager of mark's course | status, optional rejectionReason |
200; reviewed mark |
| POST | /api/v1/tutors/marks/import/preview |
Manager of supplied course | Multipart mark import | 200; parsed/matched preview |
| POST | /api/v1/tutors/marks/import/commit |
Same | Multipart mark import, optional exclusions | 200; import result |
| PUT | /api/v1/tutors/:id/availability |
Student/Tutor self or Admin | slots array |
200; replacement availability |
| GET | /api/v1/tutors/:id/availability/exceptions |
Same | User ID | 200; dated exceptions |
| PUT | /api/v1/tutors/:id/availability/exceptions |
Same | exceptions array |
200; replacement exceptions |
| POST | /api/v1/tutors/:id/timetable/import/preview |
Same | Multipart timetable file |
200; proposed availability and impact |
| POST | /api/v1/tutors/:id/timetable/import/commit |
Same | Multipart timetable file |
200; saved import result |
The directory contains active users with role TUTOR, including marks, availability, allocations and currentRate. Lecturers see all tutor rows but marks/allocations are restricted to their managed courses. Admin and machine reads are unscoped. Tutor detail similarly scopes another tutor's academic relations for Lecturers; other non-staff callers can only retrieve their own Tutor record.
Marks¶
courseId must be a UUID and mark an integer 0–100. Self-submission accepts only these fields and starts PENDING; staff entry is immediately VERIFIED with reviewer metadata. Review status is VERIFIED or REJECTED; optional rejectionReason is trimmed and limited to 500 characters. Mark changes recheck affected active allocations.
{ "courseId": "22222222-2222-4222-8222-222222222222", "mark": 78 }
Mark import uses multipart/form-data: required file (CSV, up to 1 MB) and UUID courseId. CSV needs a mark column plus email or student-number identification; common header aliases are accepted. At most 2000 data rows are parsed. Commit reparses the file and accepts optional excludedRows as a JSON-encoded array of positive row numbers. Preview reports matching/row problems; commit handles the selected recognised rows according to service validation.
Recurring Availability and Dated Exceptions¶
Availability replacement requires slots, including an empty array to clear availability. Each slot needs weekday and valid times with end after start; isRecurring defaults to true. A Lecturer cannot manage another user's availability through course-coordinator access.
{
"slots": [
{ "dayOfWeek": "MONDAY", "startTime": "08:00", "endTime": "17:00", "isRecurring": true }
]
}
Exception replacement requires exceptions. Each needs a real date and boolean isAvailable; optional/null startTime, endTime narrow the window, and optional/null trimmed reason is at most 500 characters. If both times are supplied, end must follow start. Empty arrays clear exceptions. Both replacement operations run transactionally and reject changes invalidating active allocations.
{
"exceptions": [
{ "date": "2027-01-18", "isAvailable": false, "reason": "University appointment" }
]
}
Timetable Import¶
Send one .csv file under file, up to 256 KB, with header day,start_time,end_time,module,location. Each row describes a class during which the user is busy. The parser accepts at most 200 rows and validates Monday–Friday school hours, 08:00–17:00, increasing times and required module/location values.
day,start_time,end_time,module,location
Monday,08:00,10:00,COMS3011A,MSL004
Preview returns rows, summary, clashes, duplicates, currentSlots, proposedSlots, keptSlots, affectedAllocations and canImport. It persists no changes. Commit reparses the file and replaces availability atomically; invalid rows or active-allocation conflicts prevent saving.
Allocations¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/allocations |
Authenticated, resource-scoped | Optional userId, courseId query |
200; non-removed allocations with user/course |
| GET | /api/v1/allocations/validate |
Manager of query course | userId, courseId; optional numeric hoursPerWeek |
200; validation result |
| POST | /api/v1/allocations |
Manager of supplied course | userId, courseId, hoursPerWeek, optional reason |
201; allocation and decision result |
| PATCH | /api/v1/allocations/:id |
Manager of allocation's course | Non-empty partial update below | 200; updated allocation |
| DELETE | /api/v1/allocations/:id |
Same | None | 204; allocation retired, not physically deleted |
| POST | /api/v1/allocations/generate |
Admin/Lecturer | No required body | 200; generated proposed plan |
| GET | /api/v1/allocations/drafts |
Admin/Lecturer | None | 200; draft summaries with counts and creator |
| POST | /api/v1/allocations/drafts |
Admin/Lecturer | Draft body below | 201; draft with entries and creator |
| GET | /api/v1/allocations/drafts/:draftId |
Admin/Lecturer, scoped | Draft ID | 200; draft with entries |
| POST | /api/v1/allocations/drafts/:draftId/commit |
Admin/Lecturer; Manager per entry | Commit body below | 200; committed/skipped results |
| DELETE | /api/v1/allocations/drafts/:draftId |
Admin or Lecturer creator | None | 200; { "success": true }, no data |
Admin allocation lists can filter by user/course. Non-staff are forced to their own user ID. Lecturers are restricted to managed courses; that restriction replaces a supplied courseId filter. REMOVED allocations are excluded.
Allocation Validation and Changes¶
The dry-run uses userId, not tutorId:
GET /api/v1/allocations/validate?userId=11111111-1111-4111-8111-111111111111&courseId=22222222-2222-4222-8222-222222222222&hoursPerWeek=4
It evaluates role, hours, recorded/verified course mark, minimum mark, availability, selected work, conflicts, course capacity and remaining weekly hours. Terms, holidays and dated exceptions affect scheduling checks. Results include isValid, warnings and relevant hours. A missing mark is a warning; an unverified or insufficient mark is an error. A dry-run success status does not mean the proposal is eligible.
Creation requires UUID user/course IDs and numeric hours 0.01–40. The accepted explanation field is reason, not overrideReason:
{
"userId": "11111111-1111-4111-8111-111111111111",
"courseId": "22222222-2222-4222-8222-222222222222",
"hoursPerWeek": 4,
"reason": "Pending review of the submitted course mark."
}
Manual creation requires an explanation for actionable warnings/errors and can persist an ineligible assignment as PENDING; invalid role/hours remain rejected. The response's data contains allocation plus decision fields, rather than just an allocation row. Assignment email is attempted asynchronously after commit; success does not guarantee email delivery.
Update accepts only isLocked, unique UUID sessionIds, hoursPerWeek, status (ACTIVE, PENDING, REMOVED) and reason; at least one field is required. Locked allocations permit a lock-only change until unlocked. Empty session selection represents the course's sessions; supplied IDs must belong to the course. Revalidation protects activation, and future swaps/cover can block session reassignment or retirement. DELETE sets REMOVED, preserving work history.
{ "sessionIds": ["33333333-3333-4333-8333-333333333333"], "hoursPerWeek": 1.5 }
Allocation Planning and Drafts¶
Generate derives a proposed plan from active Tutors and current database records without saving allocations; Lecturer planning is scoped to managed courses. The proposal loop does not stop when its tracked weekly-hours budget is exhausted, so generated proposals are not a guarantee that committing every entry will satisfy weekly capacity. Commit revalidates entries. Draft creation accepts a trimmed name (1–120), optional/null description (up to 500), and a non-empty proposed array. Each entry requires UUID tutorId/courseId and integer hours 1–40, with optional/null object explanation. Duplicate tutor/course pairs are rejected. Extra proposal fields are stripped.
{
"name": "Teaching allocation review",
"description": "Proposals for staff review",
"proposed": [
{
"tutorId": "11111111-1111-4111-8111-111111111111",
"courseId": "22222222-2222-4222-8222-222222222222",
"hoursPerWeek": 4,
"explanation": { "summary": "Proposed for review" }
}
]
}
Draft creation has a staff-role gate but does not independently check course-manager membership in saveDraft. Lecturer lists require at least one managed-course entry; detail filters entries to managed courses and returns 404 if none remain. Lecturer deletion requires creator ownership.
Commit requires unique non-empty UUID entryIds and optional hoursOverrides mapping entry UUIDs to integer hours 1–40:
{
"entryIds": ["44444444-4444-4444-8444-444444444444"],
"hoursOverrides": { "44444444-4444-4444-8444-444444444444": 4 }
}
Selected entries are revalidated and committed in individual serializable transactions with course permission checks. Strict commits reject actionable eligibility findings. The batch can return 200 with some or all entries skipped; inspect committed and skipped rather than treating HTTP success as universal assignment success. Stored explanations are not trusted as eligibility evidence.
Timesheets¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/timesheets |
Authenticated, scoped | Optional userId, status, include=entries |
200; timesheets with deadline fields |
| POST | /api/v1/timesheets |
Student/Tutor with qualifying work | weekStartDate, optional courseId |
201; draft timesheet |
| GET | /api/v1/timesheets/:id |
Owner or authorised staff | Timesheet ID | 200; timesheet with entries |
| POST | /api/v1/timesheets/:id/entries |
Owner | Entry body below | 201; created entry |
| DELETE | /api/v1/timesheets/:id/entries/:entryId |
Owner | Entry ID | 204; entry removed and total recomputed |
| POST | /api/v1/timesheets/:id/submit |
Owner | No required body | 200; submitted timesheet |
| POST | /api/v1/timesheets/:id/approve |
Manager; general sheet Admin-only | No required body | 200; approved timesheet |
| POST | /api/v1/timesheets/:id/dispute |
Same | disputeReason |
200; disputed timesheet |
Admins see all lists and can filter by user. Lecturers see sheets whose parent course they coordinate, excluding general course-free sheets. Other users see their own. Valid statuses are DRAFT, SUBMITTED, APPROVED, DISPUTED, REJECTED, PAID; invalid status/include filters produce 422. List rows omit submittedSnapshot; include=entries attaches entry-level course and session details. Detail permits owner, Admin, or coordinator of the parent course; general sheets require owner/Admin access. Entry session context contains id, dayOfWeek, startTime, endTime, venue and sessionType; it is null for entries without a session. Submission freezes the same context in the submitted snapshot.
Create and Record Work¶
weekStartDate must be a real Monday. A course-scoped sheet takes optional UUID courseId; otherwise entries may span courses. Creation requires an active allocation or applicable active dated coverage in that week, and rejects duplicate scope/week records.
{ "weekStartDate": "2027-01-18" }
Entries require date and numeric hoursWorked greater than zero, at most 24, with at most two decimal places. Description is optional, up to 500 characters. Optional UUID courseId becomes required when the parent sheet is not course-scoped; optional UUID sessionId/allocationId identify scheduled work.
{
"date": "2027-01-18",
"courseId": "22222222-2222-4222-8222-222222222222",
"sessionId": "33333333-3333-4333-8333-333333333333",
"allocationId": "55555555-5555-4555-8555-555555555555",
"hoursWorked": 1.5,
"description": "Assisted with the scheduled lab."
}
Service checks enforce the sheet's week/course, qualifying allocation or actual dated responsibility, permitted work and editable state. Entries can be added/removed only in DRAFT or DISPUTED; totals are recomputed transactionally.
Submission and Review¶
Submission requires entries and transitions DRAFT/DISPUTED to SUBMITTED, preserving a submitted-version snapshot. Approval requires SUBMITTED, an intact submitted version and a valid effective tutor pay rate. It records reviewer/time and appliedRate, and increments spending on existing course budgets for the submitted work. General sheets are Admin-reviewed even when their entries include coordinated courses.
Dispute requires a non-empty string disputeReason up to 1000 characters. The owner can correct and resubmit a disputed sheet.
{ "disputeReason": "Please correct the hours for the scheduled lab." }
List responses include dueDate and deadlineStatus (NOT_DUE, DUE_SOON, OVERDUE, or null for completed work). A week belongs to the first monthly 27th on or after its Sunday; submission/review are due by the end of that date in SAST. Notifications and email copies for submission/review are started after business operations and are not awaited by these endpoints.
Excusals¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/excusals |
Authenticated, scoped | Optional userId, status, courseId |
200; excusals with session/cover relations |
| POST | /api/v1/excusals |
Tutor responsible for occurrence | Excusal body below | 201; pending excusal |
| POST | /api/v1/excusals/:id/approve |
Manager | Optional reason, up to 1000 characters |
200; { excusal, overflowPost } inside data |
| POST | /api/v1/excusals/:id/decline |
Manager | Same optional reviewer reason | 200; declined excusal |
Admins see all and can apply the filters; Lecturers see managed-course excusals; other callers see their own. New requests require allocation/session UUIDs, a real date-only sessionDate and a trimmed reason of 1–1000 characters:
{
"allocationId": "55555555-5555-4555-8555-555555555555",
"sessionId": "33333333-3333-4333-8333-333333333333",
"sessionDate": "2027-01-18",
"reason": "Attending a university appointment."
}
The backend checks active allocation, session/course membership, selected-session scope and the caller's effective responsibility, including sessions received through a swap. The date must match the session weekday and a valid start time that has not passed; a session later today can qualify. Logged hours for the allocation/session/date block the request. A live request for the same allocation/session/date produces 409; declined requests may be resubmitted. The stored durationMinutes snapshots the requested work, while reason and reviewReason remain separate.
Approval rechecks schedule and responsibility and creates linked dated cover work transactionally. A changed/deleted session or invalidated occurrence can prevent approval. hoursNeeded is durationMinutes / 60, so a 90-minute session needs 1.5 hours. Cover records identify the excusal, session, date, duration and applicable responsibility. Reviewer bodies can be omitted or sent as { "reason": "Cover requested." }. Ordinary notification/email attempts are asynchronous and do not determine request success.
Overflow Volunteering¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/overflow-posts |
Authenticated, role-filtered | Optional courseId, staff status filter |
200; posts with claims/session and applicable eligibility |
| POST | /api/v1/overflow-posts |
Manager | Post body below | 201; post |
| DELETE | /api/v1/overflow-posts/:id |
Manager | None | 204; rejects posts with approved claims |
| POST | /api/v1/overflow-posts/:id/claim |
Student/Tutor | No required body | 201; pending claim |
| POST | /api/v1/overflow-claims/:id/approve |
Manager of post's course | No required body | 200; claim, allocation/responsibility and competing-claim results |
| POST | /api/v1/overflow-claims/:id/reject |
Same | No required body | 200; rejected claim |
Admins see all posts. Lecturers see all statuses for managed courses plus open posts elsewhere. Other users see open posts and posts they have claimed; their status query is not applied. Claim details are limited to the caller or managed courses. Student/Tutor open-post results include submission eligibility and stricter approvalEligibility. Returned posts also expose hoursPerWeek as an alias of hoursNeeded.
Manual post creation requires UUID courseId, description of 1–2000 characters and integer hours 1–40 as hoursNeeded or legacy hoursPerWeek; hoursNeeded wins when both are supplied. This endpoint does not accept dated cover identifiers; approved excusals create those records.
{
"courseId": "22222222-2222-4222-8222-222222222222",
"description": "Additional weekly tutorial assistance",
"hoursNeeded": 4
}
Claiming requires an open post, no prior claim, sufficient submission eligibility and Student onboarding where applicable. Weekly work checks remaining hours; dated cover defers its affected-week validation to approval. Approval requires a pending claim/open post and rechecks full eligibility. It marks the post CLAIMED, rejects competing pending claims, promotes a Student to Tutor, and either creates/reactivates an allocation or assigns the dated responsibility. The response includes claim, allocation, responsibility and rejectedSiblingClaims; one of allocation/responsibility is null according to workflow. Rejection leaves the post open for other volunteers. Notifications do not guarantee email delivery.
Session Swaps¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/swaps |
Authenticated, scoped | None | 200; swaps with participants and allocation/course relations |
| GET | /api/v1/swaps/options |
Tutor | None | 200; future available session options |
| GET | /api/v1/swaps/coverage |
Authenticated, self | None | 200; active dated responsibilities with course/session |
| GET | /api/v1/swaps/workload |
Authenticated, self | None | 200; { week, hours, remainingHours } |
| GET | /api/v1/swaps/:id |
Participants or Manager of either course | Swap ID | 200; swap |
| GET | /api/v1/swaps/:id/history |
Same | Swap ID | 200; ordered audit records with actor |
| POST | /api/v1/swaps/preview |
Tutor owning proposed origin | Dated swap body | 200; pair inspection, suitability/warnings and weekly hours |
| POST | /api/v1/swaps |
Same | Dated swap body | 201; pending swap |
| POST | /api/v1/swaps/:id/accept |
Requested Tutor | Empty body or {} only |
200; consent recorded after revalidation |
| POST | /api/v1/swaps/:id/decline |
Requested Tutor | Empty body or {} only |
200; rejected request |
| POST | /api/v1/swaps/:id/approve |
Manager of either course | Empty body or {} only |
200; approved swap |
| POST | /api/v1/swaps/:id/reject |
Same | Optional rejectionReason |
200; rejected swap |
| POST | /api/v1/swaps/:id/cancel |
Requester | Empty body or {} only |
200; cancelled pending swap |
| POST | /api/v1/swaps/:id/reverse |
Manager of either course | Required reason |
200; reversed swap |
Admins list all swaps; Lecturers list swaps touching managed courses; other users list requests in which they participate. Workload uses the current Johannesburg Monday-based week and accounts for dated responsibility changes.
Dated Swap Request and Preview¶
Creation/preview require four UUID identifiers and two real date-only values; optional trimmed reason is at most 500 characters:
{
"requesterAllocationId": "55555555-5555-4555-8555-555555555555",
"targetAllocationId": "66666666-6666-4666-8666-666666666666",
"requesterSessionId": "33333333-3333-4333-8333-333333333333",
"requesterSessionDate": "2027-01-18",
"targetSessionId": "77777777-7777-4777-8777-777777777777",
"targetSessionDate": "2027-01-21",
"reason": "Requesting a session exchange."
}
Dates must correspond to the actual sessions and remain future. Both allocations must be active/unlocked and slots still available. The service checks effective responsibility, verified incoming-course marks, availability, dated conflicts and adjusted hours in each affected week. Live excusals, logged hours or incompatible approved changes can block a slot. Same-course exchanges are supported for distinct responsibilities. Preview reports findings without creating a request; pending requests do not reserve responsibility.
Consent, Review and Reversal¶
The requested Tutor must accept before staff approval. Consent rechecks suitability and notifies participants/reviewers. Approval revalidates and records both dated responsibility transfers atomically, preserving recurring allocation ownership. Participants receive in-app notifications; approval email copies are attempted asynchronously. History records requests, consent, review, cancellation and reversal.
Reject accepts optional trimmed non-empty rejectionReason up to 500 characters. Reverse requires trimmed non-empty reason up to 500:
{ "reason": "Both tutors can resume their original sessions." }
Reversal checks that sessions remain future, the current responsibilities still belong to this swap, no blocking work/excusal changes exist and original tutors remain eligible. It permits locked underlying allocations because it restores dated responsibility without changing allocation ownership. It restores responsibility and records REVERSED. See Advanced Features and Role Workflows for the application workflow.
Notifications¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/notifications |
Authenticated, own notifications | Optional numeric limit, default 50, upper cap 100 |
200; newest-first notifications |
| GET | /api/v1/notifications/unread-count |
Same | None | 200; { count } |
| PATCH | /api/v1/notifications/:id/read |
Notification owner | None | 200; updated notification; unowned/missing ID returns 404 |
| PATCH | /api/v1/notifications/read-all |
Authenticated, own notifications | None | 200; { count } of changed records |
Responses retain notification fields and add body (from message) and a role-appropriate link. emailStatus can be null (no recorded attempt), SENT (provider accepted), FAILED or SKIPPED (email unconfigured). emailError and emailAttemptedAt record applicable attempt information. The limit query is handled by the service without a dedicated Zod schema; use a positive integer.
Problem Reports¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| POST | /api/v1/problem-reports |
Authenticated | Report body | 201; { submitted: true, delivered: true } inside data |
page is 1–100 characters, description 1–2000, blocking is boolean (default false), and optional url is a string up to 500 characters; it is not required to be an absolute URL.
{
"page": "Allocation Board",
"description": "The warning tooltip is truncated on a narrow screen.",
"blocking": false,
"url": "/allocations"
}
The server includes the registered user's name/email and awaits Brevo acceptance before responding. Reports are not saved in the database. The recipient is configured by BREVO_ISSUES_EMAIL (default toodle.issues@gmail.com). Despite the response field's name, delivered: true means provider acceptance, not confirmed inbox delivery. Failed or unconfigured email returns 503 with a generic retry/direct-email message, without exposing provider errors.
{ "success": true, "data": { "submitted": true, "delivered": true } }
Calendar¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/calendar/me/events |
Authenticated, self | Required start, end date query |
200; { start, end, events } |
| GET | /api/v1/calendar/me/feed-token |
Authenticated, self | None | 200; { connected, createdAt, lastFetchedAt } |
| POST | /api/v1/calendar/me/feed-token |
Authenticated, self | None | 200; { token, createdAt } |
| DELETE | /api/v1/calendar/me/feed-token |
Authenticated, self | None | 200; { connected: false } |
| GET | /api/v1/calendar/feed/:token |
Token in URL; no Auth0 required | Subscription token | 200; raw .ics; invalid/revoked token returns 404 |
Calendar Events¶
Query dates must be real dates, with start < end and a maximum 92-day span. The range is half-open [start, end). Events use fixed +02:00 times and include selected sessions from ACTIVE/PENDING allocations, coordinated courses and applicable incoming dated cover. Terms, holidays, unavailable exceptions, excusals and transferred responsibilities affect occurrence visibility/cancellation.
GET /api/v1/calendar/me/events?start=2027-01-18&end=2027-01-25
Example regular event fields inside data.events:
{
"id": "session-33333333-3333-4333-8333-333333333333-2027-01-18",
"type": "SESSION",
"title": "COMS3011A Lab",
"courseCode": "COMS3011A",
"courseName": "Software Design",
"courseId": "22222222-2222-4222-8222-222222222222",
"sessionId": "33333333-3333-4333-8333-333333333333",
"sessionType": "LAB",
"venue": "MSL 004",
"start": "2027-01-18T14:00:00+02:00",
"end": "2027-01-18T15:30:00+02:00",
"allocationStatus": "ACTIVE",
"cancelled": false,
"cancellationReason": null,
"link": "/courses/22222222-2222-4222-8222-222222222222"
}
Incoming cover events use cover-<responsibilityId> IDs and additionally include allocation/responsibility identifiers. Cancellation is not necessarily an excusal: the reason may identify transferred work, a holiday or unavailable date.
Subscription Credentials and Feed¶
Creation/rotation returns a 64-character hexadecimal token once. Only its SHA-256 hash is stored; status never returns the token. Rotation invalidates earlier URLs, and revocation removes the credential. Subscribe using /api/v1/calendar/feed/<token>; possession of this URL authorises access to that user's feed.
The feed returns Content-Type: text/calendar; charset=utf-8, Content-Disposition: inline; filename="toodle-calendar.ics", Cache-Control: no-store and cross-origin resource policy. It uses stable event UIDs, recurrence and dated exceptions/cover. Recurrence is bounded by the academic term covering today where available, otherwise the rolling CALENDAR_FEED_WEEKS window (default 14 weeks). Holidays and unavailable exception dates are excluded. The API does not make outbound Google Calendar API calls. Calendar clients control subscription refresh behaviour.
Search¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/search |
Authenticated | Required q, optional limit |
200; { query, courses, people, timesheets } |
q is trimmed for validation and must be 2–100 characters; limit is integer 1–25, default 5 per result group. Matching is case-insensitive. Lecturer course results are scoped to coordinated courses; other roles search the catalogue. People results contain Tutor identity fields and are staff-only; Lecturer people results require an allocation in a managed course. Timesheet results follow role/ownership scope. Results select limited fields rather than full resource records.
GET /api/v1/search?q=COMS&limit=5
Academic Terms and Holidays¶
| Method | Path | Access | Request | Success and response |
|---|---|---|---|---|
| GET | /api/v1/terms |
Authenticated | None | 200; academic terms |
| GET | /api/v1/terms/holidays |
Authenticated | Optional date-only start, end |
200; holidays in inclusive window |
| POST | /api/v1/terms |
Admin | Full term body | 201; created/upserted term |
| PATCH | /api/v1/terms/:id |
Admin | Full term body, not partial | 200; updated term |
| DELETE | /api/v1/terms/:id |
Admin | None | 200; deletion result |
| POST | /api/v1/terms/holidays |
Admin | Holiday body | 201; created/upserted manual holiday |
| DELETE | /api/v1/terms/holidays/:id |
Admin | None | 200; deletion result |
| POST | /api/v1/terms/sync/preview |
Admin | Required query year |
200; parsed Wits proposal and unresolved rows |
| POST | /api/v1/terms/sync/commit |
Admin | Required query year |
200; committed terms/holidays and unresolved rows |
Term bodies require integer/coercible year (2000–2100), trimmed name (1–120), and real startDate/endDate with end on or after start. PATCH requires all these fields. Term creation upserts by year/name; updates address the path ID. These term writes do not explicitly revalidate existing active allocations.
{ "year": 2027, "name": "Teaching Block 1", "startDate": "2027-02-01", "endDate": "2027-03-26" }
Holiday bodies require real date and trimmed name (1–160), with optional year 2000–2100; the service defaults year from the date. Manual creation sets source MANUAL.
{ "date": "2027-03-22", "name": "University closed", "year": 2027 }
Sync query year must be 2000–2100. Preview retrieves/parses the Wits website and returns { year, proposal: { terms, holidays }, unresolved } without database writes. Commit fetches again and transactionally upserts parsed records, tagging holidays WITS; it does not accept the preview as trusted input. Retrieval failure returns 503. Terms/holidays feed date-aware scheduling; see Database Design.
Public API and Machine-to-Machine Access¶
The three course GETs (catalogue, course detail, course sessions) are public. No token or read:courses permission is required. Optional authentication recognises valid active human users with current tokens and Auth0 M2M tokens with gty: "client-credentials"; invalid/expired/stale optional tokens, closed accounts and failed user lookups fall back to anonymous access. Machines receive the non-staff course shape. No claim is made that a course permission is checked or logged as an access identity.
The Tutor directory requires either a human Admin/Lecturer or a valid M2M token with permissions containing read:tutors. This is the only registered route using the human-or-service permission gate. Other application routes use their normal local-user authentication; M2M access is not a general replacement for it.
An external client configured in Auth0 can request a token from the configured tenant's /oauth/token using client_credentials, its client ID/secret and the configured Toodle API audience. Keep the secret on the client's server. Example token request structure:
{
"grant_type": "client_credentials",
"client_id": "YOUR_M2M_CLIENT_ID",
"client_secret": "YOUR_M2M_CLIENT_SECRET",
"audience": "YOUR_CONFIGURED_TOODLE_API_AUDIENCE"
}
Send the returned access token in the Bearer header. The token's permissions array, not merely a scope string, must include read:tutors. Missing required permission returns 403; missing/invalid required token returns 401. Auth0 client grants and issuer/audience settings are deployment configuration, not established by this reference.
The calendar subscription is also externally fetchable, but its private URL token is the credential rather than an Auth0 M2M token.
Interactive API Documentation¶
The backend publicly serves Swagger UI at /docs using /docs/openapi.json. These paths are outside /api/v1 and are mounted before the global application middleware and API limiter. The static specification covers six external-facing operations, not the complete 117-endpoint application API:
| Method | Specification path |
|---|---|
| GET | /health |
| GET | /api/v1/courses |
| GET | /api/v1/courses/{courseId} |
| GET | /api/v1/courses/{courseId}/sessions |
| GET | /api/v1/tutors |
| GET | /api/v1/calendar/feed/{token} |
Swagger's Authorize control supplies a Bearer token for the directory operation. The public course reads need none; calendar feeds require a valid URL token. The OpenAPI specification is a limited external contract and is not generated from every router.
The public root / advertises documentation locations, and /health returns { "status": "ok", "timestamp": "..." }. Neither uses the application success envelope. The root/specification currently advertise an external-guide URL ending /api/external-access/, while this MkDocs site's reference is /api/api-reference/.