# PRD — sawis_api

Evidence date: 2026-10-04.

Source: `git@bitbucket.org:silicon_bit_bucket/sawis_api.git`.

Behaviour below is **Implemented** on `master` at `162260a` (2025-06-20) unless a line says **On `prod_api_2020`**. That branch tip is `b0d4147` (2026-09-01). See `project-plan.md` for why both are cited. `master` was read in full. `prod_api_2020` was compared for entry points, settings files, deploy, and the `NemoAPI` method list. New methods on that branch were read. Other diffs on that branch were not re-audited line by line.

There is no requirements document in the source tree. Requirements here are recovered from code. Provenance is labelled. Passwords, the vendor-password bypass token, and the token hardcoded in `api.user.php` are not copied into this document.

## Product purpose

**Inferred.** `api.php` exposes `NemoAPI`, a partner API over the `sawis_api` database. Call names and comments cover SAWIS members, farms, vineyard blocks, wine of origin locations, cultivars, certification, wine export, and logins for sites named in vendor configuration. Mail uses the display context "SAWIS Online".

**Unknown / Requires Confirmation:** the formal product name, the owning organisation, and whether this API is still the system partners are told to call. No README states this.

## Users and actors

| Actor | How they appear | Status |
|---|---|---|
| API vendor | Row in `apiVendor` with `VendorKey` and `blnActive = 1`. The key is a request field. | **Implemented.** Keys named in PHP: `VINPRO`, `WOLINE`, `WIETA1`, `EZW1NE`, `LMS001`, `SAWIS3`, `SAW3QA`, `S3PROD`. Other active keys are whatever rows exist in the database. |
| Vendor integrator | Sends `call`, paging, filters, and for write calls an XML body. | **Implemented** as HTTP clients. No UI for them in this repo except `test/index.php`. |
| SAWIS Online user | `sysUser` row, addressed by email. Login, password, and profile calls. | **Implemented.** |
| Security group | `sysSecurityGroup` names `Member Superuser [Unapproved]` and `External Site User`. | **Implemented** inside `createUser` only. |
| Mail recipient | Password reset, and the address hardcoded in `emailTest.php`. | **Implemented.** |
| Person opening an email pixel or "read online" link | `email.read.receipt.php`, `email.read.online.php`. The online page says "NB! NOT IN USE ON API". | **Implemented** as scripts. The layout they display is not in this repo. |
| Deploy operator | Anyone who can open `deploy.php`. No login check in the file. | **Implemented.** |
| FIM operator or cron | `cron.fim.php` query parameters `Action`, `ID`, `state`. No login check in the file. | **Implemented.** Who schedules it is **Unknown**. |
| SonarQube pipeline | Bitbucket Pipelines on `master` only. | **Implemented** as YAML. |

`test/index.php` is an unauthenticated HTML form. It is a developer tool, not a partner console.

## Systems this product talks to

1. The caller, over HTTP GET or POST to `api.php`.
2. MySQL database `sawis_api` on the host selected by server name. On `master`, `system.server.PROD.php` uses `127.0.0.1` for `api.sawisonline.co.za` and `10.0.0.173`.
3. SMTP, when password reset or `emailTest.php` sends mail. Template name "Reset Password". From-address setting key `SMTP Send As Sawis3`.
4. The SAWIS web site that hosts `resetPassword.php`. Base URL depends on vendor key (`SAW3QA` → `http://10.0.0.149/html`, `S3PROD` → `https://www.sawisonline.co.za`, otherwise `$SystemSettings[SERVER_NAME]/sawis`).
5. Browsers on the CORS allow-list in `api.php`: `https://sawisonline.co.za`, `https://www.sawisonline.co.za`, `http://10.0.0.149`, `https://sungis10.stb.sun.ac.za`, `https://www.sungis10.stb.sun.ac.za`. Comment dated 2019-07-17: CORS was added for GIS. **On `prod_api_2020`** an additional branch allows origin equal to an empty string.
6. Bitbucket, as the git remote, and SonarQube when the `master` pipeline runs.
7. jQuery on Google's CDN, only from `test/index.php`.

