# 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/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`. `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:** Owner/admin OAuth against one Zeevou app (`ZEEVOU_*` env). Each team has its own Zeevou organisation tokens. Redirect URI is `ZEEVOU_REDIRECT_URI` or `{APP_URL}/zeevou/callback`.
- **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 OAuth redirect URI

Zeevou rejects the connect 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/oauth/v2/auth
ZEEVOU_TOKEN_URL=https://sandbox.zeevou.com/oauth/v2/token
```

OAuth authorize/token hosts are not in the OpenAPI spec; override from Zeevou’s credential email if they differ. The settings page shows the effective redirect URI.

## Routes

### Web

| Method | URI | Name | Controller |
|--------|-----|------|------------|
| GET | `/zeevou` | `zeevou.settings` | Web\ZeevouSettingsController@edit |
| GET | `/zeevou/connect` | `zeevou.connect` | Web\ZeevouSettingsController@connect |
| 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; owner/admin vs editor/staff; 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
