# PRD — magalies-pharma-heisenberg-serverless

Evidence date: 2026-10-09.

Source: `git@bitbucket.org:overdrive/magalies-pharma-heisenberg-serverless.git`, `master` at `7d003d5` (2023-05-03).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the Serverless templates, handlers, Objection models, SQL migrations, seeds, and `README.md`. Provenance is labelled. Credentials in gitignored `*.env.yml` files were not available. `example.env.yml` uses placeholders.

`README.md` documents naming rules and deploy commands. Where it names `re-mind-serverless` or a GitLab remote, that text disagrees with this Bitbucket repository. The code is the implemented behaviour.

## Product purpose

**Inferred.** An AWS HTTP API, branded Heisenberg, that Magalies Pharma staff and an unknown client application use to take a prescription through review, manufacture, packaging, and courier handover.

**Implemented.** Routes on API Gateway `admin-${stage}` (`serverless/formations/06 - api-gateway.yml`). Database name `heisenberg`. Package name `heisenberg-serverless`.

**Documented** only as "Heisenberg Serverless" plus database naming rules in `README.md`.

**Unknown / Requires Confirmation:** the product owner, the calling application, and whether any stage is still in use. The last commit is 2023-05-03.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| Cognito user in group `SuperAdmin` | `03 - cognito.yml` group; `src/authorizers/super-admin-authorizer` allows only that group | **Implemented**. User administration routes use this authorizer |
| Cognito user in group `Prescription` | Prescription authorizer allows `SuperAdmin` or `Prescription` | **Implemented**. Catalogue writes, company/individual create, order split and move |
| Cognito user in group `Fulfilment` | Fulfilment authorizer allows `SuperAdmin` or `Fulfilment` | **Implemented**. Batch writes and `POST stock/check` |
| Cognito user in group `Packaging` | Packaging authorizer allows `SuperAdmin` or `Packaging` | **Implemented**. Fulfilment list, scan, and pack |
| Cognito user in any of SuperAdmin, Fulfilment, Packaging, Prescription | Internal authorizer | **Implemented**. Only `POST order/{order_id}` |
| Cognito user in group `Customer` | Customer authorizer allows only `Customer` | **Implemented** as a Lambda and an API Gateway authorizer. No HTTP route references `customerAuthorizer` |
| Any authenticated Cognito user of the admin pool | `CognitoAuthorizer`, identity source `method.request.header.idtoken` | **Implemented** on most reads, prescription submit, reports, dashboard, presigned URLs, and `POST prescription-pack`. It does not check a group |
| Caller holding an API key | `private: true` on `GET courier/{order_id}`, `POST courier/{order_id}/waybill`, `POST courier/tracking` | **Implemented**. No Lambda authorizer on those three |
| Unauthenticated caller | Waybill proxy routes have CORS and no `authorizer` and no `private: true` | **Implemented** on `POST waybill/auth`, `POST waybill/capture`, `POST parcel/capture`, `POST waybill/complete`, `GET waybill/trackandtrace` |
| Person running Serverless locally | README `serverless invoke local` | **Documented** |

No screen is in this repository. The S3 bucket `heisenberg-frontend-${EnvironmentName}` is created and unused by these Lambdas.

## Systems the product talks to

1. AWS API Gateway and Lambda. Region `eu-west-1`, runtime `nodejs14.x`, default stage `dev`. Each domain is its own Serverless service (`heisenberg-user`, `heisenberg-medicine`, `heisenberg-orders`, and the others listed in technical documentation).
2. Amazon Cognito user pool `admin-user-pool-${EnvironmentName}`. Username is email. MFA is off. An identity pool allows unauthenticated identities.
3. Amazon RDS MySQL 8.0.25, database `heisenberg`, instance class `db.t3.micro`, 20 GB, single AZ `eu-west-1a`. The template sets `PubliclyAccessible: true`, `StorageEncrypted: false`, `BackupRetentionPeriod: 0`, `DeletionProtection: false`.
4. Amazon S3 buckets `heisenberg-serverless-deployments-${stage}`, `heisenberg-frontend-${stage}`, `heisenberg-prescriptions-${stage}`. Email templates expect `heisenberg-emails-${stage}` (`example.env.yml`). That emails bucket is not created by `05 - buckets.yml`.
5. Dawn Wing HTTPS API at host `swatws.dawnwing.co.za`. Path comes from `COURIER_PATH`. The example value is `/dwwebservices/v2/uat/api`. Commit `121c681` says live host credentials are kept in `live.yml`.
6. Amazon SES and a mock SMTP transporter inside the email layer. No handler under `src/` requires that layer.
7. Headless Chromium via Puppeteer in `src/puppeteer/generate`, for JPEG or PDF from a Handlebars template.

