Skip to content

Users Module

The Users module owns the canonical user record and the user-profile sub-resource. It is not responsible for authentication itself — that lives in the standalone auth server and reaches this module read-only over gRPC. What this module does own: account CRUD, search, role-gated admin lookups, and the editable profile fields the dashboard’s “User Profile” page renders.

Class Diagram

uses

uses

caches

queries

Controller

-service: Service

-logger: Logger

+GetByID(w, r)

+GetByEmail(w, r)

+GetByUsername(w, r)

+Create(w, r)

+List(w, r)

+Search(w, r)

+Update(w, r)

+Delete(w, r)

+GetProfile(w, r)

+UpdateProfile(w, r)

Service

-repo: Repository

-cache: Cache

-logger: Logger

+GetByID()

+GetByEmail()

+GetByUsername()

+Create()

+List()

Repository

-db: Database

+Query()

+Insert()

+Update()

+Delete()

Cache

Database

Fundamental Purpose

A user record is the platform-wide identity. It carries:

  • Identity — id (UUID), email, username (lowercase, unique).
  • System role — one of admin, moderator, user. Role gates are enforced inside Service (see Role gates below), not at the route layer.
  • Profile fields — display_name, avatar_url, bio, location, website, plus a free-form metadata JSON column. These are owned by the profile sub-resource (/users/profile) so that profile edits never require touching the auth-critical fields (email, username, password hash).
  • Password hash — bcrypt-hashed; never read by this module’s repository. Auth-related lookups go through the auth server’s repository.
  • Lifecycle — soft-deleted via deleted_at. List/search filter out deleted users by default.

HTTP Surface

Routes are mounted under <APIPrefix>/users (default /api/v1/users). All routes require a valid JWT or API key.

MethodPathHandlerNotes
GET/Listadmin/moderator — paginated user list
POST/Createadmin — create a user (e.g. seeded operator account)
GET/search?q=...Searchadmin/moderator — substring match on email + username
GET/by-email?email=...GetByEmailCaller must be the target OR have admin/moderator role
GET/by-username?u=...GetByUsernameSame authorization rules as by-email
GET/profileGetProfileReturns the calling user’s profile
PUT/profileUpdateProfileUpdates only profile fields (subset of User)
GET/{id}GetByIDSame authorization rules as by-email
PUT/{id}UpdateCaller must be the target user OR admin
DELETE/{id}DeleteCaller must be the target user OR admin

Special Behaviors

Role gates are enforced in the Service, not the router

List, Search, and Create go through Service.checkListOrSearchPermission before the repository is ever touched (apps/api/modules/users/service.go:253). The router doesn’t know about roles — it just authenticates the JWT. This keeps role logic together with the rest of the business rules and makes it directly testable without an HTTP harness.

Profile vs. base record split

The /users/{id} surface is for administrative changes (email, username, role). The /users/profile surface is for the calling user’s cosmetic fields (display_name, avatar_url, etc.). Two reasons:

  • The dashboard’s “Edit profile” page needs neither admin role nor the ability to change the email — separating the surfaces removes an entire class of “did I mean to expose this?” bugs.
  • Cache invalidation is finer-grained: a profile update doesn’t have to bust caches keyed by email/username.

UpdateProfile’s payload type (UpdateProfileRequest) is a strict subset of UpdateUserInput — the SDK enforces this at compile time.

Soft-delete semantics

DELETE /users/{id} sets deleted_at. The auth server’s ValidateToken RPC also checks deleted_at — a JWT issued before a soft-delete is rejected on the next request. This is one of the few cross-process invariants the platform enforces; both servers must read the same column for it to work.

Author denormalization

Other modules (most prominently Blobs) embed a trimmed BlobAuthor summary ({id, username, display_name, avatar_url}) on responses. This is computed in the consumer module, not here — the Users module only exposes full records and lets callers project them. That keeps user-record changes from forcing schema migrations across every embedding consumer.

SDK Surface

The TypeScript SDK exposes this module as the UsersService on blobs.users:

  • UsersService — full method reference (getById, getByEmail, getByUsername, list, search, update, delete, getProfile, updateProfile).
  • User — canonical wire shape.
  • UpdateUserInput — for admin updates.
  • UpdateProfileRequest — strict subset for profile edits.
  • actions/user — UpdateUserAction for undo/redo on the profile editor. Auth flows (login/register/refresh/logout) are deliberately not action-ized — their inverses are unsafe or undefined; see the user actions notes for details.

Cross-Module Integration

ModuleRelationship
Auth serverOwns the auth-critical fields (email, password_hash, refresh_tokens). Reads this module’s row via shared Postgres. The two services agree on the schema; only one writes any given column.
BlobsEmbeds BlobAuthor on responses. Updates here propagate naturally on next read — no event subscription needed.
PermissionsReads role to evaluate system-level role gates.
SettingsPer-user settings keyed by user_id.

API Reference

Open the interactive API reference and jump to the /users tag for live request/response examples.