Mobile Activities & Achievements — Integration Guide
For mobile development. Everything needed to power the Activity and Achievement create / edit screens — fetch the form schema, render the fields, hit the dropdown endpoints, attach media, submit.
- API base URL (local):
http://localhost:3001 - Auth:
accessToken(logged-in user). Send asAuthorization: Bearer <token>. - Send
X-Client-Type: mobileon every request.
Short version — just the calls
GET /api/users/me/activities/form-schema → field list + type/role dropdowns
GET /api/users/me/activities → list user's activities
POST /api/users/me/activities → create activity
PATCH /api/users/me/activities/:id → update (partial)
DELETE /api/users/me/activities/:id → delete
GET /api/users/me/achievements/form-schema → field list + level dropdown
GET /api/users/me/achievements → list user's achievements
POST /api/users/me/achievements → create achievement
PATCH /api/users/me/achievements/:id → update (partial)
DELETE /api/users/me/achievements/:id → delete
GET /api/users/topics → interest tags (multi-select source)
POST /api/media/presign → step 1 of media upload
PUT <presignedUrl> → step 2 — direct upload to S3
(no step 3 needed for these flows —
pass the inline media shape in
the create/edit body and the
route attaches it server-side)
The flow on both screens is identical:
GET …/form-schema→ render the field list. Cache for the session.- For any field with
source.endpoint→ fetch that endpoint (currently onlytopicSlugsuses/api/users/topics). - If the user uploads photos / videos → use the two-phase media flow.
POST(create) orPATCH(edit) with the assembled body.
1 — Activities
1.1 — Form schema
GET /api/users/me/activities/form-schema
Authorization: Bearer <accessToken>
Response:
{
"success": true,
"data": {
"fields": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"maxLength": 120,
"placeholder": "e.g., School football team",
},
{
"name": "type",
"label": "Type",
"type": "select",
"required": false,
"options": [
{ "value": "club_organisation", "label": "Club / Organisation" },
{ "value": "project", "label": "Project" },
{ "value": "competition", "label": "Competition" },
{ "value": "internship", "label": "Internship / Work" },
{ "value": "volunteer", "label": "Volunteer" },
{ "value": "course", "label": "Course / Program" },
{ "value": "event", "label": "Event / Workshop" },
{ "value": "research", "label": "Research / Publication" },
{ "value": "other", "label": "Other" },
],
"otherValue": "other",
"otherInput": {
"label": "Describe the activity",
"placeholder": "e.g., Chess Club",
"maxLength": 60,
"submitAs": "type",
},
},
{
"name": "role",
"label": "Role",
"type": "select",
"required": false,
"dependsOn": "type",
"optionsByDependency": {
"club_organisation": [
/* Member, Core Team, Lead, … */
],
"project": [
/* Creator, Contributor, Lead, … */
],
"competition": [
/* Participant, Team Member, … */
],
"internship": [
/* Intern, Trainee, Assistant, … */
],
"volunteer": [
/* Volunteer, Contributor, Organiser, … */
],
"course": [
/* Participant, Student, Learner, … */
],
"event": [
/* Attendee, Participant, Volunteer, … */
],
"research": [
/* Researcher, Author, Co-author, … */
],
"other": [{ "value": "other", "label": "Other" }],
},
"otherValue": "other",
"otherInput": {
"label": "Describe your role",
"placeholder": "e.g., Lead Mentor",
"maxLength": 80,
"submitAs": "role",
},
},
{
"name": "description",
"label": "Description",
"type": "textarea",
"required": false,
"maxLength": 500,
"placeholder": "What did you do? What did you learn?",
},
{
"name": "topicSlugs",
"label": "Interest Tags",
"type": "multiselect",
"required": false,
"maxItems": 10,
"source": {
"endpoint": "/api/users/topics",
"valueField": "slug",
"labelField": "name",
},
"submitAs": "topicSlugs",
},
{
"name": "media",
"label": "Photos & Videos",
"type": "media",
"required": false,
"maxItems": 8,
"acceptedTypes": ["image", "video", "gif"],
},
],
"dropdowns": {
"type": [
/* same as fields[type].options */
],
"rolesByType": {
/* same as fields[role].optionsByDependency */
},
},
},
}
Rendering rules
| Field type | UI |
|---|---|
text | Single-line input. Enforce maxLength. |
textarea | Multi-line input. Enforce maxLength. |
select | Dropdown picker. |
multiselect | Chip / tag picker. |
media | Photo / video picker, enforce maxItems & acceptedTypes. |
url | URL input. |
date | Date picker. Submit as ISO YYYY-MM-DD. |
select with otherValue + otherInput
When the picked option's value === otherValue:
- Show a text input with
otherInput.label/placeholder/maxLength. - Submit the typed string under
otherInput.submitAs— that key replaces the selected sentinel. So if the user picksOtherand typesChess Club, the request body sends"type": "Chess Club"(not"type": "other").
select with dependsOn + optionsByDependency
role.dependsOn === "type": when the value oftypechanges, look upoptionsByDependency[currentType]for role's option list.- If
typeis empty or its value isn't a key in the map, clear the role field and disable the dropdown. - If
typeis the free-text "Other" value (any string not indropdowns.type), useoptionsByDependency.other(justOther).
multiselect with source.endpoint
- Fetch
source.endpointonce on screen mount; cache for the session. - Render each item using
labelField(name); submit an array ofvalueField(slug) values undersubmitAs— e.g."topicSlugs": ["chess", "strategy"]. - Same shape + intent as
post.topicSlugs. Server resolves slugs against the Topic catalog and writes ActivityTopic / AchievementTopic join rows, so topics power discovery (e.g. "users with similar activities") not just display. - Server silently drops unknown / inactive slugs — don't depend on the response echoing every slug you sent.
1.2 — Create activity
POST /api/users/me/activities
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"title": "School football team",
"type": "club_organisation", // or free string if user picked Other
"role": "captain", // or free string if user picked Other
"description": "Led the school team to district finals.",
"topicSlugs": ["sports", "teamwork"], // array of slugs from /api/users/topics
"media": [
{ "type": "image", "url": "https://cdn.../photo.jpg", "width": 1080, "height": 1080 }
]
}
Response: 201
{
"success": true,
"data": {
"id": "act_...",
"userId": "usr_...",
"title": "School football team",
"type": "club_organisation",
"role": "captain",
"description": "...",
"order": 0,
"createdAt": "...",
"updatedAt": "...",
"media": [
{
"id": "med_...",
"type": "image",
"url": "...",
"width": 1080,
"height": 1080,
},
],
"topics": [
{ "id": "top_...", "slug": "sports", "name": "Sports", "icon": "..." },
{ "id": "top_...", "slug": "teamwork", "name": "Teamwork", "icon": "..." },
],
},
}
Server validates against activityItemSchema — title is required (≤ 120),
every other field is optional. type and role accept any string ≤ 60 / 80
so the "Other" free-text submits cleanly.
topics[] is the resolved tag list (unknown slugs are dropped silently). The
legacy category string column still exists for back-compat but new clients
should ignore it and read topics[].slug instead.
1.3 — Update activity
PATCH /api/users/me/activities/:id
Authorization: Bearer <accessToken>
Content-Type: application/json
Send any subset of the create fields. Two diff rules:
- Media — Omit
media→ unchanged. Sendmedia→ replaces the current set (URLs present kept, missing dropped + storage cleared, new appended). A re-order with no add/remove still requires the full array. - Topics — Omit
topicSlugs→ unchanged. SendtopicSlugs→ replaces the current join rows (empty array clears all tags).
1.4 — List & delete
GET /api/users/me/activities
DELETE /api/users/me/activities/:id
List response: { success: true, data: [...] } — each row has the same
shape as the create response (inline row fields + media[] + topics[]).
Ordered by order ASC then createdAt ASC.
Delete: { success: true }. Cascades media (rows + storage blobs) and
topic-join rows via FK.
2 — Achievements
2.1 — Form schema
GET /api/users/me/achievements/form-schema
Authorization: Bearer <accessToken>
Response:
{
"success": true,
"data": {
"fields": [
{
"name": "title",
"label": "Title",
"type": "text",
"required": true,
"maxLength": 120,
},
{
"name": "level",
"label": "Level",
"type": "select",
"required": false,
"options": [
{ "value": "school", "label": "School" },
{ "value": "regional", "label": "Regional" },
{ "value": "district", "label": "District" },
{ "value": "national", "label": "National" },
{ "value": "international", "label": "International" },
],
},
{
"name": "awardedAt",
"label": "Date",
"type": "date",
"required": false,
},
{
"name": "description",
"label": "Description",
"type": "textarea",
"required": false,
"maxLength": 500,
},
{
"name": "issuer",
"label": "Issuing Organisation",
"type": "text",
"required": false,
"maxLength": 120,
},
{
"name": "certificateUrl",
"label": "Certificate Link",
"type": "url",
"required": false,
"maxLength": 2048,
},
{
"name": "topicSlugs",
"label": "Categories",
"type": "multiselect",
"required": false,
"maxItems": 10,
"source": {
"endpoint": "/api/users/topics",
"valueField": "slug",
"labelField": "name",
},
"submitAs": "topicSlugs",
},
{
"name": "media",
"label": "Photos & Videos",
"type": "media",
"required": false,
"maxItems": 8,
"acceptedTypes": ["image", "video", "gif"],
},
],
"dropdowns": {
"level": [
/* same as fields[level].options */
],
},
},
}
Same rendering rules as activities. No dependsOn / otherValue here —
level is a closed enum.
2.2 — Create achievement
POST /api/users/me/achievements
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"title": "District Math Olympiad — Gold",
"level": "district",
"awardedAt": "2025-11-12",
"description": "Top score across 800 participants.",
"issuer": "CBSE",
"certificateUrl": "https://cdn.../certificate.pdf",
"topicSlugs": ["math", "olympiad"],
"media": [
{ "type": "image", "url": "https://cdn.../medal.jpg" }
]
}
Response: 201 — shape mirrors the activities create response, including
topics: [{ id, slug, name, icon }].
2.3 — Update achievement
PATCH /api/users/me/achievements/:id
Partial body. Same media + topics diff rules as activities — omit a field to leave it untouched; send it to replace.
2.4 — List & delete
GET /api/users/me/achievements
DELETE /api/users/me/achievements/:id
Same response shape as activities — each row has inline fields + media[] +
topics[]. Delete cascades media + topic-join rows.
3 — Interest tags (/api/users/topics)
Both topicSlugs multi-selects hit the same endpoint.
GET /api/users/topics
Authorization: Bearer <onboardingToken | accessToken>
Rate limit: 30 req / 60 s. Cache: Cache-Control: public, max-age=3600.
{
"success": true,
"data": [
{ "id": "...", "slug": "sports", "name": "Sports", "category": "...", "description": "...", "icon": "...", "order": 1 },
{ "id": "...", "slug": "teamwork", "name": "Teamwork", ... },
"..."
]
}
Use slug as the submitted value, name as the chip label. Group by
category in the picker if you want section headers.
4 — Media upload (two-phase)
Same flow as everywhere else in the app. Don't use the multipart
POST /api/media/upload route except as a fallback — it costs the API a
proxy hop.
4.1 — Presign
POST /api/media/presign
Authorization: Bearer <accessToken>
Content-Type: application/json
{
"fileName": "medal.jpg",
"mimeType": "image/jpeg",
"fileSize": 482910
}
Response:
{
"success": true,
"data": {
"uploadUrl": "https://s3.../...?X-Amz-Signature=...",
"publicUrl": "https://cdn.../medal.jpg",
"objectKey": "...",
},
}
4.2 — Direct PUT to uploadUrl
PUT <uploadUrl>
Content-Type: image/jpeg
<binary body>
No auth header on this one (signature carries auth). Watch for 403 — usually
mismatched Content-Type or Content-Length.
4.3 — Attach (optional during create)
For activity / achievement create + edit you can skip /api/media/attach
and pass the inline shape directly inside media[]:
{
"type": "image", // image | video | gif
"url": "<publicUrl>",
"width": 1080, // optional but recommended
"height": 1080,
"size": 482910, // bytes
"duration": 0, // seconds — video only
"altText": "Gold medal photo",
}
The route's attachMediaToOwner() helper creates the polymorphic Media row
keyed to the activity/achievement and marks it auto-approved.
5 — Validation cheat-sheet
| Field | Rule |
|---|---|
title | required, 1–120 chars |
type, role | optional, ≤ 60 / 80 chars |
description | optional, ≤ 500 chars |
topicSlugs | optional, array of ≤ 10 strings |
awardedAt | optional, ISO date (YYYY-MM-DD) |
certificateUrl | optional, valid URL ≤ 2048 chars |
media[].url | required per item, valid URL |
media[] count | ≤ 8 items |
Validation errors come back as 400 with a Zod-style payload:
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"issues": [{ "path": ["certificateUrl"], "message": "Invalid url" }],
},
}
Surface the first issue per field next to the input.
6 — Caching tips
- Form schemas are deterministic per release — fetch once on screen mount, cache for the session, refetch on app foreground after a long background.
/api/users/topicsismax-age=3600— honor theCache-Controlheader.rolesByTypeis embedded in the form-schema response — no separate call needed whentypechanges; just swap the option list in-memory.
7 — Open items (backend will follow up)
- Discovery endpoints ("users with similar activities", "find people who did the same achievement") are not yet exposed — the ActivityTopic / AchievementTopic join rows already power them server-side.
- The legacy
categorystring column is still written when present, but new clients should usetopicSlugs(request) +topics[](response) only. - Achievement-list reorder endpoint not yet exposed; reorder is one PATCH per
row using the
orderfield for now.