# 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); job create/update uses `SoftServiceAreaGuard` against `team_service_areas` (country / county / postcode match on the soft-linked service site); available-slots soft-reads timezone + business hours via `SoftBusinessHours`. Soft-consumes **Messaging** when present: appointment create/update/cancel/complete triggers service SMS via `AppointmentSmsHooks`. Soft-reads `customer_portal_users` / `customers.type` / `customers.team_id` via `PortalCustomerAccess` (current-team CRM customer) and `provider_portal_users` via `ProviderPortalAccess` (no cross-domain Eloquent imports). Soft-reads **Invoices** `invoice_settings` (`price_per_bedroom` / `price_per_bathroom` / `price_per_square_foot`) via `SoftPortalJobPricing` when a portal customer creates a job.
- **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

When a recurring job has **no appointments yet**, the first visit added via `CreateJobAppointment` automatically calls `GenerateJobAppointments` using that start time as the seed (same staff, provider, and notes).

`GenerateJobAppointments` expands from a seed `starts_at` through `recurrence_ends_on` (or one year), capped at **52** occurrences. Regenerating (manual form on job show, or API) 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` **on the current team**. One login may be linked to a customer on more than one team; `PortalCustomerAccess` resolves the link by `customers.team_id`. They may create jobs only for themselves: create/edit forms lock the customer (defaulted to their CRM record), hide status and price, and preselect their first service site. Posted `unit_price` / `pricing_unit` / `status` are ignored. On create, `unit_price` is set from the service site × team invoice-settings room/sqft rates (`price_per_bedroom` + `price_per_bathroom` + `price_per_square_foot`); if that yields nothing, the customer’s most recent priced job is copied. A portal user with exactly one service site has that site attached even when omitted. Price is omitted from Inertia and API job payloads. 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). The Dashboard calendar and job counts are scoped the same way; team-wide invoice/profit widgets are hidden.

### 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 |
| POST | `/api/jobs/{job}/appointments/{appointment}/complete` | `api.jobs.appointments.complete` | Api\JobAppointmentCompleteController |
| 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 |
| GET | `/api/jobs/{job}/available-slots?date=YYYY-MM-DD` | `api.jobs.available-slots` | Api\JobAvailableSlotsController |
| 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).

### Available slots (API)

`GET /api/jobs/{job}/available-slots?date=YYYY-MM-DD` soft-reads `company_settings.timezone` and `business_hours` for that weekday (`SoftBusinessHours`). Slot length = job `default_duration_minutes` (fallback 60). Emits non-overlapping ISO8601 `{ starts_at, ends_at }` windows inside open hours, excluding appointments on that job with status ≠ `cancelled`. Booking still re-enforces `BusinessHoursGuard` via `CreateJobAppointment`.

### Complete appointment (API)

`POST .../complete` lets the **assigned** staff member (Jetstream `read` is enough) or anyone with team `update` mark status `completed` without a full appointment update. Portal roles denied. Policy: `JobAppointmentPolicy::complete`.

## Permissions

Uses Jetstream team abilities:

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

## Tests

- Path: `laravel/tests/Feature/Domains/Jobs/`
- Cover: CRUD (web + API), appointment `ends_at` default from job duration, automatic recurrence from first appointment, manual recurrence regeneration, staff assigned-only visibility and mutation denial, soft `customer_id` / `provider_id`, portal business-hours scheduling + cancel notice/fee rules, portal self-only job create (hidden price, site/previous-job defaults, payload concealment) and dashboard, provider portal assigned-only visibility, geofenced check-in (within/outside radius, unassigned denial, duplicates, settings), service-area create/update guard, available slots (open/closed/overlap/forbidden), assignee complete / unassigned denial

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