No queue, cache, or monitoring vendor is configured. API Gateway REST logs are enabled (`logs.restApi: true` on the function templates).

## Response contract

**Implemented** in `serverless/layers/general-utils/nodejs/general-utils.js` `getResponseFormat`.

- HTTP status is the first argument.
- Headers set `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true`.
- Body JSON is `{ success, message, data, errors }`.
- Missing records often return 404. Validation on user create and presigned URLs returns 422. Several list handlers return 500 when `page` or `limit` is missing. Unexpected exceptions return 500 with a fixed message.

## Major workflows

Routes are the path in the Serverless file. The stage prefix and the public hostname are **Unknown / Requires Confirmation**. No custom domain is in the templates.

### WF-1 — Submit a prescription

**Implemented** in `src/prescription/submit/handler.js`, `POST prescription`. Authorizer: Cognito (`idtoken`).

1. Parse `event.body` as JSON.
2. Resolve a user id from the Lambda authorizer context `user`, else from Cognito claims `cognito:username`, else null. This route uses the Cognito authorizer, so the claims branch is the one that can run.
3. Inside one Objection transaction:
   - If `company.id` is set, load that company where `deleted_at` is null. If no company row was loaded and `company` is present, insert a company.
   - Same pattern for `prescriber` as an `individual`. A new prescriber gets `profile_number` = a new UUID.
   - Same pattern for `individual` (the patient or other party). A new individual is linked to the company when a company exists.
   - Address is required. Missing `address` throws `Address is required`, which the catch turns into HTTP 500 `"Failed to save prescription"`. An existing address is loaded by `address.id`; otherwise an address is inserted and linked to the individual and/or company.
   - Insert `shipping_details` with `waybill_number` null, the address id, and `address.delivery_type_id`.
   - Load order status code `AWR` (Awaiting Review) and insert an order with `barcode` = a new UUID.
   - If an individual was saved, load `individual_type` by `payload.individual.individual_type_id` and set `doctor_id` for codes `DOC` and `VET`, `patient_id` for `PNT`, or `client_id` for `PHM`. Pharmacist without a company throws `"A Pharmacist must be associated with a company"`.
   - If there is a company and no individual, set `client_id` to the company.
   - If a prescriber was saved, set `prescriber_id`.
   - For each `medicine[]` entry, insert an order item with `total_quantity` = `quantity`, `price_per_item` = 0, status code `AWF` (Awaiting Fulfillment), and `medicine_dosage_package_type_id`.
4. Success: HTTP 200 `"Prescription Saved"` and the order row. Failure: HTTP 500 `"Failed to save prescription"`.

There is no `src/prescription/submit/validate.js`. Required-field checks are the database constraints plus the address throw.

### WF-2 — Review and edit an order

**Implemented** in `src/order/edit/handler.js`, `POST order/{order_id}`. Authorizer: internal (SuperAdmin, Fulfilment, Packaging, or Prescription).

1. 404 `"Order not found"` when the id is missing or `deleted_at` is set.
2. Optional nested `patient`, `doctor`, and `prescriber` are updated or inserted as individuals. Optional `client` is updated or inserted as a company.
3. Optional `medicine[]`: existing lines whose status code is `AWF`, `AWM`, or `ISK` can be patched or deleted. Lines in another status are returned in `failed` and left unchanged. New lines (no matching id) are inserted. `price_per_item` is always written as 0.
4. Optional `shipping_details` updates or inserts the address and the shipping row, including `waybill_number` and `waybill_data`.
5. The order row is patched with barcode, shipping id, party ids, and `order_status_id` from the body. The handler does not check that the new status is a legal next step.
6. Success: HTTP 200 `"Order updated"` with the order and `{ success, failed }` item results. Failure rolls back and returns HTTP 500.

**Inferred defect, requires confirmation.** The status gate uses `item.order_item_status_id in updateable_status_ids`. In JavaScript `in` tests indexes, not values, so a real status id may be treated as not updateable and pushed to `failed`.

### WF-3 — Split an order and move lines

**Implemented** in `src/order/split-create/handler.js`, `POST order/{order_id}/split`, and `src/order/split-move/handler.js`, `POST order/{order_id}/move`. Both use the prescription authorizer.

Split body shape, from the handler comment:

```text
parentOrderItemIDs: [1, 2]
childOrdersMatrix: [[12, 13], [14, 15]]
```

