D2 Design User and Supplier Services - AY2627S1-CS3219-P23/foc GitHub Wiki

Technical Solution — D2 Design — User & Supplier Services

Answers to the eleven D2 questions (instructions) as built. Demo: Week 7, 20–30 min, live. Related: architecture.md · supplier-service.md · wireframes · demo script + curl walkthrough

flowchart LR
    subgraph M["Merged"]
        UA["user-service: signup + OTP email ·<br/>login · JWT + RBAC · owner/admin ·<br/>purge · demo-account seed"]
        SA["supplier-service: schema + seed ·<br/>browse/search/filter/sort/page ·<br/>JWT role gate · CRUD writes"]
        WA["web: auth + OTP dialog · suppliers<br/>with admin CRUD modals · admin pages"]
    end
    subgraph R["In review"]
        PROF["#149 profile screens"]
        UPD["#157 account updates (#92)"]
    end
    subgraph O["Deferred"]
        LOGOUT["logout denylist #90"]
        RESET["recovery / reset #94"]
    end
    classDef ok fill:#1a7f37,color:#ffffff
    classDef rev fill:#9a6700,color:#ffffff
    classDef gap fill:#cf222e,color:#ffffff
    class UA,SA,WA ok
    class PROF,UPD rev
    class LOGOUT,RESET gap
Loading

Part 1 — User Service

§1 Role design

Requester/courier are modes of USER, not roles — one account does both, no re-login (F5.1.1).

