# PRD — label_system

Evidence date: 2026-10-10. Source: `git@bitbucket.org:overdrive/label_system.git`, `master` at `c8b07d50` (2026-06-03).

There is no prior PRD in Silicon Ark. The README does not describe label approval. Requirements below come from controllers, models, views, migrations, and the short notes in `README.md`, `CHANGELOG.md`, and `NewKnowledgeBase.md`.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

## Product purpose

A signed-in SAWIS user submits a wine label (PDF plus product data). Staff or a label committee review it. The system records the decision, writes a meeting-minute note, and queues an email to the designer. Producers see labels for their SAWIS members. Designers see labels they submitted.

Provenance: **Inferred** from `Labels`, `MeetingMinutes`, `Email`, and `Welcome`. No product statement says this in one sentence. The README's purpose is the Admin Overdrive starter.

## Users and interactions

See the role table in `project-plan.md`. Interactions that differ by role:

- Roles 1, 2, 7, and 9 open the admin label form (`label/labelForm`).
- Role 3 opens that same form only when the label's `meeting_group_id` is one of the user's groups. If it is not, the method does not return a view. The redirect is commented out. Provenance: **Implemented** (`Labels::labelForm`).
- Roles 4 and 5 open `label/labelFormMember`.
- Roles 6 and 8 have no form branch. Provenance: **Implemented** gap.

Menu visibility is a separate check. `Authorization` loads `user_menu` by the first URL segment and `user_access` for the session role. A missing menu redirects to `/`. A missing grant redirects to `blocked`. Provenance: **Implemented**.

## Main workflows

### W1 — Sign in

Trigger: a person opens `/`.

Seamless login, when `HTTP_REFERER` starts with one of `https://www.sawisonline.co.za/`, `https://qa.sawisonline.co.za/`, `https://training.sawisonline.co.za/`, or `http://sawis.local/`:

- Query parameters `shareSesh` (email) and `code`.
- The matching SAWIS database is queried: `SELECT * FROM sysLabelSystemLogin WHERE sessionUserCode = '<code>'`.
- The app accepts the session when `sha256(email + code)` equals `shareSesh` on that row.
- If the email exists in local `users`, the session is `isLoggedIn`, `username`, `role`, `userID`.
- If it does not, `autoRegistration` copies the person from SAWIS Online and the request is redirected back through the same URL.
- Failure redirects to `identity-unknown`.

Form login (`POST GetLogin` / the login view):

- `getHasLoginFromSawisOnline` is tried first.
- If that fails, the local `users.password` is checked with `password_verify`.
- Wrong id or password sets flash text "Your ID or Password is Wrong!".

Already signed-in users are sent to `home`. A session key `redirectURL` is honoured when present.

Provenance: **Implemented** (`Welcome.php`). The SQL interpolates the code and the email. That is behaviour, not a requirement to keep.

Logout is `welcome/logout` (`Welcome::logout`).

### W2 — Home counts

Trigger: signed-in user opens `home`.

`Home::index` loads label counts through `LabelModel::getLabelsWhere`. The where-clause depends on role:

- Roles 1, 7, 2, 3: all labels with a designer.
- Role 5: labels for that designer.
- Role 4: labels whose `producer` is in the SAWIS member names linked to the user. If none, the query is `1 = 0`.
- Other roles: the initial where is `user_designer_id IS NULL`.

Provenance: **Implemented**. Whether the home cards are the operational dashboard is **Inferred** from `common/home.php`.

### W3 — Create and submit a label

Trigger: an allowed role uploads a PDF and later completes the form.

Who may upload (`createLabel`, `createMultiPageLabel`, `resubmitLabelFile`, `requestDispensation`): roles 1, 7, 5, 6, 4, 9. Others receive "You are not allowed to do this!".

Single-page upload (`createLabel`):

- Extension must be `pdf`. MIME must be `application/pdf`.
- Filename is sanitised to letters, digits, and underscores, then prefixed with a random number.
- Imagick reads the PDF at 300 DPI. More than one page is an error. Landscape pages are rotated 90 degrees. Output is a JPEG at quality 80.
- A password-protected PDF is surfaced as a warning to try again.
- `LabelModel::createLabelFiles` inserts the label with status `0` ("In Progress" in the code comment) and writes `file` rows.

