# PRD — sawis_api_old

Evidence date: 2026-10-04.

Source: `git@bitbucket.org:overdrive/sawis_api_old.git`, `master` at `1124919` (2020-10-14, "test api repo"). Parent commit `6b26d3d` (2020-08-24, "ini commit") added the tree.

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from `api.php`, `api.user.php`, `system.php`, `system.server.PROD.php`, `Rest.inc.php`, `_framework/_nemo.api.cls.php`, `_framework/system.functions.inc.php`, `_framework/_nemo.password.cls.php`, `cron.fim.php`, and the email and test scripts. Provenance is labelled. Passwords and the hardcoded token in `api.user.php` are omitted.

## Product purpose

**Inferred.** An HTTP API, repository name `sawis_api_old`, that lets an external vendor read SAWIS wine-industry records from MySQL database `sawis_api` and write a smaller set of user, certification, export, WIETA, VinPro, and WineMS records back.

**Implemented.** `api.php` loads `NemoAPI` and calls `processApi()`. The class comment history names SAWIS, WOLINE, VINPRO, WIETA, LMS, WineMS, and CIS.

**Documented.** `wsdl/wsdl.php` contains the sentence "A service for Sawis", version 1.1. That file is a WSDL document, not a requirements document. No README exists.

**Unknown / Requires Confirmation:** whether this repository is still served, who the product owner is, and which caller applications are still approved vendors.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| HTTP caller with an active `apiVendor.VendorKey` | `checkVendor()` selects `apiVendor` where `VendorKey` matches and `blnActive = 1` | **Implemented** |
| That caller, after `call=authenticate` and a matching `Password` | `authenticate()` compares `$_REQUEST["Password"]` to `apiVendor.strPassword`, then inserts `apiToken` | **Implemented** |
| That caller, sending `Token` on later calls | `checkToken()` loads `apiToken` for that vendor and token | **Implemented** |
| Caller that sends query parameter `IGNORE_TOKENZ` equal to the vendor password | `processApi()` sets the ignore-token flag and does not call `checkToken()` | **Implemented** |
| Browser on one of five listed origins | `api.php` sets CORS headers only for those origins | **Implemented** |
| Person opening `test/index.php` | Form posts a vendor key and one of eight list calls, then curls the API | **Implemented** as a manual tester |
| Person opening `phpinfo.php` | The file calls `phpinfo()` | **Implemented** |
| Vendor keys named in PHP `switch` cases | `VINPRO`, `WOLINE`, `WIETA1`, `EZW1NE`, `LMS001`, `SAWIS3`, `SAW3QA`, `S3PROD` | **Implemented** as code branches. Whether those rows exist in `apiVendor` is data, not in git |
| End user of SAWIS Online | `resetPassword` emails a link to another site's `resetPassword.php`. This repository does not contain that page | **Inferred** from the link. The user record is `sysUser` |

No role table is checked on read calls beyond the vendor key, the token, and the `apiVendorField` include list. `loginUser` and `loginToken` also check a site flag on `sysUser`.

## Systems the product talks to

1. MySQL database `sawis_api` through `mysqli`. Connection profiles are in `system.server.PROD.php`. Password omitted. Details are in the technical document.
2. `sysSettings`. After connect, every row's `strSetting` / `strValue` is copied onto `$SystemSettings`. `PageSize` is read for SQL `LIMIT`. Other keys used by name: `CURENT_BLOCK_YEAR` (spelled that way), `SMTP Send As Sawis3`, `SMTP BCC`, `FIM_EXCLUDE_PATH_LIKE`.
3. SMTP, only through `NemoEmail` on `resetPassword` and `emailTest.php`. Server host is not in this repository.
4. Browsers on the CORS 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`. The 2019-07-17 comment says the headers were added for GIS because the API is called from the same domain as a subdomain.
5. SAWIS Online password-reset pages, by URL only: vendor `SAW3QA` uses `http://10.0.0.149/html`, vendor `S3PROD` uses `https://www.sawisonline.co.za`, any other vendor uses `{SERVER_NAME}/sawis`.

No queue worker is configured. `cron.fim.php` is a request-driven script, not a scheduled job definition.