Role Problem it solves Obtained Can do
USER (default) the same student makes requests and delivers sign-up both modes · manage own account · own profile
ADMIN day-to-day governance without developer involvement promoted by an owner or admin (dashboard button → PATCH /users/{id}) list/search users · remove accounts · promote/demote user↔admin · supplier CRUD
OWNER administers the administrators — an anchor admins can't demote or remove POST /auth/setup-owner with the setup token admin-equivalent · untouchable by admins · multiple owners allowed
  • Enforced where: the JWT role claim travels with every request; each service checks it server-side (user-service #144, supplier-service #143).
  • Adding a role later (mentor question — scalable roles): add one Role enum value and authorization logic in SecurityConfig files and the UI.
  • Can an admin edit a user's profile? No — admin powers are role change and removal only; profile fields are self-service via /users/me.
  • Can a normal user edit suppliers? No — supplier writes require admin role; a direct API call gets 403 problem+json (see Part 2 §4).

§2 Database choice & schema — F1.1.4 · NFR1.1 · NFR2.1 · NFR3.1 · NFR4.1

PostgreSQL 17 + Spring Data JPA. Why:

  • Integrity rules enforced by the database: unique email/username (including soft-deleted rows by using soft-delete flag), role checks (NFR4.1).
  • Flows are transactional multi-row writes: verify→insert, lockout counters, dependents-first purge. A failed write rolls back — no partial state.
  • 60 000 accounts (NFR2.1) and operations within 2 seconds (NFR3.1) achievable.

Schema (bold = required / NOT NULL, rest nullable):

erDiagram
    users {
        bigint id PK "req"
        text email UK "req - eXXXXXXX at u.nus.edu"
        text username UK "req - 3-30 A-Za-z0-9_"
        text password_hash "req - BCrypt"
        text role "req - USER or ADMIN or OWNER"
        int failed_login_attempts "req - default 0"
        timestamptz locked_until "opt - null = unlocked"
        timestamptz created_at "req"
        timestamptz deleted_at "opt - null = active"
        bigint version "req - optimistic lock"
    }
    pending_signups {
        bigint id PK "req"
        text email UK "req"
        text username "req - not unique, first verify wins"
        text password_hash "req - BCrypt"
        text code_hash "req - BCrypt-hashed OTP"
        timestamptz last_sent_at "req - resend cooldown origin"
        timestamptz expires_at "req - 10 min, never extended"
        int attempts "req - max 5 per row, resends included"
    }
    account_tokens {
        bigint id PK "req"
        bigint user_id FK "req"
        text token_hash "req"
        text kind "req - PASSWORD_RESET or RECOVERY"
        timestamptz expires_at "req"
    }
    token_denylist {
        text jti PK "req"
        timestamptz expires_at "req"
    }
    users ||--o{ account_tokens : "has"
Loading

Why these relations:

  • pending_signups has no FK to users on purpose: with insert-after-verify (F1.1.2) there is no user row yet — the OTP state is keyed by email; the users row appears only on successful verify.
  • users 1—* account_tokens: a user may hold several outstanding hashed reset/recovery tokens (F4.1); FK ties their lifecycle to the account (purged together).
  • token_denylist stands alone: it's token-scoped (keyed by jti), not user-scoped — reserved for logout.

Credential storage (NFR1.1): only BCrypt hashes persist — passwords and OTP codes; JWT_SECRET and the owner-setup token live in env, never the DB; codes/tokens/secrets are never written to logs (mentor question).

✍️ Open inconsistency to answer if probed: D1 NFR1.2 says emails are hashed too — they're stored plaintext because email is the login/OTP identifier. Team to amend the NFR or revisit.

Account lifecycle (F3.1.1–F3.1.3, F4.1):

stateDiagram-v2
    [*] --> Pending: signup (202)
    Pending --> Active: OTP verified (users row inserted)
    Pending --> [*]: expired / 5 wrong codes / identifier taken
    Active --> Deleted: self delete or admin remove (soft)
    Deleted --> Active: login within 30 d recovers
    Deleted --> [*]: purge daily 03:00, day 31
Loading

§3 Authentication & authorization — F5.1 · F1.1.x · NFR1.1

Token-based (JWT, HS256, shared secret). Self-contained claims let every microservice and the web UI read the caller's role locally — stateless, no rerouting allowing for less latency.

Claim Value Mentor questions answered
sub user id (numeric, enforced) identity always comes from here — see IDOR below
role USER / ADMIN / OWNER RBAC input for every service
jti random UUID reserved for logout denylist
exp +1 h token expiry: 1 hour; stored by the SPA in localStorage; invalidation today = expiry only — logout denylist is designed (schema ready) but deferred #90; a demoted role is stale ≤ 1 h
  • Every request is validated by the filter chain (#144): signature (HS256 only), exp present + unexpired, numeric sub, known role — then URL rules apply (/users admin routes → hasAnyRole("ADMIN","OWNER"), fail-closed default authenticated()). Violations → problem+json 401/403.
  • IDOR (User A vs User B): self-service routes are /users/me — there is no id parameter to tamper with; the target is always the verified sub. The only id-taking read, GET /users/{id}, returns username alone.
  • Sign-up (F1.1): unique email/username (F1.1.1), u.nus.edu domain, password 10–50 with upper+lower+digit (F1.1.3), all violations returned with exact reasons (F1.1.5–6). Then OTP by email (F1.1.2):
sequenceDiagram
    actor Student
    participant SPA
    participant US as user-service
    participant SMTP as Mailpit / Gmail

    SPA->>US: POST /auth/signup
    US-->>SPA: 202 (row parked in pending_signups)
    US->>SMTP: 6-digit code (10 min, 5 attempts, BCrypt-hashed)
    Student->>SPA: enter code
    SPA->>US: POST /auth/signup/verify
    US-->>SPA: 201 (users row inserted)
Loading

The SPA side is a dialog over the register card (six code boxes, "Resend code", per wireframes signup.png) — built in #150 as the sign-up/OTP half of #109, which keeps logout and token handling.

Who may move a pending sign-up along — decided by the User Service owner in #150's reviews (options Q&A), after review found the first shape unsafe:

Repeat POST /auth/signup for a pending email Answer
same username and a password matching the stored hash resend: new code, same details, attempt count kept
anything else, while the row is live 409 — never merged, never rewritten
the row has expired any sign-up takes it over outright
inside OTP_RESEND_COOLDOWN (60s) 429 + Retry-After

Why, and what was rejected: the original "last request wins" let anyone who knew an address overwrite a pending sign-up's username/password, so the code the real student received created an account holding someone else's password. Reversing it to "first request wins, later ones only resend" was tried and dropped — it merely reverses who has to move first, since a planted row would then be what the student's own sign-up completed into. Matching on the details the requester supplied is what proves it is the same request without a token for the SPA to carry.

Two follow-on rules from the same review, both about a row that outstays its usefulness: a resend replaces the code but not expires_at, so a row lives at most OTP_TTL from creation and always reaches the takeable state (otherwise its creator could resend once per cooldown for ever and hold the address, while its owner kept getting a 409 telling them to wait for an expiry that never came); and verify discards a pending row whose email or username was taken meanwhile, since it can never complete and used to lock whoever lost a username race out of their own address for the rest of the TTL. Consequence accepted on purpose: a late resend hands out a short-lived code, and the email and the 202 quote the seconds actually left.

5 wrong codes → 429, signup discarded (counted per pending sign-up, resends included). Mail failure → 502, rolled back — on a resend that leaves the previous code live. Provider: Gmail SMTP, with a Mailpit container as the local target (inbox at localhost:8025) — switched by .env only.

  • Login (F5.1): username-or-email + password; 5 failures → 15 min lockout (429 + Retry-After); errors name the cause (decided #148 — the lockout countdown reveals existence anyway); login within 30 days recovers a soft-deleted account (F4.1).

Routes and response types (mentor ask: which fields does each operation return):

Route Returns Access
POST /auth/signup 202 SignupResponse {email, expiresInSeconds, resendInSeconds} public
POST /auth/signup/verify 201 UserResponse {id, email, username, role, createdAt} public
POST /auth/login 200 LoginResponse {accessToken, tokenType, expiresIn} public
POST /auth/setup-owner 201 UserResponse setup token
GET /users/me UserResponse bearer
GET /users/{id} PublicProfileResponse {username} bearer
DELETE /users/me 204 (soft delete) bearer
GET /users?search=&role=&sort=&page=&size= PagedModel<UserResponse> (content + page metadata) ADMIN/OWNER
PATCH /users/{id} {role} UserResponse (idempotent) ADMIN/OWNER
DELETE /users/{id} 204 (soft delete) ADMIN/OWNER
POST /auth/logout · PATCH /users/me · password reset — deferred #90 · #92 · #94

§4 Integration with the Supplier Service

Same token, verified independently by each service with a local JwtVerifier — no runtime call back to user-service, no shared library coupling:

sequenceDiagram
    actor A as ADMIN
    actor U as USER
    participant SPA
    participant US as user-service
    participant SS as supplier-service

    A->>SPA: login
    SPA->>US: POST /auth/login
    US-->>SPA: JWT (role: ADMIN, 1 h)
    SPA->>SS: POST /suppliers + Bearer
    SS->>SS: verify signature locally, read role
    SS-->>SPA: 2xx

    U->>SPA: login
    US-->>SPA: JWT (role: USER)
    SPA->>SS: POST /suppliers + Bearer
    SS-->>SPA: 403 problem+json
Loading

The supplier-side gate merged in #143 (2026-09-29) — the diagram above is live behaviour on main, including the ADMIN-or-OWNER rule on writes (owner is admin-equivalent, decided in that PR's review).

§5 User profile management — F2

  • Live now: GET /users/me (own data incl. role), DELETE /users/me (confirm dialog in UI, then soft delete).
  • Update flows (#92, screens in #149): edits re-validated like sign-up (uniqueness F2.1.2, policy re-check), gated by an OTP to the existing email first (F2.1.1), new email confirmed by its own OTP (F2.1.3), password double-entry.
  • Protected fields (role, status, id — the P1.5 question): request DTOs are allow-lists — PATCH /users/me will bind only email/username/password, so a request carrying "role": "ADMIN" has no field to land in. Already live at sign-up: a role in the body is ignored.

How account-changing requests are validated and protected

Applies to every endpoint that changes an account today (DELETE /users/me, the admin PATCH / DELETE /users/{id}, sign-up and owner setup), and is what the profile-update endpoint (#92) will plug into — there is no PATCH /users/me yet.

  1. Authentication: JwtAuthenticationFilter checks the bearer token on each request: HS256 signature with the shared secret, an exp that is present and not passed, a numeric sub, a known role. A bad token is a 401 problem+json. SecurityConfig then requires a signed-in caller on every route except /auth/** (sign-up, login, owner setup — public by design) and /actuator/health.
  2. Authorization: URL rules in SecurityConfig, checked before any controller code runs:
    • GET /users/* and DELETE /users/me — any signed-in user.
    • everything else under /users, including PATCH /users/{id} (role change) — ADMIN or OWNER only; others get 403. Unlisted methods fail closed.
    • /users/me routes act on the caller's own account: the ID comes from the token's sub, never from the request.
  3. Validation: request bodies are records checked with Jakarta Validation (@Valid), e.g. the password rules via @Pattern in AccountRules. ProblemDetailAdvice turns failures into one 400 problem+json (RFC 9457) listing each broken rule, sorted by field; an unreadable body is 400 "Request body is missing or malformed". Business rules then run in the service (uniqueness including soft-deleted accounts, "OWNER can't be changed", …).
  4. Concurrency: User has a @Version column (optimistic locking). Each save checks that the row hasn't changed since it was read; if another request saved it in between, the save fails with OptimisticLockingFailureException, answered as 409 "The account was changed by another request; reload and try again". Example: one admin changes a user's role while another admin removes that user — without the check, the role change (made from a copy read before the removal) could write the account back as active. Login's own counter update uses a row lock instead (LoginAttempts), so parallel wrong passwords all count.

How users are kept from changing protected fields (role, account status, user ID)

  • User ID: never taken from the request. /users/me routes get the caller's ID from the verified login token's sub claim (JwtAuthenticationFilter → CallerId.from), so there's no way to name someone else's account. The id column is also generated by the database.
  • Role:
    • Sign-up's request type (SignupRequest) has no role field, and AuthService always creates Role.USER. A role sent in the body is dropped (there's a test for this in AuthControllerTest).
    • The only way to change a role is the admin endpoint (§6).
    • The authorization rules use the role in the signed token, which the server issued, so a client can't make one up.
  • Account status: there's no field a client can set.
    • deleted_at changes only through softDelete (on DELETE /users/me or an admin's removal), or when logging in within 30 days recovers the account.
    • The lockout counters change only inside login (LoginAttempts).
    • created_at can't be updated (updatable = false).
  • Allow-list request types: each request type lists only the fields it accepts. For example, UpdateRoleRequest binds only role.

Examples

Run against the live stack on 2026-09-30 (main + the demo-account seeder); the responses are the service's actual output. $USER_TOKEN is a normal user's login token, $ADMIN_TOKEN an admin's.

# Attempt Request Result
1 Sign up as an admin POST /auth/signup {"email":"[email protected]","username":"sneaky_sam","password":"StudentPass123","role":"ADMIN"} 201, account created with "role":"USER" — the role field is dropped
2 Promote yourself PATCH /users/me {"role":"ADMIN"} with $USER_TOKEN 403 "You do not have permission to access this resource" — no profile-update route exists yet, and unlisted /users routes fail closed
3 Promote someone else PATCH /users/2 {"role":"ADMIN"} with $USER_TOKEN 403 — admin routes need ADMIN or OWNER
4 Read someone else's account GET /users/2 with $USER_TOKEN 200 {"username":"alex_tan"} — the public profile is the username only; no email, role or status
5 Edit the token's role claim to ADMIN GET /users with the tampered token 401 "Invalid token" — the signature no longer matches
6 Admin grants OWNER PATCH /users/2 {"role":"OWNER"} with $ADMIN_TOKEN 403 "OWNER can only be granted through owner setup"
7 Admin smuggles extra fields PATCH /users/2 {"role":"USER","id":1,"email":"[email protected]","username":"renamed","deletedAt":"2026-01-01T00:00:00Z"} with $ADMIN_TOKEN 200, account unchanged except role — UpdateRoleRequest has no other fields, so id, email, username and deletedAt are ignored

§6 Role lifecycle & administration

flowchart LR
    T["setup token in env<br/>known only to the developers"] -->|"X-Setup-Token header<br/>while OWNER_SETUP_TOKEN is set"| O["OWNER(s)<br/>POST /auth/setup-owner"]
    O -->|"dashboard promote button"| A["first ADMIN"]
    A -->|"owners and admins<br/>promote / demote"| MORE["user ↔ admin"]
Loading

The first owner comes from the setup endpoint; the first ADMIN is then promoted by an owner through the admin dashboard.

How the first administrator (the owner) is created

  • The route: POST /auth/setup-owner, in OwnerSetupController and OwnerSetupService. It takes email, username and password, validated with the same rules as sign-up. It is the only way to create an OWNER.
  • What controls it:
    • Shared secret: the request must carry an X-Setup-Token header matching the server's OWNER_SETUP_TOKEN, compared in constant time with MessageDigest.isEqual. A wrong or missing token gets a 403.
    • Off without a token: if the server has no token configured, the endpoint returns 503. The intended practice is to set the token, create the owner, then unset it.
    • No duplicates under concurrent calls: a database advisory lock (acquireSetupLock) serialises setup calls, so two at once can't both pass the uniqueness checks.
  • Things to know:
    • Each call with a valid token creates another owner. The team removed the "only when no owner exists" limit.
    • The token never expires; expiry was added and then deferred.
    • The route sits under /auth/**, which is open to anyone, so the token is the only protection.

How a user is promoted (no developer involvement needed)

  1. The request: an ADMIN or OWNER opens /admin and clicks Promote to Admin, then confirms in a dialog. The web app sends PATCH /users/{id} with {"role":"ADMIN"}.
  2. Access check: SecurityConfig allows it only if the token's role is ADMIN or OWNER; anyone else gets a 403.
  3. Business rules (AdminService.changeRole):
    • Asking for OWNER is refused (403, "OWNER can only be granted through owner setup").
    • The target must be an active account, or it's a 404.
    • The target must not be an OWNER (403).
    • A promotion to the role the user already has returns 200 without saving anything.
    • A simultaneous change that loses the race gets a 409, via the @Version check.
  4. When it takes effect: the new role is saved immediately, but permissions come from the login token. So the promoted user gets admin access at their next login, and their current token keeps the old role for up to an hour.

Can a normal user reach the promote API or the /admin page?

No — the server refuses them, and the web app doesn't show them the page.

What a normal (USER) caller tries What happens Where it's enforced
PATCH /users/{id} (promote/demote), or any admin route (GET /users, DELETE /users/{id}), with their own token 403 problem+json, before the request reaches any controller code, so the body isn't even read SecurityConfig: everything under /users except GET /users/* and DELETE /users/me needs hasAnyRole("ADMIN","OWNER"); unlisted methods fail closed
The same call with no token, or a bad/expired one 401 problem+json JwtAuthenticationFilter + SecurityConfig
Sending "role": "ADMIN" in their own sign-up ignored — the account is created as USER SignupRequest has no role field; AuthService sets Role.USER
Opening /admin in the browser redirected to the home page (logged out: to /login first) AdminRoute (web) checks the role from GET /users/me; ProtectedRoute handles logged-out visitors
Looking for an admin link not shown — the Admin Dashboard nav link and mobile tab appear only for ADMIN and OWNER NavBar / TabBar (web)
  • The web checks only shape the UI. The server check is the real control: calling the API directly with a USER token still gets 403.
  • Because the page check uses the current role (/users/me) but the API trusts the role in the token, a demoted admin loses the /admin page on their next page load but can keep calling admin APIs with their old token until it expires (up to 1 h) — the stale-role case in the table below.

Edge cases:

Case Behaviour
Admin demotes another admin allowed (confirmed)
Admin demotes themselves allowed — verified in code: AdminService.changeRole has no self-check; only OWNER targets are refused
Anyone demotes/removes an OWNER 403 — owners cannot be demoted, by anyone
Last admin demotes/deletes themselves allowed — owners are the backstop; zero admins is recoverable in-app
No owners left recover via the setup endpoint with the setup token (it doesn't expire; unsetting OWNER_SETUP_TOKEN closes the route, and re-setting it is a deliberate developer action)
Stale role in a live token accepted trade-off: up to 1 h (exp) — revisit with logout #90

Part 2 — Supplier Service

§1 Database choice & schema — F1.1.1 · NFR2.1

PostgreSQL 16 + JPA. Why:

  • Supplier metadata is fixed and structured — it comes straight from the course CSV; every CSV column maps to a typed column (below), nothing document-shaped.
  • One genuinely relational need: a supplier has multiple categories (CSV Type like Food/Coffee is split into join-table rows) — F1.1.1 says "categories", plural.
  • Capacity (NFR2.1): 1 000 suppliers — NUS-scale, not a million-row marketplace. At that size a sequential scan clears the 5 s listing bound (NFR1.1) with no indexes (supplier-service.md D7); revisit only if the target grows.

Schema (required = NOT NULL; everything else optional — real CSV rows have gaps):

erDiagram
    Suppliers {
        bigint id PK "req - the identity"
        text name "req - NOT unique, see branches note"
        text category "opt - raw CSV Type cell"
        text building "opt"
        text floor "opt"
        text location_description "opt"
        double latitude "opt - distance sort + Order 1 km rule later"
        double longitude "opt"
        time starting_time "opt - CSV 0900hrs parsed to TIME"
        time closing_time "opt"
        text image_url "opt"
        text supplier_description "opt - admin-filled"
    }
    SupplierCategories {
        bigint id PK "req - shares supplier PK"
        text category PK "req - one row per split Type value"
    }
    Suppliers ||--o{ SupplierCategories : "categorised as"
Loading
  • Types (mentor ask): text for names/locations, double lat/lng (real coordinates — Order Service's 1 km rule needs them), SQL TIME for opening hours parsed from the CSV's 0900hrs format, join rows for categories.
  • Supplier identity (mentor question — same names, branches): the id is the identity; name is deliberately not unique. Starbucks @ YIH and Starbucks @ UTown are two rows, same name, distinguished by building/floor/location_description and coordinates.
  • Validation (mentor question): name is the only hard-required field (DB NOT NULL). On the CRUD endpoints, Bean Validation enforces it plus format checks (numeric coordinate ranges, time format) — a request that bypasses the UI is rejected by the backend with 400 problem+json whose detail lists every failing field, which is also what the UI shows.

§2 Query patterns & API design — F1.2 · F2 · NFR1.1

Key patterns (all live): paged browse · search by name · filter by category · sort by name asc/desc · sort by distance ("nearest to me": UI sends the browser's lat/lng, ordering is Haversine in SQL) · supplier details from the list payload · no match → 200 with an empty page, UI shows the empty state (F2.2.1).

Route Returns Access
GET /suppliers?search=&category=&lat=&lng=&page=&size=&sort= PageResponse<SupplierResponse> — content[] {id, name, location, latitude, longitude, categories[], openingTime "HH:mm", closingTime "HH:mm", description, imageUrl} + page, size, totalElements, totalPages; defaults size 20, name asc any authenticated (#143)
GET /suppliers/categories string[] — distinct categories, used only by the filter dropdown any authenticated
POST /suppliers 201 SupplierResponse ADMIN/OWNER
PUT /suppliers/{id} 200 SupplierResponse ADMIN/OWNER
DELETE /suppliers/{id} 204 (hard delete) ADMIN/OWNER

Denied requests: missing/invalid token → 401, wrong role → 403 — problem+json with the reason in detail (a regular user calling a restricted endpoint directly gets the 403, not a hidden button).

§3 CRUD operations in the backend — F1.1

  • Delete semantics — decided (team meeting, 2026-09-29): F1.1.3 is a hard delete. Future guard once the Order Service exists: deleting a supplier with outstanding orders is rejected with an error — referential integrity by refusal, not soft-delete. (supplier-service.md D5 records the decision.)
  • The three write endpoints shipped in #153 (merged 2026-09-30, closing #106): Bean Validation on SupplierRequest, 404 problem+json for a missing id, and a demo-crud.sh walkthrough alongside the service.
  • Backend works without the UI: every operation above is demoable with curl while the web container is stopped — the demo script logs in against user-service for a real JWT, then drives every supplier endpoint, showing accepted and rejected calls.

§4 End-to-end integration

Full flow: login → JWT → supplier API → database, with role-appropriate outcomes (Part 1 §4 diagram).

  • UI reflects the role — non-admins see no "add supplier" button and no edit/delete on supplier cards — and the backend enforces it independently: the same action via curl returns 403 problem+json. Restricted buttons are hidden and the endpoints are gated (the mentor's "merely hidden in the UI?" question).
  • Accepted/rejected pairs per role are scripted in the demo script.
  • UI in place: the Admin Dashboard nav link / mobile tab appears for ADMIN and OWNER (#147); supplier management lives on the Suppliers page (add, plus edit/delete on supplier cards — modals shipped with #153). The admin dashboard shows only the Users section for now — its Suppliers section was removed (#158, Ryan Ang, 2026-09-30).

§5 Responsive supplier-management UI — UI NFR

  • Workflows live on real API data (no mocks): browse, search, filter, sort, paginate, details; admin create/edit/delete modals wired to §3's endpoints (#153).
  • Desktop NavBar / mobile bottom TabBar; every screen at both widths.
  • Demo data: seeded (#155) — DemoAccountsSeeder inserts 1 owner, 3 admins and 100 users at startup when USER_SEED_DEMO=true (off by default; passwords documented in the user-service README, local/demo only) — so pagination, search and role scenarios look real.

Demo script (20–30 min)

# Rubric Show
1 P1.1 role matrix + live USER/ADMIN/OWNER accounts
2 P1.2 schema; a BCrypt-hashed row; pending_signups mid-signup
3 P1.3 login → decoded JWT; endpoint with/without/expired token (logout deferred — say it, tokens die in 1 h)
4 P1.6 setup-owner with token → owner promotes first admin → demote; owner-untouchable refusal
5 P1.5 update carrying role has no effect (allow-list DTO)
6 P2.1–3 supplier CRUD + queries via curl script, web container stopped
7 P1.4/P2.4 admin vs user token on supplier writes: 2xx vs 403
8 P2.5 supplier UI, desktop + mobile, live data
9 wrap diagrams + "why" from the deciders

Open items

  1. Before demo: merge #149 (profile screens) and, if ready, #157 (#92's account updates). Everything else on the old list has merged — #143, #150, supplier writes (#153), the demo seed (#155), the /admin nav link (#147).
  2. Next: the rest of #109 (logout, token handling — its sign-up/OTP screens shipped in #150) · logout denylist #90 · account update #92 — in review as #157, reusing OtpService and the pending-table-per-operation shape · recovery/reset #94.
  3. Prepared answers: NFR1.2 email-hashing inconsistency (Part 1 §2) · supplier rename vs order history (delete is decided; rename/snapshot question goes to Order Service design).

Migrated from Lark 2026-09-29 (original frozen); refreshed from the PR record and the 2026-09-29 team meeting. Diffs live on the PRs.

⚠️ **GitHub.com Fallback** ⚠️