# Closebuy Mother App ↔ Studio Integration

This folder is the **single source of truth** for the contract between the
**Closebuy mother app** (system of record) and **Closebuy Studio** (the
campaign / letter / landing-page builder).

The wire format is described in [`openapi.yaml`](./openapi.yaml)
(OpenAPI 3.1) — update that file first, then regenerate Studio's typed
clients.

> **Direction is one-way.** Studio pulls from the mother app. The mother
> app does **not** expose webhooks for Studio, and Studio does **not**
> expose a public API. The only Studio-side endpoint is the SSO landing
> (a browser redirect, not a JSON API).

```text
┌──────────────────────┐      Bearer CLOSEBUY_API_KEY      ┌────────────────────┐
│                      │ ────────────────────────────────▶ │                    │
│   Closebuy Studio    │   GET /api/v1/orgs                │  Closebuy Mother   │
│ (consumer)           │   GET /api/v1/orgs/{id}           │  App  (source of   │
│ cbstudio.heyrobot.com│   GET /api/v1/orgs/{id}/users     │  truth)            │
│                      │   GET /api/v1/orgs/{id}/leads     │  closebuy.heyrobot │
│                      │ ◀──────────────────────────────── │  .com              │
│                      │                                    │                    │
│                      │   GET /sso?token=<JWT>            │                    │
│                      │ ◀──── (browser redirect) ──────── │                    │
└──────────────────────┘                                    └────────────────────┘
```

---

## 1. Secrets

Studio stores these as runtime secrets (Lovable Cloud → Project Settings → Secrets):

| Name                    | Purpose                                                                                |
| ----------------------- | -------------------------------------------------------------------------------------- |
| `CLOSEBUY_API_BASE_URL` | API base URL — `https://huvqmibfyuasnvyfguki.functions.supabase.co/studio-api`.                          |
| `CLOSEBUY_API_KEY`      | Long-lived bearer token issued by the mother app.                                      |
| `CLOSEBUY_SSO_SECRET`   | HS256 JWT signing secret for the `?token=…` on the SSO landing.                        |
| `CLOSEBUY_APP_URL`      | Public URL of the mother app (used in "Open in Closebuy" links from Studio).           |

> 🔒 None of these are ever exposed to the browser — they're read
> server-side only.

---

## 2. Endpoints the mother app exposes

Base URL: **`https://huvqmibfyuasnvyfguki.functions.supabase.co/studio-api`** (Closebuy Supabase Edge Function).
Full schemas live in [`openapi.yaml`](./openapi.yaml).