For each matrix row the handler inserts a child order copying status, shipping, patient, doctor, and client, with a new UUID barcode and `parent_order_id` set, then relates that row's order-item ids to the child. It then relates `parentOrderItemIDs` to the parent. Success returns the parent and children with medicine graphs. Failure: HTTP 500 `"Split Failed"`.

Move accepts an array of order-item ids. An item is patched onto the path order when `order_item.order.parent_id` equals the target's `parent_id` or the target's `id`. The column on `order` is `parent_order_id`. There is no `parent_id` in the migration or in `Order.js` relation names (`parent_order`). **Requires confirmation** whether move matches any row at runtime.

### WF-4 — Manufacture a batch and check stock

**Implemented** in `src/batch/create/handler.js`, `POST batch` (timeout 30 seconds), and `src/stock/stock-check/handler.js`, `POST stock/check`. Both use the fulfilment authorizer.

Batch body fields used: `medicine_dosage_package_type_id`, `quantity`, `batch_number`, `batch_status_id`, `additional_information`, `expiry_date`. Unknown package id returns 404. The handler inserts one `batch` and then `quantity` `batch_item` rows with `barcode` null, `in_stock` true, `order_item_id` null. The comment in the file shows a `reserve` array. The function never reads `formData.reserve`.

Stock check accepts an array of medicine-dosage-package-type ids and returns a map of id to the count of `batch_item` rows with that package type and `order_item_id` null. It does not filter `in_stock` or batch status.

`GET` list of packages under a dosage type (`src/medicine-dosage-package-type/list/handler.js`) adds `stock_on_hand` when query `stock` is present: count of in-stock batch items whose batch status code is `ISK`.

### WF-5 — Pack an order line

**Implemented** in `src/fulfilment/list/handler.js`, `src/fulfilment/scan/handler.js`, and `src/fulfilment/fulfill/handler.js`. Authorizer: packaging. Paths use the American spelling `fulfilment` in the URL and the British spelling `fulfill` on the pack route.

1. `GET fulfilment/{order_item_id}` returns the order item with `batch_items`.
2. `POST fulfilment/{order_item_id}/scan` body `{ barcode, medicine_number }`.
   - If linked batch items already equal `total_quantity`, HTTP 422 `"Order Item already fulfilled"` with `success: true`.
   - If any batch item already has that barcode, HTTP 422 `"Barcode already scanned"`.
   - Batch number is the barcode substring before the first `-`. Medicine number compared to the catalogue is the scanned `medicine_number` substring before the first `-`.
   - The handler loads an unassigned batch item (`order_item_id` null) on that batch number and the order line's package type. None: HTTP 404 `"No in stock items found"`.
   - If the catalogue `medicine_number` differs, HTTP 422 `"Medicine number does not match"`.
   - Otherwise it writes `barcode`, `order_item_id`, and the full `medicine_number` onto that batch item and logs `log_type` `SCAN`.
   - Success body includes `remaining`.
3. `GET fulfilment/{order_item_id}/fulfill` refuses with HTTP 422 if any linked batch item has no barcode. Otherwise it sets those batch items `in_stock` = 0 and the order item status to code `PKD` (Packed). It does not change the parent order status.

### WF-6 — Courier and Dawn Wing

**Implemented** in `serverless.courier.yml` and `src/courier/`.

| Route | Auth in the template | Behaviour |
|---|---|---|
| `GET courier/{order_id}` | `private: true` | Returns address plus a contact name and phones from patient, else client, else doctor |
| `POST courier/{order_id}/waybill` | `private: true` | Saves waybill fields on the order |
| `POST courier/tracking` | `private: true` | Writes a `tracking_details` row |
| `POST waybill/auth` | none | `POST` JSON username/password from env to `{COURIER_PATH}/token` on `swatws.dawnwing.co.za` and returns the body |
| `POST waybill/capture` | none | Forwards the raw body to `{COURIER_PATH}/waybill` with the caller's `Authorization` header |
| `POST parcel/capture` | none | Forwards to `{COURIER_PATH}/parcel` |
| `POST waybill/complete` | none | Forwards to `{COURIER_PATH}/waybill/completewaybill` |
| `GET waybill/trackandtrace` | none | Intended track-and-trace call |

Capture helpers treat a false result from `validate_response` as success (HTTP 200) and a true result as HTTP 500 with `errors`. Timeout on every courier function is 19 seconds (`7d003d5`).

**Implemented defects, requires confirmation:**

- `track_and_trace` builds a path that hardcodes `/dwwebservices/v2/uat/api/trackandtrace` and calls `dpd` with arguments that do not match `(host, path, payload, method, token)`.
- `GET courier/{order_id}` reads `first_name`, `last_name`, and `alt_phone_number` on a company client. `company` has `name` and `phone_number_alt`, not those fields. Individual phones are `phone_number_alt`, not `alt_phone_number`.