NuSOAP and `wsdl/SawisService.wsdl` (a calculator service in namespace `http://maths.lenin.com`) are in the tree. Nothing in `api.php` invokes them.

## How a call is made

**Implemented.** `system.functions.inc.php` copies `$_POST`, or else `$_GET`, into global variables (`localize`). If `$_POST` is non-empty it sets `$_GET` to null.

On `master`, `api.php` passes globals `$VendorKey`, `$call`, `$Page`, `$xmlData`, and `$Token` into `NemoAPI`. Those names match request fields after `localize`. `NemoAPI` then sets `$this->args` from `$_GET`. A POST that cleared `$_GET` therefore has no query arguments on `$this->args`.

**On `prod_api_2020`:** `api.php` assigns those five values from `$_REQUEST` before constructing `NemoAPI`.

`type=json` or `type=text` selects the body format. Any other value, including omission, stays XML (`ReturnType` default `"xml"`).

Paging uses `Page` (default 1) and `sysSettings` key `PageSize`:

`LIMIT (Page - 1) * PageSize, PageSize`

`PageSize` is not in git. **Unknown** until a database row is read.

Write calls that parse XML expect `simplexml` of `$xmlData` with shape `rows/row`. Missing or unparsable XML returns text `INVALID DATA STRUCTURE` and HTTP status from `$HTTP_ERROR_CODE`, which is the string `"202"`.

Successful `response()` calls that omit a status use HTTP 200. `INVALID CALL`, `INVALID VENDOR KEY`, and `INVALID TOKEN` use HTTP 202. `AUTHENTICATION FAILED` is returned with no status argument, so the `REST` default 200 applies. **Requires Confirmation:** whether callers were told to treat 202 as the error status.

## Authentication

### FR-001 — Vendor key

**Implemented.** `checkVendor()` loads `apiVendor` where `VendorKey` matches and `blnActive = 1`. Failure: text `INVALID VENDOR KEY`, HTTP 202, and a warning row in `apiLog`. The vendor password from `strPassword` is kept for the authenticate step. `strSite` is kept for `checkAccess`.

### FR-002 — Session token

**Implemented.** Every call except `authenticate` requires a row in `apiToken` for that token and vendor. A match refreshes `dtToken` to `NOW()`. `clearTokenCache()` deletes tokens older than `$SessionExpire`, which is the SQL interval `3 DAY`. The property comment says this was `30 MINUTE` and "CHANGE BACK AFTER TESTING". **Requires Confirmation:** whether 3 days is the accepted lifetime.

`authenticate` compares request `Password` to `apiVendor.strPassword`. Match: insert `apiToken` (`strToken` from `SELECT UUID()`, `data` = serialized args) and return the token as `text/html`. Mismatch: text `AUTHENTICATION FAILED`.

A query parameter `IGNORE_TOKENZ` equal to the vendor password skips the token check and substitutes a fixed token string. **Requires Confirmation:** whether that bypass is still allowed on a reachable host. The token string is not recorded here.

`api.user.php` does not read the request. It always calls vendor `WOLINE`, call `resetPassword`, with a fixed token. **Requires Confirmation:** whether this file is deployed. The token value is not recorded here.

### FR-003 — User login is a second gate

**Implemented.** Vendor token auth still runs before `loginUser`. `loginUser` then checks the `sysUser` row. See business rules. The login token it returns is `sysLogin.strToken`, which is not the `apiToken` value.

## Workflows

### WF-1 — Read a paged list

1. Caller sends `VendorKey`, `call`, `Token`, optional `Page`, optional `type`.
2. Vendor and token checks pass.
3. The private method runs the SQL stored on `$arrCallFROM` plus `$arrCallWHERE_Additional`.
4. `cleanVendorRST` keeps only fields listed in `apiVendorField` for this vendor (or vendor `*`) and this call, where `strLastUser = 'INCLUDE'`. A row with `strField = *` keeps every column. If no include rows match, each result row is empty.
5. Response is XML `<data><rows><row>…` or JSON `{ data: { rows: [...] } }`, unless the method forced `ReturnType` to text.

