# Merge & search

## Search the workspace

`GET /v1/search?q=`

**Permission:** Read jobs

The search box at the top of TruTec, as one call. It looks through jobs (name, job number, company, address, contact, Property ID, work order and accounting numbers), customers, contacts, sites, leads, proposals, invoices, logged notes and emails, projects and measurements, and returns the best few matches of each type. It sees what the person who created the API key sees: a member who only sees their own jobs gets only those.

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | string | Yes | 2 to 120 characters. |
| `types` | string | No | Comma list to keep: `job`, `client_account`, `contact`, `location`, `lead`, `proposal`, `invoice`, `note`, `project`, `measurement`. |

```bash
curl "https://trutec.ai/api/v1/search?q=oak%20hills" -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {"type": "job", "id": 70412, "title": "Oak Hills Plaza reseal", "subtitle": "Sealcoating · 1200 Commerce Dr",
     "status": "proposal_sent", "app_url": "https://trutec.ai/jobs/70412"},
    {"type": "client_account", "id": 3110, "title": "Oak Hills Property Group", "subtitle": "3 contacts · 12 jobs",
     "status": null, "app_url": "https://trutec.ai/jobs/companies/3110"},
    {"type": "location", "id": 812, "title": "Oak Hills Plaza", "subtitle": "1200 Commerce Dr",
     "status": null, "app_url": "https://trutec.ai/jobs/locations/812"}
  ],
  "query": "oak hills"
}
```

There is no paging: add words to narrow. Use the `id` with the matching resource (`/v1/jobs/{id}`, `/v1/client-accounts/{id}`, `/v1/contacts/{id}`, `/v1/locations/{id}`, ...).

## Merge duplicates

Two records for the same customer, person or site? Merge one into the other. The record in the URL is **merged away**; the one you name in the body **stays**. Everything on the merged record moves over: jobs, contacts, sites, notes and logged calls and emails, follow-ups, reminders, files, divisions, capital plan items, re-bid history and campaign recipients. Then:

- a merged **customer** or **contact** is deleted;
- a merged **site** is archived and points to the site it went into (its old Site ID keeps resolving).

Field values follow the app's defaults: the surviving record's value, unless it is blank (then the other one's). Lead details follow special rules (the further-along stage and the larger value win, tags and notes are combined). To pick the other side for a field, send `choices`.

!!! warning
    A merge can't be undone through the API. Preview first, and confirm with whoever owns the data.

Merges run the app's duplicate-review merge as the key's user, who must be an office user (not a crew login).

### Preview a merge

`GET /v1/client-accounts/{account_id}/merge-preview?into={keep_id}`
`GET /v1/contacts/{contact_id}/merge-preview?into={keep_id}`
`GET /v1/locations/{location_id}/merge-preview?into={keep_id}`

**Permission:** Read jobs

Changes nothing.

```json
{
  "data": {
    "keep": {"id": 3110, "name": "Oak Hills Property Group", "archived": false},
    "merge_away": {"id": 3187, "name": "Oak Hills Property Grp", "archived": false},
    "fields": [
      {"field": "billing_address", "label": "Billing address", "keep_value": "PO Box 18, Austin TX",
       "merge_away_value": "1200 Commerce Dr, Austin TX", "needs_choice": true, "default": "keep", "can_keep_both": false},
      {"field": "billing_contact_email", "label": "Billing email", "keep_value": null,
       "merge_away_value": "ap@oakhills.example", "needs_choice": false, "default": "drop", "can_keep_both": false}
    ],
    "moves": [{"count": 4, "label": "jobs"}, {"count": 2, "label": "contacts"}],
    "blockers": []
  }
}
```

`fields` lists only the fields that differ. `needs_choice: true` means both records have a value; `default` says which side is kept unless you choose (`keep` = the surviving record's). Site previews add `blockers` (reasons the merge is refused, such as both sites carrying different Site IDs from a connected system) and `invoiced_job_count`.

### Merge

`POST /v1/client-accounts/{account_id}/merge` with `into_client_account_id`
`POST /v1/contacts/{contact_id}/merge` with `into_contact_id`
`POST /v1/locations/{location_id}/merge` with `into_location_id`

**Permission:** Write jobs + Delete records

| Name | Type | Required | Description |
|---|---|---|---|
| `into_client_account_id` / `into_contact_id` / `into_location_id` | integer | Yes | The record that stays. |
| `choices` | object | No | `{field: "keep" \| "drop"}` for fields from the preview. Custom fields are `custom:<key>`. On a contact, `"phone": "both"` keeps both numbers. |
| `move_invoiced_jobs` | boolean | No | Sites only: a site with invoiced jobs is refused unless this is `true` (check with accounting first). |

```bash
curl -X POST "https://trutec.ai/api/v1/client-accounts/3187/merge" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"into_client_account_id": 3110, "choices": {"billing_address": "drop"}}'
```

```json
{
  "data": {
    "kept": {"id": 3110, "name": "Oak Hills Property Group", "archived": false},
    "merged_id": 3187,
    "merged_name": "Oak Hills Property Grp",
    "message": "Merged ‘Oak Hills Property Grp’ into ‘Oak Hills Property Group’ — 4 jobs, 2 contacts moved."
  }
}
```

**Errors:** `400` merging a record into itself, or an unknown field in `choices`; `403` when the key lacks Delete records or its user isn't an office user; `404` for either id outside the workspace; `409` when the app refuses the merge (for example the workspace has turned off deleting records, or a site has invoiced jobs), with its reason in `message`.
