# Feature: Jobs

## Purpose

Team-scoped work orders (jobs) with scheduled appointments, optional weekly/monthly recurrence materialization, soft links to Customers, and a Dashboard calendar. Exposed on web (Inertia) and Sanctum API. Includes Sanctum geofenced on-site check-in for assigned staff (Android/iOS).

## Boundaries

- **Owns:** `service_jobs`, `job_appointments`, `job_check_in_settings`, `job_appointment_check_ins`; Jobs domain Actions, Policies, HTTP (web + API), Inertia pages under `Pages/Jobs/`, `Components/MonthCalendar.vue`
- **Does not own:** Customers CRM, invoices, maps, Jetstream roles
- **Depends on (platform only):** User / Team / auth (Jetstream abilities `create` / `read` / `update` / `delete`); `staff` role for assigned-only read and on-site check-in
- **Depends on (other domains):** none required — soft ID links only (`customer_id`, `customer_service_site_id`, `provider_id` with no FK / no Customers or Providers model imports). Store/update validation uses `exists:customers,id` / `exists:customer_service_sites,id` / `exists:providers,id` while those domains are present; drop those rules if removed. Web create/edit use searchable selects via `SoftCustomerOptions` / `SoftProviderOptions` (DB reads). Soft-reads service site `latitude` / `longitude` via `SoftServiceSiteCoordinates` for geofenced check-in. Soft-consumes **CompanySettings** when present: portal scheduling uses `BusinessHoursGuard`; portal cancel uses `CancellationPolicy` (notice + fee rules). Soft-consumes **Messaging** when present: appointment create/update/cancel triggers service SMS via `AppointmentSmsHooks`. Soft-reads `customer_portal_users` / `customers.type` via `PortalCustomerAccess` and `provider_portal_users` via `ProviderPortalAccess` (no cross-domain Eloquent imports).
- **Platform UI touchpoints:**
  - Side nav links in `resources/js/Layouts/AppLayout.vue` (via `SideNavLink`; revert on remove)
  - `DashboardController` optionally calls `ListAppointmentsForRange` and `DashboardJobCounts` via `class_exists` (empty calendar / no job strip if Jobs removed)

Table is named **`service_jobs`** (model `Job`) to avoid colliding with Laravel’s queue `jobs` table.

## Models

| Model | Table | Notes |
|-------|-------|-------|
| `Job` | `service_jobs` | `team_id` FK → `teams`; status `draft` \| `active` \| `completed` \| `cancelled`; soft `customer_id` / `customer_service_site_id`; recurrence `none` \| `weekly` \| `monthly`; soft pricing `unit_price` + `pricing_unit` (`per_visit` \| `flat` \| `hourly`) for Invoices |
| `JobAppointment` | `job_appointments` | `job_id` FK → `service_jobs`; optional `assigned_user_id` FK → `users`; soft nullable `provider_id` (no FK); `starts_at` / `ends_at`; status `scheduled` \| `en_route` \| `checked_in` \| `completed` \| `cancelled`; portal cancel may set `cancelled_at`, `cancellation_fee_amount`, `cancellation_fee_type`. Add-appointment UI fills `ends_at` from `starts_at` + job `default_duration_minutes` (server also defaults when `ends_at` is omitted). |
| `JobCheckInSetting` | `job_check_in_settings` | One row per team; `radius_meters` (default **100**) for on-site geofence |
| `JobAppointmentCheckIn` | `job_appointment_check_ins` | One check-in per appointment; `user_id` FK → `users`; device `latitude` / `longitude`; audit snapshots `site_latitude` / `site_longitude` / `distance_meters` / `radius_meters` / `checked_in_at` |

Schema is normalised (3NF). Soft customer IDs are intentional cross-domain references without FK (documented above). Check-in stores site coordinates and distance as an **intentional audit snapshot** so a later site coordinate change does not rewrite the geofence decision. Migrations: `database/migrations/2026_08_12_100000_create_jobs_tables.php`, `database/migrations/2026_08_12_200000_create_job_check_in_tables.php`. Factories: `database/factories/Domains/Jobs/`.

### On-site check-in (API)

Assigned staff POST device GPS to check in. The action:

1. Requires `assigned_user_id` = authenticated user (staff `read` is enough; portal roles denied).
2. Soft-reads the job’s `customer_service_site_id` coordinates.
3. Computes Haversine distance; rejects when distance &gt; team `radius_meters`.
4. Creates `job_appointment_check_ins` and sets appointment status to `checked_in` when currently `scheduled` or `en_route`.
5. Rejects cancelled/completed appointments, missing site/coords, and duplicate check-ins.

### Recurrence

`GenerateJobAppointments` expands from a seed `starts_at` through `recurrence_ends_on` (or one year), capped at **52** occurrences. Regenerating deletes future **`scheduled`** appointments in the window; `completed` / `cancelled` are preserved.

### Staff visibility

Users with Jetstream role `staff` (read-only) only see jobs that have an appointment assigned to them, and only those appointments. Owners / admin / editor see all team jobs.

### Customer portal visibility

Users with Jetstream role `customer` (linked via `customer_portal_users`) only see jobs for their CRM `customer_id`. They may create appointments on those jobs only inside company business hours, and cancel via the dedicated cancel action (notice window + fee rules from CompanySettings).

