23 KiB
Data Layer Design and Implementation
This document describes the data-layer used in ngcp-csc-ui. It explains the responsibilities, contracts, patterns. Use this doc when adding new APIs, handling pagination, caching, or wiring data into Vuex.
Key files
src/api/common.js— main HTTP client and helpers (axios instancehttpApi,getList,get,post,put,patch,del,apiGet,apiPost).src/helpers/http-error.js—: getHttpErrorMessage(err, fallbackMessage), the single shared helper that turns an axios error into a user-facing message. Used bycommon.js,src/api/user.js,src/helpers/ui.js, andsrc/store/user.js.src/api/utils.js— minor helpers such asgetJsonBody.src/api/*— domain wrappers that callsrc/api/common.jshelpers (e.g.,src/api/communication.js,src/api/fax.js,src/api/ngcp-call.jsfor SIP control).src/store/— Vuex modules that consume API functions and convert responses to application state.src/storage.jsandsrc/auth.js— storage and JWT helpers used bysrc/api/common.js.
API contract and conventions
- Function inputs: option objects use these common fields:
path,resource,resourceId,params,body,headers,blob,responseType, andconfig. - For convenience, providing
resource/resourceIdautomatically maps the path toapi/<resource>/orapi/<resource>/<resourceId>. - Functions return either:
- A parsed entity (from JSON body),
- A generated id (when server responds with
Locationheader but no body), - A URL object for blobs (when
blob === true).
- Error semantics:
ApiResponseErroris thrown when server returns structured{ code, message }. Otherwise axios/network errors are rethrown.
Error Handling
Error handling is centralized for HTTP in common.js (axios instance + handleResponseError + ApiResponseError), propagated into Vuex modules (actions commit failed mutations with error.message), and SIP/WebRTC errors surface via callEvent events handled in ngcp-call.js which convert SIP events into store actions/mutations.
Responsibilities by layer
HTTP / API:
common.js — http client (httpApi), ApiResponseError class, initAPI, request interceptor, handleResponseError, and API helpers (get, post, getList, apiGet, apiPost, cancel helpers).
utils.js — getJsonBody used when parsing bodies.
domain wrappers: src/api/*.js (e.g., src/api/communication.js::createFax) — call the above helpers and rely on errors thrown/propagated by common.js.
Behaviour
-
Request setup:
initAPI({ baseURL })setshttpApi.defaults.baseURL. A request interceptor addsAuthorizationheader whenhasJwt()is true (callsgetJwt()inauth.js). -
Error transformation (central): The place for mapping server responses to application errors is
handleResponseError(err)incommon.js. It handles three situations, depending on what the axios error actually contains:1. A structured error body is present (
err.response.datahascode/message) — two messages get translated first (code === 403 && message === 'Invalid license'→ a friendlier i18n string;code === 403 && message === 'Password expired'→ sets an i18n message and doesthis.$router?.push({ path: PATH_CHANGE_PASSWORD }), then returns without throwing, since there's nothing further to do). Everything else becomesthrow new ApiResponseError(code, message)(the class carriescode,status,message).2. A response exists but there's no structured
{ code, message }— this is the nginx-rendered case: a 502/503/504 comes back as an HTML error page instead of JSON, or the body is a plain string or empty. There's nocode/messageto build anApiResponseErrorfrom here, so insteaderr.messageis rewritten in place viagetHttpErrorMessage(err, fallbackMessage), and the sameerrobject is rethrown — not wrapped in a new error type. Rethrowing the original object (rather than constructing a fresh error) matters because some callers read other fields off it directly, e.g.src/store/user.js'sgetOTPSecretreadserr.response.data(a Blob) directly viaparseErrorPayload/resolveBlobPayload(src/helpers/parse-error-payload.js), and a replacement error without.responsewould break that.3. No response at all (network failure, request never reached the server) — rethrow the original
erruntouched; there's nothing to transform.Many domain API helpers call
handleResponseError(err)when catching axios errors; some API wrappers return or rethrow the result so callers (store actions) get the transformed error.getHttpErrorMessage(err, fallbackMessage)(src/helpers/http-error.js)This is the single place that turns an axios error into a user-facing string, in priority order:
err.response.data.message— a structured JSON error body's own message, if present (checked unconditionally, even whenresponse.statusis missing — some mocked/edge-case responses carry a message with no status).- If there's no
response.statusat all, returnfallbackMessage. - The
<title>of an HTML error page inerr.response.data, but only if that title starts with the actual status code — this stops an unrelated HTML page (e.g. the SPA's own fallback page) from being mistaken for the real reason phrase. err.response.statusText, or failing thaterr.message, or failing that a small built-in fallback for the common 5xx statuses (STATUS_TEXT_FALLBACK:500/502/503/504), formatted as"<status> <text>".- Just
"<status>"if nothing else is available.
Outside of that small 5xx table, there's no general status-code → reason-phrase lookup: the function only trusts signals actually present in the response, and falls back honestly rather than guessing a reason phrase for a status it hasn't seen.
Besides
common.js, this same helper is called directly (without any localerr.response.data?.message ||prefix — that extraction now lives inside the helper) from:src/api/user.js—login,loginByExchangeToken,getPreLoginPasswordInfo,getUserData,changeExpiredPassword.src/helpers/ui.js—showGlobalError, the app-wide error notification shown for exceptions bubbling out of components/actions.src/store/user.js—getOTPSecretonly, as the final fallback once its own 400-specific handling (below) doesn't apply or the body fails to parse.getOTPSecretAsTextgoes throughget()/handleResponseErrorinstead, soerr.messageis already set by the time it reaches the action'scatch.
Why those
user.jsfunctions bypasshandleResponseError: they callhttpApidirectly instead ofcommon.js'sget/post, and build their own message withgetHttpErrorMessagerather than funneling throughhandleResponseError. This isn't incidental — going throughhandleResponseErrorwould break them.store/user.js'sloginaction branches on the exact, untranslated backend message ('Invalid OTP'opens the two-factor flow,'Password expired'redirects to the change-password page,'Banned'shows a dedicated notice).handleResponseErrorrewrites'Password expired'into a translated string and redirects and returns without throwing at all — so iflogin()went through it, a password-expired response wouldn't surface as an error at all; the promise would resolve withundefinedand the login action would never see the case it needs to react to.changeExpiredPasswordhas a similar reason: it needs to tell a401(wrong current password) apart from a422(new password fails policy) and show a distinct message for each, which it can only do by inspecting the raw status itself.loginByExchangeTokenandgetPreLoginPasswordInforun before there's a session to speak of, for the same category of reason.getUserDatais simpler — it just fans out several already-common.js-backed calls viaPromise.alland wraps whatever throws in one consistent plainError, so the login action always gets the same shape back regardless of which sub-call failed.
OTP / 2FA login flow (src/store/user.js)
An 'Invalid OTP' error from login() is ambiguous by itself — it can mean "this account needs a code, and we don't yet know if that's first-time setup or a repeat entry". That distinction lives in state.loginWaitingOTPCode, which the login action snapshots into a local wasWaitingForOTP before calling commit('loginRequesting'), so it reflects the state as it was when the user submitted, regardless of whether this particular submission happened to include an otp value (a resubmit with a blank/wrong code looks identical to a fresh attempt from the payload alone):
wasWaitingForOTPfalse: first attempt this session; dispatchgetOTPSecretto find out whether the account needs first-time setup (server returns a QR-code PNG blob) or already has 2FA configured (400with a'no OTP'message → prompt for a code only, no new secret).wasWaitingForOTPtrue: we already know a code is required; any further'Invalid OTP'— including a resubmit with a blank or wrong code — surfaces directly asloginFailed('Invalid OTP Code')instead of re-running discovery.
getOTPSecret's own catch mirrors this: a 400 status means the account already has 2FA, so it parses the body with parseErrorPayload and only commits loginWaitingForOTPCode (prompt for a code, no QR) when the message includes 'no OTP' and we're not already waiting — any other message is a real failure (loginFailed). Any other status, or a body that fails to parse, falls through to resolveBlobPayload + getHttpErrorMessage for a real message instead of a blind 'Unexpected error'.
loginWaitingOTPCode and OTPSecret are only ever reset by mutations representing an actual terminal outcome — loginSucceeded, logout, and (for OTPSecret only) loginWaitingForOTPCode once the account is confirmed to already have 2FA set up. loginRequesting and loginFailed deliberately leave both alone: resetting them there previously made a blank/wrong-code retry loop back into the discovery flow instead of showing 'Invalid OTP Code', and wiped the just-fetched QR secret out from under the user before they'd finished scanning it.
- axios cancellation detection
apiCreateCancelObject() produces a CancelToken source; apiIsCanceledRequest(exception) uses axios.isCancel(exception). Domain/store code can use that to ignore canceled requests.
- Return shapes on success vs error
Success: parsed JSON (via getJsonBody and normalizeEntity) or blob/url, or identifier from Location header.
Error: either
ApiResponseError(structured) or axios/network error.
Vuex / UI:
src/store/* modules — follow a request/mutation pattern; on error they commit *Failed and often pass err.message to store state/getters (example: fax.js).
Pattern:
- commit
*Requesting - call API helper (e.g., createFax)
- on error: commit
*Failedpassingerr.messageoften used by getter to provide i18n fallback text
Example: fax.js (excerpt)
- action
createFaxcommitscreateFaxRequesting(), - then calls
createFax(...) - On catch, commits
createFaxFailed(err.message). - Getter
createFaxErrorreturns eitherstate.createFaxErroror fallback i18n string.
SIP:
ngcp-call.js — JsSIP UA, emits events on error/failed/ended/ice errors via callEvent.
ngcp-call.js — listens to callEvent and maps events to store commits/dispatches (e.g., callFailed() maps some events to store.dispatch('call/end', { cause })).
Pattern: SIP errors are mapped to store actions which update UI state (call ended/failed).
ngcp-call.js uses JsSIP and emits events via callEvent
ngcp-call.js sets up high-level handlers like:
callEvent.on('connected', ...) → store.commit('call/enableCall')
callEvent.on('disconnected', ({ error, code }) => { store.commit('call/disableCall', { error: errorMessage }) })
callEvent.on('outgoingFailed', callFailed) and callFailed extracts cause and does store.dispatch('call/end', { cause })
Special behavior & notable code decisions
- Password expiry:
handleResponseErrorcode inspectscode === 403andmessage === 'Password expired'and redirects to change-password. This is done insidehandleResponseErrorwiththis.$router?.push(...). That coupling is somewhat fragile becausehandleResponseErroris a plain function and this depends on invocation context, maybe we should refactor to use a response interceptor instead. - Mapping of server error strings ('Invalid license') to i18n-friendly messages occurs inside
handleResponseError. - Many store modules expect
err.messageto be a user-friendly string (they often pass it directly tocreateXFailedmutations), so howhandleResponseErrorsets message is important. handleResponseErrormutates and rethrows the original error object rather than replacing it, specifically so that.response/.response.datasurvive for callers that read the raw response body (see the OTP blob-parsing note above). Don't change this back to constructing a new error type without auditing those callers first.
Open gap: other direct httpApi.* call sites still need review
The user.js bypasses above are deliberate and justified (see explanation above). That is not true of the rest of the codebase — a number of domain wrappers and a couple of store actions call httpApi.get/post/put/patch/delete directly instead of going through common.js's get/post/put/patch/del, with no equivalent replacement for what handleResponseError gives you. As of this writing that includes at least src/api/subscriber.js, src/api/call-blocking.js, src/api/conversations.js, src/api/pbx-auto-attendants.js, src/api/pbx-config.js, src/api/pbx-devices.js, src/api/pbx-soundsets.js, src/api/reminder.js, src/api/speed-dial.js, src/store/call-recordings.js, and a few actions in src/store/user.js (removeSubscriberRegistration, removeCustomerPhonebook, getPhonebookCustomerDetails).
Unlike the user.js login/OTP cases, there's no known reason for most of these to bypass common.js — they mostly just reject(err)/rethrow the raw axios error, so on failure err.message ends up being axios's generic "Request failed with status code 404" instead of anything from getHttpErrorMessage, and an nginx HTML error page or a network failure gets no special handling at all. A few (e.g. subscriber.js's createSubscriber/deleteSubscriber) hand-roll their own partial version of the structured-error branch (err.response.data.message) without the HTML/network fallbacks. This hasn't been audited call-site by call-site — treat it as a known inconsistency rather than an intentional pattern, and don't copy it into new code.
Storage & Auth:
auth.js, storage.js — used to attach Authorization header; errors from auth or expired password are handled in handleResponseError (see redirect behavior).
Patterns for Vuex modules
-
Single responsibility: modules should only know how to transform API results into state, and orchestrate actions/mutations for requests.
-
Action pattern:
- commit a "requesting" mutation (sets RequestState.requesting)
- call domain API function
- on success, commit a "succeeded" mutation with normalized data
- on failure, commit a "failed" mutation and surface user-friendly message from store getters
-
Example (based on
src/store/fax.js/src/store/*):actions.createFaxbuilds options (incl. subscriber id), commitscreateFaxRequesting, callscreateFaxand commits success/failure mutations.
Pagination and client-side lists
- Use
getList({ resource: 'resourceName', page, rows, headers, params, all }). - For
all === true,getListwill first fetch default rows, checktotal_countand re-request with a largerowsvalue if necessary. - Use the returned
{ items, lastPage, totalCount }shape.
Implementation guidelines (how to add a new endpoint)
-
If the endpoint is a standard REST resource (GET/POST/PUT/DELETE):
- Add a domain API wrapper in
src/api/your-resource.jswith functions that callget/post/put/del. - Use
resourceandresourceIdoptions whenever possible to benefit from path mapping.
- Add a domain API wrapper in
-
If the endpoint requires special content-type (e.g., multipart or a blob):
- Build
FormDataor setresponseType/blobappropriately and callpostorapiGetdirectly.
- Build
-
Add Vuex module changes:
- Add a new module under
src/store/or extend an existing one. - Follow the request/action/mutation pattern, and use store getters to return user-facing messages (i18n keys can be used here).
- Add a new module under
Cancellation example
import { apiCreateCancelObject, apiIsCanceledRequest } from 'src/api/common'
const canceler = apiCreateCancelObject()
httpApi.get('/api/resource', { cancelToken: canceler.token })
// To cancel:
canceler.cancel('user navigation')
// In error handlers:
if (apiIsCanceledRequest(err)) {
// ignore or handle graceful cancellation
}
Caching and invalidation
- The codebase currently does not implement a client-side cache layer (beyond Vuex state). For lists, the store is the cache.
Composables for State Access
Starting with the migration to Vue 3 Composition API, the application uses composables for accessing Vuex store:
-
Purpose: Composables provide reactive access to store state, getters, and actions in Composition API components.
-
Pattern (using
src/composables/useStore.jshelpers):- Import the store helpers (
useState,useGetters,useActions) - Map state, getters, or actions from specific store modules
- All returned values are reactive computed refs
- Import the store helpers (
-
Example:
import { useGetters, useActions } from 'src/composables/useStore' // In your component setup const { isAdmin, isLogged } = useGetters('user', ['isAdmin', 'isLogged']) const { login, logout } = useActions('user', ['login', 'logout']) // isAdmin and isLogged are computed refs // login and logout are action functions
Store Access Patterns
Options API (Legacy)
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState('user', ['subscriber']),
...mapGetters('user', ['isLogged', 'isAdmin'])
},
methods: {
...mapActions('user', ['login', 'logout'])
}
}
Composition API with <script setup> (Recommended)
For components using <script setup>, use store helpers:
<script setup>
import { useState, useGetters, useActions } from 'src/composables/useStore'
const { subscriber } = useState('user', ['subscriber'])
const { isLogged, isAdmin } = useGetters('user', ['isLogged', 'isAdmin'])
const { login, logout } = useActions('user', ['login', 'logout'])
// Use them directly
const handleLogin = async () => {
await login({ username: 'test', password: 'pass' })
if (isLogged.value) {
console.log('Welcome', subscriber.value.username)
}
}
</script>
<template>
<div>
<button @click="handleLogin">Login</button>
<div v-if="isLogged">Welcome {{ subscriber.username }}</div>
</div>
</template>
Note: Store helpers return computed refs, so access values with .value in script, but not in template.
Direct Store Access (Services/Utilities)
For non-component files:
import { store } from 'src/boot/store'
export function someService() {
const user = store.state.user.subscriber
const isLogged = store.getters['user/isLogged']
store.dispatch('user/login', credentials)
}
Generic Store Helpers (useStore)
For modules without dedicated composables, use generic helpers:
<script setup>
import { useState, useGetters, useActions } from 'src/composables/useStore'
// Map state
const { myData } = useState('myModule', ['myData'])
// Map getters
const { isValid } = useGetters('myModule', ['isValid'])
// Map actions
const { loadData, saveData } = useActions('myModule', ['loadData', 'saveData'])
const handleLoad = async () => {
await loadData({ id: 1 })
if (isValid.value) {
console.log('Data:', myData.value)
}
}
</script>
Store Helpers
Request State Management
Located in src/store/common.js:
RequestState— Standard state values (initiated, requesting, succeeded, failed)createRequestState()— Creates standard request state objectcreateRequestMutations()— Generates standard mutations for request lifecycleisRequesting(),isSucceeded(),isFailed()— Helper functions
Example:
import { RequestState, createRequestMutations } from './common'
const state = {
user: null,
loadUserState: RequestState.initiated,
loadUserError: null
}
const mutations = {
...createRequestMutations('loadUser', 'user')
}
API Action Wrapper
Located in src/store/apiHelper.js:
withApiCall()— Wraps API calls with automatic mutation handlingcreateLoadingAction()— Generates action with loading/error states
Example:
import { createLoadingAction } from './apiHelper'
import { getUser } from 'src/api/user'
const actions = {
// Automatically handles requesting/succeeded/failed mutations
loadUser: createLoadingAction('loadUser', getUser, {
showError: true,
errorMessage: 'Failed to load user'
})
}
Using Store Data in <script setup> Components
Complete Example: User Profile Component
<script setup>
import { ref, computed } from 'vue'
import { useState, useGetters, useActions } from 'src/composables/useStore'
// Get user state and getters
const { subscriber } = useState('user', ['subscriber'])
const { isLogged, isAdmin } = useGetters('user', ['isLogged', 'isAdmin'])
// Get profile actions
const { loadProfile, updateProfile } = useActions('profile', [
'loadProfile',
'updateProfile'
])
// Local state
const editing = ref(false)
const formData = ref({})
// Computed values
const displayName = computed(() =>
subscriber.value ? `${subscriber.value.firstname} ${subscriber.value.lastname}` : ''
)
// Methods
const startEdit = () => {
formData.value = { ...subscriber.value }
editing.value = true
}
const saveChanges = async () => {
await updateProfile(formData.value)
editing.value = false
}
// Load data on mount
await loadProfile()
</script>
<template>
<div v-if="isLogged">
<h1>{{ displayName }}</h1>
<div v-if="isAdmin" class="admin-badge">Admin</div>
<button v-if="!editing" @click="startEdit">Edit</button>
<button v-else @click="saveChanges">Save</button>
</div>
</template>
Handling Errors in <script setup>
<script setup>
import { ref } from 'vue'
import { useState, useActions } from 'src/composables/useStore'
const { loadUserError } = useState('user', ['loadUserError'])
const { loadUser } = useActions('user', ['loadUser'])
const loading = ref(false)
const handleLoad = async () => {
try {
loading.value = true
await loadUser()
} catch (err) {
console.error('Load failed:', err)
// Error already in store via loadUserError
} finally {
loading.value = false
}
}
</script>
<template>
<div>
<button @click="handleLoad" :disabled="loading">Load User</button>
<div v-if="loadUserError" class="error">{{ loadUserError }}</div>
</div>
</template>