# Feature: Zeevou

## Purpose

Per-team Zeevou OpenAPI connection so housekeeping and maintenance tasks created in Zeevou are pulled into ServDiary as jobs and appointments.

## Boundaries

- **Owns:** `zeevou_connections`, `zeevou_tasks`; OAuth connect/callback/disconnect; `POST /zeevou/connect-client-credentials`; `POST /zeevou/sync`; artisan `zeevou:sync-tasks`; queued `SyncZeevouTasksJob`; Inertia `Pages/Zeevou/Settings.vue`
- **Does not own:** Job totals, customers, service sites, pushing ServDiary jobs back to Zeevou, Zeevou staff → assignee mapping, webhooks
- **Depends on (platform only):** User / Team / auth; Laravel HTTP client; queue; scheduler
- **Depends on (other domains):** none required — optional `class_exists` consumption of Jobs `CreateJob` / `CreateJobAppointment` / `Job` / `JobAppointment`. Soft `job_id` / `job_appointment_id` (no FK). If Jobs is removed, sync records an error and creates no work orders.
- **Platform UI touchpoint:** Optional card on Jobs index (`Pages/Jobs/Index.vue`) when this domain is present. Revert that card if Zeevou is removed.

## Models

| Model | Table | Notes |
|-------|-------|-------|
| `ZeevouConnection` | `zeevou_connections` | One row per team. Encrypted `access_token` / `refresh_token`. `grant_type` is `authorization_code` or `client_credentials`. `connected_at` null means disconnected; task mappings are kept. `connected_by_user_id` is the Jobs actor on sync (falls back to team owner). |
| `ZeevouTask` | `zeevou_tasks` | Soft `job_id` + `job_appointment_id`. Unique `zeevou_task_id` per connection — pull idempotency. `last_error` when a task cannot be imported (e.g. missing start time). |

Schema is normalised (3NF). No Zeevou ids on `service_jobs`. Tokens use Laravel `encrypted` casts (ciphertext at rest; **intentional**: not business data duplication). Soft job ids are intentional cross-domain references without FK. Migration: `database/migrations/2026_09_05_090000_create_zeevou_tables.php`. Factories: `database/factories/Domains/Zeevou/`.

## Behaviour

- **Connect (Authorization Code):** Owner/admin OAuth against the Authorization Auth public app (`ZEEVOU_CLIENT_ID` / `ZEEVOU_CLIENT_SECRET`). Each team connects its own Zeevou organisation. Redirect URI is `ZEEVOU_REDIRECT_URI` or `{APP_URL}/zeevou/callback`.
- **Connect (Client Credentials):** Optional. Owner/admin posts `POST /zeevou/connect-client-credentials` using `ZEEVOU_CC_CLIENT_ID` / `ZEEVOU_CC_CLIENT_SECRET`. Authenticates as the organisation that issued that Client Credential app (the partner sandbox org), not an arbitrary host’s org. Use this for sandbox development when you want to skip the browser login.
- **Pull:** Every 15 minutes (`zeevou:sync-tasks`) and on **Sync now**. `GET {ZEEVOU_API_BASE}/tasks` (Hydra `hydra:member` / `member`, follows `hydra:next`).
- **Import:** Housekeeping and maintenance `type` only. Payment reminders and unknown types are skipped.
- **Mapping:** `subject` → job title; description + type/priority + booking IRI → notes; `not_sooner` (else `due_date_from` / `start_date`) → appointment `starts_at`; `not_later` / `deadline` → `ends_at`; `estimated_time` hours → `default_duration_minutes` (fallback 60). Jobs are created **without** `customer_id` / site.
- **Closed tasks:** Job status `completed`; no new appointment.
- **Missing start time:** Mapping row with `last_error`; no job.
- **Re-sync:** Same `zeevou_task_id` updates title/notes/status and reschedules only `scheduled` appointments.
- **Queue:** Default `QUEUE_CONNECTION=database`. On sandbox without a worker, set `ZEEVOU_QUEUE_SYNC=true` (or `QUEUE_CONNECTION=sync`) so pulls run immediately.
- **Actor:** Connecting user if they still belong to the team; otherwise the team owner.

### Zeevou is OroCRM

Zeevou’s OpenAPI host is OroCRM. Token requests are **JSON** `POST {host}/oauth2-token` (not Symfony FOSOAuth `/oauth/v2/token`, and not `application/x-www-form-urlencoded` + HTTP Basic).