### WF-7 — Dashboard and reports

**Implemented** in `serverless.misc.yml`. Authorizer: Cognito.

- `POST dashboard` body `{ start_date, end_date }`. Counts orders and batches created in that range, grouped by every status row. Soft-deleted rows are not excluded.
- `POST report/order` returns orders created in the range with status, patient, doctor, client, and prescriber.
- `POST report/batch` returns batches in the range and sets `stock_remaining` to the number of related in-stock batch items.
- `POST report/medicine` body uses `start_date`, `end_date`, `client_id`, `patient_id`, `doctor_id`, `type` (`daily`, `weekly`, or other), and `format` (`default` or `xls`). It builds a per-medicine time series. The error string on failure says `"Failed to generate medicine report"` in the order and batch handlers as well.

### WF-8 — Catalogue, parties, and lookups

**Implemented.** Create and update of medicine, dosage unit, dosage type, and package type use the prescription authorizer. Deletes of those entities use SuperAdmin and set `deleted_at` (soft delete). Reads use the Cognito authorizer.

`POST form-data` accepts a JSON array of names and returns the matching lookup tables: `order_status`, `order_item_status`, `batch_status`, `individual_type`, `company_type`, `delivery_type`. Seed values are in `database/seeds/form-data.sql`.

List endpoints generally require query `page` and `limit`. The guard is `(!page && page != 0) || !limit` because `&&` binds before `||`. A missing limit always returns HTTP 500. Page `0` does not trip that guard. Objection pagination is zero-based, so a truthy page is decremented before `.page()`. Page `0` is falsy, so `.page()` is skipped and the query is not paginated. **Requires confirmation** of the client's page numbering. The error text spells the word "parametre" on several lists.

`GET order` also accepts `search`, `order_status` (status code), `order_by`, and `order_direction`. Search applies `where name like`. The `order` table has no `name` column.

`GET medicine` reads `sellable` with `== true`. Query-string values are strings, so the comparison does not become true for `"true"`. The flag is then unused in the query.

`GET individual` accepts `search` (first name, last name, profile number) and `types` (comma-separated individual type codes).

Company and individual have no delete route.

### WF-9 — Users

**Implemented** in `src/user/`, all routes SuperAdmin, header `accesstoken`.

Create body needs `email`, `name`, `phone` (length at least 12 and leading `+`), and a `role` group name. The handler replaces any password with `randomTempPassword(16)` and the validator then requires length at least 8. Cognito `adminCreateUser` uses the email as username, marks email verified, and sets desired delivery medium EMAIL. `adminAddUserToGroup` uses `role` with no allow-list in code. Delete calls `adminDisableUser`. It does not delete the Cognito user.

**Discrepancy.** Handlers read `process.env.USER_POOL_ID`. `example.env.yml` key `admin-cognito-lambda` sets `COGNITO_POOL_ID` and `env`. The gitignored stage files were not readable. Whether production env files define `USER_POOL_ID` is **Unknown / Requires Confirmation**.

### WF-10 — Files and pack documents

**Implemented.**

- `POST presigned-url/generate-upload` and `POST presigned-url/generate-download` use the Cognito authorizer. Body fields `bucket`, `key`, and `region` (upload). The Lambda signs a `putObject` or `getObject` URL with signature version v4. It does not restrict the bucket to `heisenberg-prescriptions-*`.
- `POST prescription-pack` (timeout 30 seconds, Cognito authorizer) calls `generator.generate(template_name, data, type)`. `type` `image` returns a JPEG data URL. `type` `pdf` returns a PDF. The template compiler loads a Handlebars file. The only template in the repo is `order_summary.hbs`.

## Functional requirements

### FR-001 — Prescription becomes an awaiting-review order

Requirement: A Cognito-authenticated caller can submit a prescription with an address and zero or more medicine lines. The system stores an order in status `AWR` and lines in status `AWF`, creating company, prescriber, individual, and address rows when ids are absent.

Trigger: `POST prescription`.

Inputs: JSON body described in WF-1.

Expected behaviour: HTTP 200 and the new order. Address missing or a pharmacist without a company yields HTTP 500.

Evidence: `src/prescription/submit/handler.js`, `serverless.order.yml`.

Implementation: Implemented.

Confidence: High for the handler path. The live caller is Unknown.

### FR-002 — Role-limited writes

Requirement: Write routes attach one of the Lambda authorizers. Group membership is the Cognito group list in the matching authorizer file. SuperAdmin is included on every Lambda authorizer except Customer.

