Skip to content

Utils Documentation ​

Overview ​

This section documents the utility modules in the Brand Portal. Utilities are located in app/utils/ and provide shared, framework-agnostic helpers used across composables, plugins, and components.

File Information ​

  • Path: app/utils/
  • Purpose: Centralized utility functions for error handling, request lifecycle management, route id and post-login redirect validation, custom field values and Amplitude analytics
  • Auto-import: The nuxt.config includes utils/** in imports.dirs, so these utilities are auto-imported where used.

Utils Structure ​

app/utils/
├── amplitudeSessionReplay.ts # Amplitude session replay init, masking, shutdown
├── analyticsIds.ts           # Id property normalization for Amplitude events
├── cancelTokenManager.ts     # Request cancellation and throttling
├── customFieldValues.ts      # Custom field value parsing and formatting
├── error.ts                  # API error message extraction
├── redirect.ts               # Post-login ?redirect= validation
└── resourceId.ts             # Route id validation for detail pages

Modules ​

error.ts ​

Path: app/utils/error.ts

Purpose: Centralized API error message extraction for consistent user-facing error display. Ensures that API responses (e.g. {"status":false,"code":402,"message":"Folder not found."}) are surfaced as the actual backend message in snackbars and UI instead of generic text.

Exports:

  • getApiErrorMessage(error: unknown): string

Behavior:

  • Handles $fetch/ofetch: reads error.data?.message
  • Handles Nuxt response: reads error.response?._data?.message
  • Handles Axios-style: reads error.response?.data?.message
  • Falls back to error.message or string coercion
  • Returns "An error occurred" when no message is available

Usage:

Used by the Vue Query plugin for global query and mutation error handling (snackbar display), and by composables that show API errors to the user.

typescript
import { getApiErrorMessage } from '~/utils/error'

try {
  await someApiCall()
} catch (e) {
  useSnackbar().showError(getApiErrorMessage(e))
}

Related:


cancelTokenManager.ts ​

Path: app/utils/cancelTokenManager.ts

Purpose: Manages AbortController instances for request cancellation and throttling. Allows the application to cancel in-flight requests (e.g. on route change or search debounce) and to clear both cancel and throttle controllers in a single operation.

Exports:

  • addCancelController(controller: AbortController): void
  • removeCancelController(controller: AbortController): void
  • addThrottleController(controller: AbortController): void
  • removeThrottleController(controller: AbortController): void
  • cancelAllRequests(): void — Aborts all registered controllers and clears both sets

Usage:

Typically used by the API client or composables that perform fetch requests with an AbortSignal, so that navigation or user actions can cancel pending requests.

Related:


resourceId.ts ​

Path: app/utils/resourceId.ts (added in ee26b58)

Purpose: Rejects malformed :id route params before the folder and collage detail pages call the API.

Exports:

  • isValidResourceId(raw: unknown): boolean

Behavior:

  • If raw is an array (Vue Router repeated param), only raw[0] is checked.
  • Returns true only for a string matching /^[1-9]\d*$/: a positive integer without leading zeros, sign, spaces or decimals.
InputResult
"42"true
["42", "7"]true (first element)
"0", "042", "-1", "4.2", "abc", ""false
42 (number), undefinedfalse

Usage:

typescript
import { isValidResourceId } from "~/utils/resourceId";

onMounted(async () => {
  if (!isValidResourceId(route.params.id)) {
    snackbar.showError("Folder not found.");
    await navigateTo(`/${getBrandName()}/folders`, { replace: true });
    return;
  }
  // ...load folder
});

Related:


redirect.ts ​

Path: app/utils/redirect.ts

Purpose: Validates a ?redirect= query value before the app sends a just-authenticated visitor there, so a deep link survives login without opening an open-redirect or a login loop.

Exports:

  • resolveBrandRedirect(redirect: string | string[] | null | undefined, brandUrlSlug: string, router: Router): string | null

Callers: app/pages/[brand_name]/login.vue (manual login), app/middleware/public-portal-login-redirect.ts (public-portal auto-login) and app/middleware/redirect-if-logged-in.ts. Each falls back to `/${brand}` when the function returns null.

Rules (checked in this order; the first failure returns null):

#RuleRejects
1Value present; for an array only redirect[0] is used; brandUrlSlug non-emptyundefined, ""
2After trim(), starts with / and not // or /\https://evil.com, //evil.com, /\evil.com
3No whitespace and no control characters (code <= 0x1f or 0x7f)/acme/files 1, /acme%00 is allowed (encoded), a raw \u0000 is not
4Pathname (text before ? or #) equals /<brand> or starts with /<brand>//other-brand/files/1
5First segment after the brand is not an auth route: login, forgot-password, reset-password, generate-password/acme/login?redirect=...
6router.resolve(pathname).name is set and is not slug, brand_name-slug or brand_name-token; a throw from resolve counts as a failure/acme/does-not-exist, /acme/abc123

On success the original trimmed value is returned, including its query string and hash.

Why rule 6 uses the route name: every path matches something in this router (the [...slug] catch-alls and the bare [brand_name]/[token] route), so matched.length proves nothing. brand_name-token is also excluded because it serves hex token-login links and check-url clears the session on those; redirecting into it after login would log the visitor straight back out. This is a routing check only: /acme/collage/999999 passes even if that record does not exist.

typescript
// login.vue
await navigateTo(
  resolveBrandRedirect(route.query.redirect, brandName, router) || `/${brandName}`
);

customFieldValues.ts ​

Path: app/utils/customFieldValues.ts

Purpose: Normalizes the many shapes a custom field value arrives in (array, JSON-encoded array string, plain string, number, option objects) into a list of display strings.

Exports:

  • toCustomFieldValues(raw: unknown): string[]
  • formatCustomFieldValues(raw: unknown): string - toCustomFieldValues(raw).join(", ")

Rules:

  • Array: each entry is converted, empty results are dropped.
  • String: trimmed; empty gives []. If it starts with [ and JSON.parse yields an array, the decoded entries are converted; otherwise (bad JSON or not an array) the whole trimmed string is one value.
  • Number: [String(raw)]. Anything else (null, objects, booleans): [].
  • Entry conversion: strings are trimmed, numbers stringified, objects with value or text use value ?? text (trimmed); anything else becomes "" and is dropped.
InputtoCustomFieldValues
'["Red","Blue"]'["Red", "Blue"]
[{ text: "Red", value: "red" }, " Blue "]["red", "Blue"]
"[not json"["[not json"]
" ", null, {}[]
42["42"]

Callers: useAssetDetails (asset detail custom fields), useListLevelThree (search list custom field filter options), useHelpers.


analyticsIds.ts ​

Path: app/utils/analyticsIds.ts

Purpose: Normalizes id-type event properties before they are sent to Amplitude, so the same property always has the same type across events.

Exports: ID_KEYS, MULTI_KEYS, COUNT_KEYS, SINGLE_VALUED, isSingleValued(eventType, key), normalizeIdProperties(properties, eventType = null).

Key sets:

ConstantValues
ID_KEYSasset_id, category_id, collection_id, version_id, share_id, instance_id, banner_id, tile_id, collage_id, tag_id, workspace_id, user_id, dealer_user_id
MULTI_KEYSasset_id, category_id, share_id
COUNT_KEYSasset_id -> asset_count, category_id -> category_count, share_id -> share_count
SINGLE_VALUEDasset viewed: asset_id, category_id; asset converted: asset_id; folder viewed: category_id; collage viewed: collection_id

normalizeIdProperties rules:

  1. null, undefined or a non-object input returns {}.
  2. Keys outside ID_KEYS, and id keys whose value is null/undefined, pass through unchanged.
  3. Single-id keys (in ID_KEYS, not in MULTI_KEYS) become String(value).
  4. Multi keys: the value is wrapped in an array if needed and null/undefined entries are removed; if nothing is left the key is dropped. One id is sent as a string, several as a string array.
  5. A count property (asset_count etc.) is added with the number of ids unless the caller already set it, or there is exactly one id and the event/key pair is in SINGLE_VALUED.
typescript
normalizeIdProperties({ asset_id: [101, 102], workspace_id: 7 })
// { asset_id: ["101", "102"], asset_count: 2, workspace_id: "7" }

normalizeIdProperties({ asset_id: 101 }, "asset viewed")
// { asset_id: "101" }   (no asset_count: single-valued event)

Callers: useAmplitude (on picked properties) and plugins/amplitude.client.ts (on every event's event_properties).


amplitudeSessionReplay.ts ​

Path: app/utils/amplitudeSessionReplay.ts

Purpose: Wraps @amplitude/session-replay-browser with the portal's sample rate parsing, privacy masking and an idempotent init/shutdown.

Exports:

  • parseSessionReplaySampleRate(raw): number - empty/null/undefined/NaN gives the default 0.1; otherwise the number clamped to 0..1.
  • getSessionReplayPrivacyConfig(): SessionReplayPrivacyConfig
  • initSessionReplay({ apiKey, sampleRate, deviceId, sessionId }): void
  • setSessionReplaySessionId(sessionId): void - ignores null; swallows the error when replay is not initialized.
  • shutdownSessionReplay(): Promise<void> - flushes (only if initialized), calls shutdown, always resets the initialized flag; best-effort, never throws.

Init guards (any one returns without initializing): already initialized, running on the server, empty apiKey, sampleRate === 0, missing deviceId, or sessionId null/undefined. The module-level flag is set only after the SDK's init promise resolves.

Options passed to the SDK: serverZone: 'US', storeType: 'idb', useWebWorker: true, performanceConfig: { enabled: true, timeout: 2000 }, optOut: false, debugMode: false, applyBackgroundColorToBlockedElements: false.

Privacy config:

SettingValue
defaultMaskLevelmedium
maskSelectorpassword and email inputs; cc-number, cc-csc, cc-exp autocomplete inputs; inputs whose name contains password, credit, card, cvv, ssn; textarea[data-sensitive="true"]; .amp-mask, [data-amp-mask]
blockSelector.amp-block, [data-amp-block], [data-no-replay]
unmaskSelector.amp-unmask, [data-amp-unmask]

To keep an element out of recordings entirely add data-no-replay; to mask text add data-amp-mask.

Wiring: plugins/amplitude.client.ts reads the sample rate from runtimeConfig.public.amplitudeSessionReplaySampleRate and, because Amplitude's device and session ids are not available immediately, retries initSessionReplay up to REPLAY_READY_MAX_ATTEMPTS (10) times, REPLAY_READY_DELAY_MS (100 ms) apart.