Complete submission (`completeLabelSubmission`):

- `labelFormsTamperProtection` requires posted `userID` and `labelID` to match the session.
- Label fields are updated.
- Status is set to `1`.
- Flash: "This label has been submitted successfully!"

Multi-page upload is a separate method, `createMultiPageLabel`. Provenance: **Implemented**.

### W4 — Find a label

Trigger: user opens the labels list (`Labels::index`, auto-routed).

Rules:

- Only `label.active = 1`.
- Only rows with `created_at` after `2024-04-28 00:00:00`.
- Default date range is the last 14 days through today, stored in session by `updateDateRangeFilter`.
- Status filter query `status` (also remembered in session):

| Query value | Status ids included |
|---|---|
| `submitted` | 1, 2, 3 |
| `pending` | 2, 3 |
| `rejected` | 4 |
| `approved` | 5, 6 |
| `contest` | 7 |
| `resubmit` | 8, 9, 10 |
| `dispensation` | 11 |
| `all` or empty | 0 through 11 |

Role 3 adds a further constraint: `label.status = 2` and `meeting_group_id` in the user's groups. A user in no group gets a dummy group id `99999`, which returns nothing.

Provenance: **Implemented**.

### W5 — Review, comment, decide

Trigger: an authorised user opens `labels/labelform?id=`.

The form shows trademark, estate, SAWIS producer, year, alcohol percentage, cultivar (and up to five blend cultivars), production area (`woo`), warning, bottle size, CC list, SAWIS agent, approver, status, note, and dates. Producer, year, cultivar, and production area are required on the member form when the current user created the label. Designer and agent are read-only on the member form.

Comments (`commentLabel`) insert `label_note` type `1`, optionally save the text to `saved_comments`, and write a log action `comment`. Replies are also type `1` with `comment_id` set. Agree and delete exist (`agreeComment`, `deleteComment`). Designers do not see comment, reply, or agree history (`label_log.action` excluded).

Committee readiness: the comments counter tells the user when the label has no meeting-group members, and the admin modal script blocks approval UI with "Not all members have commented on this label yet." Provenance: **Implemented** in the view script. The server methods `approveLabel` and `rejectLabel` do not re-check that every member commented.

Approve (`LabelModel::approveLabel`):

- Status `5` when `labelHasProvisions` is not `1`. Code comment: "approved as is".
- Status `6` when provisions are set. Code comment: "approved with provisions". Provisions text is stored on the label.
- A note, a log action `approve`, a `meeting_minutes_groups` row for today (reused if that group already sat today), and a `meeting_minutes_notes` row are written.
- An email is queued with type `5` or `6` and subject `Label #<id> has been approved` or `... approved with provision`.

Reject:

- Status `4`, note, meeting minutes, log action `reject`, email type `4`, subject `Label #<id> has been rejected`.

Contest (`contestRejectedlabel`):

- Status `7`, note type `3`, log action `contest`. No email is queued in this method.

Dispensation (`requestDispensation`):

- Files may be pdf, doc, or docx. Stored under `uploads/dispensation/` with file type `99`.
- Note type `6`. Status `11`. Log action `dispensation`.
- A cron job `sendDispensationNotifications` emails while any active label remains in status `11`.

Assign to a meeting group (`reassignToMeetingGroup`): status becomes `2` and `meeting_group_id` is set, for each id in a comma-separated list.

Manual status change (`manualLabelSubmissionStatusUpdate`) sets whatever status id is posted and logs it. Manual meeting-group update does not by itself change status.

After a decision, `labelApprovalPDF` runs for both approve and reject. It builds a certificate PDF. Provenance: **Implemented**. The certificate layout was not transcribed field by field.

### W6 — Resubmit a file

Trigger: user uploads a replacement PDF.