There is no Bitbucket Pipelines file.

## How a call is received

**Implemented** in `_framework/system.functions.inc.php`, which `system.php` includes before `api.php` builds `NemoAPI`.

1. If `$_POST` is not empty, `localize($_POST)` copies those fields onto `$GLOBALS`, then sets `$_GET = null`.
2. If `$_GET` is not empty, `localize($_GET)` copies those fields onto `$GLOBALS`.
3. Cookies are copied the same way.
4. `api.php` passes `$VendorKey`, `$call`, `$Page`, `$xmlData`, and `$Token` into the constructor. Those names are the request field names.
5. The constructor stores `$_GET` on `$this->args`. Return type is `json` or `text` when `$_GET['type']` is `json` or `text`. Otherwise the type stays `xml`.

Because a non-empty POST clears `$_GET` before the constructor reads it, a POST call does not keep query-string filters or `type` on `$this->args`. The globals `$VendorKey`, `$call`, `$Page`, `$xmlData`, and `$Token` are still set from the POST body. **Implemented.**

`Page` defaults to 1 when it is not set. Paging SQL is `LIMIT (Page - 1) * PageSize, PageSize`, where `PageSize` comes from `sysSettings`.

## Major workflows

### WF-1 — Authenticate a vendor

**Implemented** in `NemoAPI::processApi` when `call` is `authenticate`.

1. `checkVendor()` loads `apiVendor` for the key with `blnActive = 1`. Failure records `INVALID VENDOR KEY` and responds with that text and HTTP code 202.
2. `authenticate()` returns true when `$_REQUEST["Password"]` equals `apiVendor.strPassword`.
3. On success, `generateToken()` reads `SELECT UUID()`, inserts `apiToken` (`strToken`, `VendorKey`, serialized `data`), records the token in `apiLog` as info, and responds with the token as `text/html`. `response()` is called without a status, so the code is 200.
4. On failure, records `AUTHENTICATION FAILED` and responds with that text. No status is passed, so the code is 200.

The failure path does not use HTTP 202. The invalid-vendor path does.

### WF-2 — Call a method with a token

**Implemented** in `processApi` for every call other than `authenticate`, unless WF-3 applies.

1. `clearTokenCache()` deletes `apiToken` rows whose `dtToken` is older than `3 DAY`.
2. `checkToken()` loads the newest `apiToken` row for that `strToken` and `VendorKey`. On a hit it sets `dtToken` to `NOW()` and returns true.
3. On a miss, records `INVALID TOKEN` and responds with that text and HTTP 202.
4. The call name, with `/` removed, must be a method of `NemoAPI`. A missing method records `INVALID CALL` and responds with that text and HTTP 202.
5. A successful method is logged as `Great Success!` with type info before the method runs.
6. If the method returns a result object or array, `cleanVendorRST` keeps only columns listed in `apiVendorField` for this vendor and this call where `strLastUser = 'INCLUDE'`. A row with `strField = '*'` keeps every column. Colon in a column name is replaced with an underscore in the output key.
7. Default response is XML: `<?xml version='1.0' encoding='UTF-8'?><data><rows><row><field>value</field></row></rows></data>`. `type=json` returns `{"data":{"rows":[...]}}`. `type=text` returns the method's return value as HTML. Values pass through `htmlspecialchars` with `ENT_XML1` and UTF-8. JSON uses the same escaping.

`Rest::response` looks up a reason phrase for 200, 201, 204, 404, and 406. Code 202 is not in that map, and the fallback index `500` is not in the map either. **Implemented.** The status line for an error call can therefore have an empty reason phrase.

### WF-3 — Skip the token check

**Implemented.** If `$_GET["IGNORE_TOKENZ"]` equals the vendor password loaded in `checkVendor()`, `processApi` does not require a session token. The class also contains a property `$blnIgnoreVendorKey` that would force vendor key `PON135`. Nothing in this repository sets that property to true.

### WF-4 — Read a reference list

**Implemented** for each name in `$arrCalls` except `authenticate`, via `arrCallFROM` and `arrCallWHERE_Additional`. The method bodies add filters from `$this->args` (member id, farm id, and similar). The FROM/WHERE map is the source for table names. See `features.md` for the call list.

