# Technical documentation — magalies-pharma-heisenberg-serverless

Evidence date: 2026-10-09. Source: `master` at `7d003d5` (2023-05-03). 189 commits, first `88f2a57` on 2021-11-15.

## Application shape

Node.js Lambdas deployed with the Serverless Framework. Domain logic is one handler (and sometimes a `validate.js`) per route under `src/`. Shared code is five Lambda layers under `serverless/layers/`. Infrastructure that is not a function is CloudFormation in `serverless/formations/`.

```text
serverless/formations/     VPC, Cognito, RDS, S3, API Gateway
serverless/functions/      one Serverless service per domain
serverless/layers/         authorize, aws-services, database, email, general-utils
serverless/helpers/        account-id.js
src/                       handlers, grouped by domain
database/migrations/       hand-run SQL, not a Knex migration runner in package.json
database/seeds/            form-data.sql, medicine.sql
database/diagrams/         main.png, main.uxf
example.env.yml            stage env template
```

`package.json` `main` is `serverless-dynamic.js`. That file is not in the tree.

## Services

Each file under `serverless/functions/` is a separate Serverless service. They share API Gateway `admin-${stage}` via `serverless-import-apigateway`, except the layers and authorizer services.

| Service | File |
|---|---|
| heisenberg-layers | `serverless.layers.yml` |
| heisenberg-authorizers | `serverless.authorizers.yml` |
| heisenberg-user | `serverless.user.yml` |
| heisenberg-company | `serverless.company.yml` |
| heisenberg-individual | `serverless.individual.yml` |
| heisenberg-medicine | `serverless.medicine.yml` |
| heisenberg-orders | `serverless.order.yml` |
| heisenberg-batches | `serverless.batch.yml` |
| heisenberg-batch-item | `serverless.batch-item.yml` |
| heisenberg-stock | `serverless.stock.yml` |
| heisenberg-fulfilment | `serverless.fulfilment.yml` |
| heisenberg-courier | `serverless.courier.yml` |
| heisenberg-misc | `serverless.misc.yml` |
| heisenberg-log | `serverless.log.yml` |
| heisenberg-presigned-url | `serverless.presigned-url.yml` |
| heisenberg-puppeteer | `serverless.puppeteer.yml` |
| heisenberg- | `serverless.template.yml` (no functions) |

Provider defaults copied across the function files: runtime `nodejs14.x`, stage `dev`, region `eu-west-1`, memory 128 MB, deployment bucket `heisenberg-serverless-deployments-${stage}`. Packaging is individual per function (`serverless-jetpack`).

## Deploy

**Documented** in `README.md`. Commands use `sls` or `serverless`, `--aws-profile`, `--stage dev`, `--region eu-west-1`, `--config {yml file}`.

Order written in the README:

1. `01 - base.yml` — IAM role so Cognito can `sns:publish` on `*`.
2. `02 - network.yml` — VPC `10.192.0.0/16`, two public subnets `10.192.10.0/24` and `10.192.11.0/24`, internet gateway. Exports Lambda and database security groups and the subnet ids.
3. `03 - cognito.yml` — user pool, app client, identity pool, five groups.
4. `04 - database.yml` — RDS. Parameter `MasterPassword` and `EnvironmentName`.
5. `serverless.layers.yml`
6. `serverless.authorizers.yml`
7. `05 - api-gateway.yml`
8. The other function services.

The README numbers the API Gateway file as step 05 and does not mention `05 - buckets.yml`. The buckets file is present. **Requires confirmation** of the order the team actually used. Buckets are needed before anything that assumes those names exist.

`{stage}.env.yml` is required at package time (`file(${self:custom.stage}.env.yml)`). Gitignore lists `dev.env.yml`, `stage.env.yml`, `live.env.yml`, `prod.env.yml`. Only `example.env.yml` is committed.

Layer version numbers are pinned in that env file (`AUTHORIZE`, `AWS_SERVICES`, `DATABASE`, `EMAIL`, `GENERAL_UTILS`). Example values are 3, 2, 2, 1, 2. A layer publish that is not followed by an env bump leaves functions on the old layer.

## Auth

Two mechanisms on the same API:

- Cognito user-pool authorizer. Identity source `method.request.header.idtoken`. TTL is the API Gateway default (the property is not set).
- Lambda TOKEN authorizers. Identity source `method.request.header.accesstoken`. `AuthorizerResultTtlInSeconds: 0`.