Blocked when session `labelLockdown` is true, with the message "Label can not be changed after it's been approved". The same lock blocks update, file delete, approve, reject, contest, and comment.

Lockdown is set in the view, not in the model:

- Admin form (`labelForm.php`): lockdown when `status_name` is `Approved` or `Approved with provision`.
- Member form (`labelFormMember.php`): lockdown when `status_name` is `Approved`, `Approved with provision`, or `Pending`.

If the member form sees unprocessed `sent_queue` rows, it overwrites the in-memory status to `2` and the name to lowercase `pending` after the lockdown check. Provenance: **Implemented**. Whether that overwrite is intentional is **Requires Confirmation**. The comment in the view says the overwrite is required for lockdown, but it runs after lockdown is set.

Resubmit file types: `2` Supporting Document, `3` Front Label, `4` Back Label, `5` Neck Label, otherwise `Label`. The new `file` row uses `file_version + 1`. The previous file is not marked inactive. A TODO in `LabelModel::resubmitLabelFile` says that was left undecided.

`deleteLabel` sets `active` to `0`. It does not remove the row.

### W7 — Meeting minutes

Trigger: user opens `meeting-minutes`.

The user loads notes for a meeting group and sitting date, edits a note, saves a draft, generates a PDF (mPDF), downloads or views it, emails it, or uploads a signed copy. Public holidays in `public_holidays` affect the header date (`MeetingMinutes::isPublicHoliday`, `calculateHeaderDate`). Approval text in the preview is rewritten to "Approved, provided that:" or "Approved."

Provenance: **Implemented**. Who is allowed to send minutes, beyond the menu grant, is **Unknown**. The controller does not check a role id.

### W8 — Analytics email

Trigger: `GET /cronJobs` runs active rows in `cronjob_actions`.

`sendWeeklyAnalyticsReport`:

- Documented and implemented: Monday after 07:00 Africa/Johannesburg, previous Monday 00:00 through Sunday 23:59:59, not twice in the same week.
- Recipients are the `action` column of that cron row, split on commas.
- Tables compare this year with last year (week, month-to-date, year-to-date) plus absolute totals.
- Non-production may pass `?force=1` to skip the Monday guard. Production cannot.

On-screen analytics (`Analytics::getAnalytics`) groups active labels by container type and counts status in `(1,2,3)` as submissions, `(5,6)` as approved, and `4` as rejected. Optional producer and date filters.

Provenance: **Documented** (`README.md`, `CHANGELOG.md`, `NewKnowledgeBase.md`) and **Implemented**.

### W9 — System settings

`system_settings` covers cron rows, email schedule, public holidays, saved comments, label provisions, container types, and the dispensation notification address. Each has add or update and an active flag where the route exists.

Label statuses, meeting groups, and email templates have their own screens: `labels/status`, `labels/groups`, `labels/email-templates`.

Provenance: **Implemented**.

## Functional requirements

### FR-001 — Session required

The system redirects to `/` when `isLoggedIn` is not true, except `/`, `getLogin`, `register`, `organisations` and children, `tracking-pixel`, `show404`, `404`, `cronJobs`, and `identity-unknown`.

Evidence: `app/Config/Filters.php`. Implementation: **Implemented**. Confidence: High.

### FR-002 — Menu grant required

For other URLs, the first path segment must exist in `user_menu` and the role must have `user_access`. Otherwise the user is sent to `/` or `blocked`.

Evidence: `app/Filters/Authorization.php`. Implementation: **Implemented**. Confidence: High.

CSRF and honeypot filters are present and commented out. Provenance: **Implemented**. Whether that is accepted is **Requires Confirmation**.

### FR-003 — Label list cutoff

Lists exclude labels created on or before 2024-04-28 and exclude `active = 0`.

Evidence: `Labels::index`. Implementation: **Implemented**. Confidence: High. The business reason for the date is **Unknown**.

### FR-004 — Designer and producer visibility

Designers see only their labels. Producers see labels for SAWIS members linked through `sysUserCompanyGroup` (active, security group not 5 or 14) and `tblMember.strMember`, plus labels they designed.

