# PRD — thegsa

Evidence date: 2026-10-01.

Source: `git@bitbucket.org:overdrive/thegsa.git`, `master` at `522705535dc8a80d3676a76d4b3a189dd7a924b4` (2025-11-10).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the implementation. Provenance is labelled. Database passwords in `include/system.inc.php` and `include/systemLive.inc.php` are not reproduced.

## Product purpose

**Documented.** The HTML title is `GSA`. The footer says `GSA System [v0.0A, 2008]`. The login image is titled `The GSA`.

**Implemented.** Staff manage magazine advertising: advertisers, contacts, sales people, contracts and monthly insertions, contact logs, renewal documents, and reports. A disabled monthly job was written to email advertisers a "GSA Star" summary of bookings in publications marked `blnSTAR`.

**Unknown / Requires Confirmation:** the business owner, whether the product is still in daily use, and whether "The GSA" is a publisher, a sales house, or both.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Person at the login screen | `login.php`. Username and password | **Implemented** |
| Signed-in user | Cookie `ckyGSA` = `UserID\|strUser`. Renewed by `checkLogin` | **Implemented** |
| Inactive user | `tblUser.blnActive` must be 1. Message: "Login failed, your user account has been disabled." | **Implemented** |
| Page permission | `getAccess` reads `tblSecurity.intLevel` for the page file name and the user. `0` none, `1` view, `2` full. The user form labels these None, View, Full | **Implemented**. Which rows exist is **Unknown** (database) |
| Item permission | `tblSecurityItem` against `tblmenulevel1Item`. Known item names in code: menu item id `1` (edit-all when level > 1), menu item id `3` (commissions when level > 0), item label `Default` (may set the default contact), item label `Sales Person` (search screen) | **Implemented**. Labels for ids 1 and 3 are **Unknown** |
| Sales-person scope | `tblUserSalesPerson`. `UserOwnsSalesPerson` must be true, or the user has edit-all, or the sales person id is empty, before an advertiser or contact log can be saved | **Implemented** |
| Cron / import | `cron.daily.php`, `ximport.php`, and `zimport.php` do not require the cookie. The cron dies immediately. The imports have `checkLogin` commented out | **Implemented** |
| Deploy operator | Pipeline runs SonarQube only | **Inferred** from `bitbucket-pipelines.yml` |

The menu is not in source. `include/menu.inc.php` loads `tblmenulevel0` and `tblmenulevel1` where `tblSecurity.intLevel > 0` for the signed-in user. Logout is a fixed last item, `Log Out [username]`, pointing at `login.php`.

A page class with access below 2 renders inputs `disabled`. Methods that list data require access above 0. Methods that save or delete require access above 1. If `getAccess` finds no row, the code still reads `$row->intLevel`. **Unknown** what PHP 8 does in that case on the live host. `display_errors` is on, and notices and warnings are suppressed.

## Systems the product talks to

1. The browser, over PHP pages and form posts. `contract.php` also returns a small XML document of contacts and contracts when `arefCompanyID` is set, before the login check.
2. MySQL selected in `include/system.inc.php`: host `localhost`, database `gsa_qa`. Commented alternatives name database `gsa`, database `thegsegszw_db6` on `dedi483.jnb2.host-h.net`, and host `cmysql5-1.wadns.net`.
3. PHP `mail()` from `NemoEmail::Send` in `_nemo.email.gsa.cls.php`. The STAR script sets From to `GSA STAR Newsletter <info@thegsa.co.za>` and names `sham@the-gsa.co.za` and `jeff@the-gsa.co.za` in the template values. SMTP classes exist under `include/mailer/` (`SMTP5.php`, `MAIL5.php`). `NemoEmail::Send` uses `mail()`, not those classes.
4. No HTTP API client, queue, or object store appears in the application PHP.

## Major workflows

### WF-1 — Log in

`login.php` clears `ckyGSA`, then includes the system file.

On `btnLogin`:

1. Load `tblUser` where `strUser` matches the quoted username.
2. If no row: "Login failed, please try again".
3. If `md5` of the posted password does not equal `strPassword`: the same message.
4. If `blnActive` is not 1: "Login failed, your user account has been disabled. Please contact a systems administrator about this".
5. Success sets cookie `ckyGSA` to `UserID|strUser` for `$intCookieTime` seconds (3600000, about 41.7 days) and redirects to `index.php`.

The password is posted in clear text. An `onclick` that would MD5 the password in the browser is present as attribute `x`, not `onclick`, so it does not run.

`checkLogin` sends the browser to `login.php` when the cookie is missing, and otherwise refreshes the cookie.

Provenance: **Implemented**.

### WF-2 — Land on the home page

`index.php` requires the cookie, prints the heading `GSA`, and includes the menu and footer.

Provenance: **Implemented**.

### WF-3 — Maintain an advertiser

`advertiser.php` requires the cookie and switches on `act`.

- `0`: search and list.
- `1`: edit the company, list its contacts, and show contract history.
- `2`: save, then return to search and list.
- `3`: delete, then search and list.
- Further cases open contact and contract sub-forms on the same page.

Search can filter by company name, a computed Active/Prospective flag, agency flag, and bad-payer flag.

The edit form requires a name. Save is allowed when page access is above 1 and the user owns the sales person, has edit-all (menu item id 1, level above 1), or the sales person id is not a positive number. Otherwise the page says "You have insufficient access levels to change sales person."

The company name must be unique. A duplicate shows "An advertiser with the name … already exists!"

Save writes name, address lines, city, postal code, account number, phone, fax, email, website, company registration number, VAT number, VAT flag, bad-payer flag, `strC_I_NO`, and, when edit-all is true, the sales person. It also replaces `tblAdvertiserPosition` rows from the posted position list. Insert sets `strNotes` to empty, `intNoOfContracts` to 0, and `NoOfContacts` to 0.

`sql_company_update` and `sql_Company_insert` do not write `refStatusID` or `blnAgency`. The list's Active/Prospective filter does not read `tblStatus`. It treats the company as active when it has a non-cancelled contract item, as client or as agency, whose `strDate` is after the current `YYYYMM`.

Delete is refused when any contract references the company, then when any contact does.

Provenance: **Implemented**.

### WF-4 — Maintain a contact

`contact.php` and the contact panel on the advertiser and search pages use `include/contact.cls.php`.

Server save requires page access above 1.

- A contact name must be unique for that company. Duplicate message: "… is already added to that advertiser's contact list!"
- If this contact is marked default, other contacts of the same company have `blnDefault` cleared first.
- If the company then has no default contact, the saved contact is set as the default.
- Empty subscribe status becomes `0`. A new contact form initialises subscribe status to `2` before load. The label for id `2` is data, not code.
- The Default checkbox follows a menu item whose label `LIKE 'Default'`. `blnDefaulAccess` is true when that item's level is above 0, and that flag is passed into `sql_contact_update`.

Delete checks `tblContactLog` for the contact. If any row exists, the message says the contact is associated with a Fee Structure. The SQL does not query a fee table.

Provenance: **Implemented**. The delete message and the query disagree.

### WF-5 — Record a contact log

`contactlog.php` uses `include/contactlog.cls.php`.

Save requires page access above 1 and the same sales-person ownership rule as advertisers. Failure uses the same sales-person sentence.

Stored on update: sales person, contact, log type, log status, contract, follow-up date (converted from `dd-mm-YYYY` to `YYYYMMDD`), discussion, action, last user, last edit. The update does not change `strDate`.

After save, `tblContactLogMagazine` rows for the log are deleted and reinserted from the posted magazine list.

Delete runs with no dependency check.

The log screen can open a new advertiser or a new contact inline.

Provenance: **Implemented**.

### WF-6 — Create and maintain a contract

`contract.php` requires the cookie unless `arefCompanyID` is set, in which case it returns XML and exits (WF-11).