Provenance: **Implemented.**

`getListPages` returns, for each call in `$arrCalls` except `authenticate`, `ceil(COUNT(*)/PageSize)` as `intPages`. Optional argument `List` limits that to one call name.

### WF-2 — Create a user and set a password

1. `createUser` reads XML `rows/row`.
2. Requires `strEmail` and `strPassword`. Missing: text "Please make sure you have filled in all required fields", HTTP 202.
3. Inserts `sysUser` via `NemoDatabase`.
4. If vendor is `SAWIS3` and `refSecurityGroupID` is 0, sets group name `Member Superuser [Unapproved]`. Any other vendor sets group name `External Site User`.
5. Calls `setPassword` for that email. On success returns the new `sysUser` row as XML. Duplicate email: "Cannot add User, Email already in use".

Provenance: **Implemented.**

### WF-3 — Reset a password by email

1. `resetPassword` looks up `sysUser.strEmail` from `Username`.
2. Generates a random password (`Obfuscate`), stores it with `dtPassword` `2010-01-01` (comment: default aged date), and emails a link to `resetPassword.php` with `strEmail` and `ResetKey`.
3. Unknown email returns text "Invalid username".

The page `resetPassword.php` is not in this repository. What happens after the link is clicked is **Unknown**.

`emailTest.php` loads the same template and sends it to `maanie@overdrive.co.za`. The send call is not commented out.

### WF-4 — Login

`loginUser` looks up `sysUser` by `urldecode(Username)`.

- No row: `sysLogin` failure text "Login failed: User not found or SG not active", response "Login Details are invalid."
- `checkAccess` false: "Access Restricted: User cannot log into this system".
- `blnActive` not 1: "User account is inactive."
- Password hash mismatch: "Username or Password incorrect."
- Success: inserts a login row and returns `sysLogin.strToken` plus `sysUser` columns.

`checkAccess` is true when `$rowUser->{"blnSite_" . $vendor->strSite} == 1`. The column name is data-driven. **Unknown** which `strSite` values exist.

`loginToken` loads `sysLogin` by `LoginToken`. The code comment says an expired token must fail. The comparison used is `dtTokenExpiration < now()`, and that branch is the success branch. **Requires Confirmation:** the comparison does the opposite of the comment.

### WF-5 — Submit certification or export XML

`setCertificationRecord`, `setCertificationResultsRecord`, `setWineExportRecord`, `setWietaMembership`, `setWineMSBlockGUID`, `setWineMSCellar`, and `setWineMSBlockActivity` upsert from XML. Required keys are in `business-rules.md`. Success returns text (an id or `true`). Database errors return `$result->Message`.

`setCertificationRecord` on update, when `strCertificationStatusCode` changes to `ST`, `CS`, `ES`, or `KS`, sets `blnSent = 0` and `dtCertificateDate = NOW()`. The comment maps those codes to `CERT_SENT_YN = N` and a stamp date.

`setWineExportRecord` and `setWietaMembership` contain comments `//TODO!!!` on further validation. MemberID / ExportCertificateID presence is checked. Extra field checks are not implemented.

### WF-6 — File integrity cron

`cron.fim.php` with no recognised `Action` deletes today's `sysAudit_{environment}` rows, records the tree, compares, and chmods directories toward `0755` and files toward `0644`. Actions `FIM: RESET MASTER`, `FIM: IGNORE`, and `FIM: UPDATE` call `NemoFIM` methods. `FIM: PULL MASTER DATA` is an empty `case`.

### WF-7 — EzyWine harvest read (prod branch only)

**On `prod_api_2020`.** `getEzyWine` requires `producer_num` and `Harvest_year`. It loads `tblMember` by `strWOLCode`. If `blnEzyWine` is `"0"`, it returns JSON text `[403 => "Member did not give consent to share data."]`. That 403 is an array key inside the body, not the HTTP status. `Harvest_year=all` drops the vintage filter. Otherwise harvest rows are limited to `tblHarvest.intVintage`.

### WF-8 — Block coordinates (prod branch only)