Evidence: `Labels::index`. Implementation: **Implemented**. Confidence: High.

### FR-005 — Single-page PDF rule

A standard label upload must be one PDF page. Multi-page files use `createMultiPageLabel`.

Evidence: `Labels::createLabel`. Implementation: **Implemented**. Confidence: High.

### FR-006 — Decision statuses

Approval without provisions stores status 5. Approval with provisions stores status 6 and the provisions text. Rejection stores status 4. Contest stores status 7. Dispensation stores status 11. Completing a submission stores status 1. Reassignment to a meeting group stores status 2.

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

The display names `Approved`, `Approved with provision`, `Rejected`, `Contested`, and `Pending` are compared as strings and therefore must exist in `label_status.name`. Provenance: **Implemented** assumption about data. Live rows were not queried.

### FR-007 — Email is queued, not sent inline

`Email::sendEmailNotification` inserts `sent_queue` with `processed = 0`, substitutes `{userName}`, `{LabelNumber}`, `{Note}`, and `{labelURL}`, and appends a SAWIS logo plus a tracking pixel. If no `label_email_template` matches the type, it logs "No email template found." and does not insert.

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

### FR-008 — Tamper check

Mutating label posts must carry the session `labelID` and `userID`. A mismatch returns JSON "Invalid form data" and stops.

Evidence: `labelFormsTamperProtection` in `app/Common.php`. Implementation: **Implemented**. Confidence: High.

`createLabel` has a TODO that this check breaks that function, and `createLabel` does not call it. Provenance: **Implemented**.

### FR-009 — Late submission hint

`lateSubmissionIndicator` marks a submission late when it falls in the current ISO week and is Tuesday at or after 14:00, or Wednesday. The function returns no mark on Thursday after 14:00 or later in the week, and never for status 0. The admin form prints "This label was submitted after 2pm Tuesday this week." only when status is not in 0, 2, 3, 4, 5, 6.

Evidence: `app/Common.php`, `labelForm.php`. Implementation: **Implemented**. Confidence: High. It does not block submission.

### FR-010 — Registration

`GET/POST register` exists (`Welcome::register`, `Welcome::registration`). The seeded starter user is a Developer. How a producer or designer is supposed to be created, beyond SAWIS auto-registration, is **Unknown / Requires Confirmation**.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| SAWIS Online MySQL | Read | Login, auto-register, producer lookup, SAWIS menu | `sysUser`, `sysLabelSystemLogin`, `sysUserCompanyGroup`, `tblMember`, `sysSecurity`, menu tables | DB user in `Database.php` | Query failure is not given a product message in the paths read. Login falls through or redirects to `identity-unknown` |
| Mail | Outbound, deferred | Decision, dispensation cron, weekly analytics, meeting-minute send | To, CC, subject, HTML body, tracking code | SMTP or SendGrid keys exist as comments in `env`. The helper used is `mail` | Unsent rows stay `processed = 0` |
| Tracking pixel | Inbound | Email client loads the image | `code` query | None. Route is public | Not specified |
| SonarQube | Outbound from CI | Push to `master` or `qa` | Source scan | `SONAR_TOKEN` pipeline variable | Pipeline failure. No deploy gate is defined in the YAML |
| OpenAI | Outbound | `chatGPT/sent` | A message | `OPENAI_API_KEY` in `env` (commented sample) | Not specified. This is starter code |

## Constraints

- Timezone is `Africa/Johannesburg`, set in `BaseController::initController`.
- PHP 8.0 or newer, as declared. Imagick and Ghostscript are required for upload even though Composer does not require them.
- Lists are memory-capped upward to 512M in `Labels::index`.
- Session cookie `ci_session`, database handler, `sessionMatchIP` true, regenerate interval 1440 seconds (`env`).
- Auto-routing is on (`setAutoRoute(true)`), so controller methods are reachable when the menu grant passes.

## Non-goals confirmed by absence

No mobile app, no public unauthenticated label form, and no payment flow were found in the label controllers. The car and organisation APIs are starter leftovers, not label requirements.
