> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Current-user preferences model

> The /me surface — JWT-scoped user resolution, the users.preferences JSONB store it writes to, and the six per-user facets (campaign drafts, generated reports, recommendations tester, favorites, distinctive ring, voicemail-to-email) that share it.

# Current-user preferences model

Every dashboard surface that remembers *your* setup — the AI campaign draft
you saved and closed, the report you generated yesterday, the favorites on
your dialpad, the ring pattern for an important caller — hangs off one
prefix: `/api/v1/me`. That prefix is the "current user" surface, and this
page explains what it resolves to, where it persists, and how the facets
fit together. For route-by-route request and response shapes, see the
[Me API reference](/api-reference/endpoints/me).

## What "me" means — JWT-scoped user resolution

No `/me` route takes a user id in the path. The caller is resolved from
the credential, not the URL: a dashboard session token carries the signed
Clerk user id, and the auth middleware resolves that id to the request
context before any handler runs. The handler then reads that user's own
row — never anyone else's. A request from a different session resolves to
a different user; there is no `/me/{user_id}` form to target another person.

The same row is matched two ways, because a user provisioned through
SCIM or enterprise SSO carries a Clerk `clerk_user_id` that differs from
their internal `users.id`. Every `/me` query matches on
`clerk_user_id = $caller OR id = $caller` so both classes of user find
their own row. A leaked row id from another user therefore never resolves
here — the caller's own id is the only key that can open their preferences.

An API key is an authenticated principal too, but it has no human
`users` row. `/me` synthesizes a machine identity for it rather than
failing, so server-to-server callers can still bootstrap their tenant
context. The per-user facets below are a human-dashboard concern, so a
machine principal's preferences are empty by construction — the
preferences blob is never read off a row that does not exist.

## The `users.preferences` JSONB column

Most facets on this surface persist to a single column:
`users.preferences`, a JSONB blob on the public `users` row. The column
already existed before any of these features shipped, so adding a facet
to it is migration-free — a new key is merged in without altering the
schema. The shape is a flat object, one key per facet:

```
users.preferences = {
  "inbox_signature": "<operator's signature block>",
  "inbox_show_archived_tags": true,
  "saved_ai_campaign_drafts": [ { ... }, { ... } ],
  "generated_reports": [ { ... } ],
  "recommendations_tester_draft": { ... },
  "distinctive_ring_rules": [ { ... } ],
  "predictive_activation_schedules": { ... },
  ...
}
```

The write contract is **patch-by-merge, not replace-the-column**. Every
write merges the one key a facet owns into the existing blob — the key is
overwritten while every sibling key (`inbox_signature`, onboarding state,
any other facet) is preserved untouched. This is why the facets are
independent: each one writes only its own key, and none of them ever
re-sends the whole blob.

### The one exception: favorites

Favorites are the exception. Speed-dial pins live in a dedicated table
(`user_call_favorites`) with their own columns — `target_kind`,
`target_value`, `label`, `sort_order` — because the dialpad treats them
as per-row records with create, relabel, reorder, and delete operations,
not a replaceable array. The `/me/favorites` routes run full CRUD
against that table; the WHERE clause on every query pins both `id` and
`user_id`, so a row id leaked from another user matches zero rows and
returns `404`, never another user's data. Every other facet on this
page is a JSONB key; favorites are the only table-backed one.

## The facets

Each facet below is one key under `users.preferences` (or, for favorites,
one table), persisted from the authenticated caller's session and scoped
so no other user can read or write it. The read path on the hottest route
— `GET /api/v1/me` — projects only the two scalar keys it needs
(`inbox_signature`, `inbox_show_archived_tags`) out of the blob, so the
rest of the facets add no cost to the dashboard bootstrap.

### Campaign drafts

`GET` / `PUT /api/v1/me/campaign-drafts` — your saved AI campaign briefs.

The AI Studio "Brief to draft" flow kept its "Save draft and close"
history in browser storage, so a draft saved on one device never
appeared on another. The history now persists server-side under the
`saved_ai_campaign_drafts` key — an array of up to 50 drafts, each
carrying its id, name, brief, the generated draft blueprint, the
channels it covers (`sms`, `rcs`, `whatsapp`, `email`, `push`), and
timestamps. The PUT replaces the whole array; the GET returns it
ordered as saved. Only the operator's own campaign brief and generated
copy is stored — never another tenant's customer data — and a total-size
guard bounds the blob so a heavy draft cannot bloat the row.

### Generated-report history

`GET` / `PUT /api/v1/me/generated-reports` — your Insights report
history.

The Insights → Reports page kept generated-report history in browser
`localStorage`, so clearing site data or signing in from another device
wiped it. The history now persists under `generated_reports` — an
array of up to 50 report metadata entries. Only report *metadata* is
stored (id, name, type, generated timestamp, columns); row-level data,
which can carry customer phone numbers and email addresses, is stripped
at the trust boundary and never persisted. The accepted report types are
`message_delivery`, `channel_performance`, `cost_analysis`,
`contact_growth`, and `campaign_roi`; an unrecognized type is rejected
so a drifted client cannot smuggle arbitrary strings into the blob.

