Skip to content

Commit 10be795

Browse files
authored
Merge pull request #154 from feyishola/feat/replace-mock-user-helpers-with-profile-api
feat(users): replace mock user helpers with the Prisma-backed Profile…
2 parents 15a9f44 + df5b36f commit 10be795

26 files changed

Lines changed: 4538 additions & 870 deletions

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules,
1616

1717
- [x] Express/TypeScript service, versioned router, Prisma/PostgreSQL setup, security headers, logging, and error middleware exist.
1818
- [~] JWT registration/login exists with rotating refresh sessions, reuse detection, and logout; verification, recovery, session management, and wallet provisioning flows are partly present.
19-
- [~] User routes exist, but user persistence helpers currently return mock users.
19+
- [x] User routes are Prisma-backed: account/profile aggregate read, validated and audited profile update, consent-aware public profile read, real password change with session revocation, and wallet address persistence. No mock user helpers remain.
2020
- [~] Flat module list/detail/start/complete routes exist without Course, LearningPath, Lesson, Quiz, Question, Attempt, Enrollment, or detailed Progress models.
2121
- [~] Reward, credential, referral, offline-sync, notification, webhook, employer, Stellar, and Soroban services/routes exist at varying levels of completeness.
2222
- [~] 155 tests currently pass, but seven suites fail to load in the local environment.
@@ -65,7 +65,7 @@ Status baseline: 17 July 2026. The present schema supports users, flat modules,
6565
- [ ] Extend `User` with account status and verification fields; separate private identity from public profile data.
6666
- [ ] Add `LearnerProfile` for display name, bio, avatar, country, timezone, languages, skill level, interests, goals, and profile visibility.
6767
- [ ] Add onboarding state/version, consent records, terms/privacy versions, analytics consent, and data-sharing consent.
68-
- [ ] Add account/profile read and update endpoints using Prisma; remove all mock user helpers.
68+
- [x] Add account/profile read and update endpoints using Prisma; remove all mock user helpers — owner account/profile aggregate on `GET /users/me` (identity, profile, completion, onboarding, consents), allow-listed audited update on `PATCH /users/me`, consent-aware public read on `GET /users/{id}`, real password change with in-transaction session revocation, and wallet address persistence with conflict handling. See [`docs/decisions/0004-profile-api.md`](docs/decisions/0004-profile-api.md).
6969
- [ ] Add preferences endpoints for locale, timezone, low-data mode, accessibility, content, notifications, and privacy.
7070
- [ ] Add avatar signed-upload/finalization/delete flow with validation and image processing.
7171
- [ ] Add data export, account deactivation, deletion request, retention, and irreversible deletion workflows.

docs/API.md

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -301,65 +301,152 @@ Verifies the code from `otp/request`. Codes are single-use, expire after 5 minut
301301

302302
### `GET /users/me` 🔒
303303

304-
Returns the authenticated user's full profile.
304+
Owner-only aggregate: account identity, learner profile, profile completion,
305+
onboarding state, and the current consent record per purpose. One read, so a
306+
client does not have to fan out across four endpoints to render a settings or
307+
"finish setting up" screen.
305308

306309
```json
307310
{
308-
"id": "uuid", "email": "...", "username": "...",
309-
"firstName": null, "lastName": null,
310-
"bio": null, "avatar": null, "walletAddress": null,
311-
"isActive": true, "createdAt": "...", "updatedAt": "..."
311+
"data": {
312+
"account": {
313+
"id": "uuid", "email": "...", "username": "...",
314+
"role": "LEARNER", "status": "ACTIVE",
315+
"isVerified": true, "phoneVerifiedAt": null,
316+
"walletAddress": null,
317+
"createdAt": "...", "updatedAt": "...", "lastLoginAt": null
318+
},
319+
"profile": {
320+
"id": "uuid", "userId": "uuid",
321+
"displayName": null, "bio": null, "avatarUrl": null,
322+
"country": null, "timezone": null,
323+
"languages": [], "level": "beginner",
324+
"interests": [], "goals": [],
325+
"visibility": "private",
326+
"createdAt": "...", "updatedAt": "..."
327+
},
328+
"completion": { "percent": 0, "missingFields": ["displayName", "bio", "..."] },
329+
"onboarding": {
330+
"version": "v1", "status": "in_progress", "currentStep": "profile_basics",
331+
"completedSteps": ["profile_basics"], "requiredStepsRemaining": ["consent"],
332+
"startedAt": "...", "completedAt": null
333+
},
334+
"consents": [
335+
{ "purpose": "terms_of_service", "status": "granted", "required": true,
336+
"policyVersion": "2026-01", "grantedAt": "...", "withdrawnAt": null }
337+
],
338+
"requiredConsentsGranted": false
339+
}
312340
}
313341
```
314342

343+
The profile row is created on first access, so a brand-new learner gets a
344+
deterministic 0%-complete profile rather than a `null`. `onboarding` is `null`
345+
until the learner starts onboarding. The password hash is never included.
346+
347+
**Responses:** `200` · `401` no/invalid token · `404` account unknown or tombstoned
348+
315349
---
316350

317351
### `PATCH /users/me` 🔒
318352

319-
Update profile fields. All fields optional.
353+
Partial learner-profile update. At least one field is required, and the body is
354+
**closed**: any property outside the table below — including account fields such
355+
as `status`, `isVerified`, `role`, `email`, `password` or `walletAddress` — is a
356+
`400`, not a silently ignored key. Every accepted change is written together with
357+
its audit event in one transaction.
320358