| act | Behaviour |
|---|---|
| 1 | Search and list |
| 20 | Edit header and list items |
| 21 | Save header and items, stay on the edit |
| 23 | Save header and items, return to the list |
| 4 | Delete, then list |
| 6 | Duplicate header and items, then list |
| default | List, or the email branch in WF-12 |

Client-side `validateContractForm` requires advertiser, sales person, status, publication, position, size, a positive integer number of items, payment type, a numeric amount, a numeric discount, and a start date. Blank discount becomes 0 on the server. Amount boxes that fail `checkAmountTexts` block save.

Server save requires access above 1. It does not re-check the client-side required fields. Missing `blnFree` becomes 0. The start date is converted with `makeShortDateToODBCDate`.

On insert, if the client has no existing contract, every contact of that company is updated to `refSubscribeStatusID = 3`. The comment says this is for a prospective client. The label for id `3` is **Unknown**.

A block that would copy the client's contract positions into `tblAdvertiserPosition` is commented out, with the note that GSA did not want to pay for it.

`ContractItem::Save` then aligns `tblContractItem` rows with `intItems`:

- Fewer items than requested: insert months from the contract start date, or from the month after the latest item.
- More items than requested: delete the extras.
- Posted per-item position, edited amount, size, colour, shape, update label, `strC_I_NO`, skipped, and cancelled flags are written.
- Checkboxes `blnPosition`, `blnSize`, `blnColour`, and `blnShape` overwrite that field on every item.
- `tblPaymentType.intInterval` decides which items receive `dblAmount`. Item index modulo the interval equal to 0 receives the amount when the edited amount is null or `blnAmount` is 1. Other items are set to 0 under the same condition.

When the contract has no items yet, the start date used for the first insert is the start date with the characters `."))` appended in the PHP source.

Delete is refused when contact logs reference the contract.

Duplicate copies the header and every item. The browser function `renewContract` clears the contract id, amount, and discount, sets `blnRenew`, and posts `act` 20. `Contract::Save` does not branch on `blnRenew`.

Provenance: **Implemented**.

### WF-7 — Run a report

`reports.php` shows `ReportConsole`. The user picks one report and View Report, Export, or Pdf. The console opens `{report}.php` with the filter query string.

| Radio value | Label |
|---|---|
| invoicelist | Invoice List |
| adlist | Planning List |
| canceled | Cancelled List |
| movelist | Move List |
| prospectlist | Prospect List |
| prospectlistbycontact | Prospect List by Contact |
| prospectexportlist | Prospect Export List |
| salespersonreport | Commissions |
| expiry | Sales Person Expiry Report |
| contactreport | Contact Report |
| clientspendreport | Client Spend Report |
| incomelistreport | Income List Report |
| followupreport | Followup Report |
| summarisedtotals | Summarised Totals |
| advertchange | Advert Updates |

Commissions is disabled unless `tblSecurityItem` for menu item id `3` has level above 0. The sales person report checks that item again. Advert Updates is annotated: "Prints only for: A (Excludes: SU, SD, NN, NB)".

Default dates are the first and last day of the current month. Publication id `-1` means magazines 3 and 12. Publication id `-2` means magazines 14 and 19. The names of those magazines are **Unknown**.

Provenance: **Implemented**.

### WF-8 — Renewal document and PDF

`renewaldoc.php` requires login and uses `include/renewaldoc.cls.php`. Default `act` shows a form. `act` 2 saves. `act` 3 deletes. A 2017-02-02 comment says the renewal document was revamped and that the id was always 0 because a detail query did not exist.

`renewalpdf.php` does not call `checkLogin`. It builds an FPDF A4 document.

Provenance: **Implemented**. Whether the PDF URL is intentionally public is **Unknown / Requires Confirmation**.

### WF-9 — Users, access, and sales people

`user.php` lists and edits `tblUser`.

The form requires a user name. Password is required in the browser only when creating a user. The password input type is `text`. Save stores `md5` of the password when the field is non-empty. An empty password on update leaves the old hash.

