# APIs, deploy, and raw-JS patterns

Everything data-, deploy-, and raw-JS-related. Base URL is same-origin `/api/...` on `app.salesys.se`
(views) and `admin.salesys.se` (presets). All calls use `Authorization: Bearer <token>`;
`sortOrder` must be lowercase `asc`/`desc`.

---

## Auth inside a view

**Native views**: use `window.form.getAccessToken()` (runtime-provided).
**Raw-JS views**: read the cookie (both examples define this helper):
```js
window.getAccessToken = function () {
  const cookies = document.cookie.split(';').reduce((o, c) => {
    const m = /(.+)=(.+)/.exec(c.trim()); if (m) o[m[1]] = m[2]; return o;
  }, {});
  return cookies['s2_utoken'];           // Bearer token; cookies['s2_uid'] = current user id
};
```
`GET /users/users-v1/me` returns the current user (works, undocumented).

---

## CORS & cross-origin — the mental model

A view is served from **`https://app.salesys.se`**, so the browser's same-origin policy is the thing
that decides what your `fetch` can reach.

- **SaleSys API = same-origin = no CORS.** Every `/api/...` call on `app.salesys.se` (use a relative
  path or `new URL('/api/...', location.origin)`) is same-origin: no CORS preflight, no
  `Access-Control-*` needed, and the **`s2_utoken` cookie is sent automatically**. This is the whole
  reason the cookie-auth pattern and the private-file proxy (`/api/users/files-v1<path>`) work — keep API
  calls on `app.salesys.se` and there is no CORS to think about.
- **Anything on another host = cross-origin = full CORS applies.** An absolute URL to a different origin
  (an external API, S3, a partner service — **and note `salesys.se`, `admin.salesys.se`, and any
  `*.sevalla.app` are all _different_ origins from `app.salesys.se`**) is cross-origin. The browser will
  only expose the response if the remote server returns `Access-Control-Allow-Origin` that allows
  `https://app.salesys.se` (and, for a preflighted request, answers the `OPTIONS` with the right
  `Access-Control-Allow-Methods/Headers`). You **cannot** fix a missing/incorrect CORS header from view JS —
  it's the remote server's job.
- **Preflight triggers.** A request is "simple" (no preflight) only for GET/POST/HEAD with simple headers.
  As soon as you send `Content-Type: application/json`, an `Authorization` header, or use `PUT/DELETE/PATCH`,
  the browser fires an `OPTIONS` preflight first — the remote must handle it. (Same-origin SaleSys calls skip
  all of this.)
- **Never send SaleSys credentials cross-origin.** Do not attach the `s2_utoken` (as a `Bearer` header or
  cookie) to a third-party request — that leaks the user's token to another origin. Cookies aren't sent
  cross-origin anyway unless the server opts in with `Access-Control-Allow-Credentials: true` *and* you set
  `credentials: 'include'` — which you should only do for SaleSys-controlled origins.
- **`mode: 'no-cors'` is a trap.** It doesn't bypass CORS — it just makes the response *opaque* (status 0,
  unreadable body). Useless for anything you need to read. Don't reach for it.
- **CSP can block you even when CORS is fine.** The app ships a Content-Security-Policy; `connect-src` /
  `script-src` / `img-src` may forbid arbitrary external hosts regardless of CORS. If a cross-origin `fetch`
  or `<script>`/`<img>` silently fails with a CSP violation in the console, the host isn't allowlisted —
  that's a platform decision, not something a view can override.

**When you genuinely need a third party:** route it through a server you control that (a) holds any secrets
and (b) returns CORS headers for `app.salesys.se` — or, better, does the outbound call entirely server-side.
That's exactly the pattern the Webhooks / Stängtider views use (config is stored in `presetState`, and a
`salesys.se` action-preset endpoint fires the real cross-origin work) and what the Netlify **hook-runner**
relay is for. Keep the browser talking only to `app.salesys.se`; let a relay talk to the outside world.

---

## View / preset APIs

**Org views** (`app.salesys.se/api`, org user token):
- `POST /users/views-v1 { presetId, teamIds?, roleIds?, projectIds?, name?, iconName?, iconColor?, locations? }` → `{ viewId }`.
  `teamIds: null` = everyone, `[]` = admin-only. `projectIds: null` = all projects.
- `PUT /users/views-v1/:viewId` — same fields **plus `presetState`** (per-view config, see below).
  **`locations` is per-org and NOT synced from the preset** — move a view in the nav here.
- `DELETE /users/views-v1/:viewId`. `GET /users/views-v1` → all org views incl. `presetState`.
- `GET /users/views-v1/:viewId` → single org view (read its `presetState`).

**`locations`** (where it appears):
```js
{ type: "navigation", position: 0, label: "Min vy" }
// type: settings | navigation | widgets | widgetsLeft
// navigation positions → tab groups: 0 Ringlistor/Samtal/Kontakter · 1 Ordrar/Avtal · 2 Statistik · 3 Kalender · 4 Inställningar
// position -1 = own left section; >4 = own section right of Inställningar
```

