Skip to main content
Version: 1.0

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 as Authorization: Bearer <token>.
  • Send X-Client-Type: mobile on 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:

  1. GET …/form-schema → render the field list. Cache for the session.
  2. For any field with source.endpoint → fetch that endpoint (currently only topicSlugs uses /api/users/topics).
  3. If the user uploads photos / videos → use the two-phase media flow.
  4. POST (create) or PATCH (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 typeUI
textSingle-line input. Enforce maxLength.
textareaMulti-line input. Enforce maxLength.
selectDropdown picker.
multiselectChip / tag picker.
mediaPhoto / video picker, enforce maxItems & acceptedTypes.
urlURL input.
dateDate picker. Submit as ISO YYYY-MM-DD.

select with otherValue + otherInput

When the picked option's value === otherValue:

  1. Show a text input with otherInput.label / placeholder / maxLength.
  2. Submit the typed string under otherInput.submitAs — that key replaces the selected sentinel. So if the user picks Other and types Chess Club, the request body sends "type": "Chess Club" (not "type": "other").

select with dependsOn + optionsByDependency

  • role.dependsOn === "type": when the value of type changes, look up optionsByDependency[currentType] for role's option list.
  • If type is empty or its value isn't a key in the map, clear the role field and disable the dropdown.
  • If type is the free-text "Other" value (any string not in dropdowns.type), use optionsByDependency.other (just Other).

multiselect with source.endpoint

  • Fetch source.endpoint once on screen mount; cache for the session.
  • Render each item using labelField (name); submit an array of valueField (slug) values under submitAs — 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. Send mediareplaces 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. Send topicSlugsreplaces 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

FieldRule
titlerequired, 1–120 chars
type, roleoptional, ≤ 60 / 80 chars
descriptionoptional, ≤ 500 chars
topicSlugsoptional, array of ≤ 10 strings
awardedAtoptional, ISO date (YYYY-MM-DD)
certificateUrloptional, valid URL ≤ 2048 chars
media[].urlrequired 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/topics is max-age=3600 — honor the Cache-Control header.
  • rolesByType is embedded in the form-schema response — no separate call needed when type changes; 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 category string column is still written when present, but new clients should use topicSlugs (request) + topics[] (response) only.
  • Achievement-list reorder endpoint not yet exposed; reorder is one PATCH per row using the order field for now.