`getListPages` returns, for each call in `$arrCalls` except `authenticate`, `ceil(COUNT(*)/PageSize)`. If the argument `List` is set, only that call name is counted.

`getCGABlockList` requires `FarmID`. It filters `intYear` to `$SystemSettings["CURENT_BLOCK_YEAR"]`. Missing `FarmID` returns the text `Missing Parameters [FarmID]`.

### WF-5 — Vendor-specific member filters

**Implemented** in `setVendor_CallWHERE_Additional`. Comments above the VINPRO and WOLINE cases state the intended member rules. The SQL that follows is the rule that runs.

| Vendor key | Extra SQL on member list, address, contact, and profile |
|---|---|
| `VINPRO` | Status `Inactive` and `blnVinPro = 1`, or status `Active`; `tblMemberType.refVinPro <> 9` |
| `WOLINE` | Status `Active` and `tblMemberType.refWineOnline <> 9` |
| `WIETA1` | `tblMemberType.refWieta <> 9` on those four calls, and the same exclusion on `getMemberTypeList` |
| `EZW1NE` | `AND 1=0` on those four calls |
| `LMS001` | No extra SQL in this switch |

`getMemberList`, `getMemberAddressList`, `getMemberContactList`, and `getMemberProfile` also require `tblMember.MemberID > 0` before the vendor clause. `getMemberTypeList` requires `MemberTypeID > 0`. `getTitleDeedsList` adds `EntityType NOT LIKE '%TMP%'`. `getWS` adds `tblHarvestItem.blnDeleted = 0`.

`getMemberProfileLMS` keeps members that are Inactive or Active when `tblMemberType.refLMS <> 9`, or Active when `refLMS` is null, and `MemberID > 0`. A comment says the page size was cut to a tenth. The division is commented out. The call uses the normal `PageSize` limit. **Implemented** as the normal limit.

`getMemberIDListLMS` keeps `strMemberTypeCode` in `FRMOWN`, `FRMTNT`, `FRMSCP`.

WineMS farm and member reads add the flags in `arrCallWHERE_Additional`: active farm status, and for `getWineMSFarm` and `getWineMSMember` also `blnWineMS = 1`, `MRStatus` Active, and `tblMemberType.refWineMS` in `1` or `3`.

### WF-6 — Create a user

**Implemented** in `createUser`. Input is XML `xmlData` shaped as `rows/row`. Required elements: `strEmail`, `strPassword`. Missing XML or a missing required element responds `Please make sure you have filled in all required fields` with HTTP 202. The log text still says `INVALID DATA STRUCTURE`.

The row is saved to `sysUser`. `strSetting:Language` is taken from XML element `strSetting_Language`. If the vendor key is `SAWIS3` and `refSecurityGroupID` is 0, the group is set to the `sysSecurityGroup` named `Member Superuser [Unapproved]`. Every other vendor gets the group named `External Site User`. A unique-key failure whose message contains `Unique key Violation` is returned as `Cannot add User, Email already in use`. On success the method continues into a password-reset email path (the same family as WF-8).

### WF-7 — Update a user, set a password, log in

**Implemented.**

- `updateUser` requires XML and a `UserID` element. It loads `sysUser` and writes back. `strUser` becomes `strName` plus a space plus `strSurname`. `strSetting:Language` is taken from `strSetting_Language`.
- `setPassword` loads `sysUser` by `Username` (the argument is the email). Length must be 8 to 14 after the raw length is measured. Spaces and `&` are removed before the character check. The remaining string must match `^[A-Za-z0-9\d=!\-@#%._*]*$`, contain one `A-Z`, and contain one digit. The warning text also mentions `^&*()` and `+=`, which the pattern does not allow. **Implemented** as the pattern, not as the warning sentence. A passing password is stored through `NemoPassword::createPassword` and `savePassword("User", ...)`.
- `loginUser` loads `sysUser` by `urldecode(Username)`. `checkAccess` requires `sysUser` column `blnSite_{apiVendor.strSite}` to equal 1. The user must have `blnActive = 1`. The password is checked with `NemoPassword::checkPassword`. Success inserts a `sysLogin` row via `tblLoginInsert` and returns that login token with `sysUser` columns. Failures return text: incorrect password, inactive user, access restricted, or invalid login details.
- `loginToken` loads `sysLogin` joined to `sysUser` on `strToken = LoginToken`. It then runs `checkAccess`. The branch `if ($row->dtTokenExpiration < now)` is the success branch and inserts another login row. The `else` returns `Login Token has expired`. **Implemented** with that comparison. A token whose expiration is still in the future takes the expired branch. Whether that inversion was intended is **Unknown / Requires Confirmation**.
- `getUser` returns one `sysUser` row matched on `UserID` or `Username` (email). No row returns the text `Invalid User`.

