Skip to content

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.

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.

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/.