> ## 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.

# Per-user state persistence model

> Why Devotel Orbit stores your saved AI campaign drafts, generated report history, and dialpad favorites on your account instead of in the browser, what each /me endpoint persists, and how to lift any state you still keep in localStorage.

# Per-user state persistence model

Your saved AI campaign drafts, your generated report history, and your dialpad favorites all live on your Orbit account. This page explains why those surfaces moved out of browser storage, what each `/me` endpoint stores, and what to do if an older integration or browser profile of yours still keeps a local copy.

## The problem with browser-bound state

These surfaces first shipped writing to the browser's `localStorage`. That made "draft saved" true only for this browser, on this profile, on this device:

* A draft saved on the office workstation did not exist on the laptop at home.
* Clearing site data, a private-browsing window, or a fresh browser profile erased the history.
* On a shared workstation, the next operator to sign in saw the previous operator's drafts, or lost their own.

The dashboard said "draft saved", and the sentence was only true about one machine. Account-bound storage fixes that by making your account, not your browser, the source of truth.

## What the platform now persists per surface

Each surface has a dedicated `/me` endpoint, and each stores per signed-in user:

| Surface | Endpoints | What is persisted |
| - | - | - |
| Saved AI campaign drafts (AI Studio, Brief to draft) | `GET`/`PUT /v1/me/campaign-drafts` | The brief text, the generated draft blueprint, the display name, the channels it produced copy for, and create/update timestamps. The 50 most recent drafts are retained. |
| Generated report history (Insights → Reports) | `GET`/`PUT /v1/me/generated-reports` | Report metadata only: `id`, `name`, `type`, `generatedAt`, and the column layout. Row data is never persisted; see [the PII note](#pii-report-history-is-metadata-only). The 50 most recent reports are retained. |
| Recommendations tester draft | `GET`/`PUT /v1/me/recommendations-draft` | The working draft of an audience recommendations run. |
| Speed-dial favorites (dialpad) | `GET /v1/me/favorites`, plus add, remove, and reorder | Your pinned numbers and contacts with an explicit sort order; see [the favorites section](#speed-dial-favorites-a-real-table-not-a-preferences-key). |

The drafts, reports, and recommendations entries are stored as keys in the preferences record that already lives on your user row. The keys are:

* `saved_ai_campaign_drafts`
* `generated_reports`
* `recommendations_tester_draft`
* `inbox_show_archived_tags` (your Inbox archived-tags toggle)
* `inbox_signature` (your Inbox reply signature)

That storage shape was chosen deliberately:

* **No schema change was required.** The preferences column already existed and already held `inbox_signature` and `inbox_show_archived_tags`, so each new surface shipped without a migration window and without touching unrelated data.
* **Writes never clobber each other.** Every update merges exactly its own key into the record and leaves every sibling key untouched, so saving your report history cannot erase a draft, a signature, or a toggle.
* **The pattern is a template.** If you build tooling on the API that keeps per-operator state, treat these endpoints as the model: one namespaced key per surface, merged writes, and defensive reads.

### The GET/PUT shape

`GET /v1/me/campaign-drafts` answers `{ "data": { "drafts": [...] } }`, where each draft carries `id`, `name`, `brief`, `draft` (the generated blueprint, or `null` when saved before generation), `channels`, `createdAt`, and `updatedAt`. `PUT` sends the complete list, `{ "drafts": [...] }`, and stores exactly what you send, newest first, capped at 50.

Generated reports mirror the shape: `GET` answers `{ "data": { "reports": [...] } }` and `PUT` accepts `{ "reports": [...] }`, each entry carrying `id`, `name`, `type`, `generatedAt`, and `columns`, never `rows`.

Both sides read defensively: entries with missing or drifted fields are skipped rather than treated as errors, so an older client and a newer server keep working against each other.

## Lifting state you already saved in the browser

The platform will not read your browser's storage for you; there is no server-side auto-migration. If your team built tooling or a custom build that persisted these surfaces locally, run a one-time lift-and-shift from the client: read the local key once on app start, `PUT` it up to the matching `/me` endpoint, then clear the local copy.

The local key names the platform surfaces wrote before account-bound storage:

| Surface | `localStorage` key |
| - | - |
| Generated report history | `orbit-generated-reports-v2` |
| Saved AI campaign drafts | `campaign:ai-draft-history:v1:<user-id>` (falling back to `campaign:ai-draft-history:v1:anon` until sign-in resolves) |

The dashboard still uses these keys as a fast local cache it reconciles against the account on every load, with the account copy winning. If your own tooling wrote to them, treat them as a read-once input for the lift, not as an authoritative store.

### Sync then clear

The sequence the dashboard itself runs at start, in full:

```tsx theme={null}
"use client";

import { useEffect, useRef } from "react";

const LEGACY_REPORTS_KEY = "orbit-generated-reports-v2";

function readLocal(): unknown[] {
  try {
    const parsed: unknown = JSON.parse(
      window.localStorage.getItem(LEGACY_REPORTS_KEY) ?? "[]",
    );
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

/** Lift a local report history into the account, then clear the local copy. */
export function useLiftLocalReports(): void {
  const ran = useRef(false);

  useEffect(() => {
    if (ran.current) return;
    ran.current = true;

    const local = readLocal();
    if (local.length === 0) {
      window.localStorage.removeItem(LEGACY_REPORTS_KEY);
      return;
    }

    void (async () => {
      // 1. Read the account copy FIRST: a PUT before this GET would clobber
      //    history another device already lifted.
      const get = await fetch("/api/v1/me/generated-reports", {
        credentials: "include",
      });
      if (!get.ok) return; // keep the local copy; retry on next start

      const body: { data?: { reports?: unknown } } = await get.json();
      const server = Array.isArray(body?.data?.reports)
        ? (body.data.reports as Array<{ id?: unknown }>)
        : [];

      // 2. Union by id; the server copy wins for ids it already has.
      const byId = new Map<string, unknown>();
      for (const r of server) {
        if (typeof r.id === "string") byId.set(r.id, r);
      }
      for (const r of local) {
        const id = (r as { id?: unknown }).id;
        if (typeof id === "string" && !byId.has(id)) byId.set(id, r);
      }

      // 3. Push the union, and only clear locally once the PUT succeeded.
      const put = await fetch("/api/v1/me/generated-reports", {
        method: "PUT",
        credentials: "include",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ reports: [...byId.values()] }),
      });
      if (put.ok) window.localStorage.removeItem(LEGACY_REPORTS_KEY);
    })();
  }, []);
}
```

The rules encoded there are the contract:

1. Read the local key once on app start.
2. Fetch the account copy first; never `PUT` before that `GET` settles.
3. Union by `id`, server copy winning.
4. Clear the local copy only after the `PUT` succeeds; on any failure leave it in place and retry on the next start.

## Cross-device behaviour after the lift

Once state is lifted, or simply once you use the surfaces normally (the dashboard syncs on every change), the account copy is what every session reads:

* Sign in on a second machine and your saved drafts, your report history, and your pinned favorites are already there.
* The mobile dashboard reads the same account store, so the dialpad favorites on your phone are the same pins as on the web dialpad.
* Clearing browser data no longer erases history; it only drops the local cache the dashboard uses to paint instantly before the network answers.

Restored report entries hydrate with their metadata: a historical generated report opens with its columns and an empty result set, and you regenerate it for fresh figures. That matches how the pre-persistence page behaved, and it follows from the PII rule below.

## PII: report history is metadata-only

A generated report's rows can carry your customers' phone numbers and email addresses. The `/v1/me/generated-reports` `PUT` contract therefore has no `rows` field at all: only `id`, `name`, `type`, `generatedAt`, and `columns` are accepted and stored. The dashboard strips rows before every write, and the API contract enforces the same rule, so report history persists per user without persisting customer PII. If you extend the pattern in your own tooling, keep that discipline: persist the pointer, not the payload.

## Speed-dial favorites: a real table, not a preferences key

`/v1/me/favorites` is the one surface in this model that does not live in the preferences record. Favorites are rows: each pin records what it points at (a number or a contact) and a `sort_order`, which is what lets you reorder the pinned strip on the dialpad. The API caps how many favorites one user may pin and answers `409 FAVORITE_LIMIT_REACHED` when the cap is reached, and a duplicate pin of the same target is rejected. Favorites give every operator a personal speed dial on the shared dialpad, on web and mobile alike.

The distinction is the design lesson: a preferences key suits a bounded blob that one surface reads and writes; a real table is the right shape the moment a per-user feature needs ordering, per-row operations, or per-row limits.

## Extending the pattern in your own tooling

When your integration keeps per-operator state (a composer draft, a saved filter, a console layout), follow the same contract the platform uses:

* Key state to the signed-in user, never to the browser alone; where you keep a local cache for instant paint, key it by user id and treat the account copy as authoritative.
* One namespaced key per surface, merged on write, never a whole-record write-back.
* Decide what the payload may contain before you persist it, and strip contact-level data at the boundary the way report history does.
* Read defensively: skip unknown or drifted fields instead of erroring, so older clients survive newer payloads.

## See also

* [Developer console overview](/developer/overview) — the hub that groups the operator and developer surfaces built on the same API.
* [Dashboard home](/guides/dashboard-home) — the landing surface whose per-user widgets read from the account store described here.


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