Skip to main content

Feature: User Profile

Version: 1.0.0 Last Reviewed: 2026-02-10 Status: Approved

User Story

As a parent, I can view and update my profile settings (name, timezone, day start time) and change my password so my experience is personalized and my account is secure.

MVP Scope

  • View current profile
  • Update name, timezone, and day_start_time
  • Change password (requires current password)
  • Password change invalidates all other sessions (security)

NOT in MVP

  • Change email (can only change via admin/DB for MVP)
  • Account deletion (can delete via DB)
  • Profile photo/avatar
  • Notification preferences
  • Theme preferences (dark mode is always-on in iOS)

API Contract

Get Profile

GET /api/v1/user/profile
Authorization: Bearer <token>

Response 200:
{
"user": {
"id": "uuid",
"email": "parent@example.com",
"name": "Johnny",
"timezone": "America/New_York",
"day_start_time": "07:00",
"created_at": "2026-02-10T12:00:00.000Z",
"updated_at": "2026-02-10T12:00:00.000Z"
}
}

Update Profile

PUT /api/v1/user/profile
Authorization: Bearer <token>

Request (all fields optional - partial update):
{
"name": "John",
"timezone": "America/Los_Angeles",
"day_start_time": "06:30"
}

Response 200:
{
"user": {
"id": "uuid",
"email": "parent@example.com",
"name": "John",
"timezone": "America/Los_Angeles",
"day_start_time": "06:30",
"created_at": "2026-02-10T12:00:00.000Z",
"updated_at": "2026-02-10T14:00:00.000Z"
}
}

Response 400 (validation error):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body validation failed",
"details": [
{ "field": "timezone", "message": "Invalid IANA timezone", "code": "invalid_string" }
]
}
}

Change Password

PUT /api/v1/user/password
Authorization: Bearer <token>

Request:
{
"current_password": "oldpassword123",
"new_password": "newsecurepassword456"
}

Response 204: (No Content)

Response 400 (validation error):
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body validation failed",
"details": [
{ "field": "new_password", "message": "Must be at least 8 characters", "code": "too_small" }
]
}
}

Response 401 (wrong current password):
{ "error": { "code": "UNAUTHORIZED", "message": "Current password is incorrect", "details": [] } }

Note: On success, all other sessions for this user are invalidated.
The current session (used to make this request) remains valid.
API tokens are NOT invalidated (they are separate from sessions).

Error Responses

Standard error format. Error codes: 400 VALIDATION_ERROR, 401 UNAUTHORIZED

Business Rules

Profile Fields

  1. name: Required, 1-100 characters, trimmed. Same validation as child name.
  2. timezone: Must be a valid IANA timezone string. Validated via Intl.supportedValuesOf('timeZone') in Node.js. Examples: "America/New_York", "Europe/London", "Asia/Tokyo", "UTC".
  3. day_start_time: HH:MM format (24-hour). Valid range: "00:00" to "23:59". Stored as PostgreSQL TIME type. Defines when "today" starts for dashboard and timeline calculations.
  4. email: Read-only in MVP. Shown in GET response but cannot be changed via this endpoint.

Partial Updates

  1. PUT /user/profile accepts partial updates. Only provided fields are changed.
  2. Sending an empty body is a no-op (returns current profile, 200).
  3. Name is trimmed before storage (leading/trailing whitespace removed).

Password Change

  1. current_password is required and verified via bcrypt.compare.
  2. new_password must be at least 8 characters (same rules as registration).
  3. On successful password change, all other sessions are invalidated (deleted from sessions table).
  4. The current session (identified by the Bearer token used for this request) is preserved.
  5. API tokens are NOT affected by password change (they are a separate auth mechanism).
  6. New password is hashed with bcrypt (12 rounds) before storage.

Timezone Impact

  1. Changing timezone immediately affects all "today" calculations for dashboard and timeline.
  2. No historical data is modified - only future queries use the new timezone.
  3. iOS app should refresh dashboard data after timezone change.