| Method | Path                            | Returns                  | Used for                                    |
| ------ | ------------------------------- | ------------------------ | ------------------------------------------- |
| GET    | `/api/v1/orgs`                  | `CloseBuyOrg[]`          | Org picker in Studio.                       |
| GET    | `/api/v1/orgs/{orgId}`          | `CloseBuyOrg`            | Brand + sender resolution for campaigns.    |
| GET    | `/api/v1/orgs/{orgId}/users`    | `CloseBuyUser[]`         | Studio role bootstrap on SSO.               |
| GET    | `/api/v1/orgs/{orgId}/leads`    | `CloseBuyLead[]` (or `{ lists, leads }` when `includeLists=true`) | Hydrated leads with full CVR/enrichment payload, list context, and AI analysis. Every lead includes `latitude`, `longitude`, `cvrNumber`, and — for production-unit leads — `pNumber`, `isProductionUnit`, and `parentCompanyName` (physical address/coords are the production-unit's own, not the HQ). `distanceKm` (great-circle) is present when the list was built from a location-based search. `drivingDistanceKm`, `drivingDurationMin`, and `distanceMin` are **only present when route data actually exists** for that lead — treat their absence as "not calculated". **Only lists flagged `for_letters = true` in Closebuy are returned.** Query params: `updatedSince`, `listId`, `includeLists`. |
| POST   | `/api/v1/auth/password`         | `PasswordLoginResponse`  | Native email + password login from Studio. Returns the same `StudioSsoTokenPayload` JWT used by SSO. **401** on bad creds, **429** on lockout (5 failed attempts per email+IP in 15 min). |

All require `Authorization: Bearer <CLOSEBUY_API_KEY>` and return JSON
matching the OpenAPI schemas exactly.

### Password login

```http
POST /api/v1/auth/password
Authorization: Bearer <CLOSEBUY_API_KEY>
Content-Type: application/json

{ "email": "user@example.com", "password": "…", "organizationId": null }
```

Response (`200`):

```json
{
  "token": "<jwt>",
  "expiresAt": "2026-06-29T10:35:00.000Z",
  "user": { "id": "…", "email": "user@example.com", "name": "User Name" },
  "orgId": "<uuid|null>",
  "role": "owner|manager|member|superadmin"
}
```

Verify the JWT exactly as the SSO landing does (HS256, `CLOSEBUY_SSO_SECRET`,
`iss=closebuy`, `aud=studio`, 5 min TTL).


`GET /api/v1/orgs` accepts an optional `?userId=<uuid>` or `?email=<addr>`
to scope the response to the orgs that user belongs to.

### `CloseBuyOrg` payload

Every organization field stored in Closebuy is included on both the list
and single-org endpoints:

```json
{
  "id": "8c1f…",
  "name": "Acme Tagdækning ApS",
  "description": "Familieejet tagfirma siden 1998.",
  "industry": "Tagdækning",
  "businessType": "B2B",
  "targetMarket": "Erhverv i Region Nordjylland",
  "serviceDescription": "Nye tage, reparation, tagrender.",
  "aiAnalysisPrompt": "Fokusér på lokal forankring …",
  "onboardingComplete": true,
  "createdAt": "2025-04-12T08:14:00Z",
  "updatedAt": "2026-06-24T13:02:11Z",
  "website": "https://acme-tag.dk",
  "websiteUrl": "https://acme-tag.dk",
  "email": "kontakt@acme-tag.dk",
  "phone": "+45 98 12 34 56",
  "cvrNumber": "12345678",
  "address": "Industrivej 12",
  "postalCode": "9000",
  "city": "Aalborg",
  "latitude": 57.0488,
  "longitude": 9.9217,
  "employeeCount": 24,
  "publicHost": "acme.heyrobot.com",
  "brand": { "primary": "#FB3C95", "…": "…" },
  "sender": { "senderName": "Kim", "companyVat": "12345678", "…": "…" }
}
```


### Example

```http
GET https://huvqmibfyuasnvyfguki.functions.supabase.co/studio-api/api/v1/orgs/org_closebuy_demo/leads
Authorization: Bearer <CLOSEBUY_API_KEY>
```

```json
[
  {
    "id": "d7e4a7b4-b237-471f-a7cc-63388af108f8",
    "cvrNumber": "25229649",
    "company": "Azets ATB ApS",
    "legalForm": "Anpartsselskab",
    "companyStatus": "NORMAL",
    "foundingDate": "2000-02-03",
    "address": "Lyskær 3C",
    "zip": "2730",
    "city": "Herlev",
    "country": "Danmark",
    "fullAddress": "Lyskær 3C, 2730 Herlev",
    "latitude": 55.7155278,
    "longitude": 12.4366938,
    "industry": "Administration af fast ejendom på kontraktbasis",
    "industryCode": "683210",
    "employeeCount": 19,
    "numberOfUnits": 1,
    "totalArea": 9886,
    "email": "atb-dk@azets.com",
    "phone": "38607030",
    "website": "www.azets.dk",
    "socialLinkedin": null,
    "logoUrl": "https://azets.dk/wp-content/uploads/logo.svg",
    "imageAssets": [
      { "url": "https://azets.dk/wp-content/uploads/team-photo.jpg", "width": 1600, "height": 900, "type": "jpeg" }
    ],
    "recipientName": "June Mejlgaard Jensen",
    "participantsManagement": [
      "June Mejlgaard Jensen",
      "Lars Buck-Arentsen",
      "Miriam Nadia Buck-Arentsen",
      "Ella Buck"
    ],
    "participantsFounders": ["BUCK-ARENTSEN HOLDING ApS"],
    "totalActive": 26,
    "totalBankruptcy": 1,
    "advertisingProtected": true,
    "latitude": 55.6761,
    "longitude": 12.5683,
    "pNumber": "1008495811",
    "isProductionUnit": true,
    "parentCompanyName": "HRH GARTNER & EJENDOMSSERVICE A/S",
    "distanceKm": 0.24,
    "drivingDistanceKm": 0.2,
    "drivingDurationMin": 1,
    "distanceMin": 1,

    "aiAnalysis": null,
    "listId": "632bbc08-97ca-432c-a884-2d10e8f964c6",
    "listName": "Companies 2026-06-04 13:47",
    "listType": "companies",
    "sourceLocation": null,
    "filtersApplied": null,
    "createdAt": "2025-10-29T08:44:55.810213+00:00",
    "updatedAt": "2026-03-12T13:56:21.63171+00:00",
    "raw": { "…": "original lead row exactly as stored" }
  }
]
```

Pass `?includeLists=true` to receive `{ lists: [...], leads: [...] }` instead — handy when you want to reproduce the list-level UI (name, filters, source location, route data) inside Studio.

#### Logo & image assets

Every lead includes two enrichment fields sourced from the company's website:

- `logoUrl` — a single best logo URL. Any size is accepted; PNG or SVG is preferred, then WebP, JPEG, GIF, ICO. `<link rel="icon">`, `apple-touch-icon`, and `<img>` tags with `logo` in `src`/`class`/`alt` are considered; `og:image` is used as a fallback. Use this for letterheads, PDFs, and previews — Closebuy does not store the file; render the URL directly.
- `imageAssets` — up to **10** hero-quality images from the site, each with a minimum resolution of **800×800** (`width` and `height` are always populated). SVGs and icons are excluded here (use `logoUrl` for the vector logo). URLs only — Closebuy does not proxy or re-host the files, so hotlink them at render time and cache on your side if the origin is unreliable.

Both fields are refreshed every time a company is enriched (manual "Find contacts", the letter-list background job, or a superadmin re-run).


---

## 3. SSO landing (browser redirect)

The mother app links a signed-in user to:

```text
GET https://cbstudio.heyrobot.com/sso?token=<JWT>&redirect=/
```

The JWT is signed with `CLOSEBUY_SSO_SECRET` (HS256) and contains:

```json
{
  "sub": "uuid-of-mother-user",
  "email": "kim@heyrobot.com",
  "name": "Kim",
  "org_id": "org_closebuy_demo",
  "org_name": "Closebuy Demo",
  "role": "owner",
  "iat": 1798765432,
  "exp": 1798765732,
  "iss": "closebuy",
  "aud": "studio"
}
```

Studio must:

1. Verify the JWT signature with `CLOSEBUY_SSO_SECRET` (HS256).
2. Check `aud === "studio"`, `iss === "closebuy"`, `exp - iat ≤ 300`, and `exp` not expired.
3. Find or create a Supabase auth user matching `email`.
4. Mint a Supabase session and 302-redirect to `redirect` (default `/`).

> Superadmin status is granted automatically when `email` is on Studio's
> `pending_superadmin_emails` allow-list.

---

## 4. Ownership

| Domain                                  | Owner   | Notes                                                          |
| --------------------------------------- | ------- | -------------------------------------------------------------- |
| Organisations (identity, brand, sender) | Mother  | Studio caches a projection — never edited in Studio.           |
| Users + global roles                    | Mother  | Surfaced to Studio through the SSO JWT.                        |
| Leads                                   | Mother  | Pulled on demand via `/api/v1/orgs/{id}/leads`.                |
| `letterSettings`, `customFonts`         | Studio  | Per-org overrides for letter rendering.                        |
| `organization_templates`                | Studio  | Named brevskabelon presets.                                    |
| `campaigns`, `letters`, `campaign_jobs` | Studio  | Generation pipeline state.                                     |
| `studio_defaults`                       | Studio  | Global fallback brand + letter defaults.                       |

**Rule of thumb:** any field the mother app renders in its own UI is
mother-owned; anything Studio needs to produce a printable A4 letter or
landing page is Studio-owned.

---

## 5. Versioning

1. Edit `openapi.yaml` with the new field / endpoint.
2. Bump `info.version` (semver — minor for additive, major for breaking).
3. Regenerate the typed client on the Studio side:

   ```bash
   bunx openapi-typescript \
     https://closebuy.heyrobot.com/integrations/studio/openapi.yaml \
     -o src/lib/closebuy.types.gen.ts
   ```

Breaking changes must be released as `/api/v2/…` paths — never mutate
the shape of an existing `/api/v1/…` response.