Evidence: `src/authorizers/*/handler.js`, each `authorizerId` in `serverless/functions/`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Soft-deleted catalogue rows stay hidden

Requirement: List and view queries add `whereNull('deleted_at')`. Delete routes set `deleted_at` to the current time and write a log with `log_type` `DELETE`.

Evidence: medicine, dosage, and package delete/list handlers.

Implementation: Implemented for those catalogues. Orders are filtered the same way on list, view, edit, and split. No order-delete route exists.

Confidence: High.

### FR-004 — Pack scan binds a barcode to one free batch item

Requirement: Packaging can scan a barcode onto an order line only when the barcode is unused, a free batch item exists for that batch number and package type, and the medicine number prefix matches the dosage type.

Evidence: `src/fulfilment/scan/handler.js`.

Implementation: Implemented.

Confidence: High for the checks that are written. Whether `batch_items.length == total_quantity` matches the intended "already fulfilled" rule needs confirmation, because new batch items start with `order_item_id` null and are not linked until scan.

### FR-005 — Packed line leaves stock

Requirement: Marking a line fulfilled sets status `PKD` and `in_stock` 0 on its batch items, and refuses when any linked item has no barcode.

Evidence: `src/fulfilment/fulfill/handler.js`, seed status `PKD`.

Implementation: Implemented. The route method is GET.

Confidence: High.

### FR-006 — Dawn Wing proxy

Requirement: The courier service can obtain a Dawn Wing token and forward waybill, parcel, complete, and track calls.

Evidence: `src/courier/waybill/handler.js`, `example.env.yml` `courier`.

Implementation: Implemented, with the track-and-trace call shape called out in WF-6.

Confidence: Medium. Live versus UAT host is selected by gitignored env.

### FR-007 — Audit log on model writes

Requirement: Objection `$afterInsert` and `$afterUpdate` on domain models insert a `log` row with entity name, old and new JSON, user id from query context, and log type defaulting to `INSERT` or `UPDATE`.

Evidence: `serverless/layers/database/nodejs/models/Medicine.js` (same hook shape on the other mutable models). `GET log` is SuperAdmin-only and requires `page` and `limit`, with optional `start_date` and `end_date`.

Implementation: Implemented.

Confidence: High.

## Integrations

| System | Purpose | Direction | Auth | Failure |
|---|---|---|---|---|
| Cognito user pool | Identity and groups | Outbound admin API; inbound authorizer | IAM on the Lambda role; access token or id token from the caller | Authorizer Deny policy, or HTTP 500 on user admin errors |
| RDS MySQL `heisenberg` | System of record | Outbound from VPC Lambdas | `RDS_USER` / `RDS_PASS` / `RDS_DB` from `{stage}.env.yml` | Handlers catch and return HTTP 500 |
| S3 | Deployment artifacts, optional uploads, email templates | Presigned URL is returned to the caller; the Lambda does not upload the object | Caller uses the signed URL. Signing uses the Lambda role | HTTP 500 from the S3 signer |
| Dawn Wing | Waybill and tracking | Outbound HTTPS from the courier Lambdas | JSON login, then `Authorization` header forwarded from the caller | HTTP 500 and Dawn Wing `errors` when `validate_response` is true |
| SES / mock SMTP | Email layer | Not called by `src/` | `FROM_MAIL` and `MOCK_MAILER_*` | Not on a request path |

## Important constraints

- Lambda memory is 128 MB on the functions inspected. Timeouts are 6 seconds except courier (19), batch create (30), and puppeteer (30).
- Database naming **documented** in the README: lowercase, singular table names, snake_case columns, foreign keys named `{table}_id`.
- Prices on order lines are stored as 0 by the submit and edit handlers. Package `price` is a float column added in `08_08_2022_1230_package_type_updates.sql`. `medicine_dosage_type.default_price` is dropped by that same migration. The medicine seed still inserts `default_price`. Running the seed after the later migrations is **Unknown**.
- `schedule_status` is an integer on `medicine`. The seed uses `1`. The meaning of other values is **Unknown**.
- Node 14 and Serverless 2 are what the templates pin. Both are past upstream support as of this evidence date. That is an observation, not a requirement to upgrade.

## Assumptions that must not be treated as requirements

- That Magalies Pharma still operates this API.
- That `Customer` was meant to call a patient portal. The group and authorizer exist; no route uses them.
- That order status must move AWR, then AWF, then PKG, then SHP. Those four codes are seeded. Edit accepts any `order_status_id`. No handler advances the order status when a line is packed or shipped.
- That `active_ingredient_nappy_code` is a NAPPI code. The column name is the only evidence. The seed inserts an empty string.