day_start_time Impact

  1. Changing day_start_time immediately affects all "today" calculations.
  2. Example: changing from 07:00 to 06:00 means a feeding at 06:30 AM now counts in "today" instead of "yesterday."
  3. iOS app should refresh dashboard data after day_start_time change.

Audit Logging

  1. AuditLog entry on profile update (entity_type: "user", action: "update", changes: before/after).
  2. AuditLog entry on password change (entity_type: "user", action: "update", changes: {password: "changed"} - never log actual passwords).
  3. No audit log for GET profile (read-only).

Acceptance Criteria

  • Can view current profile (GET returns user with name, email, timezone, day_start_time)
  • Can update name (trimmed, 1-100 chars)
  • Can update timezone (validated against IANA list)
  • Can update day_start_time (HH:MM format, "00:00" to "23:59")
  • Partial update works (send only one field, others unchanged)
  • Can change password (requires current_password + new_password)
  • Wrong current_password returns 401
  • New password validated (min 8 chars)
  • Password change invalidates all other sessions
  • Current session survives password change
  • API tokens survive password change
  • Password stored as bcrypt hash (never plaintext)
  • Invalid timezone returns 400 with details
  • Invalid day_start_time format returns 400 with details
  • AuditLog entries created for profile updates and password changes

Test Cases

Get Profile

  1. Success: GET /user/profile → 200, {user: {id, email, name, timezone, day_start_time, created_at, updated_at}}
  2. Unauthenticated: → 401

Update Profile

  1. Update name: name="John" → 200, name changed
  2. Update timezone: timezone="America/Los_Angeles" → 200, timezone changed
  3. Update day_start_time: day_start_time="06:30" → 200, day_start_time changed
  4. Update all fields: name + timezone + day_start_time → 200, all changed
  5. Partial update: only name → timezone and day_start_time unchanged
  6. Empty body: {} → 200, nothing changed (no-op)
  7. Name trimmed: " Johnny " → stored as "Johnny"
  8. Name too long: 101 chars → 400
  9. Name empty: "" → 400
  10. Invalid timezone: "Not/A/Timezone" → 400, details: [{field: "timezone"}]
  11. Invalid day_start_time format: "7am" → 400
  12. day_start_time out of range: "25:00" → 400
  13. day_start_time boundary: "00:00" → 200 (valid)
  14. day_start_time boundary: "23:59" → 200 (valid)
  15. updated_at changes: After update, updated_at is newer
  16. Audit log: AuditLog entry with before/after values

Change Password

  1. Success: Correct current_password + valid new_password → 204
  2. Wrong current password: → 401 "Current password is incorrect"
  3. New password too short: <8 chars → 400
  4. Missing current_password: → 400
  5. Missing new_password: → 400
  6. Same password: current = new → 204 (allowed, no error)
  7. Other sessions invalidated: Login on 2 devices, change password from device 1 → device 2's token returns 401
  8. Current session preserved: After password change, same token still works
  9. API tokens preserved: After password change, API tokens still work
  10. Can login with new password: After change, login with new_password → 200
  11. Cannot login with old password: After change, login with old password → 401
  12. Password stored as hash: Check DB → password column is bcrypt hash
  13. Audit log: AuditLog entry with {password: "changed"}, never actual password

Timezone/day_start_time Effects

  1. Dashboard reflects timezone change: Change timezone, GET dashboard → "today" uses new timezone
  2. Dashboard reflects day_start_time change: Change day_start_time, GET dashboard → counts use new boundary

Boundaries

  • No email change in MVP (set at registration, change via admin/DB)
  • No account deletion (can delete via DB)
  • No profile photo or avatar
  • No multi-field validation (e.g., timezone + day_start_time interaction)
  • Password change does not affect API tokens (only sessions)
  • No rate limiting on password change beyond global rate limit