### WF-8 — Reset a password

**Implemented** in `resetPassword`, and also as the only call made by `api.user.php`.

1. Load `sysUser` by email argument `Username`. Missing user returns `Invalid username`.
2. Build a random password with `Obfuscate()`, save it through `NemoPassword` with date `2010-01-01` (comment: default aged date).
3. Load email template `Reset Password`, substitute display name, a link to `{base}/resetPassword.php` with `strEmail` and `ResetKey`, and a logo image.
4. From and BCC come from `sysSettings` keys `SMTP Send As Sawis3` and `SMTP BCC`.
5. `NemoEmail::Send()`.

`api.user.php` does not read the request for vendor, call, or token. It always uses vendor `WOLINE`, call `resetPassword`, and a fixed token. The token value is omitted here.

`email.read.online.php` starts with the comment `NB! NOT IN USE ON API`. If `UID` is present it sets `tblEmail.strStatus` to `Read` unless the status is `Error`, then tries to render `tblEmail.txtBody`. It calls `Display("layout.online.email.incl.php")`. That layout file is not in this repository.

`email.read.receipt.php` performs the same status update and returns a 1×1 JPEG.

### WF-9 — Write certification, export, WIETA, VinPro, and WineMS rows

**Implemented.** Each writer expects `xmlData` as `rows/row` unless noted. Invalid or missing XML responds `INVALID DATA STRUCTURE` with HTTP 202.

| Call | Table | Key checked in code | Required elements checked |
|---|---|---|---|
| `setCertificationRecord` | `tblCertification` | `TransactionID` | `ApplicationID`, `MemberID`, `intVintage`, `strOrigin`, `strCultivarCode`, `strCertificationStatusCode` |
| `setCertificationResultsRecord` | `tblLabAnalysis` | `TransactionID` (comment 2020-01-13: taken from XML; comment 2020-01-22: do not unset ID if the id comes from the transaction) | `TransactionID`, `LabID`, `ApplicationID` |
| `setWineExportRecord` | `tblExportTransaction` | `ExportCertificateID` | Comment says `TODO`. No required-field loop |
| `setWietaMembership` | `tblWietaCertification` | `MemberID` | Comment says `TODO`. Comment 2018-03-27: default `dtCertificationDate` to today and `dtCertificationExpiryDate` to NULL |
| `setVinProMembers` | `tmpVinPro`, then `UPDATE tblMember` | Columns `MemberID` and `blnVinPro` on the first row | Truncates with the SQL text `TRUNCATE TABEL tmpVinPro`, then inserts, then copies `blnVinPro` onto `tblMember` |
| `setWineMSCellar` | `stg_wmsBlock` | Query arg `refCellarID` required or the method returns `Missing refCellarID` | Deletes existing rows for that cellar, then inserts. Required list includes `GUID`, `refCellarID`, `refMemberID`, `refFarmID`, `refBlockID`, `strBlock`, `intYearPlanted`, cultivar and spacing and vine fields, `IrrigationCode`, `TrellisCode`, `intVinesOpening`, `intVinesAmmended` |
| `setWineMSBlockActivity` | `stg_wmsBlockActivity` | XML row fields | Required list includes GUID, cellar, member, farm, block, names, cultivar, irrigation, trellis, years, `strWMSActivity`, `intVines`, `dblHectare` |
| `setWineMSBlockGUID` | `wmsBlockGUID` | | `refCellarID`, `refMemberID`, `refFarmID`, `refBlockID`, `strStatus` |