**View presets / vymallar** (`admin.salesys.se/api`, admin token — via `deploy-preset.py`):
- `GET /users/view-presets-v1`, `GET …/:presetId`.
- `POST /users/view-presets-v1` → `{ presetId }`; `PUT …/:presetId` to update.
- Body: `{ name, html, settingsHtml?, iconName, iconColor, entityGroupIds: [], locations, rights?, description?, isRemoved? }`.

---

## Per-view config: `settingsHtml` + `presetState`

A preset can carry a **settings view** (`settingsHtml`) that admins use to configure it. Only a preset (not
a plain view) can have one. Config is stored **per org view** as `presetState`.

- **In the settings pane** (`settingsHtml` context): `window.form.settings` = the persisted presetState
  (READ initial config here); `window.form.setSettings(objOrFn)` = WRITE it (shallow-merge; persists when the
  admin clicks the modal's **Spara**). `setValidationMessage(id,msg)` / `removeValidationMessage(id)` for
  required-field logic. **Do NOT use `custom`/`setState` in the settings pane for config — they're transient.**
- **In the running view**: there is **no `settings`** in the handlebars context — only `custom` + `resources`.
  Read persisted config via `window.form.view.id` → `GET /users/views-v1/<id>` → `presetState` (Stängtider's
  `window.updateViewSettings` does exactly this: re-fetch latest, then PUT a merged `presetState`).
- You can `PUT /users/views-v1/<id> { presetState: {...} }` directly (round-trips arbitrary nested JSON) —
  handy for an in-view admin-only gear (`me.type === 'admin'`) instead of a handlebars form.
- `deploy-preset.py` sends `settingsHtml` from an optional `settings_file` per view (same `{{`-refusal guard
  for raw-JS settings; native settings views embrace `{{`). See `vyer/lista/` and `vyer/playbook/`.

---

## Custom rights (`rights`)

```jsonc
"rights": [ { "right": "manageSettings", "name": "Hantera inställningar", "description": "…" } ]
```
- This view's granted rights for the current user = `window.form.rights` (unprefixed). Other views' rights =
  `window.form.currentUser.rights` (prefixed with the view id).
- **Admin (`window.form.currentUser.type === 'admin'`) always has all rights.**
- View rights are **UI-gating only** — the underlying API writes still need the real platform rights
  server-side (`users.updateForOwnTeam`, `calls.getAll`, …). Say so in the description.
- Handlebars: `{{#ifIncludes rights "manageSettings"}}…{{else}}…{{/ifIncludes}}`, and
  `{{#ifIncludes currentUser.rights "orders.getAll"}}` for other-view rights.
- When using a vymall, set `rights` on the **preset**, not the org view.

---

## The user `reference` object = the database (raw-JS pattern)

Views have no dedicated storage; raw-JS views persist shared state in each user's `reference`:
- `PUT /users/users-v1/me { reference:{…}, replaceReference:false }` (own — always allowed) or
  `/users-v1/{id}` (needs `users.updateForOwnTeam`).
- Merge is a **shallow `$mergeObjects` on top-level keys** → always write the WHOLE top-level key; one key
  per concern. Dot-keys (`"a.b"`) collapse to a literal key — avoid.
- `GET /users/users-v1` returns every org user's full `reference` to any user → that's how views share data.
  **Distributed model**: each user writes only their own reference; the client assembles the whole picture.
- **Keep references small** (loaded for all users on every view load) and **self-GC** (prune only own
  reference, throttled via a `…Meta.gcAt` timestamp; cap arrays). Full schema table in `vyer/README.md`.
- Trap: render the current user's own row from a fresh `/me`, not the `users-v1` list copy (self-writes
  aren't in the list until the next poll).

Native views should prefer `presetState` (per org view) over `reference` for config; use `reference` when
data is genuinely per-user or shared across users.

---

## In-app notifications, files, entity groups

**Persistent cross-user notification** (right `users.createUserMessage`):
`POST /users/user-messages-v1 { target:{userIds|teamIds|types:['admin'|'standard']}, notification:{title,text,iconName,link,confetti}, expireAfterSeconds }` (FA5-free icons only).

**Upload a file** (`public:true` = reachable via returned `url`; else fetch via `/api/users/files-v1/<key>`):
```js
const fd = new FormData();
fd.append('public','true'); fd.append('usage','Beskrivning för spårning'); fd.append('file', f);
const r = await fetch(new URL('/api/users/files-v1', location.origin),
  { method:'POST', body:fd, headers:{ Authorization:'Bearer '+token }}); // → { key, url }
```
(Stängtider uploads a `.wav` this way.) **Private order/S3 files** (raw `uri` is 403): proxy through
`GET https://app.salesys.se/api/users/files-v1<new URL(uri).pathname>` (auth via `s2_utoken` cookie,
works in `<iframe>`/`<a>`; re-type the blob to `application/pdf` for inline render).

**Entity groups** (group presets/templates in the picker):
`GET/POST/PUT /users/entity-groups-v1 { name, iconName, iconUrl?, usage:["views"|"actions"|"forms"|"assistants"] }`;
set `entityGroupIds` on the preset.

---

## Common data endpoints (any user token — full list in `vyer/README.md`)

- **Stats — calls**: `GET /dial/statistics-v1/own/issue_1238_2?from&to&fixedIntervalType=hour` (userId/tagId/projectId/count). Intervals are **UTC** — fetch hourly, group into Swedish local days.
- **Stats — offers**: `GET /offers/statistics-v1/own/issue_1238_status_user_fi` (`date/status/userId/count`; statuses pending/distributed/read/signed/canceled/expired).
- **Active time**: `GET /users/statistics-v1/own/user_tabs?from&to&fixedIntervalType=day` (per user/project/day; preferred for per-project time).
- **Orders**: `GET /orders/orders-v3?…` (+ `/orders/orders-v2/count`); fields `GET /orders/fields-v1`; tags `GET /orders/tags-v1`; comment `POST /orders/orders-v1/<id>/comments`; tag non-destructively `PUT /orders/orders-v2/<id> { addTagIds, removeTagIds }`.
- **Calls / recordings**: `GET /dial/calls-v1?userIds=&count=1&connectionState=any&sortBy=date&sortOrder=desc`; recording `GET /dial/calls-v1/<id>/recording.wav`; transcript `GET /dial/call-insights-v1/<id>/transcript`.
- **Phone numbers**: `GET /dial/phone-numbers-v1` (filter `status === 'ready'`); `PUT /dial/phone-numbers-v1/<id> { inboundConfiguration:{ enabled, webhookAuth, url } }` (Stängtider routes inbound calls to a webhook this way).
- **Contacts**: `GET /contacts/contacts-v1?…` (needs `contacts.getAll`); tags `GET /contacts/tags-v1`.
- **Webhooks / events**: the Webhooks view stores config in `presetState` and a generic PHP endpoint
  (`https://salesys.se/api/action-presets/webhooks/webhook.php?viewId=…`) fires actions; see
  `docs-research`/`reference-salesys-arbeten-apis` memory for the event catalog.
- **Service discovery**: `GET /api/<service>/service-v1` lists every endpoint; every statistics endpoint has a `.yml` self-docs variant.

---

## Deploy & local test (the `vyer/` repo workflow)

```bash
# admin token (admin.salesys.se cookie s2_utoken) in /tmp/ksv-token
python3 vyer/deploy-preset.py <name>       # or --all ; POST creates + records id in presets.json, else PUT
# local test (raw-JS views):
python3 vyer/build-harness.py              # → /tmp/ksv-<name>-test.html with mocked APIs + fake cookies
cd /tmp && python3 -m http.server 8471     # open http://localhost:8471/ksv-<name>-test.html
```
Register a new view in `deploy-preset.py`'s `VIEWS` dict (`file`, `name`, `iconName`, `iconColor`,
`locations`, optional `settings_file`, optional `rights`) and in `build-harness.py`. Update `vyer/README.md`
and `presets.json`. If the repo uses graphify, run `graphify update .` after editing code.

