@thejob/users-client
v1.0.1
Published
Typed TypeScript/JavaScript client for the thejob-user-service HTTP API.
Readme
@thejob/users-client
Typed client for thejob-user-service. Published by the service that owns the data, so consumers never hand-roll HTTP calls or redeclare user types.
import { UsersClient } from '@thejob/users-client';
const users = new UsersClient({
baseUrl: process.env.USERS_SERVICE_URL!,
apiKey: process.env.USERS_SERVICE_API_KEY, // backend-to-backend
// token: userJwt, // user-scoped calls (/me)
});
const { results, total } = await users.listUsers(
{ countries: ['IN'], skills: ['python'] },
{ limit: 50 },
);
// -> ?marketingSubscribed=true&countries[0]=IN&skills[0]=python&limit=50Methods
Twenty-nine methods, one per endpoint, plus two helpers: fileUrl builds a URL
without requesting it, and getFile fetches the bytes that URL serves.
There is no countUsers: a count is listUsers(filter, { limit: 1 }) read off
total, which is one line at the call site and keeps what total means visible.
There is no listUserPreviews either. The fuller per-user shape is the same
query over the same users with more fields per row, so it is an option —
listUsers(filter, { detailed: true }) — sitting beside withCompleteness and
withMatchProfile. A second method implied a second question, and the two
returned contradicting shapes because nothing forced them to agree.
| | |
|---|---|
| Read | listUsers · getUser · getUserBySlug · getMe |
| Admin | updateRoles · deleteUser |
| Profile sections | updateGeneralDetails · updateWorkExperiences · updateEducations · updateSkills · updateLanguages · updateCertifications · updateInterests · updateSocialAccounts · updateAdditionalInfo · updateMarketingConsent · updateNotificationPrefs · updateOnboarding |
| Prefill | dismissPendingPrefill |
| Files | uploadProfileImage · uploadResume · importResume · parseStoredResume · setPrimaryResume · deleteResume · fileUrl · getFile |
| Internal | getNotifyProfile · health |
One method per PATCH /me/* section rather than a single updateMe(section,
patch): the sections are not interchangeable, and two of them
(marketing-consent, notification-prefs) carry opposite consent semantics that
deserve to be named and documented separately.
Consent
listUsersappliesmarketingSubscribed: trueby default. The server no longer forces this gate — it became an ordinary filter so one endpoint could serve every query shape — so this default is the only thing standing between a caller and mailing people who never opted in. An explicitmarketingSubscribed: falsereally does return non-consenting users: legitimate for analytics, never for a send. Tagged@todo CONSENT-FILTER-ENFORCEMENT.
The two consent shapes are deliberately not one shape:
marketingConsent.subscribedis opt-IN, read as=== true.notificationPrefsis opt-OUT, read as!== false, so a user whose document predates the field reads as enabled.
Collapsing them is a compliance bug, not a tidy-up.
Design
One method per endpoint, never per use case. Methods are named for the
resource and operation (listUsers, getUser), not for why a caller wants the
data. A getMarketingRecipients would be the same mistake as an endpoint called
/users/recipients: one caller's intent baked into a shared contract. Narrowing
is the filter's job.
POST /users/recipients has been removed from the service. It was a narrower
form of this same query behind a different verb and shape — the per-use-case
endpoint proliferation the filter design exists to prevent. Everything it
answered is a filter on listUsers.
A read is a GET. The filter is a structured document (token facets, numeric
ranges, relative day-windows, a withinRadius geo group, a one-level or union)
and it encodes into the query string, so no POST shadows the resource it reads.
Query strings are built by buildQuerySuffix from
@thejob/util/query, shared with every service
client. It drops what would encode to a parameter matching nothing — a blank
value, an empty array, an untrimmed key — while keeping false and 0, which
are meaningful.
Facets sit at the top level, next to paging, exactly as they appear in the
URL — no filter[...] wrapper. One convention across every service client, and
the query string mirrors the type.
buildQueryString also enforces the limits qs.parse fails silently past: it
throws rather than emitting nesting deeper than QUERY_DEPTH (which the server
would read as a literal "[key]") or an array longer than QUERY_ARRAY_LIMIT
(which the server returns as an object keyed by index). The server parses with
the same shared constants, so the two cannot drift.
total is the filter's count, not the page size. For a count alone, pass
limit: 1 and read total.
Types
There is ONE user shape: User. It is @thejob/schema's canonical user —
derived from UserSchema, so it cannot drift from what the schema validates —
plus the two response-only fields (completeness, matchProfile). Every read
returns it; a projection returns fewer fields, never different ones.
PaginatedResponse<T> is re-exported from @thejob/schema rather than
redeclared, and ListUsersOptions extends its PaginationSchema fields rather
than restating page / limit / sortBy / sortDirection.
UserFilter, NotifyProfile, MatchProfile, Resume and friends are the
canonical definitions owned here and imported back by user-service, so there
is exactly one definition of each.
This package once published five overlapping user types (UserListItem,
UserDetail, UserProfile, plus two envelopes). They CONTRADICTED each other:
name was a string in two and {first, last} in the third, and the same value
was userId in two and shortId in the third. If a shape already exists in
@thejob/schema, use it; do not restate it here.
They are named for what they are, never for what a caller wants them for.
These types were once Recipient / RecipientFilter / RecipientPreview —
marketing's word for a user it is about to mail. user-service has no recipients;
it has users, and a filter that selects some of them. Naming the type after one
consumer's use is the same mistake as an endpoint called /users/recipients,
just moved into the type system, where every other consumer then inherits it.
pendingPrefill is seed values only: it populates a form's initial value and
must never be read as a fallback for the user's actual value anywhere else.
Coverage
client.test.ts reads src/routes/users.ts, src/routes/files.ts and
src/app.ts, collects every registered route, then drives a fake fetch through
every method and asserts nothing is left over — in both directions, so
a method pointing at a path the service does not register also fails.
Excluded: GET /, /login, /logout and /dashboard/* (the operations UI —
session-cookie gated and same-origin, so an API-key client could not authenticate
against them anyway).