### Provider portal visibility

Users with Jetstream role `provider` (linked via `provider_portal_users`) only see jobs that have an appointment with soft `provider_id` matching their CRM provider. Appointment show/index/calendar payloads are limited (title, times, status, service site label) — no customer notes/contacts/billing, fee internals, or unassigned team jobs. Providers cannot create/update/delete jobs or appointments (v1: `read` only). Staff `assigned_user_id` may still be set alongside `provider_id`.

## Routes

### Web

| Method | URI | Name | Controller |
|--------|-----|------|------------|
| GET | `/jobs` | `jobs.index` | Web\JobController@index |
| GET | `/jobs/create` | `jobs.create` | Web\JobController@create |
| POST | `/jobs` | `jobs.store` | Web\JobController@store |
| GET | `/jobs/{job}` | `jobs.show` | Web\JobController@show |
| GET | `/jobs/{job}/edit` | `jobs.edit` | Web\JobController@edit |
| PUT/PATCH | `/jobs/{job}` | `jobs.update` | Web\JobController@update |
| DELETE | `/jobs/{job}` | `jobs.destroy` | Web\JobController@destroy |
| GET | `/jobs/calendar` | `jobs.calendar` | Web\JobController@calendar |
| POST | `/jobs/{job}/appointments` | `jobs.appointments.store` | Web\JobAppointmentController@store |
| POST | `/jobs/{job}/appointments/{appointment}/cancel` | `jobs.appointments.cancel` | Web\JobAppointmentController@cancel |
| PUT | `/jobs/{job}/appointments/{appointment}` | `jobs.appointments.update` | Web\JobAppointmentController@update |
| DELETE | `/jobs/{job}/appointments/{appointment}` | `jobs.appointments.destroy` | Web\JobAppointmentController@destroy |
| POST | `/jobs/{job}/appointments/generate` | `jobs.appointments.generate` | Web\JobAppointmentController@generate |

### API

| Method | URI | Name | Controller |
|--------|-----|------|------------|
| GET | `/api/jobs` | `api.jobs.index` | Api\JobController@index |
| POST | `/api/jobs` | `api.jobs.store` | Api\JobController@store |
| GET | `/api/jobs/{job}` | `api.jobs.show` | Api\JobController@show |
| PUT/PATCH | `/api/jobs/{job}` | `api.jobs.update` | Api\JobController@update |
| DELETE | `/api/jobs/{job}` | `api.jobs.destroy` | Api\JobController@destroy |
| GET | `/api/job-appointments?from=&to=` | `api.job-appointments.index` | Api\JobController@appointments |
| POST | `/api/jobs/{job}/appointments` | `api.jobs.appointments.store` | Api\JobAppointmentController@store |
| POST | `/api/jobs/{job}/appointments/{appointment}/cancel` | `api.jobs.appointments.cancel` | Api\JobAppointmentController@cancel |
| PUT | `/api/jobs/{job}/appointments/{appointment}` | `api.jobs.appointments.update` | Api\JobAppointmentController@update |
| DELETE | `/api/jobs/{job}/appointments/{appointment}` | `api.jobs.appointments.destroy` | Api\JobAppointmentController@destroy |
| POST | `/api/jobs/{job}/appointments/generate` | `api.jobs.appointments.generate` | Api\JobAppointmentController@generate |
| POST | `/api/jobs/{job}/appointments/{appointment}/check-in` | `api.jobs.appointments.check-in` | Api\JobAppointmentCheckInController@store |
| GET | `/api/jobs/{job}/appointments/{appointment}/check-in` | `api.jobs.appointments.check-in.show` | Api\JobAppointmentCheckInController@show |
| GET | `/api/job-check-in-settings` | `api.job-check-in-settings.show` | Api\JobCheckInSettingsController@show |
| PUT | `/api/job-check-in-settings` | `api.job-check-in-settings.update` | Api\JobCheckInSettingsController@update |

All records are scoped to the authenticated user’s **current team**.

Check-in body: `{ "latitude": number, "longitude": number }`. Settings body: `{ "radius_meters": integer }` (10–5000).

## Permissions

Uses Jetstream team abilities:

- `read` — list / view (staff further scoped to assignments); assigned staff may check in; staff may view check-in settings
- `create` — create jobs / appointments
- `update` — update jobs / appointments / generate recurrence; owners/admin/editor update check-in radius
- `delete` — delete jobs / appointments

## Tests

- Path: `laravel/tests/Feature/Domains/Jobs/`
- Cover: CRUD (web + API), appointment `ends_at` default from job duration, recurrence generation, staff assigned-only visibility and mutation denial, soft `customer_id` / `provider_id`, portal business-hours scheduling + cancel notice/fee rules, provider portal assigned-only visibility, geofenced check-in (within/outside radius, unassigned denial, duplicates, settings)

## 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

### Remove

- [ ] Unregister `JobsDomainServiceProvider`
- [ ] Delete domain folder, this page, and index link
- [ ] Delete Feature tests under `tests/Feature/Domains/Jobs/`
- [ ] Revert AppLayout Jobs nav links
- [ ] Drop `service_jobs` / `job_appointments` / `job_check_in_settings` / `job_appointment_check_ins` migrations (or reverse migrate)
- [ ] Remaining suite still green (Dashboard calendar becomes empty via `class_exists`)