### Recommendations tester draft

`GET` / `PUT /api/v1/me/recommendations-draft` — your Audience
recommendations tester state.

The Audience → Recommendations tester held its catalog, profile
signals, and scoring weights in ephemeral React state and then in
`localStorage`, so a product added on one device vanished on a fresh
session. The draft now persists under `recommendations_tester_draft` —
the full editable input set: the catalog (up to 200 items), the signals
(up to 200), the profile id, the limit and half-life inputs, the
"suppress purchased" toggle, and the per-factor weight strings. A
non-empty catalog is required, because the tester grid always renders at
least one product row; an empty catalog is not a restorable state.

### Favorites

`GET` / `POST` / `PATCH` / `DELETE /api/v1/me/favorites` — your
speed-dial pins.

The dialpad's Favorites tab reads this list to render a one-tap call
surface for your top contacts. Each pin carries a `target_kind`
(`pstn`, `extension`, `contact_id`, or `sip`), the `target_value` in the
format that kind implies (E.164 for `pstn`, a `sip:`-prefixed URI for
`sip`), a display `label`, and a `sort_order`. The list is capped at 50
pins per user; a duplicate target is rejected. Favorites store dial
*intent* only — placing a call to a pinned target routes through the
normal outbound calling surface, so this controller never touches a
termination provider. As noted above, favorites are the table-backed
facet, not a JSONB key.

### Distinctive-ring rules

`GET` / `PUT /api/v1/me/ring-rules` — per-caller ring patterns for
your registered device.

A distinctive-ring rule assigns a ring pattern (VIP, priority, urgent, …)
to a specific caller number or number prefix, so an important inbound
call can be told apart by sound — long-standing PBX parity. The rules
array persists under `distinctive_ring_rules`; the PUT replaces the
whole list atomically (validated per-rule, de-duplicated by target, then
merged in). The GET also returns the pattern catalog the picker
renders. The controller only governs how an *inbound* caller's ring is
presented on the callee's own device; it never originates a leg and
never touches a termination provider.

### Voicemail-to-email

`PATCH /api/v1/me` — your voicemail email routing.

Two fields control voicemail-to-email dispatch:
`voicemail_to_email_enabled` (boolean, defaults false) and
`voicemail_to_email_address` (an alternate routing address, or `null`
to fall back to your `users.email`). The address is validated as an
email by the route's Zod schema — a strict second line of defence behind
the database check constraint. The `PATCH /me` route is also the write
surface for `softphone_layout` (your softphone widget layout, or `null`
to revert to the platform default) and the `inbox_signature` /
`inbox_show_archived_tags` keys. All of these merge into the
`preferences` blob (or the dedicated columns, for the voicemail fields)
without clobbering siblings.

## PATCH semantics and the NULL-to-revert convention

The write contract for this column has three modes per field, and they
are not interchangeable:

* **A value present in the body** writes that value.
* **The key omitted from the body** is a no-op — the existing value is
  left untouched. The handler checks `in patch`, not truthiness, so an
  explicit `false` or `null` still flows through.
* **The key present with `null`** reverts that field. For `softphone_layout`,
  `null` resets to the platform default. For `voicemail_to_email_address`,
  `null` clears the alternate address and falls back to your auth email.
  For `inbox_signature`, `null` (or a whitespace-only string) deletes the
  key from the blob entirely.

This is why the client can patch one field without re-sending the rest:
the merge writes only the keys you supplied, and the blob's siblings
survive. The array facets (`campaign-drafts`, `generated-reports`,
`recommendations-draft`, `ring-rules`) use `PUT` rather than `PATCH`
because they are whole-list replaces — the client sends its full,
validated list on every settled change, and the server re-validates and
re-bounds it before the merge.

## Multi-org picker consequences

The user row spans organizations: a member of more than one org picks
which one to operate against through the multi-org picker, and the
active org's id rides on the session. Preferences, however, are
per-*user*, not per-org — your saved campaign drafts, your signature,
your distinctive-ring rules travel with you across the orgs you belong
to, because they all key off the same `users` row. Switching orgs
changes which tenant schema the rest of the API reads; it does not
change whose preferences the `/me` surface returns. The `organizations`
list in the `GET /me` response is what the picker renders, and the
preferences blob is independent of which entry is active.

## Related pages

* [Authentication and session model](/concepts/authentication-model) —
  the credential classes and the resolution rule that resolves the
  signed-in user for every `/me` request.
* [Roles, teams, and permissions](/concepts/roles-teams-permissions) —
  the role vocabulary the per-role 2FA gate on `GET /me` references.
* [Me API](/api-reference/endpoints/me) — the route-by-route request and
  response reference for every `/me` endpoint.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.