| Purpose | Sandbox | Production |
|---------|---------|------------|
| API + OAuth | `https://sandbox.zeevou.com` | `https://hub.zeevou.com` |
| Authorize | `{host}/oauth2-token/authorize` (redirects to `/oauth2-token/login`) | same path |
| Token | `POST {host}/oauth2-token` (`Content-Type: application/json`) | same path |
| Tasks | `GET {host}/apis/tasks` | same path |
| Host UI (create test tasks) | [appbox.zeevou.com](https://appbox.zeevou.com) | same SPA |

`app.zeevou.com` and `appbox.zeevou.com` are the product SPA, not the OAuth token server.

Authorization-code token responses often omit `refresh_token`. Client-credentials tokens last about an hour; ServDiary re-POSTs `grant_type=client_credentials` when they expire.

### Partner email mapping

From Zeevou’s partner welcome email (put values on the **server** `.env` only — **never commit secrets**):

| Email field | Env |
|-------------|-----|
| OAuth 2.0 Public App (Authorization Auth) Client ID / Secret | `ZEEVOU_CLIENT_ID` / `ZEEVOU_CLIENT_SECRET` |
| OAuth 2.0 Public App (Client Credential) Client ID / Secret | `ZEEVOU_CC_CLIENT_ID` / `ZEEVOU_CC_CLIENT_SECRET` |

Create housekeeping/maintenance test tasks in Appbox, then Sync now.

### Zeevou OAuth redirect URI

Zeevou rejects the Authorization Code flow when the `redirect_uri` is not listed **exactly** on the partner OAuth app (scheme, host, path; no trailing slash unless registered).

ServDiary sends:

- `ZEEVOU_REDIRECT_URI` when set in `.env`, otherwise
- `{APP_URL}/zeevou/callback`

Sandbox partner form (creates a sandbox OAuth app):

```
https://sandbox.servdiary.com/zeevou/callback
```

Example:

```env
APP_URL=https://sandbox.servdiary.com
ZEEVOU_REDIRECT_URI=https://sandbox.servdiary.com/zeevou/callback
ZEEVOU_ENVIRONMENT=sandbox
ZEEVOU_API_BASE=https://sandbox.zeevou.com/apis
ZEEVOU_AUTH_URL=https://sandbox.zeevou.com/oauth2-token/authorize
ZEEVOU_TOKEN_URL=https://sandbox.zeevou.com/oauth2-token
```

The settings page shows the effective redirect URI. Override hosts from `.env` if Zeevou changes them.

## Routes

### Web

| Method | URI | Name | Controller |
|--------|-----|------|------------|
| GET | `/zeevou` | `zeevou.settings` | Web\ZeevouSettingsController@edit |
| GET | `/zeevou/connect` | `zeevou.connect` | Web\ZeevouSettingsController@connect |
| POST | `/zeevou/connect-client-credentials` | `zeevou.connect-client-credentials` | Web\ZeevouSettingsController@connectClientCredentials |
| GET | `/zeevou/callback` | `zeevou.callback` | Web\ZeevouSettingsController@callback |
| POST | `/zeevou/disconnect` | `zeevou.disconnect` | Web\ZeevouSettingsController@disconnect |
| POST | `/zeevou/sync` | `zeevou.sync` | Web\ZeevouSettingsController@sync |

### API

N/A — mobile clients do not connect Zeevou.

## Permissions

- View settings / Jobs index card: not staff/customer/provider (same billing-style restriction as QuickBooks)
- Connect / disconnect / sync now: team owner, `admin` role, or super admin

## Tests

- Path: `laravel/tests/Feature/Domains/Zeevou/`
- Cover: OAuth callback stores encrypted tokens and POSTs JSON to `/oauth2-token`; owner/admin vs editor/staff; client-credentials connect; expired client-credentials re-POST; Http-faked task collection creates one job + appointment; duplicate task id no-ops; payment-reminder skipped; missing start time records `last_error`; disconnect clears tokens; Jobs index includes the optional card

## Add / remove checklist

### Add

- [x] Domain folder + `DomainServiceProvider`
- [x] Provider registered in `bootstrap/providers.php`
- [x] This wiki page linked from `docs/README.md`
- [x] Feature tests passing
- [x] Env keys in `.env.example` / Zeevou partner OAuth app

### Remove

- [ ] Provider unregistered
- [ ] Domain folder, factories, migration, `config/zeevou.php`, wiki, tests deleted
- [ ] Optional Jobs index card (`class_exists(ZeevouJobSync::class)`) left harmless or reverted
- [ ] Remaining suite still green