`authorize.js` calls Cognito `GetUser` with the access token, then `ListUsersInGroup` for each allowed group, and allows the request when the username appears in one of those lists. The whole group membership is listed on every call. Context `user` is the attribute map plus `userName`.

User-admin Lambdas call `AdminCreateUser`, `AdminAddUserToGroup`, `AdminDisableUser`, and list or get user. The role statements are on `serverless.user.yml`.

Identity pool `AllowUnauthenticatedIdentities: true`. The authenticated role allows `lambda:InvokeFunction` on `*`. API routes themselves use the authorizers above, not IAM auth.

## Database

Engine MySQL, `EngineVersion: 8.0.25`, database name `heisenberg`, master username `admin`, class `db.t3.micro`, 20 GB standard storage, single AZ `eu-west-1a`, publicly accessible, storage not encrypted, backup retention 0, deletion protection off. Subnets are the two public subnets. These are template settings, not a recommendation.

Access from Lambdas: Knex in `serverless/layers/database/nodejs`. Host is CloudFormation import `${stage}PrimaryDatabaseAddress`. User, password, and database name come from env.

ORM: Objection models in `serverless/layers/database/nodejs/models/`. `models/index.js` exports Address, Batch, BatchItem, BatchStatus, Company, CompanyType, DeliveryType, DosageType, DosageUnit, Individual, IndividualType, Log, Medicine, MedicineDosagePackageType, MedicineDosageType, Order, OrderItem, OrderItemStatus, OrderStatus, PackageType, ShippingDetails, TrackingDetails, UserProfile.

Migrations are raw SQL files, dated, applied by hand. There is no `knex migrate` script in `package.json`. Several ALTER statements qualify tables as `heisenberg.table`. The initial file does not.

Charset on the initial tables is `latin1`.

### Tables

| Table | Role |
|---|---|
| user_profile | Cognito user id, first and last name. Not the Cognito user record |
| individual_type, company_type, delivery_type | Lookups |
| order_status, order_item_status, batch_status | Lookups |
| company | Practice or pharmacy. Optional `distributor_id` |
| individual | Person. Unique `profile_number`. Optional company. Email columns added May 2022 |
| address | Belongs to an individual, a company, or both |
| shipping_details | Waybill number, waybill JSON, address, delivery type |
| tracking_details | Text tracking blob per shipping row |
| order | Barcode, status, client, doctor, patient, prescriber, shipping, parent order |
| order_item | Quantity, price, status, order, package type |
| medicine | Name, description, unique barcode prefix, active ingredient, schedule status |
| dosage_unit, dosage_type | Units and forms |
| medicine_dosage_type | Strength, instructions, unique medicine number, active flag |
| package_type | Container, bottle, and the other seeded forms |
| medicine_dosage_package_type | Quantity, label, price, active flag |
| batch | Quantity produced, unique batch number, status, expiry, notes |
| batch_item | Unique barcode (nullable), in_stock, order line, batch, package, scanned medicine number |
| log | Entity audit |

Soft delete is a `deleted_at` timestamp on the entities that have it. `shipping_details` in the initial migration has no `deleted_at`. `tracking_details` does.

## HTTP surface

Base path is whatever stage name was deployed, on the regional API `admin-${stage}`. Custom domain: none in the templates.

| Method | Path | Authorizer |
|---|---|---|
| POST | prescription | Cognito |
| GET | order | Cognito |
| GET | order/{order_id} | Cognito |
| POST | order/{order_id} | Internal |
| POST | order/{order_id}/split | Prescription |
| POST | order/{order_id}/move | Prescription |
| GET | order/{order_id}/tracking | Cognito |
| POST, GET | user | SuperAdmin |
| PUT, GET, DELETE | user/{username} | SuperAdmin |
| GET, POST | company | GET Cognito, POST Prescription |
| GET, PUT | company/{company_id} | GET Cognito, PUT SuperAdmin |
| GET, POST | individual | GET Cognito, POST Prescription |
| GET, PUT | individual/{individual_id} | GET Cognito, PUT SuperAdmin |
| POST, GET | medicine, dosage-unit, dosage-type, package-type | POST Prescription, GET Cognito |
| PUT, GET | those resources by id | PUT Prescription, GET Cognito |
| DELETE | those resources by id | SuperAdmin |
| POST, GET | medicine/{id}/dosage-type and nested package | same pattern |
| GET, PUT, DELETE | batch/{batch_id} | GET Cognito, PUT and DELETE Fulfilment |
| GET, POST | batch | GET Cognito, POST Fulfilment |
| GET | batch-item, batch-item/{id} | Cognito |
| POST | batch-item | Fulfilment |
| POST | stock/check | Fulfilment |
| GET | fulfilment/{order_item_id} | Packaging |
| POST | fulfilment/{order_item_id}/scan | Packaging |
| GET | fulfilment/{order_item_id}/fulfill | Packaging |
| GET | courier/{order_id} | API key |
| POST | courier/{order_id}/waybill | API key |
| POST | courier/tracking | API key |
| POST | waybill/auth, waybill/capture, waybill/complete, parcel/capture | none |
| GET | waybill/trackandtrace | none |
| POST | form-data, dashboard, report/medicine, report/batch, report/order | Cognito |
| GET | log | SuperAdmin |
| POST | presigned-url/generate-upload, generate-download | Cognito |
| POST | prescription-pack | Cognito |