**On `prod_api_2020`.** `putCGABlockSpatial` requires arguments `FarmID`, `BlockID`, and `Coordinates`. It reads `sysYear.intYear`, finds `tblBlock` for that farm, block, and year, and sets `Coordinates` with `ST_GeomFromText`. Missing block: text "Farm and Block combination does not exist! Cannot update Coordinates."

## Functional requirements

### FR-010 — Call must exist as a method

**Implemented.** `processApi` uses `method_exists`. Unknown `call`: text `INVALID CALL`, HTTP 202, `apiLog` warning.

The callable surface is every private method on `NemoAPI`, not only the names in `$arrCalls`. `$arrCalls` is the paging list used by `getListPages` and the constructor's sort. A method can exist and still be absent from that list (`getMemberProfile` is in `$arrCallFROM` and not in `$arrCalls` on `master`).

### FR-011 — Read calls on `master`

Each of these is a private method that selects from the table or view named in `$arrCallFROM`. Provenance **Implemented**.

| Call | From |
|---|---|
| `getCultivarList` | `tblCultivar` |
| `getWineTypeList` | `tblWineType` |
| `getIrrigationList` | `tblIrrigation` |
| `getTrellisList` | `tblTrellis` |
| `getLocationList` | `dwdLocation` |
| `getMemberTypeList` | `tblMemberType` |
| `getMemberList` | `tblMember` join `tblMemberType` |
| `getMemberProfile` | member, relationship, member type |
| `getMemberAddressList` | `tblMember_Address` |
| `getMemberContactList` | `tblMember_Contact` |
| `getFarmList` | `tblFarm` plus a farm-to-member derivation |
| `getVineyardBlockList` | `tblBlock` |
| `getPackingMaterialList` | `tblPackingMaterial` |
| `getIncotermList` | `tblIncoterm` |
| `getCountryCodesList` | `tblCountryCode` |
| `getBg9_11_TypeList` | `tblBG911Type` |
| `getCertificateTransationCodeList` | `tblCertificateTransationCode` (spelling as in code) |
| `getRejectCodesList` | `tblCertificateRejectionCode` |
| `getPackingCodesList` | `tblCertificatePackingCode` |
| `getCertificateStatusList` | `tblCertificateStatus` |
| `getCertificateResultsList` | `tblCertificateResult` |
| `getPackVolumeColourList` | `tblPackVolumeColour` |
| `getPackVolumeClosureTypeList` | `tblPackVolumeClosureType` |
| `getBottleDescriptionList` | `tblBottleDescription` |
| `getManufacturerList` | `tblManufacturer` |
| `getCertificationList` | `tblCertification` |
| `getCertificationResultsList` | `tblLabAnalysis` |
| `getTitleDeedsList` | `tblTitleDeed` |
| `getMagisterialDistrictList` | `tblMagisterialDistrict` |
| `getWIETAFarmList` | `tblFarm` left join `tblBlock` |
| `getWietaMembershipList` | `tblWietaCertification` |
| `getBlendMaster` | `tblBlendMaster` left join `tblMember` |
| `getWSB` | blend master, member, member type |
| `getWS` | harvest, cellar member, harvest item, farm |
| `getUser` | `sysUser` |
| `getMemberProfileLMS` | `tblMember` join `tblMemberType`, then farms |
| `getMemberIDListLMS` | `tblMemberRelationship` |
| `getWineMSFarmList` | `vieWMS` and location views |
| `getWineMSBlockGUID` | `vieWMS` |
| `getWineMSMemberList` | `vieWMS` |
| `getWineMSFarm` | `vieWMS` |
| `getWineMSMember` | `vieWMS` |
| `getWineMSSawis1` | `vieWMS` |
| `getCGABlockList` | `tblBlock` left join `tblFarm` |

**On `prod_api_2020` only:** `getBottleWeightList` from `tblBottleWeight`, `getEzyWine` from `tblMember` then `tblHarvest`, `putCGABlockSpatial` updating `tblBlock.Coordinates`.

### FR-012 — Write calls on `master`

