Skip to content

feat(users): replace mock user helpers with the Prisma-backed Profile… - #154

Merged
3m1n3nc3 merged 1 commit into
learnault:mainfrom
feyishola:feat/replace-mock-user-helpers-with-profile-api
Aug 29, 2026
Merged

feat(users): replace mock user helpers with the Prisma-backed Profile…#154
3m1n3nc3 merged 1 commit into
learnault:mainfrom
feyishola:feat/replace-mock-user-helpers-with-profile-api

Conversation

@feyishola

Copy link
Copy Markdown
Contributor

feat(users): replace mock user helpers with the Prisma-backed Profile API

UserController carried five private helpers that never touched a database:
findUserById returned a hard-coded [email protected] record, updateUserProfile
echoed the request body back, validatePassword returned false unconditionally,
updateUserPassword threw "Not implemented", and updateUserWallet faked a
persisted user. Worse, the fixture described a schema that does not exist —
firstName/lastName/bio/avatar are not columns on User — so /users/me and
/users/:id served a shape no query could produce, and the OpenAPI User /
PublicUser / UpdateUserInput components documented that shape as real.

The persistence was already there, built by earlier issues and explicitly
deferred to this one by ADRs 0002 and 0003: LearnerProfile with its visibility
model and serializers, OnboardingProgress and ConsentRecord, and the
audited-mutation helper.

  • GET /users/me returns an owner aggregate: account identity, profile, profile
    completion, onboarding state and current consent per purpose, in one read.
    The account field list is closed (field-by-field copy, not a spread), so
    password and any column added to User later must be opted in to be
    disclosed. Completion and outstanding onboarding steps are computed on read,
    so they cannot disagree with the rows they summarise.
  • PATCH /users/me is bounded by one strict Zod object, shared with
    PATCH /users/me/profile so the two routes cannot diverge on what an owner may
    write. Account fields (status, isVerified, role, email, password,
    walletAddress) and internal columns (id, userId, archived*) are absent from
    the allow-list and therefore 400, not silently ignored. The write goes
    through auditedMutation and records which fields changed, never their values.
  • GET /users/:id layers two consent gates on the existing visibility threshold:
    only an ACTIVE account is disclosed, and an explicit data_sharing withdrawal
    overrides a stale visibility: public. Absence of a data_sharing record does
    not block disclosure — it is optional consent, and setting visibility above
    private is itself a deliberate choice. Every refusal returns the identical
    redacted stub, so a caller cannot tell a private profile from a withdrawn
    consent from a deactivated account. Reads via findFirst so the
    archive-exclusion extension applies.
  • PATCH /users/password verifies, rehashes, and revokes every session and
    refresh-token family in the same transaction as the password write. Doing the
    revocation separately would leave a window where the password has changed and
    the attacker's stolen session is still alive.
  • PATCH /users/wallet persists the learner's Stellar public key, is idempotent,
    and returns 409 for an address claimed elsewhere — checked up front for a
    clear error and again by catching P2002, since two concurrent claims both pass
    the up-front read.

Also removes the now-dead mock-era request types, replaces the OpenAPI
components, drops the "Preview / not-yet-implemented" banners, and unmounts
(with a do-not-rewire comment) the deprecated validateProfileUpdate middleware.

Verification evidence: tests/mock-user-scan.test.ts (static scan for mock
literals, removed helpers and direct Prisma access from the controller),
docs/evidence/profile-api-curl.txt (21 redacted requests against a running
server on a disposable test database), and tests/integration/profile-api.test.ts
(51 tests through the real app, middleware, JWT and database). Rationale and
the debatable consent call are written up in docs/decisions/0004-profile-api.md.

closes #133

… API

UserController carried five private helpers that never touched a database:
findUserById returned a hard-coded [email protected] record, updateUserProfile
echoed the request body back, validatePassword returned false unconditionally,
updateUserPassword threw "Not implemented", and updateUserWallet faked a
persisted user. Worse, the fixture described a schema that does not exist —
firstName/lastName/bio/avatar are not columns on User — so /users/me and
/users/:id served a shape no query could produce, and the OpenAPI User /
PublicUser / UpdateUserInput components documented that shape as real.

The persistence was already there, built by earlier issues and explicitly
deferred to this one by ADRs 0002 and 0003: LearnerProfile with its visibility
model and serializers, OnboardingProgress and ConsentRecord, and the
audited-mutation helper.

- GET /users/me returns an owner aggregate: account identity, profile, profile
  completion, onboarding state and current consent per purpose, in one read.
  The account field list is closed (field-by-field copy, not a spread), so
  `password` and any column added to User later must be opted in to be
  disclosed. Completion and outstanding onboarding steps are computed on read,
  so they cannot disagree with the rows they summarise.
- PATCH /users/me is bounded by one strict Zod object, shared with
  PATCH /users/me/profile so the two routes cannot diverge on what an owner may
  write. Account fields (status, isVerified, role, email, password,
  walletAddress) and internal columns (id, userId, archived*) are absent from
  the allow-list and therefore 400, not silently ignored. The write goes
  through auditedMutation and records which fields changed, never their values.
- GET /users/:id layers two consent gates on the existing visibility threshold:
  only an ACTIVE account is disclosed, and an explicit data_sharing withdrawal
  overrides a stale `visibility: public`. Absence of a data_sharing record does
  not block disclosure — it is optional consent, and setting visibility above
  private is itself a deliberate choice. Every refusal returns the identical
  redacted stub, so a caller cannot tell a private profile from a withdrawn
  consent from a deactivated account. Reads via findFirst so the
  archive-exclusion extension applies.
- PATCH /users/password verifies, rehashes, and revokes every session and
  refresh-token family in the same transaction as the password write. Doing the
  revocation separately would leave a window where the password has changed and
  the attacker's stolen session is still alive.
- PATCH /users/wallet persists the learner's Stellar public key, is idempotent,
  and returns 409 for an address claimed elsewhere — checked up front for a
  clear error and again by catching P2002, since two concurrent claims both pass
  the up-front read.

Also removes the now-dead mock-era request types, replaces the OpenAPI
components, drops the "Preview / not-yet-implemented" banners, and unmounts
(with a do-not-rewire comment) the deprecated validateProfileUpdate middleware.

Verification evidence: tests/mock-user-scan.test.ts (static scan for mock
literals, removed helpers and direct Prisma access from the controller),
docs/evidence/profile-api-curl.txt (21 redacted requests against a running
server on a disposable test database), and tests/integration/profile-api.test.ts
(51 tests through the real app, middleware, JWT and database). Rationale and
the debatable consent call are written up in docs/decisions/0004-profile-api.md.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@3m1n3nc3
3m1n3nc3 merged commit 10be795 into learnault:main Aug 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Replace Mock User Helpers with Profile API

2 participants