321-
**Request body** (any subset of):
359+
**Request body** (any non-empty subset of):
322360

323361
| Field | Type | Constraints |
324362
|-------|------|-------------|
325-
| `username` | string | 3–30 chars, alphanumeric + underscore |
326-
| `firstName` | string | max 50 |
327-
| `lastName` | string | max 50 |
328-
| `bio` | string | max 500 |
329-
| `avatar` | string (URL) | |
363+
| `displayName` | string \| null | 1–80 chars |
364+
| `bio` | string \| null | max 1000 |
365+
| `avatarUrl` | string (URL) \| null | normally set by the avatar upload flow |
366+
| `country` | string \| null | 2–60 chars |
367+
| `timezone` | string \| null | |
368+
| `languages` | string[] | max 20 |
369+
| `level` | enum | `beginner` \| `intermediate` \| `advanced` \| `expert` |
370+
| `interests` | string[] | max 50 |
371+
| `goals` | string[] | max 20 |
372+
| `visibility` | enum | `private` \| `employer` \| `public` |
373+
374+
**Response:** `200` `{ "message": "...", "data": { … } }` — the same aggregate as
375+
`GET /users/me`, recomputed from the persisted row.
330376

331-
**Response:** `200` full user object (same as `GET /users/me`)
377+
**Responses:** `200` · `400` validation failed · `401` · `404`
378+
379+
`PATCH /users/me/profile` accepts the same body and returns the profile alone
380+
(without the account aggregate).
332381

333382
---
334383

335384
### `GET /users/:id`
336385

337-
Public — no auth needed. Returns a reduced public profile.
386+
Public — no auth needed. Returns the learner's public profile subset, or the
387+
redacted stub `{ "id": "...", "visible": false }`.
338388

339389
```json
340-
{ "id": "...", "username": "...", "firstName": null, "lastName": null, "avatar": null, "role": "learner", "createdAt": "..." }
390+
{
391+
"data": {
392+
"id": "uuid", "displayName": "Grace H.", "bio": "Learning Soroban",
393+
"avatarUrl": null, "country": "NG", "level": "intermediate",
394+
"interests": ["soroban"], "visible": true
395+
}
396+
}
341397
```
342398

399+
Disclosure requires **all** of:
400+
401+
1. `profile.visibility === "public"`,
402+
2. the account status is `ACTIVE`, and
403+
3. `data_sharing` consent has not been withdrawn.
404+
405+
Any refusal returns the same stub, so a caller cannot tell a private profile
406+
from a withdrawn consent from a deactivated account. Archived profiles read as
407+
`404`. Private account data (email, username, wallet address, status,
408+
verification, password) never appears here — not even for the owner, who gets
409+
the same public view as anyone else on this route and uses
410+
`GET /users/me` or `GET /users/:id/profile` for their own full record.
411+
412+
**Responses:** `200` · `400` malformed id · `404` unknown learner
413+
343414
---
344415

345416
### `PATCH /users/password` 🔒
346417

347-
> ⚠️ **Preview** — the service implementation is stubbed. Will return `500` until completed.
418+
Verifies the current password, stores a new bcrypt hash, and revokes every
419+
session and refresh-token family for the account **in the same transaction**
420+
so the caller must sign in again, and so does anyone holding a stolen session.
421+
The change is audited; neither password reaches the audit trail.
348422

349423
**Request body:** `{ "currentPassword": "...", "newPassword": "..." }`
350424

351425
Password rules: min 8 chars, must contain uppercase, lowercase, digit, and special character (`@$!%*?&`). Must differ from current.
352426

427+
**Response:** `200` `{ "message": "...", "revokedSessionCount": 2 }`
428+
429+
**Responses:** `200` · `400` validation failed · `401` no token, or wrong current
430+
password (`code: STEP_UP_FAILED`) · `404` account unknown or tombstoned
431+
353432
---
354433

355434
### `PATCH /users/wallet` 🔒
356435

357-
> ⚠️ **Preview** — the wallet update may not persist to the database until the service layer is completed.
436+
Sets the learner's Stellar **public** key on their account. Never accepts or
437+
returns a secret seed. The change is audited.
358438

359439
**Request body:** `{ "walletAddress": "G..." }`
360440

361441
Address must match `^G[A-Z0-9]{55}$`.
362442

443+
Re-sending the address already on file is a no-op (`200`, `"Wallet address
444+
unchanged"`, no second audit event). Addresses are unique across accounts.
445+
446+
**Responses:** `200` · `400` invalid address · `401` · `404` account unknown or
447+
tombstoned · `409` address already claimed by another account
448+
(`code: WALLET_ADDRESS_TAKEN`)
449+
363450
---
364451

365452
## Modules — `/modules`
@@ -806,14 +893,9 @@ Record a candidate outreach attempt. Requires **pro** or **enterprise** plan —
806893

807894
## Unimplemented / Stubbed Routes
808895

809-
The following routes are wired but not fully implemented:
810-
811-
| Route | Status |
812-
|-------|--------|
813-
| `PATCH /users/password` | Service method throws "Not implemented" — returns 500 |
814-
| `PATCH /users/wallet` | Service method uses mock data — changes do not persist |
815-
816-
These are marked as **Preview** in the OpenAPI spec (`/api-docs`).
896+
None on `/users`. `PATCH /users/password` and `PATCH /users/wallet` were the last
897+
two stubs here; both are Prisma-backed and audited as of the Profile API work
898+
(see [`docs/decisions/0004-profile-api.md`](decisions/0004-profile-api.md)).
817899

818900
---
819901

0 commit comments

Comments
 (0)