| Call | Table | Provenance |
|---|---|---|
| `createUser`, `updateUser` | `sysUser` | **Implemented** |
| `setPassword` | `sysPassword` | **Implemented** |
| `resetPassword` | `sysPassword` plus email | **Implemented** |
| `loginUser`, `loginToken` | `sysLogin`, `sysUser` | **Implemented** |
| `setCertificationRecord` | `tblCertification` | **Implemented** |
| `setCertificationResultsRecord` | lab analysis table via `NemoDatabase` | **Implemented.** 2020-01-13 comment: TransactionID is taken from the XML. |
| `setWineExportRecord` | `tblExportTransaction` | **Implemented** |
| `setWietaMembership` | `tblWietaCertification` | **Implemented** |
| `setVinProMembers` | `tmpVinPro`, then `tblMember.blnVinPro` | **Implemented.** SQL text is `TRUNCATE TABEL tmpVinPro`. `TABEL` is not valid MySQL. `print_rr` of the XML runs before the write. |
| `setWineMSCellar` | deletes then inserts `stg_wmsBlock`, then inserts `wmsBlock` | **Implemented** |
| `setWineMSBlockActivity` | `wmsBlockActivity` | **Implemented** |
| `setWineMSBlockGUID` | `wmsBlockGUID` | **Implemented.** Comment: not properly dev-tested. |

### FR-013 — Logging

**Implemented.** `record()` inserts `apiLog` (vendor, token, type, message, call, page, IP, serialized args in `encArgs`). Then deletes `apiLog` rows older than 3 months.

### FR-014 — Character set

**Implemented.** After connect, `mysqli_set_charset(..., "utf8")`. JSON and XML encoding use `htmlspecialchars` with `ENT_XML1` and `UTF-8`. XML also runs `utf8_for_xml`.

## Error catalogue

| Condition | Body | HTTP status argument |
|---|---|---|
| Inactive or unknown vendor | `INVALID VENDOR KEY` | 202 |
| Bad or missing token | `INVALID TOKEN` | 202 |
| Wrong vendor password on `authenticate` | `AUTHENTICATION FAILED` | omitted (REST default 200) |
| Unknown call | `INVALID CALL` | 202 |
| XML missing or not parseable | `INVALID DATA STRUCTURE` | 202 |
| Required XML field empty | `INVALID DATA STRUCTURE [Missing {field}]`, except `createUser` which uses the "filled in all required fields" sentence | 202 |
| `getUser` no row | `Invalid User` | 200, `ReturnType` text |
| `getWineMSFarm` missing `refMemberID` or `refFarmID` | `Missing Parameters […]` | 200, text |
| `getWineMSMember` missing `CellarID` or `MemberID` | `Missing Parameters […]` | 200, text |
| `getWineMSSawis1` missing `refMemberID` or `refFarmID` | `Missing Parameters […]` | 200, text |
| `getCGABlockList` missing `FarmID` | `Missing Parameters [FarmID]` | 200, text |
| Database connect failure | `SystemMessage` "Database Connection has Failed!" plus the driver error, then the script still continues far enough to use `$xdb` only in the success branch | **Implemented** in `system.php` |

`REST::inputs()` answers an unsupported HTTP method with an empty body and 406.

## Constraints

- One MySQL schema, name `sawis_api`. No migration files.
- Partner field visibility is a database whitelist, not PHP.
- `master` cannot load QA or DEV settings files because they are not in that commit. **On `prod_api_2020`** the switch lists host names that the included settings file does not define (for example `qa.sawisonline.co.za`, `www.sawisonline.co.za`, `10.0.0.60`). A hit on those names includes a file that has no matching `$DATABASE_SETTINGS` key. **Requires Confirmation** that those vhosts are unused or configured elsewhere.
- `display_errors` is `1`. `error_reporting` hides strict, notice, and warning.
- `phpinfo.php` is `<?php phpinfo(); ?>` with no auth.

## Out of scope for this repository

The SAWIS Online screens, GIS application, and reset-password page are referenced by URL and are not in this tree. Their requirements are **Unknown**.
