# Business rules — unity-api

Evidence date: 2026-09-30.

Source: `git@bitbucket.org:overdrive/unity-api.git`, `master` at `eeafec9` (2019-08-30).

Rules below are taken from controllers, models, migrations, auth config, and the trigger text in `db/unity.sql`. Each rule is marked **Inferred** when the code behaves that way and no written rule says so. Nothing here is a confirmed product decision.

Row contents of the dump are not reproduced.

## Rules enforced by the PHP API

### Identity

**Inferred.** A person is a row in `users` with `name`, `email`, `password`, nullable `email_verified_at`, and `remember_token`.

Evidence: `database/migrations/2014_10_12_000000_create_users_table.php`, `app/User.php`.

**Inferred.** `email` is unique. The database index enforces it. The routed validators do not.

Evidence: unique index on `users.email`. Both API register methods validate `email` as `required|email` only (`RegisterController`, `AuthController`).

**Inferred.** The password stored for an API registration is `bcrypt` of the submitted `password`. `c_password` must equal `password` and is not a column.

Evidence: both API register methods. `$fillable` is `name`, `email`, `password`.

**Inferred.** There is no minimum password length on the routes that actually exist. The unused web `app/Http/Controllers/Auth/RegisterController.php` would require `min:8` and `confirmed`. That controller is not routed, so that stricter rule is not in force.

**Inferred.** Login succeeds only when `Auth::attempt` accepts `email` and `password`. Failure is a single message, `Unauthorised`, HTTP 401. The application does not reveal whether the email exists.

Evidence: `AuthController::login`.

**Inferred.** A successful login or registration yields a Passport personal access token. The token's stored name is `MyApp` for `POST /api/register` and `AppName` for `POST /api/v1/register` and `POST /api/v1/login`. No scope is passed to `createToken`.

**Inferred.** Holding any valid Passport token is enough to read the user and to change every product. There is no role, department, or "own records only" rule in PHP.

Evidence: `auth:api` on the product group and on get-user. No `Gate::define` and no policy class in the tree.

**Inferred.** `MustVerifyEmail` does not stop login or product access. The `verified` middleware is never applied. API registration leaves `email_verified_at` null. The four `users` rows in the dump also have that column null.

### Products

**Inferred.** A product is `name` (string) and `detail` (text), plus timestamps. Both fields are required to create and both are required again to update, including `PATCH`.

Evidence: `ProductController::store`, `ProductController::update`, products migration.

**Inferred.** Product names may repeat. No unique index.

**Inferred.** A missing product on `GET /api/products/{id}` is HTTP 404 with message `Product not found.` A missing product on update or delete is the framework binding response, because those actions type-hint `Product $product` and `show` uses `Product::find`.

**Inferred.** Deleting a product returns the deleted attribute array with HTTP 200 and `success: true`. It is not a 204.

### Validation and errors

**Inferred.** The two register endpoints do not share an error contract.

| Endpoint | Validation failure |
|---|---|
| `POST /api/register` | HTTP 404, envelope `success: false`, message `Validation Error.`, errors under `data` |
| `POST /api/v1/register` | HTTP 401, body `{ error: <messages> }` |

Evidence: `BaseController::sendError` default `$code = 404`. `AuthController` returns 401 explicitly.

**Inferred.** Product validation failures use the same 404 envelope as `POST /api/register`.

**Inferred.** A duplicate email is not a validation message. It surfaces as an unhandled query exception.

### Request limits and browser access

**Inferred.** Each client may call `/api/*` 60 times per minute. The limiter is the `api` middleware group `throttle:60,1`.

**Inferred.** v1 login and register add a second CORS header layer that allows origin `*`. The Spatie profile also allows origin `*` and sets `allow_credentials` to true. The root `.htaccess` sets the same origin header when Apache `mod_headers` is on.

### What the model claims and the routes do not do

**Inferred.** Email verification is configured and not executed on the live routes.

- `EventServiceProvider` listens for `Registered` and sends `SendEmailVerificationNotification`.
- `User::create` in the API controllers does not dispatch `Registered`.
- The web register controller would register through the `RegistersUsers` trait, which does dispatch that event, and no route points at it.

**Inferred.** Password reset tokens would expire after 60 minutes, if a reset route existed. It does not.

Evidence: `config/auth.php` `expire` 60. `routes/web.php` has only `GET /`.

## Rules present only in `db/unity.sql`

These rules run inside MySQL if that dump is loaded and a client writes the named tables. This application's PHP does not call those tables. They are **not** API business rules. They are recorded so the dump is not mistaken for unused empty schema.

Provenance for this whole section: **Implemented in the dump's trigger text. Not implemented in PHP.**

### Contact display name

`CONTACT_Contact_INSERT` and `CONTACT_Contact_UPDATE` on `crmcontact`, before insert and before update.

`strContact` is set to `strName` and `strSurname` trimmed, joined by a space. Null name parts are treated as empty strings. Comment in the dump: `20180305 - onContact.Save, set strContact = Name + Surname`.

### Product cost date

`PRODUCT_COST_UPDATE` on `crmproduct`, before update.

If `dblCost` changes, `dtDateLastUpdated` is set to `CURDATE()`.

The comment on that trigger says `onTicket.Update.Status == Completed, set dtCompleted`. The body does not do that. The comment does not match the body.

### Task assignment queue

`TASK_ASSIGNED_INSERT` on `crmtask`, after insert.

If `strStatus` is not `New` and not `Unassigned`, insert `syscronjob` with `strCronJob = 'Task.Assigned'`, `strStatus = 'New'`, `para` `TaskID=<id>;`.

`TASK_ASSIGNED_UPDATE`, after update.

