This file did not exist before F6.5 — many inline code comments across the codebase (M0–M11) reference a "README" that was never actually written. Rather than retroactively reconstruct that history, this file documents only what F6.5 (Admin Bootstrap + User Management) added. Earlier milestones remain documented solely in their own inline code comments and tests.
Run the dedicated local demo seed after migrations:
npm run prisma:deploy
npm run seed:demoThe command reuses the Acme Indonesia organization, upserts the five
standard departments and stable demo users, then replaces only requests
whose numbers start with DEMO-REQ-. It never deletes unrelated local
requests or users, and it is safe to rerun without accumulating duplicates.
The dataset contains 27 requests spread across more than one year, valid
approval cycles, comments, read/unread notifications, and chronological
audit activity. Attachment fixtures are deliberately omitted because this
deployment stores attachment bytes on disk; metadata without owned files
would create broken downloads.
All accounts below use the shared password ReqFlowDemo123! and have
mustChangePassword=false. These credentials are DEVELOPMENT / DEMO ONLY
and must not be used in production.
| Role | |
|---|---|
| Admin | siti.demo@reqflow.local |
| Employee | andi.demo@reqflow.local |
| Manager | rina.demo@reqflow.local |
| Finance | dewi.demo@reqflow.local |
| Director | bambang.demo@reqflow.local |
Bootstraps the very first ReqFlow Administrator (and, if needed, its
Organization) from a trusted backend terminal. This is not a public
registration endpoint — ReqFlow has no /register route and no
self-service sign-up page. After the first Admin exists, every other user
is created by an Admin through the authenticated /users API
(user.service.ts), never through this script.
The command never starts an HTTP server, never bypasses password hashing,
and never prints a password, password hash, or DATABASE_URL.
npm run create:adminPrompts interactively for organization name, admin name, admin email, and password (entered with hidden/starred input, with a confirmation prompt).
Non-interactive / scripted bootstrap:
npm run create:admin -- --organizationName "Acme Inc" --name "System Admin" --email admin@example.comThe password is never accepted as a command-line flag (it would leak into shell history). When stdin is a real terminal, it's prompted for directly. When stdin is not a terminal (CI, scripted deploys), the entire stdin stream is used as the password instead:
echo "the-password" | npm run create:admin -- --organizationName "Acme Inc" --name "System Admin" --email admin@example.comThe organization name is matched by a deterministic, counter-free slug
(the same upsert-by-slug pattern prisma/seed.ts already uses): running
the command twice with the same organization name reuses that
organization rather than creating a duplicate. A new name always creates
a new organization — this never happens silently from the operator's point
of view, since the name is always explicitly provided.
- Running the command again with an email that already exists fails safely with a clear message and a non-zero exit code — it never silently creates a duplicate and never resets an existing user's password (this is create, not reset-password).
- Organization creation (or reuse) and Admin user creation happen inside a single database transaction, so a failure partway through never leaves an organization with no administrator.
- Password hashing reuses
hashPassword()(Argon2id) — the exact function normal registration/login uses — so a CLI-created Admin authenticates exactly like any other user.
The business logic lives in src/scripts/lib/create-admin.ts
(createAdminUser()), independent of any terminal I/O, and is covered
directly by tests/create-admin.test.ts. src/scripts/create-admin.ts is
only the thin CLI adapter (argument parsing, prompts, console output).
Every User Management endpoint below is Admin-only
(requireRole(Role.ADMIN)) and always scoped to the authenticated Admin's
own organizationId — a request body's organizationId (if present) is
always ignored; tenant scope is never client-controlled. Cross-organization
targets resolve to 404 USER_NOT_FOUND, matching this codebase's existing
"don't reveal what you can't access" convention elsewhere (e.g. Request
visibility).
| Method | Path | Purpose |
|---|---|---|
| GET | /users/me |
Own profile (any authenticated user) |
| GET | /users |
List, paginated, filterable (Admin) |
| POST | /users |
Create a user (Admin) |
| GET | /users/:id |
Detail (Admin) |
| PATCH | /users/:id |
Edit name/email (Admin) |
| PATCH | /users/:id/status |
Activate/deactivate (Admin) |
| PATCH | /users/:id/department |
Assign/unassign department (Admin) |
| PATCH | /users/:id/role |
Change role (Admin) |
GET /users filters: q (matches name or email, case-insensitive),
role, departmentId, isActive, plus page/pageSize. All optional and
additive over the base organization scope — never fetched unfiltered and
filtered client-side.
The user DTO (AdminUserSummary) never includes passwordHash or any
other authentication internal.
User has no hard-delete endpoint. Request.createdBy, Approval.approver,
Comment.author, and Attachment.uploadedBy are all onDelete: Restrict
in the schema — a User with any history literally cannot be hard-deleted at
the database level. User.isActive (default true, additive migration)
is the only lifecycle action User Management exposes.
Inactive-user authentication: an inactive user can neither log in
(loginUser in auth.service.ts rejects with the same generic
401 INVALID_CREDENTIALS a wrong password would — never a distinct
"account deactivated" message, so an unauthenticated caller can't use this
to enumerate account state) nor keep using an existing session
(getUserBySessionToken re-checks isActive on every authenticated
request, so deactivation takes effect immediately on that user's very next
request — no separate session-revocation system needed).
Approval.approverId is a fixed user id decided once at submit/resubmit
time (request.service.ts's submitOrResubmit) — a role change never
retroactively touches an already-created Approval row. The guard here is
deliberately conservative anyway: any role change (or deactivation) for
a user who currently has a PENDING or WAITING Approval assigned to them
is refused outright with 409 USER_HAS_PENDING_APPROVALS. There is no
automatic reassignment — the Admin must resolve the approval first (have it
decided, or reassign the underlying policy) before the role/status change
is allowed.
An organization must always retain at least one active Admin.
assertNotLastActiveAdmin() blocks a deactivation or role change away from
ADMIN that would leave zero active Admins (409 LAST_ACTIVE_ADMIN_REQUIRED).
Self-deactivation is separately forbidden outright
(403 SELF_DEACTIVATION_NOT_ALLOWED), mirroring the existing
self-role-change rule (403 SELF_ROLE_CHANGE_NOT_ALLOWED).
In practice, because only an Admin can call these routes at all and
self-action is already blocked, the acting Admin always remains active
after any action targeting a different user — so LAST_ACTIVE_ADMIN_REQUIRED
is only reachable sequentially through the service layer directly (see its
test coverage). The real, narrow residual risk is a concurrent race:
two Admins simultaneously demoting/deactivating each other under
PostgreSQL's default READ COMMITTED isolation. The guard runs inside the
same transaction as the write it protects, which narrows that window, but
does not fully close it without explicit row locking (SELECT ... FOR UPDATE) — a level of concurrency engineering this codebase has no evidence
of needing yet. Documented here as a known, accepted limitation rather than
built speculatively.
AuditAction gained USER_CREATED, USER_UPDATED, USER_ACTIVATED,
USER_DEACTIVATED, and ROLE_CHANGED; AuditEntityType gained USER.
Every User Management mutation writes an audit event through the same
audit.service.ts record() function every other domain uses —
requestId is always null for these (they are organization-scoped, not
request-scoped), entityId is the affected User's id. A no-op write (e.g.
PATCH /users/:id with a field equal to its current value, or
PATCH /users/:id/status toggling to the status it's already in) writes no
audit event, mirroring request.service.ts's existing no-op-PATCH
convention.
POST /users no longer accepts an Admin-selected password. It generates a
cryptographically random temporary password, stores only its Argon2id hash,
sets mustChangePassword = true, and returns the plaintext value only in the
immediate create response. POST /users/:id/reset-password follows the same
one-time handoff pattern and invalidates every target-user session.
While mustChangePassword is true, authenticated middleware permits only
current-user bootstrap, logout, and POST /auth/complete-password-change.
Normal business endpoints return 403 PASSWORD_CHANGE_REQUIRED. A regular
user can later call PATCH /users/me/password; successful self-service
changes preserve the current session and invalidate all other sessions.
The npm run create:admin contract remains intentionally unchanged: its
interactive/stdin password is treated as permanent and the resulting Admin
has mustChangePassword = false. This keeps non-HTTP bootstrap compatible.
GET /dashboard/requests-over-time?range=... accepts 1m, 2m, 6m,
1y, or all. Six months is the frontend default. Calendar subtraction and
bucket boundaries use UTC, matching the existing UTC createdAt and
PostgreSQL date_trunc convention; no organization timezone exists yet.
1m: daily buckets from one UTC calendar month ago until now.2m: weekly buckets from two UTC calendar months ago until now.6m: monthly buckets from six UTC calendar months ago until now.1y: monthly buckets from one UTC calendar year ago until now.all: monthly buckets from the earliest request visible to the caller until now. This means all-time-to-date, not year-to-date.
The lower boundary is inclusive and the current-time upper boundary is
exclusive. Empty buckets are omitted and an empty range returns points: [].
The earlier explicit from/to/bucket query remains supported for
backward compatibility.