The form shows None / View / Full for every menu row and for each menu item. Save replaces `tblSecurity` and `tblSecurityItem` for the user.

Save also replaces `tblUserSalesPerson` links from the checked sales people. A user with no links shows a red warning. The sales-person checkboxes render only when `UserID` is already non-zero, so the first save cannot link sales people. `user.php` returns to the detail page after save so the links can be set.

Delete removes the user. The check against `tblFeeStructure` is commented out.

`salesperson.php` maintains code, name, email, cell, fax, branch, and active flag.

Provenance: **Implemented**.

### WF-10 — Lookup maintenance

These pages follow the same list / add / save / delete pattern, each with its own class and `sql_*` functions:

`branch.php`, `colour.php`, `contactlogstatus.php`, `contactlogtype.php`, `emailtemplate.php`, `magazine.php`, `paymenttype.php`, `position.php`, `shape.php`, `size.php`, `status.php`, `statuscontract.php`, `subscribestatus.php`, `title.php`, `updatelabel.php`.

Magazine rows have `blnSTAR` and `blnDefault`. Payment types have `intInterval`. Contact log types have `blnActive`.

Provenance: **Implemented**.

### WF-11 — Contract page XML for contacts

If `contract.php` is requested with `arefCompanyID` set and not `0`, it does not call `checkLogin`. It returns XML listing contacts for that company and contracts where the company is the client or the agency and `refContractID = 0`.

`ajax.php` tries a similar contact list, includes `includes/database/sql.inc.php` (the directory is `include/`), and calls `mysql_query`. That path and that function do not match this tree on PHP 7+.

Provenance: **Implemented**.

### WF-12 — Email from the contract list

The default branch of `contract.php` looks for email buttons and then calls `ShowEmailList`, `PreviewMail`, `MailContacts`, or `ShowEmailTemplate`.

Those methods were not found on `include/contract.cls.php`. `PreviewMail` and `MailContacts` exist on `include/zzcontract.cls.php`, which `contract.php` does not include.

Email templates are maintained by `emailtemplate.php`. `NemoEmail::LoadEmailTemplate` loads a template by name. The STAR cron is the only caller found that both constructs `NemoEmail` and calls `Send`, and that script dies first.

Provenance: the button branch is **Implemented** in `contract.php`. The methods are not on the included class.

### WF-13 — STAR newsletter

Comments in `cron.daily.php` describe a job for the 1st of the month: magazines with `blnSTAR = 1`, companies with a non-cancelled item in the current `YYYYMM`, and an email table of description, position, size, amount, and expiry. Free contracts show `Free`. Amount uses `dblAmountEdit * (1 - discount/100)`, then divides by `intItems`. A magazine with no booking is "Not ordered".

The first statement after the includes is `die("site inactive 20180108")`.

Provenance: **Documented** intent in the file comment. **Implemented** behaviour is to stop.

### WF-14 — Imports

`ximport.php` and `zimport.php` include the system file, leave `checkLogin` commented out, and insert sales people, branches, companies, contacts, sizes, colours, positions, contracts, and prospects. They resolve lookup ids into variables named `IDStatusActive`, `IDStatusProspective`, `IDStatusContract`, `IDGSAMagazine`, `IDGSAOnline`, and `IDPamynetTypeMonthly`.

Provenance: **Implemented**. Whether they are still used is **Unknown**.

## Functional requirements

### FR-001 — Authenticate with username and password

The system accepts a username and password, compares `md5` of the password to `tblUser.strPassword`, and requires `blnActive = 1`.

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

### FR-002 — Keep the user signed in with a cookie

A successful login sets `ckyGSA`. Protected pages call `checkLogin`, which redirects to `login.php` when the cookie is absent and refreshes it when present.

Evidence: `include/system.inc.php`. Implementation: **Implemented**. Confidence: High.

### FR-003 — Show only menu entries the user may view

The menu lists level-0 groups that have at least one level-1 item with `intLevel > 0` for that user, then those items.

