@winwinmbs/portal-api
v2.0.0
Published
Business API client for WINWIN Portal (files, email, workflow, user, etc.)
Readme
@winwinmbs/portal-api — Implementation Guide
Typed REST client for the WINWIN Portal API. Wraps every public endpoint (users, roles, permissions, workflows, files, todos, etc.) with type-safe methods. Uses axios under the hood; bearer-token + API-key injection are handled automatically.
For AI agents: this package is just an HTTP client. It does not manage auth state, refresh tokens, or persist anything. Pair it with
@winwinmbs/portal-auth(browser) or a service-to-service token (backend) for the auth side.
1. Install
npm install @winwinmbs/portal-api
# or
pnpm add @winwinmbs/portal-apiPeer requirement: axios (declared as dep — installed automatically).
2. The whole shape
import { PortalApiClient } from '@winwinmbs/portal-api';
const portal = new PortalApiClient({
apiUrl: 'https://api.winwinmbs.com',
getToken: () => localStorage.getItem('access_token'), // sync read
apiKey: 'app_xxx', // optional
timeout: 30_000, // optional, ms
});
// All API surfaces are properties on the client:
const me = await portal.user.findById(userId);
const orgs = await portal.organization.tree();
const todos = await portal.todo.search({ /* ... */ });3. Available API namespaces
Each namespace maps to a portal feature module. Consult the IDE's autocomplete for the full method list — types are exported from the package.
| Namespace | Endpoint group | Common methods |
|-----------|----------------|----------------|
| portal.user | /users/* | search, findById, create, update, delete, setRoles, setPositions |
| portal.organization | /organizations/* | tree, findById, create, update, move, delete |
| portal.role | /roles/* | search, findById, create, update, setPermissions |
| portal.permission | /permissions/* | search, findById, create, update |
| portal.permissionCategory | /permission-categories/* | search, tree, create |
| portal.workflow | /workflows/* | search, findById, instances, tasks, approve, reject |
| portal.todo | /todos/* | search, findById, create, update, complete |
| portal.files | /files/* | upload, download, findById, delete |
| portal.email | /emails/* | send, templates, history |
| portal.otp | /otp/* | request, verify |
| portal.line | /line/* | link, unlink, notify |
| portal.webhook | /webhooks/* | search, findById, create, delete |
| portal.systemConfig | /system-configs/* | getCategory, getByKey, update |
| portal.eventLog | /event-logs/* | search, findById |
| portal.health | /health | check |
Every method returns the underlying portal response shape (typed via
@win-portal/shared DTOs). Most search/list endpoints return
ApiPaginatedResponse<T>; single-resource endpoints return ApiResponse<T>.
4. Configuration reference
interface PortalApiConfig {
/** Portal API base URL (no trailing slash) */
apiUrl: string;
/**
* Sync getter for the current access token. Called on every request.
* Return null when there's no session — requests will go out without
* Authorization, and the portal will respond 401 if auth is required.
*/
getToken: () => string | null;
/**
* Optional app-scoped API key. Required for /v1/auth/* endpoints and any
* other route that gates access via x-api-key. Sent as 'X-API-Key' header.
*/
apiKey?: string;
/** Per-request timeout in ms. Default 30_000. */
timeout?: number;
}getToken is synchronous on purpose. Most consumers cache the current
access token in memory or localStorage and read it cheaply. If you only
have an async source, do the refresh out-of-band (e.g. on app boot or via
portal-auth.getAccessToken()) and write the result somewhere sync-readable.
5. Quickstart — browser app paired with portal-auth
import { AuthClient } from '@winwinmbs/portal-auth';
import { PortalApiClient } from '@winwinmbs/portal-api';
// 1) Auth side — manages tokens, refresh, storage
const auth = new AuthClient({
apiUrl: 'https://api.winwinmbs.com',
mode: 'sso',
portalUrl: 'https://portal.winwinmbs.com',
appId: 'your-app-id',
});
await auth.start();
// 2) API client — reads the token written by auth
let cachedToken: string | null = null;
auth.on('token_refreshed', ({ token }) => { cachedToken = token; });
auth.on('authenticated', () => { auth.getAccessToken().then(t => cachedToken = t); });
const portal = new PortalApiClient({
apiUrl: 'https://api.winwinmbs.com',
getToken: () => cachedToken,
});
// 3) Use it
const users = await portal.user.search({ limit: 20 });Or (simpler, but does an async read every request):
const portal = new PortalApiClient({
apiUrl,
// axios wrapper is sync, so we can't await here. Pre-cache and let auth
// events keep the local token current.
getToken: () => auth['token'] ?? null, // 👈 if you want to read internal cache
});The recommended pattern is the event-listener one above — it keeps the
getToken callback predictably synchronous.
6. Quickstart — backend service-to-service
import { PortalApiClient } from '@winwinmbs/portal-api';
// Backend has its own long-lived API key + can mint its own service token
// (or proxy the user's token through). Simplest: read both from env.
const portal = new PortalApiClient({
apiUrl: process.env.PORTAL_API_URL!,
apiKey: process.env.PORTAL_API_KEY!,
getToken: () => process.env.PORTAL_SERVICE_TOKEN ?? null,
});
// Hit any read endpoint:
const apps = await portal.organization.tree();For per-request user-scoped calls (e.g. an Express handler forwarding the caller's token), close over the request:
app.get('/me/orgs', async (req, res) => {
const portal = new PortalApiClient({
apiUrl: process.env.PORTAL_API_URL!,
apiKey: process.env.PORTAL_API_KEY!,
getToken: () => req.token ?? null, // populated by portal-auth-server
});
const orgs = await portal.organization.tree();
res.json(orgs);
});The PortalApiClient is cheap to construct — instantiating per-request is
fine. Or hoist + close over a mutable token holder if you prefer.
7. Common scenarios
7.1 Search with pagination
const page = await portal.user.search({
page: 1,
limit: 20,
filter: { status: ['active'] },
sort: [{ field: 'created_at', order: 'DESC' }],
});
// Returned shape (ApiPaginatedResponse<UserResponseDto>):
// { success, data: User[], pagination: { page, limit, total, total_pages } }7.2 File upload
const formData = new FormData();
formData.append('file', file);
const result = await portal.files.upload(formData, { category: 'avatar' });
// result.data.id is the file_id you can store on user.avatar_file_id(Browser FormData for browser; Node consumers use FormData from
undici or form-data.)
7.3 Workflow approval
const tasks = await portal.workflow.tasks({ assignee_user_id: meUserId });
const myPending = tasks.data.filter(t => t.status === 'pending');
await portal.workflow.approve(taskId, {
comment: 'LGTM',
metadata: { /* per-workflow custom data */ },
});7.4 Composing typed responses
The DTOs are exported by @win-portal/shared (the portal's source of truth).
This client re-uses those types directly:
import type { UserResponseDto } from '@win-portal/shared/user';
async function findUser(id: string): Promise<UserResponseDto | null> {
try {
const res = await portal.user.findById(id);
return res.data;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) return null;
throw err;
}
}8. Error handling
The client lets axios errors propagate. Patterns:
import axios from 'axios';
try {
await portal.user.findById(id);
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const body = err.response?.data; // ApiResponse-style error envelope
if (status === 401) /* token expired or invalid */;
if (status === 403) /* forbidden — wrong app or missing permission */;
if (status === 404) /* not found */;
if (status === 429) /* rate limited; body.retry_after_ms tells you when */;
throw err;
}
throw err;
}The portal's error envelope (post-2026-05-05) is:
{
"success": false,
"error": {
"code": "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "VALIDATION_ERROR" | ...,
"message": "<localized human message>",
"details": { ... },
"timestamp": "2026-05-05T...",
"path": "/users/abc",
"method": "GET",
"statusCode": 401,
"requestId": "req_..."
}
}requestId is searchable in the portal's audit log — pass it to portal
support when reporting issues.
9. Don'ts
- ❌ Don't manage tokens inside this client. It's a transport, not an
auth library. Refresh, storage, and lifecycle live in
portal-auth. - ❌ Don't decode
getToken's return value to check expiry. Just return it. If the portal says 401, deal with it then. - ❌ Don't omit
apiKeyfor/v1/auth/*calls. That whole surface requires it; you'll get 401 withMISSING_API_KEY. - ❌ Don't share one
PortalApiClientacross users on a backend. It closes overgetToken, so per-request construction (or a mutable holder) is the correct pattern. - ❌ Don't write your own retry logic on top of this. The portal's rate
limiter returns 429 with
retry_after_ms; respect that. Generic exponential backoff against any 5xx is reasonable, but skip 4xx (it won't recover by retrying).
10. Cross-package integration
| Combine with | Why |
|--------------|-----|
| @winwinmbs/portal-auth | Browser auth — feeds getToken from the SDK's storage |
| @winwinmbs/portal-auth-react | React app structure — useSession().getAccessToken for token reads (note: that's async, see §5) |
| @winwinmbs/portal-auth-server | Backend that validates AND forwards the user's token via this client |
Two-package combo (browser):
React component
└─ useApi() {
const { getAccessToken } = useSession();
return useMemo(() => new PortalApiClient({
apiUrl,
getToken: () => /* sync mirror of getAccessToken result */,
}), [...]);
}The trick: React renders synchronously, but getAccessToken is async
(because it may refresh). Either:
- Maintain a sync mirror updated by the
'token_refreshed'event (recommended — illustrated in §5). - Or wrap your fetch helpers in
async (path, init) => { const t = await getAccessToken(); return fetch(...) }and skipPortalApiClientfor that one call site.
11. Where to look in source
| File | Contents |
|------|----------|
| src/portal-api-client.ts | The PortalApiClient class, axios setup, interceptors |
| src/api/user.api.ts | User CRUD + role/position management |
| src/api/organization.api.ts | Org tree CRUD + move |
| src/api/role.api.ts | Role CRUD + permission attachment |
| src/api/permission.api.ts | Permission CRUD |
| src/api/workflow.api.ts | Workflow definition + instance + task |
| src/api/files.api.ts | File upload/download |
| src/api/email.api.ts | Email send + templates |
| src/api/otp.api.ts | OTP request/verify |
| src/api/todo.api.ts | Todo CRUD |
| src/api/system-config.api.ts | System config read/write (admin-scoped) |
| src/api/health.api.ts | Health check |
| src/api/event-log.api.ts | Audit log search |
| src/api/line.api.ts | LINE link/unlink/notify |
| src/api/webhook.api.ts | Webhook subscriptions |
| src/api/permission-category.api.ts | Permission grouping tree |
| src/types/ | Re-exported DTO types from @win-portal/shared |
| src/portal-api-client.spec.ts | Reference for instantiation + interceptor behaviour |
12. Reference: API surface contract
This client tracks the portal's HTTP API. Every method here corresponds to a
NestJS controller route in apps/api/src/modules/features/<domain>/. If the
portal adds an endpoint, it must be added here too — but the portal team
keeps the two in sync via a shared DTO package, so renames or signature
changes break compilation immediately rather than at runtime.
For a list of capabilities (high-level inventory of what the portal can do),
see docs/PORTAL_CAPABILITIES.md.
For low-level type definitions, browse packages/shared/src/dto/ — the
client re-exports those types unchanged.