`setVinProMembers` also `print_rr`s the XML before writing. The word `TABEL` is what the source contains. **Implemented.**

### WF-10 — File integrity request

**Implemented** in `cron.fim.php`. The script prints `$_REQUEST`, sets `display_errors` to 1, `max_execution_time` to 1200, and `memory_limit` to 256M. It does not include `system.php`.

| `Action` | Behaviour |
|---|---|
| `FIM: PULL MASTER DATA` | Empty `break` |
| `FIM: RESET MASTER` | `NemoFIM::ResetMaster()`, then `die` with the action name |
| `FIM: IGNORE` | `NemoFIM::Ignore($ID, $state)`, then `die` |
| `FIM: UPDATE` | `NemoFIM::Update($ID, $state)`, then `die` |
| Any other value, including empty | Deletes `sysAudit_{environment}` rows whose `dtAudit` is today, calls `Record`, `Compare`, and `iniSetPermissions` |

`iniSetPermissions` sets directories to mode `0755` and files to mode `0644` under `env_root`, skipping names in `sysSettings` key `FIM_EXCLUDE_PATH_LIKE`.

### WF-11 — Manual list test

**Implemented** in `test/index.php`. The form offers `getCultivarList`, `getWineTypeList`, `getIrrigationList`, `getFarmList`, `getMemberTypeList`, `getMemberList`, `getVineyardBlockList`, and `getLocationList`. Submit builds `http://{HTTP_HOST}/sawis_api_v1/api.php?call=...&VendorKey=...&type=json|xml` and, for vineyard blocks, adds `FarmID`. The scheme is not prefixed in the curl URL (`$_SERVER[HTTP_HOST]` only).

## Functional requirements

### FR-001 — Vendor must be active

The system rejects a call when `apiVendor` has no row for that `VendorKey` with `blnActive = 1`.

Response text: `INVALID VENDOR KEY`. HTTP code: 202. An `apiLog` warning row is written.

Evidence: `NemoAPI::checkVendor`. Implementation: **Implemented**. Confidence: High.

### FR-002 — Authenticate returns a token

When `call` is `authenticate` and the request password equals `apiVendor.strPassword`, the system inserts an `apiToken` row and returns the UUID as the body.

Evidence: `authenticate`, `generateToken`. Implementation: **Implemented**. Confidence: High.

### FR-003 — Other calls require a live token

Except for FR-002 and FR-004, the system requires an `apiToken` row for the same vendor and token, refreshes `dtToken`, and deletes tokens whose `dtToken` is older than 3 days.

Evidence: `checkToken`, `clearTokenCache`, `$SessionExpire`. Implementation: **Implemented**. Confidence: High.

### FR-004 — Password match on IGNORE_TOKENZ skips the token

When the query parameter `IGNORE_TOKENZ` equals the vendor password, the system does not check `apiToken`.

Evidence: `processApi`. Implementation: **Implemented**. Confidence: High.

### FR-005 — Unknown call name is rejected

When the call string is not a method on `NemoAPI`, the system returns `INVALID CALL` with HTTP 202 and writes a warning log.

Evidence: `processApi`. Implementation: **Implemented**. Confidence: High.

### FR-006 — Output columns follow the include list

For object or array results, the system returns a column only when `apiVendorField` has that vendor (or `*`), that call, that field (or `*`), and `strLastUser = 'INCLUDE'`.

Evidence: `cleanVendorRST`. The previous exclude-list function is commented out. Implementation: **Implemented**. Confidence: High.

### FR-007 — Default body is XML; json and text are optional

Absent `type`, the body is the XML envelope in WF-2. `type=json` and `type=text` select the other two.

Evidence: constructor and `processApi`. Implementation: **Implemented**. Confidence: High.

### FR-008 — Lists are paged

List SQL uses `Page` and `sysSettings.PageSize`. `getListPages` returns the page count per call.

Evidence: constructor, `getListPages`, `$arrCalls`. Implementation: **Implemented**. Confidence: High.

### FR-009 — Vendor key changes which members are visible

For the keys in WF-5, member reads add the SQL in that table. `EZW1NE` adds a false condition.