---

## Raw-JS-only hard rules (these break the view in prod if ignored)

- **Never emit `{{`** — the view is rendered through handlebars first; `deploy-preset.py` refuses any file
  containing `{{`. Build literal braces if needed (`'{' + '{'`). (This rule is the whole reason the repo is raw JS.)
- **Event delegation on `document`** only (click/change/input/keydown/drag…). Element-bound listeners die
  silently when the app re-renders the view DOM. Resolve the target by `id`/`data-*` at event time.
- **`window.__<ns>Cleanup`** detaches listeners/timers on unmount; call it at the top of init so re-init
  doesn't stack duplicate intervals/listeners.
- **Contrast probe is mandatory for text on `--color-primary`.** The app applies dark theme by rewriting a
  `<style>` in `<head>` *after* init — so measure resolved `--color-primary` luminance, set your own
  `--<ns>-primary-fg`, observe `document.head` (childList+subtree) AND re-probe inside the poll loop
  (one-shot leaves white-on-white after a theme switch; MutationObservers on html/body never fire for theme).
- **Test after `document.body.innerHTML = document.body.innerHTML`** (simulated re-render) — a view that
  breaks there is broken in prod.
- `api()` helper: keep query params vs body args separate (a body object in the param slot serializes as `[object Object]`).

## Gotchas checklist (raw JS)
- [ ] No `{{`. [ ] Document-level delegation. [ ] `window.__<ns>Cleanup`. [ ] Contrast probe in the poll loop.
- [ ] No emojis. [ ] No native `<select>` in forms. [ ] Tested post-re-render. [ ] Whole-top-level-key reference writes.
- [ ] `deploy-preset.py`/`build-harness.py`/`presets.json`/`README.md` updated for the new view.