If `refUserID` changes and the new status is not `New` or `Unassigned`, insert the same `Task.Assigned` job. `para` uses `OLD.TaskID`.

`TASK_COMPLETED_UPDATE`, before update.

If status changes to `Completed`, set `dtCompletedDate` to `CURDATE()` and `blnCompleted` to 1.

### Ticket queue

`TICKET_ASSIGNED_INSERT` on `crmticket`, after insert.

Always inserts `syscronjob` `Ticket.New` with `para` `TicketID=<id>;`. The comment says the job is queued for a new ticket. The body does not check owner or status.

`TICKET_ASSIGNED_UPDATE`, after update.

If `refOwnerID` changes, insert `Ticket.New` (the enum also contains `Ticket.Update`; this trigger does not write `Ticket.Update`). The comment says `queue cronjob Ticket.New`.

`TICKET_COMPLETED_UPDATE`, before update.

If status changes to `Completed`, set `dtCompletedDate` to `CURDATE()`. Unlike the task trigger, this body does not set a `blnCompleted` column.

### Proposal revision and large-value job

`before_proposal_update` on `crmproposal`.

If the new status is `Revised` and the status currently stored on that `ProposalID` is different, `strProposalNumber` is rewritten by incrementing the segment after the last `.`.

`after_proposal_update`.

Reads `dwfproposal.dblCostExcluding` for the proposal and `syssettings.strValue` where `strSetting = 'PROPOSAL_Large_Notification_Amount'`. If cost is greater than that setting, inserts `syscronjob` `Proposal.LargeNotification`.

The `SELECT ... INTO` names the variable `PROPOSAL_Large_Notification_Amount`. The `IF` reads `Proposal_Large_Notification_Amount` (different capitalisation). Whether those are one variable on the server that produced the dump is **Unknown / Requires Confirmation**.

### Leave status to cron job

`hrLeaveApplication_AFTER_UPDATE` on `hrleaveapplication`.

When status changes, `blnActive` is 1, and the new status is one of the values below, insert one `syscronjob` row (`INSERT IGNORE`):

| New `strStatus` | `strCronJob` |
|---|---|
| `Submitted` | `Leave.Submit` |
| `Pending Approval` | `Leave.ApproveHR` |
| `Approved` | `Leave.ApproveMAN` |
| `Declined` | `Leave.Decline` |
| `Canceled` | `Leave.Cancel` |

`para` is `LeaveApplicationID=<old id>;`. The status spelling in the trigger is `Canceled`, not `Cancelled`.

### Performance review

`HR_PR_INI` on `hrreview`, after insert.

Always inserts `syscronjob` `PR.INI` with `para` `ReviewID=<id>;`.

### Cron job names the dump allows

`syscronjob.strCronJob` is an enum:

`Proposal.LargeNotification`, `Proposal.Invoice`, `Ticket.New`, `Ticket.Notification`, `Task.Assigned`, `Ticket.Update`, `Leave.New`, `Leave.ApprovedHR`, `Leave.Approved`, `Leave.Declined`, `Leave.Cancelled`, `Leave.Submit`, `Leave.ApproveHR`, `Leave.ApproveMAN`, `Leave.Decline`, `Leave.Cancel`, `PR.INI`.

`strStatus` is an enum: `New`, `Processing`, `Processed`, `Unprocessed`, `Warning`, `Error`. Default `New`.

Several enum values (`Proposal.Invoice`, `Ticket.Notification`, `Ticket.Update`, `Leave.New`, `Leave.ApprovedHR`, `Leave.Approved`, `Leave.Declined`, `Leave.Cancelled`) are not written by the triggers quoted above. No PHP in this repository processes the queue. What consumed `syscronjob` is **Unknown / Requires Confirmation**.

### Staff flags on `sysuser`

Columns, not PHP:

- `refSecurityGroupID` (default 0)
- `strLocation` enum `On Site` / `Off Site` (default `Off Site`)
- `blnLineManager`
- `blnSalesPerson` (default 0)
- `blnStaff` (default 1), comment on the column: `HR.Staff`
- `blnTicketOwner` (default 0), comment: `SDM - Manages Tickets`
- `blnActive` (default 1)

No PHP reads these flags. They are not the Passport `users` table.

## Access rules

| Surface | Who can call it | Evidence |
|---|---|---|
| `POST /api/register`, `POST /api/v1/register`, `POST /api/v1/login` | No login required | No `auth` middleware |
| `GET /api/products`, product write routes, `POST /api/v1/getUser`, `GET /api/user` | Any valid Passport token for the `users` provider | `auth:api` |
| `GET /` | No login required | Closure returns the welcome view |
| Web auth controllers | Not exposed | No routes |
| CRM / HR tables | Not exposed by this API | No queries |

`AuthServiceProvider::$policies` maps a model class that is not in the repository to a policy class that is not in the repository. No rule in that map is executable from the current tree.

## Conflicts inside the repository

1. Two register contracts (HTTP 404 vs 401, token names `MyApp` vs `AppName`, envelope vs `{ success: { token } }`). No document says which one stands.
2. `User` implements `MustVerifyEmail`, and nothing on the routed API checks it.
3. Web `RegisterController` has a stricter password rule than the API, and it is unwired.
4. `PRODUCT_COST_UPDATE`'s SQL comment describes ticket completion. The body updates `dtDateLastUpdated` when cost changes.
5. `TICKET_ASSIGNED_UPDATE` writes cron job `Ticket.New` when the owner changes, not `Ticket.Update`.
6. The API `products` table and the dump's `crmproduct` table are different shapes. The API does not apply CRM cost, SKU, or bundle rules.
7. `.env.example` database name `homestead`, dump database name `unity`, config fallback `forge`.