Evidence: `include/menu.inc.php`. Implementation: **Implemented**. Confidence: High.

### FR-004 — Register an advertiser

A user with full access on `advertiser.php`, and sales-person scope, can create a company with a unique name and the address and account fields in `sql_Company_insert`.

Evidence: `Advertiser::Save`. Implementation: **Implemented**. Confidence: High.

### FR-005 — Classify advertisers as active or prospective from future bookings

Filter value `1` means the company has a non-cancelled contract item, as client or agency, with `strDate` after the current `YYYYMM`. Value `0` means it does not.

Evidence: `Advertiser::ShowList`. Implementation: **Implemented**. Confidence: High.

### FR-006 — Keep one default contact per advertiser

Saving a default contact clears the flag on the company's other contacts. If none remain default, the saved contact becomes the default.

Evidence: `Contact::Save`. Implementation: **Implemented**. Confidence: High.

### FR-007 — Capture an advertising contract and its monthly items

A user with full access on `contract.php` can save a contract header. `ContractItem::Save` creates or removes monthly rows to match `intItems`, then applies the payment-type interval to item amounts.

Evidence: `contract.php`, `Contract::Save`, `ContractItem::Save`. Implementation: **Implemented**. Confidence: High.

### FR-008 — Refuse deletes that would orphan operational history

An advertiser with contracts or contacts cannot be deleted. A contract with contact logs cannot be deleted. A contact with contact logs cannot be deleted.

Evidence: `Advertiser::Delete`, `Contract::Delete`, `Contact::Delete`. Implementation: **Implemented**. Confidence: High. The contact message text does not match the query.

### FR-009 — Produce the report catalogue

A signed-in user can open `reports.php` and run the fifteen reports in WF-7, except Commissions when menu item 3 is not granted.

Evidence: `include/reports/reports.cls.php`. Implementation: **Implemented**. Confidence: High.

### FR-010 — Administer users and per-page access

A user with full access on `user.php` can create users, set None/View/Full per menu row, and link sales people.

Evidence: `include/user.cls.php`. Implementation: **Implemented**. Confidence: High.

### FR-011 — Send the STAR newsletter

**Documented** in `cron.daily.php` comments. **Implemented** behaviour is to stop with `site inactive 20180108` before any send.

Confidence: High.

## Constraints

- Contract item dates are `YYYYMM`. Other dates are often `YYYYMMDD` or `dd-mm-YYYY`.
- Lists page at 30 rows (`$arrSys["intRowsPerPage"]`).
- Passwords are MD5.
- The cookie is not signed. It is the user id and the username joined by `|`.
- `include/system.inc.php` copies every GET, POST, and cookie key into `$GLOBALS`.
- SQL is concatenated. `quote_smart` is used for some strings and not for numeric ids taken from the request.
- `display_errors` is on. Notices and warnings are suppressed.

## Error conditions

| Condition | Behaviour | Evidence |
|---|---|---|
| Unknown user or bad password | "Login failed, please try again" | `login.php` |
| Inactive user | Disabled-account message | `login.php` |
| Missing cookie on a protected page | Redirect to `login.php` | `checkLogin` |
| Duplicate advertiser name | Page message, name not saved | `Advertiser::Save` |
| Duplicate contact on a company | Page message | `Contact::Save` |
| Duplicate user name | Page message | `User::Save` |
| Save advertiser or contact log outside sales-person scope | "You have insufficient access levels to change sales person." | `Advertiser::Save`, `ContactLog::Save` |
| Delete advertiser with contracts or contacts | Page message, no delete | `Advertiser::Delete` |
| Delete contract with contact logs | Page message, no delete | `Contract::Delete` |
| Delete contact with contact logs | Page message mentioning Fee Structure | `Contact::Delete` |
| MySQL error | `doQuery` dies with the SQL and `mysqli_error` | `include/database/mysql.cls.php` |
| STAR cron | Dies with `site inactive 20180108` | `cron.daily.php` |