CORS origin `*` on the routes, with headers Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, AccessToken, IdToken.

## Integrations

### Dawn Wing

Host hardcoded `swatws.dawnwing.co.za`, port 443, package `follow-redirects`. Env `COURIER_USER`, `COURIER_PASS`, `COURIER_PATH`. Example path `/dwwebservices/v2/uat/api`. Commit `121c681` says live credentials stay in `live.yml`. Commit `130338a` says the path switches Dawn Wing between live and staging.

### S3 and email

Buckets created: deployments, `heisenberg-frontend-${stage}`, `heisenberg-prescriptions-${stage}` (CORS GET, PUT, POST, HEAD from `*`). Presigned URL handlers sign whatever bucket, key, and region the body names.

Email layer (`nodemailer`, Handlebars, SES, S3 templates) reads `ENVIRONMENT`, `MOCK_MAILER_*`, `EMAIL_TEMPLATE_BUCKET_NAME`. No file under `src/` imports it.

### Puppeteer

`src/puppeteer/generate` has its own `package.json`. `generate.js` compiles a Handlebars template and renders JPEG or PDF. Only `lib/templates/order_summary.hbs` is in the repo. The function timeout is 30 seconds. Memory is 128 MB in `serverless.puppeteer.yml`. Whether that memory is enough for Chromium in AWS was not tested here.

## Environment

From `example.env.yml`:

| Key | Used for |
|---|---|
| RDS_HOST | Import of the RDS address |
| RDS_USER, RDS_PASS, RDS_DB | Database layer. Example leaves user, password, and database blank |
| FROM_MAIL, MOCK_MAILER_HOST, MOCK_MAILER_PASS, MOCK_MAILER_PORT, MOCK_MAILER_USER | Email layer |
| EMAIL_TEMPLATE_BUCKET_NAME | `heisenberg-emails-${stage}` |
| COGNITO_POOL_ID | Import of the user pool id. Handlers read `USER_POOL_ID` instead |
| env | Stage name |
| layer-versions.* | Published layer versions |
| COURIER_USER, COURIER_PASS, COURIER_PATH | Dawn Wing. Example values are the literals `username`, `password`, and the UAT path |

## Local development

**Documented.** Install AWS CLI, `npm install -g serverless`, `npm ci`. On PowerShell, remove the `sls` alias. To invoke a function locally, the README says to rewrite layer imports to relative paths such as `require('../../../../../layers/database/nodejs/database')`, then `serverless invoke local --function functionName -p pathToJSON`.

`npm test` is a script that prints "Error: no test specified" and exits 1. No test directory, no CI file, and no Sonar properties are on this revision.

## Security notes visible in the templates

These are facts about the committed infrastructure, for reviewers.

- RDS is created publicly accessible, without storage encryption, with backups disabled and deletion protection off.
- Waybill proxy routes have no API Gateway authorizer.
- Presigned URL generation does not pin the bucket name.
- Cognito identity pool allows unauthenticated identities, and the authenticated role can invoke any Lambda.
- Authorizer result cache is 0, and group checks list every user in the group.
- MFA on the user pool is off.

## Logging and errors

API Gateway execution logs are turned on in the Serverless provider block. Handlers log the event and the error with `console.log`. The HTTP body stays the `{ success, message, data, errors }` envelope from `general-utils.js`. There is no correlation id and no third-party APM config in the repo.

## What this document does not establish

- The AWS account id, the deployed stage names that actually exist, or the public URL.
- Contents of `dev.env.yml`, `stage.env.yml`, `live.env.yml`, or `prod.env.yml`.
- Whether `USER_POOL_ID` is injected outside the example file.
- Whether the diagram `database/diagrams/main.uxf` still matches the SQL after the 2022 migrations. The SQL was used as the schema source.

