All endpoints are prefixed with /api/v1/. The server also exposes an interactive Swagger UI at /api-docs (auto-generated from JSDoc comments in the route files).
Base URLs:
- Production:
https://via-backend-2j3d.onrender.com - Local:
http://localhost:3000
Protected endpoints require a valid Supabase JWT in the Authorization header:
Authorization: Bearer <supabase_access_token>
The token is validated by the requireAuth middleware (src/middleware/auth.js), which calls supabase.auth.getUser(token) and attaches the result to req.user. Public endpoints do not require a token.
| Endpoint | Auth required |
|---|---|
POST /api/v1/auth/verify-school-email |
No |
GET /api/v1/tags |
No |
GET /api/v1/users/me |
Yes |
GET /api/v1/users/me/friends |
Yes |
POST /api/v1/users/friends/request |
Yes |
POST /api/v1/users/friends/:id/accept |
Yes |
DELETE /api/v1/users/friends/:id |
Yes |
GET /api/v1/routes |
No |
GET /api/v1/routes/search |
No |
GET /api/v1/routes/feed |
Yes only when tab=friends; tab=top and tab=new are public |
POST /api/v1/routes |
Yes |
GET /api/v1/routes/:id |
No |
PATCH /api/v1/routes/:id |
Yes |
DELETE /api/v1/routes/:id |
Yes |
POST /api/v1/routes/:id/vote |
Yes |
GET /api/v1/routes/:id/comments |
No |
POST /api/v1/routes/:id/comments |
Yes |
GET /api/v1/routes/:id/notes |
No |
POST /api/v1/routes/:id/notes |
Yes (creator only) |
PATCH /api/v1/routes/:id/notes/:noteId |
Yes (creator only) |
DELETE /api/v1/routes/:id/notes/:noteId |
Yes (creator only) |
GET /api/v1/events |
No |
POST /api/v1/events |
Yes |
DELETE /api/v1/events/:id |
Yes |
Root health probe.
Response 200
{ "message": "VIA API" }Lightweight liveness check.
Response 200
{ "status": "ok" }Returns every row from the tags lookup table, sorted alphabetically by name. Public; no authentication. Clients can use this to build tag pickers and filters instead of hard-coding tag UUIDs or labels.
Response 200
JSON array of tag objects. May be an empty array if no tags exist.
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "quiet",
"category": "environment"
},
{
"id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"name": "shade",
"category": null
}
]| Field | Type | Description |
|---|---|---|
id |
UUID | Primary key; use when creating routes with tags in POST /api/v1/routes |
name |
string | Unique tag label |
category |
string | null | Optional grouping label |
Response 500
{
"error": "Internal server error",
"message": "…"
}Validates that an email address belongs to an allowed school domain before a user signs up. This is a pre-registration check only — it does not create an account.
Allowed domains: @utexas.edu, @eid.utexas.edu, @my.utexas.edu
Request body
{
"email": "student@utexas.edu"
}| Field | Type | Required | Description |
|---|---|---|---|
email |
string | Yes | Email address to validate |
Response 200 — allowed
{
"allowed": true,
"message": "Email verified successfully"
}Response 200 — not allowed
{
"allowed": false,
"message": "Email domain not allowed. Please use a valid school email address."
}Response 400 — missing or malformed (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "email", "message": "Invalid email format" }]
}Returns the authenticated user's profile and activity statistics.
stats.friends_count is the number of accepted mutual friendships involving the current user.
Required header
Authorization: Bearer <supabase_access_token>
Response 200
{
"id": "a1b2c3d4-...",
"email": "student@utexas.edu",
"display_name": "Alex Student",
"created_at": "2024-09-01T12:00:00Z",
"stats": {
"routes_created": 5,
"routes_saved": 12,
"friends_count": 8
}
}Response 401 — missing or malformed Authorization header
{
"error": "Authentication required",
"message": "Missing or malformed Authorization header. Expected: Bearer <token>"
}Response 401 — expired or invalid token
{
"error": "Invalid token",
"message": "The provided token is invalid or has expired."
}Response 404 — user not found
{
"error": "User not found",
"message": "Could not find user profile"
}Returns all accepted mutual friends for the authenticated user with basic profile info for each.
Required header
Authorization: Bearer <supabase_access_token>
Response 200
{
"data": [
{
"id": "a1b2c3d4-...",
"display_name": "Alex Student",
"email": "alex@utexas.edu",
"friends_since": "2024-09-15T10:00:00Z"
}
],
"count": 1
}Response 401 — missing or invalid JWT
Send a friend request to another user, with mutual-add semantics.
- If no relationship exists between the two users, a pending request is created (
201). - If the target user has already sent you a pending request, it is automatically accepted and a mutual friendship is formed (
200). - Self-requests return
400. - A duplicate pending outbound request or an already-accepted friendship returns
409.
Required header
Authorization: Bearer <supabase_access_token>
Request body
{
"friend_id": "123e4567-e89b-12d3-a456-426614174000"
}| Field | Type | Required | Description |
|---|---|---|---|
friend_id |
UUID | Yes | UUID of the user you are requesting a friendship with |
Response 201 — new pending request created
{
"message": "Friend request sent",
"status": "pending",
"friend_id": "123e4567-e89b-12d3-a456-426614174000"
}Response 200 — reciprocal request auto-accepted
{
"message": "Friend request accepted — you are now mutual friends",
"status": "accepted",
"friend_id": "123e4567-e89b-12d3-a456-426614174000"
}Response 400 — self-request
{
"error": "Invalid request",
"message": "You cannot send a friend request to yourself"
}Response 404 — target user not found
{
"error": "User not found",
"message": "The specified user does not exist"
}Response 409 — duplicate or already friends
{
"error": "Conflict",
"message": "A friend request to this user is already pending"
}Response 401 — missing or invalid JWT
Explicitly accepts an inbound pending friend request from the user identified by :id.
Use this endpoint when the request was not automatically accepted through the reciprocal-request path. :id is the UUID of the other user (the requester), not a friendship-row identifier.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | UUID of the user whose request you are accepting |
Response 200
{
"message": "Friend request accepted",
"status": "accepted",
"friend_id": "123e4567-e89b-12d3-a456-426614174000"
}Response 404 — no inbound pending request from this user
{
"error": "Not found",
"message": "No pending friend request from this user"
}Response 409 — already friends
{
"error": "Conflict",
"message": "You are already friends with this user"
}Response 400 — invalid UUID or self-accept attempt
Response 401 — missing or invalid JWT
Removes the friendship or pending request for the unordered pair (current user, :id), regardless of who originally sent the request or how the row is stored.
Handles three cases with one endpoint:
- Unfriend — removes an accepted friendship.
- Cancel request — removes an outbound pending request you sent.
- Decline request — removes an inbound pending request from the other user.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | UUID of the other user in the relationship |
Response 204 — relationship removed (no body)
Response 404 — no relationship exists
{
"error": "Not found",
"message": "No friendship or pending request exists with this user"
}Response 400 — invalid UUID
Response 401 — missing or invalid JWT
Create a new walking/biking route by submitting recorded GPS points. The server calculates duration and total distance automatically.
Required header
Authorization: Bearer <supabase_access_token>
The authenticated user is recorded as the route's creator_id.
Request body
{
"title": "Quickest way to GDC from Jester",
"description": "Avoids the Speedway crowd.",
"start_label": "Jester West",
"end_label": "GDC 2.216",
"start_time": "2023-10-27T10:00:00Z",
"end_time": "2023-10-27T10:15:00Z",
"tags": ["uuid-of-tag-1", "uuid-of-tag-2"],
"points": [
{ "seq": 1, "lat": 30.2849, "lng": -97.7341, "acc": 3.5, "time": "2023-10-27T10:00:00Z" },
{ "seq": 2, "lat": 30.2855, "lng": -97.7335, "acc": 4.0, "time": "2023-10-27T10:01:00Z" }
]
}| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Short display name for the route |
description |
string | No | Longer optional description |
start_label |
string | Yes | Human-readable start location name |
end_label |
string | Yes | Human-readable end location name |
start_time |
ISO 8601 datetime | Yes | When the route recording started |
end_time |
ISO 8601 datetime | Yes | When the route recording ended |
tags |
UUID[] | No | Array of tag UUIDs from the tags table |
points |
object[] | Yes (≥1) | Array of GPS point objects (see below) |
GPS point object
| Field | Type | Required | Description |
|---|---|---|---|
seq |
integer | Yes | Sequence number (used to order points) |
lat |
float | Yes | Latitude |
lng |
float | Yes | Longitude |
acc |
float | No | GPS accuracy in meters |
time |
ISO 8601 datetime | Yes | Timestamp of the point |
How the server processes points:
- Points are sorted by
seq. - Duration is calculated from
start_timeandend_time. - Total distance is computed with the Haversine formula over consecutive points.
- The route row is inserted via the
create_route_with_geographyRPC function (handles PostGIS types). - All GPS points are inserted via the
insert_route_pointsRPC function. - Tag associations are inserted into
route_tags.
Response 201
{
"route_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Response 400 — missing or invalid fields (Zod validation error shape)
{
"error": "Validation error",
"issues": [
{ "field": "title", "message": "title is required" },
{ "field": "points", "message": "points must contain at least one GPS point" }
]
}Search and list active routes. Supports location-based filtering, tag filtering, multiple sort orders, and offset pagination (limit / offset). Pagination is applied after location and tag filters and after the selected sort order. The filters.total field is the number of matching routes before paging; count is the number of items in the current page (same pattern as GET /api/v1/routes/feed).
Location filtering: When both lat and lng are supplied, the server calls the get_routes_near PostGIS RPC (ST_DWithin on start_point) and restricts results to routes whose start point falls within radius metres of the given coordinate. An empty data array is returned when no routes match.
Destination filtering (
dest_lat,dest_lng) is deprecated — useGET /api/v1/routes/searchinstead. The parameters are still accepted but have no effect.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
lat |
float | — | User's current latitude — activates location filtering when combined with lng |
lng |
float | — | User's current longitude — activates location filtering when combined with lat |
radius |
integer | 500 |
Search radius in metres (applied when lat + lng are provided) |
dest_lat |
float | — | Deprecated — use GET /api/v1/routes/search. Accepted but unused. |
dest_lng |
float | — | Deprecated — use GET /api/v1/routes/search. Accepted but unused. |
tags |
string | — | Comma-separated tag names to filter by (e.g., shade,quiet) |
sort |
string | recent |
Sort order: recent, popular, or efficient |
limit |
integer | 20 |
Page size (minimum 1, maximum 100) |
offset |
integer | 0 |
Number of matching routes to skip (non-negative) |
Sort options
| Value | Behavior |
|---|---|
recent |
Newest routes first (created_at descending) |
popular |
Most total votes first |
efficient |
Shortest distance first (distance_meters ascending) |
Response 200
{
"data": [
{
"id": "f47ac10b-...",
"title": "Quickest way to GDC from Jester",
"start_label": "Jester West",
"end_label": "GDC 2.216",
"distance_meters": 820,
"avg_rating": 0.75,
"tags": ["shade", "quiet"],
"preview_polyline": "ypzpDfkrpNqAzB...",
"created_at": "2023-10-27T10:15:00Z"
}
],
"count": 1,
"filters": {
"lat": null,
"lng": null,
"radius": 500,
"tags": "shade,quiet",
"sort": "popular",
"limit": 20,
"offset": 0,
"total": 1
}
}preview_polyline details: A Google Encoded Polyline string derived from the route's GPS points. Up to 20 evenly-sampled points are encoded (first and last points are always preserved). The field is null when the route has no stored points.
Response 400 — invalid query parameters (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "lat", "message": "Number must be greater than or equal to -90" }]
}avg_rating calculation: (upvotes − downvotes) / total_votes, rounded to 2 decimal places. Returns 0 when there are no votes.
Search for routes that connect a specific origin to a specific destination. The server uses PostGIS ST_DWithin on both start_point and end_point to find full matches, then ranks them by duration_seconds ascending. When no route satisfies both proximity constraints, routes near the origin are returned as a fallback.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
from_lat |
float | required | Origin latitude |
from_lng |
float | required | Origin longitude |
to_lat |
float | required | Destination latitude |
to_lng |
float | required | Destination longitude |
from_radius |
integer | 300 |
Metres from origin to match a route's start_point |
to_radius |
integer | 300 |
Metres from destination to match a route's end_point |
Logic:
- Calls the
get_routes_betweenRPC —ST_DWithinon bothstart_pointandend_point. - Sorts matching routes by
duration_secondsascending (shortest trip first) and returns them indatawithmatched: true. - If no matches: falls back to
get_routes_nearon the origin and returns those results indatawithmatched: false.
Response 200 — full match found (matched: true)
{
"data": [
{
"id": "f47ac10b-...",
"title": "Quickest way to GDC from Jester",
"start_label": "Jester West",
"end_label": "GDC 2.216",
"distance_meters": 820,
"avg_rating": 0.75,
"tags": ["shade", "quiet"],
"preview_polyline": "ypzpDfkrpNqAzB...",
"created_at": "2023-10-27T10:15:00Z"
}
],
"count": 1,
"search": {
"from_lat": 30.284,
"from_lng": -97.734,
"to_lat": 30.286,
"to_lng": -97.731,
"from_radius": 300,
"to_radius": 300,
"matched": true
}
}Response 200 — no full match (matched: false)
{
"data": [{ "id": "...", "title": "..." }],
"count": 1,
"search": {
"from_lat": 30.284,
"from_lng": -97.734,
"to_lat": 30.286,
"to_lng": -97.731,
"from_radius": 300,
"to_radius": 300,
"matched": false
}
}When matched: true, data is sorted by duration_seconds ascending (shortest trip first). When matched: false, data contains routes near the origin only. Route objects share the same shape as the items returned by GET /api/v1/routes.
Response 400 — missing or invalid query parameters (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "from_lat", "message": "from_lat is required" }]
}Response 500 — database error
Home feed for Top, Friends, and New tabs. Each route object matches the list shape from GET /api/v1/routes (id, creator, title, labels, distance_meters, avg_rating, tags, preview_polyline, created_at), except the Top tab also includes feed_score (see below).
Authentication: Required only when tab=friends (Authorization: Bearer <token>). Missing or invalid tokens return 401 with the same shape as other protected routes.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
tab |
string | — | Required. top, friends, or new |
limit |
integer | 20 |
Page size (max 100) |
offset |
integer | 0 |
Number of rows to skip (offset pagination) |
lat |
float | — | When set with lng, restricts to routes whose start point is within radius m (same RPC as GET /api/v1/routes) |
lng |
float | — | See lat |
radius |
integer | 500 |
Search radius in metres when lat + lng are provided |
Tab behavior
tab |
Ordering / selection |
|---|---|
top |
Loads up to 500 most recently created active routes (after any location filter), computes a hot score from upvotes and age, sorts descending, then applies offset / limit. |
new |
Newest routes first (created_at descending), with database-level offset / limit. |
friends |
Routes whose creator_id belongs to one of the caller's accepted mutual friends (either side of friends); merged and sorted by created_at descending, then offset / limit in memory. Large friend lists are queried in chunks of 100 creator IDs. |
Top tab hot score
The server ranks top using:
feed_score = (1 + upvotes) / ((age_hours + 2) ^ 1.5)
where upvotes is the count of votes rows with vote_type = 'up' for that route, and age_hours is the non-negative number of hours since routes.created_at. Responses expose this as feed_score (rounded to 6 decimal places). Ties are broken by newer created_at first.
Pagination note: For tab=top, ordering is by score over a capped candidate set; if underlying vote counts or ages change between requests, offset pagination can shift slightly. Prefer smaller pages or refetch from offset=0 when refreshing the Top feed.
Response 200
{
"data": [
{
"id": "f47ac10b-...",
"creator_id": "...",
"creator": { "id": "...", "full_name": "Alex", "email": "..." },
"title": "Quickest way to GDC from Jester",
"start_label": "Jester West",
"end_label": "GDC 2.216",
"distance_meters": 820,
"avg_rating": 0.75,
"tags": ["shade"],
"preview_polyline": "ypzpDfkrpNqAzB...",
"created_at": "2023-10-27T10:15:00Z",
"feed_score": 0.142857
}
],
"count": 1,
"filters": {
"tab": "top",
"limit": 20,
"offset": 0,
"lat": null,
"lng": null,
"radius": 500,
"total": 42
}
}filters.total is the number of routes matching the tab before applying the current page slice (for new, this is the full matching count from the database; for top, the number of scored candidates, at most 500 before location filter). count is the number of items in data for this response.
The feed_score field is present only when tab=top.
Response 401 — tab=friends without a valid Bearer token.
Response 400 — invalid query (e.g. missing tab, bad limit).
Get the full details of a single route including all GPS points and tags. Public; no authentication required.
Geo-tagged notes for the route are fetched separately via GET /api/v1/routes/:id/notes.
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Response 200
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"title": "Quickest way to GDC from Jester",
"description": "Avoids the Speedway crowd.",
"start_label": "Jester West",
"end_label": "GDC 2.216",
"distance_meters": 820,
"duration_seconds": 900,
"start_time": "2023-10-27T10:00:00Z",
"end_time": "2023-10-27T10:15:00Z",
"avg_rating": 0.75,
"vote_count": 4,
"tags": ["shade", "quiet"],
"route_points": [
{ "seq": 1, "lat": 30.2849, "lng": -97.7341, "accuracy_meters": 3.5, "recorded_at": "2023-10-27T10:00:00Z" },
{ "seq": 2, "lat": 30.2855, "lng": -97.7335, "accuracy_meters": 4.0, "recorded_at": "2023-10-27T10:01:00Z" }
],
"created_at": "2023-10-27T10:15:00Z"
}route_points are sorted by seq (ascending). avg_rating uses the same calculation as GET /api/v1/routes: (upvotes − downvotes) / total_votes, rounded to 2 decimal places.
Response 404 — route not found or inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Response 500 — database error
{
"error": "Failed to fetch route",
"message": "..."
}Update the editable fields on a route you created. At least one of title or description must be provided. Sending an empty string for description clears the field and stores null.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Request body
{
"title": "Quieter walk to GDC",
"description": "Cuts behind the library and avoids Speedway."
}All fields are optional, but the request body must include at least one of them.
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | No | Public route title; must not be empty when provided |
description |
string | No | Public route description |
Response 200
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"title": "Quieter walk to GDC",
"description": "Cuts behind the library and avoids Speedway."
}Response 400 — invalid or empty body (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "(root)", "message": "At least one field must be provided" }]
}Response 401 — missing or malformed Authorization header, or invalid token
Response 403 — authenticated user is not the route creator
{
"error": "Forbidden",
"message": "You can only update routes you created"
}Response 404 — route not found or inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Soft-deletes a route by setting is_active = false. Only the original creator may call this endpoint.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Response 200
{ "message": "Route deactivated successfully" }Response 403 — caller is not the route creator
{
"error": "Forbidden",
"message": "You can only delete routes you created"
}Response 404 — route not found or already inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Cast an upvote or downvote on a route with a context category. One vote per user per route — re-voting replaces the previous vote (upsert).
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Request body
{
"vote_type": "up",
"context": "safety"
}| Field | Type | Required | Values |
|---|---|---|---|
vote_type |
string | Yes | up, down |
context |
string | Yes | safety, efficiency, scenery |
Response 201
{
"message": "Vote recorded successfully",
"route_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"vote_type": "up",
"context": "safety",
"vote_count": 5,
"upvotes": 4,
"downvotes": 1,
"avg_rating": 0.60
}Response 400 — missing or invalid fields (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "vote_type", "message": "vote_type must be 'up' or 'down'" }]
}Response 404 — route not found or inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Response 500 — database error
{
"error": "Failed to record vote",
"message": "..."
}List comments for a route in chronological order.
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Query parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
limit |
integer | No | 20 |
Maximum comments to return; capped at 100 |
cursor |
UUID | No | - | The last comment ID from the previous page |
Response 200
{
"comments": [
{
"id": "a1b2c3d4-...",
"route_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"user_id": "e5f6a7b8-...",
"content": "Super cool route Nolan!",
"created_at": "2024-09-01T12:00:00Z",
"author_display_name": "Nolan"
}
],
"next_cursor": "a1b2c3d4-..."
}Comments are sorted by created_at ascending. next_cursor is null when there are no more comments to fetch.
Response 400 — invalid query params or unknown cursor
{
"error": "Validation error",
"issues": [{ "field": "cursor", "message": "cursor must be a valid UUID" }]
}Or:
{
"error": "Invalid cursor",
"message": "cursor must reference an existing comment for this route"
}Response 404 — route not found or inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Response 500 — database error
{
"error": "Failed to fetch comments",
"message": "..."
}Add a comment to a route.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Request body
{
"content": "Super cool route Nolan!"
}| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Yes | Comment text (must not be empty) |
Response 201
{
"message": "Comment added successfully",
"comment_id": "a1b2c3d4-...",
"route_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"user_id": "e5f6a7b8-...",
"content": "Super cool route Nolan!",
"created_at": "2024-09-01T12:00:00Z"
}Response 400 — missing or empty content (Zod validation error shape)
{
"error": "Validation error",
"issues": [{ "field": "content", "message": "content must not be empty" }]
}Response 404 — route not found or inactive
{
"error": "Route not found",
"message": "No active route found with id f47ac10b-58cc-4372-a567-0e02b2c3d479"
}Response 500 — database error
{
"error": "Failed to add comment",
"message": "..."
}Returns all geo-tagged notes attached to a route, ordered by creation time ascending. Public — no authentication required.
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Response 200 — JSON array (may be empty)
[
{
"id": "a1b2c3d4-...",
"route_id": "f47ac10b-...",
"author_id": "e5f6a7b8-...",
"content": "Watch your step here — loose pavement",
"lat": 30.2849,
"lng": -97.7341,
"created_at": "2024-09-01T12:00:00Z",
"updated_at": null
}
]Response 404 — route not found or inactive
Creates a new geo-tagged note on a route. Only the route creator can add notes. The coordinate (lat, lng) should be a point snapped to the route path.
Required header
Authorization: Bearer <supabase_access_token>
Path parameter
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
Request body
{
"content": "Watch your step here — loose pavement",
"lat": 30.2849,
"lng": -97.7341
}| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Yes | Note text (must not be empty) |
lat |
float | Yes | Latitude of the note's map pin |
lng |
float | Yes | Longitude of the note's map pin |
Response 201 — the created note object (same shape as GET list items)
Response 400 — validation error
Response 401 — missing or invalid token
Response 403 — authenticated user is not the route creator
{
"error": "Forbidden",
"message": "Only the route creator can add notes"
}Response 404 — route not found or inactive
Updates the text content of an existing note. Only the route creator can edit notes. The note's coordinates cannot be changed after creation.
Required header
Authorization: Bearer <supabase_access_token>
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
noteId |
UUID | Note UUID |
Request body
{
"content": "Repaired — safe to cross now"
}| Field | Type | Required | Description |
|---|---|---|---|
content |
string | Yes | Replacement note text (must not be empty) |
Response 200 — the updated note object
Response 400 — validation error
Response 401 — missing or invalid token
Response 403 — caller is not the route creator
Response 404 — route or note not found
Permanently deletes a note. Only the route creator can delete notes.
Required header
Authorization: Bearer <supabase_access_token>
Path parameters
| Parameter | Type | Description |
|---|---|---|
id |
UUID | Route UUID |
noteId |
UUID | Note UUID |
Response 204 — no content
Response 401 — missing or invalid token
Response 403 — caller is not the route creator
Response 404 — route or note not found
Files a new time-bounded campus event at a given location. The server computes expires_at as NOW() + duration_minutes * 1 minute.
Required header
Authorization: Bearer <supabase_access_token>
Request body
{
"type": "crowd_protest",
"duration_minutes": 30,
"lat": 30.2849,
"lng": -97.7341,
"description": "Large gathering near the union",
"location_label": "West Mall",
"route_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}| Field | Type | Required | Description |
|---|---|---|---|
type |
string | Yes | One of construction, muddy_path, crash, weapon, unsafe, blocked_road, police, crowd_protest |
duration_minutes |
integer | No | Positive integer; controls how long the event is visible. If omitted, the database RPC defaults to 120 minutes |
lat |
float | Yes | Latitude of the event location |
lng |
float | Yes | Longitude of the event location |
description |
string | No | Optional free-text detail |
location_label |
string | No | Human-readable location name |
route_id |
UUID | No | Route the user was navigating when they filed the event |
Optional body fields (duration_minutes, location_label, route_id) are forwarded to create_event_with_geography when present.
Response 201
{
"event_id": "a1b2c3d4-...",
"message": "Event created successfully"
}Response 400 — validation error
{
"error": "Validation error",
"issues": [{ "field": "type", "message": "type is required" }]
}Response 401 — missing or invalid token
{
"error": "Unauthorized",
"message": "..."
}Returns all active, non-expired campus events. When lat and lng are supplied, results are filtered to within radius metres of that point.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
lat |
float | — | Latitude of the centre point (requires lng) |
lng |
float | — | Longitude of the centre point (requires lat) |
radius |
integer | 500 |
Spatial filter radius in metres (only used when lat/lng are provided) |
Response 200
{
"data": [
{
"id": "a1b2c3d4-...",
"reporter_id": "b2c3d4e5-...",
"type": "crowd",
"description": "Big crowd near the union",
"lat": 30.2849,
"lng": -97.7341,
"location_label": "West Mall",
"route_id": null,
"duration_minutes": 30,
"expires_at": "2025-10-27T11:30:00Z",
"is_active": true,
"created_at": "2025-10-27T11:00:00Z"
}
],
"count": 1
}Response 400 — lat supplied without lng or vice-versa
Soft-deletes an event by setting is_active = false. Only the original reporter may call this endpoint.
Required header
Authorization: Bearer <supabase_access_token>
Response 200
{ "message": "Event deactivated successfully" }Response 403 — caller is not the reporter
{
"error": "Forbidden",
"message": "You can only deactivate events you reported"
}Response 404 — event not found or already inactive
{
"error": "Event not found",
"message": "No event found with id a1b2c3d4-..."
}All error responses share a consistent shape:
{
"error": "Short machine-readable label",
"message": "Human-readable explanation"
}Some endpoints include a details field with the underlying database error message for debugging:
{
"error": "Failed to create route",
"details": "insert or update on table \"routes\" violates foreign key constraint ..."
}When a request body or query string fails Zod schema validation, the server returns:
{
"error": "Validation error",
"issues": [
{ "field": "points", "message": "points must contain at least one GPS point" },
{ "field": "start_time", "message": "start_time must be a valid ISO 8601 datetime" }
]
}Each entry in issues has:
| Field | Type | Description |
|---|---|---|
field |
string | Dot-path to the offending field (e.g. points.0.lat), or (root) for top-level type errors |
message |
string | Human-readable reason the field failed validation |
This shape is returned by every endpoint that uses the validateBody / validateQuery middleware — the legacy one-off 400 shapes documented per-endpoint below are superseded by this format for field-level errors. Non-validation 400 responses (e.g. business-logic rejections) continue to use the standard { error, message } shape.