Evidence: `setVendor_CallWHERE_Additional`. The comment block is the stated spec; the SQL is what runs. Implementation: **Implemented**. Confidence: High. Whether the comment and the SQL were both approved is **Unknown / Requires Confirmation** where they differ. For VINPRO the comment says inactive members must also be VinPro members, and the SQL matches that. For WOLINE the comment does not mention status Active; the SQL requires Active.

### FR-010 — User create assigns a security group from the vendor

Vendor `SAWIS3` with a zero group id gets `Member Superuser [Unapproved]`. Other vendors get `External Site User`.

Evidence: `createUser`. Implementation: **Implemented**. Confidence: High.

### FR-011 — Password write enforces the pattern in setPassword

See WF-7. Evidence: `setPassword`. Implementation: **Implemented**. Confidence: High.

### FR-012 — Login requires the vendor site flag

`loginUser` and `loginToken` succeed only when `sysUser.blnSite_{strSite}` is 1 and, for password login, `blnActive` is 1 and the password hash matches.

Evidence: `checkAccess`, `loginUser`, `loginToken`. Implementation: **Implemented**. Confidence: High. The token-expiry comparison is the inverted one in WF-7.

### FR-013 — Password reset emails a link on another host

See WF-8. Evidence: `resetPassword`. Implementation: **Implemented**. Confidence: High. Delivery depends on `NemoEmail` and `sysSettings`, which are not fully described in git.

### FR-014 — Write calls persist the tables in WF-9

Evidence: the `NemoDatabase` constructor arguments in each setter. Implementation: **Implemented**. Confidence: High. `setVinProMembers` uses the SQL token `TABEL`.

### FR-015 — Calls are logged and old logs are deleted

Every vendor failure, auth result, invalid call, and accepted call inserts `apiLog` (`VendorKey`, token, type, message, call name, page, `REMOTE_ADDR`, serialized args in `encArgs`). The same function deletes `apiLog` where `dtLog` is older than 3 months.

Evidence: `record`. Implementation: **Implemented**. Confidence: High.

### FR-016 — CORS is an allow-list of five origins

`api.php` echoes `Access-Control-Allow-Origin` for the request origin only when it equals one of the five values in WF systems item 4, and then sets `Access-Control-Allow-Credentials: true` and `Access-Control-Max-Age: 86400`. Other origins get no CORS headers from this file.

Evidence: `api.php`. Implementation: **Implemented**. Confidence: High.

### FR-017 — Boot forces HTTPS in the URL it builds

`system.php` overwrites `SERVER_PROTOCOL` to the string `https` after reading the real protocol. `FULL_URL`, `FULL_PATH`, and `BASE_URL` use that string.

Evidence: `system.php`. Implementation: **Implemented**. Confidence: High.

### FR-018 — The tree as committed does not finish booting

`system.php` includes `system.server.QA.php` and `system.server.DEV.php`. Those files are not in the repository.

Evidence: `system.php` and the git file list. Implementation: **Implemented** (the include statements). The files themselves are **Not in git**.

## Constraints

- Character set on the MySQL link is `utf8` (`mysqli_set_charset`). Failure is ignored.
- `Rest::cleanInputs` runs `strip_tags` and trim on request fields. `get_magic_quotes_gpc` is called when present.
- Unsupported HTTP methods hit `REST::inputs` default and respond with an empty body and 406.
- `api.php` turns error display on through `system.php` before any call runs.
- Session cookie lifetime configuration is `session.gc_maxlifetime` 7200. `session_start()` runs on every include of `system.php`, including the API.
- There is no schema migration tool in the repository. The database is assumed to already contain the tables the SQL names.

## Non-goals visible in the tree

- `project.functions.inc.php` farm and block helpers are not called by `NemoAPI`.
- `wsdl/SawisService.wsdl` describes `add`, `sub`, `mod`, `mul`, and `div` in namespace `http://maths.lenin.com`. It is not referenced by `api.php`.
- The member-level `authenticate` that joins `apiVendorMember` is commented out.
- `cron.fim.php` action `FIM: PULL MASTER DATA` has an empty body.
