# Mobile Profile — Edit Profile Integration Guide

> For mobile development. Everything the **Edit profile** screen needs: read the
> current values, save name / bio / display name, change or remove the avatar,
> and manage school info.

- API base URL (local): `http://localhost:3001`
- Auth: `Authorization: Bearer <accessToken>` on every call below.
- Send `X-Client-Type: mobile` on every request.

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-09** — Deleting an account now gives the user **30 days** to change their mind, not 14 — if your screen says 14 days anywhere, fix the copy. Logging in again inside that window still restores everything. Two things are kept after the 30 days and are not restorable: a record of the name, username, display name, signup date and **email address**, deleted 180 days after the request, and anything already removed for a rule violation, deleted 180 days after that decision. New section: [6 — Deleting the account](#6--deleting-the-account). **Correction, same day:** an earlier version of this line said the email was kept only as a one-way hash. That was wrong — the address itself is retained, so a legal or support request can be answered. Don't tell users their address is unrecoverable.

- **2026-09-08** — Every education response now carries a nested `school` object (`id`, `name`, `state`, `district`) for entries saved with a `schoolId` — read the school's state/district from there, they were never stored on the entry itself. Applies to `GET /api/users/me`, and to `GET` / `POST` / `PUT` / `PATCH` on `/api/users/me/education`. `school` is `null` when the entry has no `schoolId`; nothing else about the shape changed.

- **2026-09-07** — `PATCH /api/users/me/profile` now saves `firstName` and `lastName` too, so the whole Edit-profile form is one call (except the avatar and school info). **Breaking:** the same endpoint no longer accepts an avatar **URL** — `avatarUrl` takes only `null`, which removes the photo. Setting one has always gone through the presign → `PUT /me/avatar` flow, and now that is the only way. `GET /api/users/me` also returns `education`, so the form prefills in a single request.

---

## Short version — just the calls

```
GET   /api/users/me                       → prefill the whole form (profile + names + education)

PATCH /api/users/me/profile               → save firstName, lastName, displayName, bio,
                                            location, website, isPrivate
                                            (and avatarUrl: null to REMOVE the photo)

POST  /api/users/me/avatar/presign        → change the photo, step 1
PUT   <uploadUrl>                         → step 2 (raw bytes, straight to storage)
PUT   /api/users/me/avatar                → step 3 (confirm; builds the webp variants)

GET   /api/users/me/education             → school info, read
PUT   /api/users/me/education             → school info, save the whole list at once
POST  /api/users/me/education             → add one entry
PATCH /api/users/me/education/:id         → edit one entry
DELETE /api/users/me/education/:id        → remove one entry

DELETE /api/users/me                      → delete the account (password in the body;
                                            30 days to undo by logging back in)
```

**Three things save separately.** The text fields are one `PATCH`, the avatar is
the 3-step upload, school info is its own list. If your Save button changes all
three, fire all three — there is no combined endpoint, deliberately: the avatar
is binary and needs server-side processing, and school info is a list with its
own add/edit/delete affordances.

---

## 1 — Prefill the form

```http
GET /api/users/me
Authorization: Bearer <accessToken>
```

One call gives you everything the form needs:

```jsonc
{
  "success": true,
  "data": {
    "id": "cml805q0u0000iaqhjdfk6z2y",
    "email": "alice@example.com",
    "username": "alice",
    "firstName": "Alice", // ← User row, editable below
    "lastName": "Kaur", // ← User row, editable below
    "dateOfBirth": "2010-05-05T00:00:00.000Z",
    "gender": null,
    "phoneNumber": null,
    "isMinor": true,
    "emailVerifiedAt": null,
    "onboardedAt": "2026-02-04T12:27:15.068Z",
    "profile": {
      "displayName": "Alice Johnson",
      "bio": "hello",
      "avatarUrl": "https://…/avatars/<userId>/display.webp",
      "location": "Mumbai, IN",
      "website": null,
      "isPrivate": false,
    },
    "education": [
      // ← school info, same rows as GET /me/education
      {
        "id": "edu_…",
        "schoolId": "sch_…",
        "schoolName": "Delhi Public School",
        "level": "high",
        "grade": "10th",
        "startYear": null,
        "endYear": null,
        "isCurrent": true,
        "city": "Pune",
        "country": "IN",
        "order": 0,
        "school": {
          // ← joined catalog row, null when schoolId is null
          "id": "sch_…",
          "name": "Delhi Public School",
          "state": "Maharashtra",
          "district": "PUNE",
        },
      },
    ],
    "_count": { "followers": 12, "following": 30, "posts": 4 },
  },
}
```

`education` is ordered by `order`, then by creation time — render it in that
order and it matches `GET /api/users/me/education` exactly.

**Name vs display name.** `firstName`/`lastName` are the real name on the user
record. `profile.displayName` is what appears on posts, in chat and in search.
They are independent — editing one does not touch the other. If your form shows
a single "Full name" field, map it to `firstName` + `lastName` yourself (split on
the first space, the way signup does).

---

## 2 — Save the text fields

```http
PATCH /api/users/me/profile
Authorization: Bearer <accessToken>
Content-Type: application/json
```

**Partial update — send only what changed.** Any field you omit is left alone.

```jsonc
{
  "firstName": "Alice",
  "lastName": "Kaur",
  "displayName": "Alice K",
  "bio": "10th grade · debate club",
  "location": "Pune, IN",
  "website": "https://alice.example.com",
  "isPrivate": false,
}
```

| Field         | Type           | Limit         | Notes                                                                                          |
| ------------- | -------------- | ------------- | ---------------------------------------------------------------------------------------------- |
| `firstName`   | string \| null | 1–50 chars    | Trimmed. `null` or `""` clears it.                                                             |
| `lastName`    | string \| null | 1–50 chars    | Trimmed. `null` or `""` clears it.                                                             |
| `displayName` | string         | ≤ 50 chars    | The public name on posts/chat/search.                                                          |
| `bio`         | string         | ≤ 160 chars   |                                                                                                |
| `avatarUrl`   | `null` only    | —             | **Removes** the photo. See §3.2.                                                               |
| `location`    | string         | ≤ 100 chars   | Free text, not validated against a place list.                                                 |
| `website`     | string \| null | must be a URL | `""` is accepted and stored as `null`.                                                         |
| `isPrivate`   | boolean        | —             | Private-profile toggle — see [`mobile-follow-graph-guide.md`](./mobile-follow-graph-guide.md). |

**Response** — the profile plus the two name fields merged in, so you can write
the whole form state back from one response:

```jsonc
{
  "success": true,
  "data": {
    "id": "…",
    "userId": "…",
    "firstName": "Alice",
    "lastName": "Kaur",
    "displayName": "Alice K",
    "bio": "10th grade · debate club",
    "avatarUrl": "https://…/display.webp",
    "location": "Pune, IN",
    "website": null,
    "isPrivate": false,
    "createdAt": "…",
    "updatedAt": "…",
  },
}
```

Name and profile fields are written in **one transaction** — either the whole
save lands or none of it does. You never need to reconcile a half-saved form.

### 2.1 — Errors

| Status | When                                                                                                  | What to show                                                                                                                                                                                                                                  |
| ------ | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | A field failed validation — name over 50 chars, `website` not a URL, **`avatarUrl` sent as a string** | Field-level error.                                                                                                                                                                                                                            |
| `401`  | Missing/expired token                                                                                 | Re-auth.                                                                                                                                                                                                                                      |
| `422`  | The text tripped the content filter                                                                   | `code` is `CONTENT_BLOCKED`, `message` is "Please use kinder words.", and `field` names the offending input (e.g. `bio`). Highlight that field. See [`mobile-content-moderation-guide.md`](../moderation/mobile-content-moderation-guide.md). |

The filter applies to every text field here — `bio`, `displayName`, `firstName`,
`lastName`, `location`. It does **not** apply to `website` (validated as a URL).

---

## 3 — Avatar

### 3.1 — Change the photo (3 steps)

The avatar is not part of the `PATCH` body — it is binary, and the server builds
two square webp variants from it (480×480 display, 240×240 thumbnail).

**Step 1 — ask for an upload URL:**

```http
POST /api/users/me/avatar/presign
Content-Type: application/json

{ "contentType": "image/jpeg", "size": 842311 }
```

```jsonc
{
  "success": true,
  "data": {
    "uploadUrl": "https://…?X-Amz-Signature=…", // PUT the bytes here
    "publicUrl": "https://…/avatars/<userId>/1757246000.jpg",
    "key": "avatars/<userId>/1757246000.jpg",
  },
}
```

JPG, PNG and WebP only. `size` must be the real byte length — it is pinned into
the signature, so a mismatched upload is rejected by storage.

| Status                   | When                         |
| ------------------------ | ---------------------------- |
| `415` `UNSUPPORTED_TYPE` | Not an image type we accept. |
| `413` `FILE_TOO_LARGE`   | Over the image size cap.     |

**Step 2 — upload the bytes** straight to `uploadUrl` with `PUT` and the same
`Content-Type`. Does not go through our API — no `Authorization` header.

**Step 3 — confirm:**

```http
PUT /api/users/me/avatar
Content-Type: application/json

{ "avatarUrl": "<publicUrl from step 1>" }
```

```jsonc
{
  "success": true,
  "data": { "avatarUrl": "https://…/avatars/<userId>/display.webp" },
}
```

This step is what actually processes the image: it verifies the bytes really are
an image (`415` if not), writes `display.webp` + `thumbnail.webp`, deletes the
raw upload **and** the previous avatar, then stores the new URL. **The URL you
get back is not the one you sent** — always render the returned one.

There is a multipart fallback, `PATCH /api/users/me/avatar` (one file field,
JPG/PNG), for clients that cannot do a presigned `PUT`. Prefer the 3-step flow —
the fallback skips variant generation.

### 3.2 — Remove the photo

```http
PATCH /api/users/me/profile
Content-Type: application/json

{ "avatarUrl": null }
```

Clears the avatar and deletes the stored files. Response has
`"avatarUrl": null`.

> **`avatarUrl` accepts only `null` here.** Sending a URL string is a `400`.
> Setting an avatar has to go through §3.1 so the variants get built and the old
> file is cleaned up — a client-supplied URL skipped both. If you were previously
> PATCHing an avatar URL, switch to the presign flow.

---

## 4 — School info

School info is a **list** (`Education` rows), not a single field — a user can
have several entries. It lives on its own endpoints.

```http
GET    /api/users/me/education
POST   /api/users/me/education
PUT    /api/users/me/education
PATCH  /api/users/me/education/:id
DELETE /api/users/me/education/:id
```

**Entry shape** (all fields optional except `schoolName`):

| Field                   | Type    | Limit                                                      | Notes                                                                           |
| ----------------------- | ------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `schoolName`            | string  | 1–120 chars, **required**                                  | The displayable label — source of truth for rendering.                          |
| `schoolId`              | string  | ≤ 64 chars                                                 | Catalog id from the school picker. Omit for a school the user typed themselves. |
| `level`                 | enum    | `elementary` \| `middle` \| `high` \| `college` \| `other` |                                                                                 |
| `grade`                 | string  | ≤ 20 chars                                                 | "5th", "10th".                                                                  |
| `startYear` / `endYear` | int     | 1900 – next year                                           | Number, not a string.                                                           |
| `isCurrent`             | boolean |                                                            |                                                                                 |
| `city` / `country`      | string  | ≤ 80 chars                                                 |                                                                                 |
| `order`                 | int     | ≥ 0                                                        | Display order. Defaults to list position on bulk save.                          |

**`school` is response-only.** Every education response (including `GET /api/users/me`)
joins the catalog row behind `schoolId` and returns it as a nested object:

```jsonc
"school": { "id": "sch_…", "name": "Delhi Public School", "state": "Maharashtra", "district": "PUNE" }
```

It is `null` when the entry has no `schoolId`. `state` and `district` exist **only**
here — the entry's own `city`/`country` are free text the user typed. Don't send
`school` in a request body; it is ignored.

**Always send `schoolId` when the user picked from the catalog.** Get it from the
school picker in [`mobile-schools-guide.md`](./mobile-schools-guide.md) — it is
what links the user to a real school. `schoolName` alone still saves and still
renders, it just isn't linked.

**For an Edit-profile screen, use `PUT` (bulk replace).** It takes the whole list
and replaces it in one transaction, which is exactly what a form's Save button
means:

```http
PUT /api/users/me/education
Content-Type: application/json

{
  "items": [
    { "schoolId": "sch_…", "schoolName": "Delhi Public School",
      "level": "high", "grade": "10th", "isCurrent": true,
      "city": "Pune", "country": "IN", "order": 0 }
  ]
}
```

Sending `{ "items": [] }` removes every entry. The response is the saved list,
re-read in display order — render from it, don't re-render from your local state.

`POST` / `PATCH` / `DELETE` exist for a screen that edits one entry at a time.
`PATCH` and `DELETE` return `404` for an id that isn't yours — the same 404 a
missing row gives, so ids can't be probed.

---

## 5 — What the form maps to

| Form field                     | Call                                     | Body key                                                                     |
| ------------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------- |
| First name                     | `PATCH /me/profile`                      | `firstName`                                                                  |
| Last name                      | `PATCH /me/profile`                      | `lastName`                                                                   |
| Display name                   | `PATCH /me/profile`                      | `displayName`                                                                |
| Bio                            | `PATCH /me/profile`                      | `bio`                                                                        |
| Location                       | `PATCH /me/profile`                      | `location`                                                                   |
| Website                        | `PATCH /me/profile`                      | `website`                                                                    |
| Private account toggle         | `PATCH /me/profile`                      | `isPrivate`                                                                  |
| Change photo                   | presign → `PUT` bytes → `PUT /me/avatar` | `avatarUrl` (the presigned `publicUrl`)                                      |
| Remove photo                   | `PATCH /me/profile`                      | `avatarUrl: null`                                                            |
| School / grade / year          | `PUT /me/education`                      | `items[]`                                                                    |
| Interests                      | `PUT /api/users/me/interests`            | see [`mobile-topics-interests-guide.md`](./mobile-topics-interests-guide.md) |
| Username, email, date of birth | **not editable**                         | —                                                                            |

Username, email and date of birth are read-only on this screen — there is no
endpoint to change them.

---

## 6 — Deleting the account

```
DELETE /api/users/me     body: { "password": "<their current password>" }  → 202
```

Password-gated, and it takes effect immediately: the account goes dormant, every
session and refresh token is revoked, live sockets are dropped. Treat the `202`
as a logout — your tokens are dead from that moment.

**It is reversible for 30 days.** Logging in with the same credentials inside that
window restores the account and everything in it; `POST /api/auth/login` returns
`data.reactivated: true` so you can say "welcome back" instead of "logged in".
After 30 days — or once the purge has started — the same login returns `410 Gone`
and there is nothing to restore.

Say 30 days in your confirmation screen. Two things deliberately outlive it, and
neither is restorable:

| Kept after the 30 days | For how long | Why |
| --- | --- | --- |
| Name, username, display name, signup date and the **email address** | 180 days from the moment they asked to be deleted | So a legal or support request about the account can be answered. Nothing in the app serves this — there is no endpoint that reads it. |
| Anything already removed for a rule violation | 180 days from the moderation decision | Evidence for a dispute or appeal. It stays hidden the whole time — nobody, including the author, can see it. |

Both windows run from those two events, not from each other: the profile goes at
day 30 and the identity record at day 180 **from the same request**, not 30 + 180.

There is no "download my data" endpoint yet — don't offer one in the UI.
