TutorFlow REST API
The TutorFlow backend is a hand-written REST API built with Node.js and Express. Every endpoint is
defined explicitly in backend/routes/ — no endpoint is generated by a scaffolding tool — and every
request travels over HTTP(S) as JSON.
Base URL
| Environment | Base URL |
|---|---|
| Production (Azure App Service) | https://tutor-api.azurewebsites.net/api |
| Local development | http://localhost:3000/api |
All paths below are relative to the base URL, e.g. GET /users/me means
GET https://tutor-api.azurewebsites.net/api/users/me.
Authentication
The API uses Firebase Authentication. Clients sign in with Firebase (email/password or Google), receive an ID token, and send it with every request:
Authorization: Bearer <firebase-id-token>
Acquiring a token as a web client:
import { getAuth } from 'firebase/auth';
const token = await getAuth().currentUser.getIdToken();
fetch(`${BASE_URL}/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
The backend verifies every token with the Firebase Admin SDK and loads the caller's database record. Missing, malformed, or expired tokens return:
{ "error": "Invalid Firebase ID token" }
Tokens are short-lived (1 hour) — request a fresh one with getIdToken() rather than caching it long-term.
Roles
Authorization is role-based. The role is stored on the user record and normalised to uppercase:
| Role | Who they are | Typical access |
|---|---|---|
STUDENT | Signed-up students | Own profile, availability, tutoring applications, volunteer views |
TUTOR | Approved tutors | Student access plus assignments, events, timesheets |
LECTURER | Course owners | Own courses/events, timesheet approval for supervised entries |
ORGANISER | Course coordinators | Everything: applications review, allocation, settings, user directory |
ADMIN | System administrators | Same as organiser |
Conventions
-
Format — requests and responses are JSON (
Content-Type: application/json), except file uploads (multipart/form-data) and PDF downloads. -
Status codes —
200success,201created,204no content,400validation error,401missing/invalid token,403insufficient role/ownership,404not found,409conflict. -
Error envelope — errors raised by controllers share one shape:
{"message": "Only a lecturer, organiser or admin can create a course","path": "/api/courses","timestamp": "2026-09-14T10:12:00.000Z"} -
Timestamps — ISO 8601 UTC strings.
-
Personal data — user endpoints never return the
firebaseUidcolumn; profile lookups are restricted to the caller's own record (organisers/admins excepted).
Endpoint Index
Legend for the Access column: 🔓 no token required, ✅ any authenticated user.
| Method | Path | Access | Purpose |
|---|---|---|---|
| GET | / (service root) | 🔓 | Liveness check for hosting health probes |
| GET | /health (service root) | 🔓 | Database connectivity check |
| GET | /users | ORGANISER, ADMIN | User directory (sanitised fields) |
| GET | /users/me | ✅ | Current user's profile |
| GET | /users/verify-role | ✅ | Check the caller's role against an allow-list |
| GET | /users/:firebaseUid | Self or ORGANISER/ADMIN | Single profile lookup |
| POST | /users | 🔒 token-verified | Sign up — create own user record |
| PUT | /users | ✅ | Update own profile |
| DELETE | /users | ✅ | Deactivate own account |
| GET | /courses | ✅ | List courses with allocation counts |
| GET | /courses/time-slots | ✅ | The bookable time-slot catalogue |
| POST | /courses | LECTURER, ORGANISER, ADMIN | Create a course with slots |
| GET | /courses/:courseId | ✅ | Course detail with slots and assigned tutors |
| PUT | /courses/:courseId | LECTURER, ORGANISER, ADMIN | Update a course |
| PUT | /courses/:courseId/slots | LECTURER, ORGANISER, ADMIN | Replace a course's slots |
| PUT | /courses/open-applications | LECTURER, ORGANISER, ADMIN | Open courses for applications |
| GET | /availability | ✅ | Own availability grid + locked slots |
| PUT | /availability | ✅ | Replace own availability |
| GET | /applications | STUDENT, TUTOR | Tutoring opportunities matching availability |
| GET | /applications/mine | ✅ | Own course applications |
| GET | /applications/:courseId | STUDENT, TUTOR | One opportunity's details |
| POST | /applications/:courseId/apply | STUDENT, TUTOR | Apply with a course mark |
| POST | /onboarding/apply | ✅ | Apply for the TUTOR or LECTURER role |
| GET | /onboarding/tutor-eligible-courses | ✅ | Courses that fit the caller's availability |
| GET | /onboarding/my-applications | ✅ | Own role applications |
| GET | /onboarding/applications | ORGANISER | Pending role applications |
| GET | /onboarding/applications/:id | Organiser or owner | Role application detail |
| GET | /onboarding/applications/:id/rank | ORGANISER | Rank suggestion for a tutor applicant |
| PATCH | /onboarding/applications/:id/review | ORGANISER | Approve/reject a role application |
| GET | /allocation/available-tutors | ORGANISER, ADMIN | Applicant pool ranked by mark |
| GET | /allocation/assignments | ORGANISER, ADMIN | Current tutor assignments |
| POST | /allocation/assignments | ORGANISER, ADMIN | Assign a tutor to a course |
| DELETE | /allocation/assignments/:applicantId | ORGANISER, ADMIN | Unassign (returns applicant to pool) |
| DELETE | /allocation/assignments/:applicantId/remove | ORGANISER, ADMIN | Remove permanently (rejects applicant) |
| GET | /events | TUTOR, LECTURER, ORGANISER, ADMIN | Events visible to the caller's role |
| GET | /events/my-courses | LECTURER | Own courses for event creation |
| GET | /events/claimable | TUTOR | Past unclaimed events in the claim window |
| POST | /events | LECTURER | Create an event on an own course |
| GET | /events/:id | Role-scoped | Single event |
| PUT | /events/:id | LECTURER (own course) | Update an event |
| GET | /swaps/my-sessions | TUTOR | Own sessions available for swapping |
| GET | /swaps/candidates/:eventId | TUTOR | Eligible swap partners for a session, with constraint verdicts |
| GET | /swaps | TUTOR, ORGANISER, ADMIN | Swap requests (own for tutors, all for staff, ?status= filter) |
| POST | /swaps | TUTOR | Request a session swap (one-way or two-way) |
| POST | /swaps/:id/accept | Recipient TUTOR | Agree to the trade |
| POST | /swaps/:id/decline | Recipient TUTOR | Decline with an optional reason |
| POST | /swaps/:id/cancel | Requester TUTOR | Withdraw a pending request |
| POST | /swaps/:id/approve | ORGANISER, ADMIN | Approve — re-validates constraints, then moves the claims |
| POST | /swaps/:id/reject | ORGANISER, ADMIN | Reject an agreed swap with an optional reason |
| GET | /timesheets | ✅ | Own timesheets |
| GET | /timesheets/pending-review | LECTURER, ORGANISER, ADMIN | Submitted timesheets to review |
| GET | /timesheets/supervisors | ✅ | Supervisor directory for entry forms |
| POST | /timesheets | ✅ | Create a draft timesheet |
| GET | /timesheets/:id | Owner/supervisor/staff | Single timesheet |
| PUT | /timesheets/:id | Owner | Replace entries by claiming events |
| DELETE | /timesheets/:id | Owner (draft only) | Delete a draft timesheet |
| POST | /timesheets/:id/submit | Owner | Submit for approval |
| PUT | /timesheets/:id/entries/:entryId/approve | Supervising staff | Approve one entry |
| PUT | /timesheets/:id/reject | Supervising staff | Reject with a reason |
| GET | /timesheets/:id/export | ✅ | Download an approved timesheet as PDF |
| GET | /settings | ✅ | System settings (hours cap, claim window, rates) |
| PUT | /settings/tutor-monthly-hours | ORGANISER, ADMIN | Set the monthly claimable-hours cap |
| PUT | /settings/tutor-event-claim-months | ORGANISER, ADMIN | Set the event claim window |
| PUT | /settings/hourly-rates | ORGANISER, ADMIN | Set hourly rates per year of study |
| PUT | /settings/pdf-template | ORGANISER, ADMIN | Upload the timesheet PDF template |
| GET | /settings/pdf-template | ORGANISER, ADMIN | Download the template |
| DELETE | /settings/pdf-template | ORGANISER, ADMIN | Remove the template |
Users
GET /users — user directory
Organiser/admin view of every registered account. Responses contain only profile fields —
id, studentNumber, employeeNumber, firstName, lastName, email, phoneNumber,
yearOfStudy, role, isActive, createdAt, updatedAt. The Firebase identifier is never exposed.
GET /users/me — own profile
Returns the caller's profile in the same sanitised shape as above.
GET /users/verify-role?roles=TUTOR,LECTURER — role gate check
Returns { "role": "TUTOR", "allowed": true } when the caller's role is in the comma-separated
allow-list, otherwise 403. The frontend route guards use this to reject direct navigation to
another role's page server-side.
GET /users/:firebaseUid — single profile
Fetches any profile by the caller's own Firebase UID. Access is enforced in the controller: a caller may only look up their own record; organisers and admins may look up anyone.
POST /users — sign up
Creates the caller's own user record after Firebase account creation. The request must carry the caller's Firebase ID token, and the token's UID (and email, when present) must match the body:
{
"firstName": "Alan",
"lastName": "Turing",
"email": "alan.turing@wits.ac.za",
"firebaseUid": "the-firebase-uid-of-the-caller"
}
Responds 201 with the created profile. A token belonging to a different Firebase account, or an
email mismatch, returns 403; an already-registered email or UID returns 409. New users get the
STUDENT role.
PUT /users — update own profile
Editable fields only: firstName, lastName, phoneNumber, yearOfStudy
(one of 3rd year, honours, masters, phd). Email and department are read-only.
DELETE /users — deactivate own account
Soft-deletes the caller: sets isActive: false in the database and disables the Firebase account.
Responds 204.
Courses
GET /courses
All courses with allocation progress (assignedCount, tutorsRequired, lecturer name).
POST /courses
Creates a course with its weekly tutoring slots in one atomic call:
{
"courseCode": "COMS3007",
"courseName": "Machine Learning",
"minimumMark": 70,
"tutorsRequired": 2,
"slots": [{ "dayOfWeek": 1, "timeSlotId": 3 }]
}
dayOfWeek is 0 (Monday) to 4 (Friday); timeSlotId references the time-slot catalogue.
Duplicate course codes return 409.
PUT /courses/:courseId and PUT /courses/:courseId/slots
Full course update and slot replacement respectively — both validated the same way as creation.
PUT /courses/open-applications
Opens the application process for the listed courses: { "courseIds": [1, 2] }. Courses that
already have tutors assigned or are already open are skipped, and the response reports
requestedCount, updatedCount and skippedCount.
Availability
GET /availability
The caller's availability grid plus the time-slot catalogue and slots locked by submitted
applications: { timeSlots, availability, lockedSlots }.
PUT /availability
Replaces the caller's availability wholesale: { "availability": [{ "dayOfWeek": 1, "timeSlotId": 3 }] }.
Locked slots (held by submitted applications) must remain in the set — removing them returns 400.
Applications (tutoring opportunities)
Available to students and tutors. Only courses whose tutoring slots fit inside the caller's availability are returned.
GET /applications / GET /applications/:courseId
Opportunity lists/detail with weekly claimable hours and hours-cap warnings (hoursExceeded).
POST /applications/:courseId/apply
Body: { "courseMark": 82 }. The server enforces the course's minimum mark, availability coverage,
no duplicate applications (409), and the organiser-set claimable-hours cap.
Onboarding (role applications)
POST /onboarding/apply
Submits an application for the TUTOR or LECTURER role.
GET /onboarding/applications / GET /onboarding/applications/:id/rank / PATCH /onboarding/applications/:id/review
Organiser tools: the pending queue, a rank suggestion computed from course marks and availability,
and approve/reject ({ "action": "approve" } or { "action": "reject", "reason": "..." }).
Approving a tutor application promotes the user to TUTOR and creates their tutor record.
Allocation
All allocation endpoints are organiser/admin only — they expose applicant personal data and mutate assignments.
GET /allocation/available-tutors?courseId=…
Applicants ranked by course mark (descending), excluding already-assigned applicants.
POST /allocation/assignments
Body: { "applicantId": 12, "allocatedHours": 5, "semester": "2026-S2" }. Creates (or reuses) the
tutor record and the assignment transactionally, then fires a notification email. Enforces the
course's tutor cap (409 on overflow).
DELETE /allocation/assignments/:applicantId and .../remove
Unassign returns the applicant to the pending pool; remove permanently rejects them. Both send an
email notification and respond 204.
Events
GET /events
Role-scoped: tutors see events on their allocated courses, lecturers see events on courses they created, organisers/admins see everything.
POST /events / PUT /events/:id
Lecturer-only, own courses only. Body: { "courseId": 3, "date": "2026-09-21", "description": "tutoring" }
where description is one of tutoring, marking, invigilation, admin. One event per course
per date — duplicates return 409.
GET /events/claimable
Tutor-only: past, unclaimed events on the tutor's allocated courses, inside the organiser-set claim window.
Timesheets
GET /timesheets / POST /timesheets
Own timesheets, and creating a draft (optional body { "name": "September claim" }).
PUT /timesheets/:id
Replaces all entries by claiming events — the body only carries event IDs:
{ "name": "September claim", "entries": [{ "eventId": 41 }, { "eventId": 42 }] }
Course, date, supervisor and duration are derived server-side from the claimed event; manual
entries are rejected. Every event can be claimed exactly once (409 on a taken event).
POST /timesheets/:id/submit
Submits for approval; enforces the monthly claimable-hours cap per rolling 31-day window.
PUT /timesheets/:id/entries/:entryId/approve / PUT /timesheets/:id/reject
Supervising lecturer (or organiser/admin) actions. Rejection requires a reason and resets the
timesheet so the tutor can edit and resubmit. A timesheet becomes APPROVED once every entry is
approved.
GET /timesheets/:id/export
Renders an approved timesheet onto the uploaded PDF template and streams it back as
application/pdf.
Session Swaps
Tutors trade claimed sessions (TimesheetEntry claims on events) peer-to-peer. The platform
validates every swap against the same constraints used when a tutor is allocated — course
allocation, availability coverage, busy blocks, double-booking and the monthly claimable-hours
cap — and the organiser gives the final approval before any session moves.
Status flow: PENDING → (recipient accepts) → ACCEPTED → (organiser approves) → APPROVED.
The recipient can decline and the organiser can reject (both land on REJECTED), and the
requester can CANCEL while the request is still PENDING.
GET /swaps/my-sessions / GET /swaps/candidates/:eventId
Tutor-only. The first lists the caller's swappable sessions (pending claimed events). The second
returns every other tutor allocated to the same course with an eligible flag, the blocking
constraint reasons, and that tutor's own swappable sessions (for two-way trades).
POST /swaps
Body: { "recipientId": 20, "requesterEventId": 100, "recipientEventId": 200, "reason": "…" }.
recipientEventId is optional — omit it for a one-way handover. Every constraint violation
returns 403 with the reasons; a session with a live request returns 409.
POST /swaps/:id/accept|decline|cancel
Recipient-side responses and the requester's withdrawal, each allowed only while the request is
PENDING. Declining and rejecting accept an optional free-text reason.
POST /swaps/:id/approve|reject
Organiser/admin review of ACCEPTED requests. Approval re-validates constraints against current
data — when anything no longer passes, the failure returns 409 and nothing moves. On success
the session's claim (the unique TimesheetEntry.eventId row) is transferred to the receiving
tutor's newest draft timesheet (a fresh draft is opened when none exists) and both affected
timesheet totals are recomputed, all inside one transaction.
Settings
GET /settings
Current system settings: tutorMonthlyClaimableHours, tutorEventClaimMonths, hourlyRates
keyed by year of study, and pdfTemplateFilename.
PUT /settings/…
Organiser/admin configuration updates (hours cap, claim window, hourly rates, PDF template upload
via multipart/form-data field template). Values are validated server-side with sensible bounds
(e.g. hourly rates 0–5000 ZAR).
Service Health
Two unauthenticated endpoints exist purely for infrastructure monitoring and expose no user data:
GET /—{"message":"TutorFlow API is running","timestamp":"…"}(Azure health probe)GET /health— pings the database withSELECT 1, returninghealthyor503 unhealthy
External Service Integrations
The API integrates with two external services over HTTPS:
- Firebase Authentication (Google Identity Platform) — every request's bearer token is verified server-side with the Firebase Admin SDK; account deactivation propagates back by disabling the Firebase account.
- Resend (email delivery) — transactional notification emails (assignment added/removed,
timesheet approved/rejected, swap requested/responded/reviewed) are sent through the Resend
HTTP API, with every attempt recorded and de-duplicated in the
EmailLogtable.