# PRD — ohlief-app-api

Evidence date: 2026-10-08. Source: `git@bitbucket.org:overdrive/ohlief-app-api.git`, `master` `08d6e03` (2020-07-31).

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

No formal PRD existed in the repository or in `silicon_ark/ohlief-app-api/` before this file. Requirements below are recovered from `serverless.yml` and the handlers. They are not a new product decision.

`package.json` still says "A Node.js starter for the Serverless Framework". That sentence describes the upstream starter, not Oh-lief. The service name and the EDI handlers are the product surface.

The browser that calls most of these routes is documented in `silicon_ark/ohlief-app-client/`. Where that set says the server effect is unknown, this file states what this repository does. The client set was not rewritten here.

## Product purpose

An HTTP API, service name `ohLief-app-api`, stores order lines and products in one DynamoDB table and moves pipe-delimited files over FTP.

A caller can list orders, invoices, and products; change the invoice quantity on an order line; mark every stored row with a given order number as an invoice; create and edit products. Two further routes pull order files from FTP and push an invoice file back to FTP, then email a copy of the text. A notes-style create, a delete, and a Stripe charge remain deployed.

**Documented** title: `README.md`. **Implemented** routes: `serverless.yml`. **Unknown / Requires Confirmation:** the commercial meaning of "EDI" and "xProcure". Those words are in the client, not in this repository.

## Users and roles

This API does not read a role, group, or tenant claim.

| Actor | How the API treats them | Provenance |
|---|---|---|
| IAM-signed caller | May call every route whose `authorizer` is `aws_iam` | **Implemented** |
| Unsigned caller | `GET /ftptos3` and `GET /invoicetoftp/{id}` have the authorizer line commented out | **Implemented** |
| Cognito identity | Used as the DynamoDB partition key only by `create.js` and `delete.js`. Order and product handlers use the fixed strings `OrderRow` and `ProductRow` | **Implemented** |

The template also creates a user pool (email as username, email auto-verified) and an identity pool that refuses unauthenticated identities and may `execute-api:Invoke` the whole API. Whether that pool is the one the client uses is **Unknown / Requires Confirmation**. The HTTP authorizer in the template is `aws_iam`, not a Cognito user-pool authorizer.

Any caller who can invoke a route sees every order, invoice, and product. There is no per-user filter on those scans.

## Major workflows

### 1. Import an order file

Trigger: `GET /ftptos3`. Nothing in this repository calls it. The `rate(1 minute)` schedule is commented out.

Inputs: FTP connection `FTP_HOST_ORDER`, `FTP_USERNAME_ORDER`, `FTP_PASSWORD_ORDER`. AWS keys `AWS_ACCESS_KEY_VALUE` and `AWS_SECRET_ACCESS_KEY_VALUE`. No path or body is read.

Behaviour:

- Connect, list the FTP directory, and take entries whose `type` is `-` (a file).
- Read the file as text. Split lines on `\n` and fields on `|`.
- A line is considered only when field index 9 is defined (at least ten fields).
- Look up products with `packageBarcode` equal to field 15. For each match, put an item with `userId` `OrderRow`, `type` `order`, `process` `"0"`. If nothing matches, no item is written for that line. If several match, one item is written per match.
- Several file columns are replaced by the product row. See BR-007 and BR-017.
- Copy the file to `processed/{file name}` on the same FTP host and delete the original. This is not waited on, and it does not depend on the DynamoDB put succeeding.
- Upload `JSON.stringify` of the accumulated text to S3 bucket `ohlief-app-uploads`, key equal to the file name.
- Send an SES email, subject `OH-LIEF New Order submitted`, with the text and a link to `http://orders.ohlief.com`.

The handler function returns no HTTP body. The FTP work runs in callbacks after `main` has already finished. Whether Lambda stays alive long enough is **Inferred** as a runtime risk, not something this inspection executed.

Output: DynamoDB items, an FTP archive copy, an S3 object, and an email, when the callbacks complete. **Implemented** control flow in `ftptos3.js`.

### 2. Edit an invoice quantity

Trigger: `PUT /ohlief/{id}` with a JSON body.

The path `id` is the `transactionId`. The partition key written in the update is the literal `OrderRow`, not the caller.

The update sets `content`, `attachment`, and `invoiceQty`. Each value uses `|| null`, so `0`, `""`, and any other falsy value is stored as `null`.

Success body: `{ status: true, table: <tableName> }` with HTTP 200. A DynamoDB error returns HTTP 500 `{ status: false }`. The updated item is not returned, even though `ReturnValues` is `ALL_NEW`.

The client sends `{ content, attachment, invoiceQty }` and treats this as saving the invoice quantity. **Implemented** here. The client rule that Save stays disabled when `content` is empty is a client rule, not a check in this handler.

### 3. Mark an order number as invoiced

Trigger: `GET /invoiceupdate/{id}`. The client calls this after a confirm dialog, with the order number.

Behaviour:

- Scan the table where `orderNumber` equals the path `id`. There is no `type` filter, so rows that are already invoices match again.
- For each hit that has a `transactionId`, update key (`OrderRow`, that id) and set `type` to `invoice`.
- The update uses a callback and is not awaited. The HTTP response is `{ status: true }` as soon as the scan returns.
- `process` is not changed. A second call does not undo the first. It sets `type` to `invoice` again.

This is the server effect the client document left as unknown. **Implemented** in `invoiceupdate.js`.

### 4. Drop an invoice file

Trigger: `GET /invoicetoftp/{id}`. The path `id` is not read.

Behaviour:

- Scan where `type` is `invoice` and `process` is `"0"`.
- Sum line amounts across that whole result, using a factor of `0.21` on the ex-VAT amount. The per-order split in the source is commented out, so one buffer holds every matching line, and the file name uses the first `orderNumber` seen (`I{orderNumber}.csv`).
- Each line is also updated to `process` `"1"`. That call is not awaited, and it runs before the length check.
- If the buffer's length is greater than 10, upload it to the invoice FTP host (`FTP_HOST_INVOICE` and the matching user and password) and send an SES email, subject `OH-LIEF New Invoice submitted`.
- Respond `{ status: true }` even when the buffer was too short to upload. FTP and SES errors are not turned into the HTTP failure response.

**Implemented** in `invoicetoftp.js`. Who is supposed to call this route is **Unknown**.

### 5. Maintain products

Trigger: `POST /createProduct`, `GET /listProducts`, `GET /getproduct/{id}`, `PUT /updateProduct/{id}`.

Create writes `userId` `ProductRow`, `type` `product`, and the body fields listed in FR-002. Get and update address that same partition. List scans `type` `product`.

There is no check that barcode, price, or any other field is present. **Implemented.**

### 6. Create a note, or delete by caller

`POST /ohlief` still inserts a note under the caller's Cognito identity id (`create.js`). It does not set `type`, and it does not use `OrderRow`. `GET /ohlief/{id}` looks up `OrderRow`, so it does not return that note.

`DELETE /ohlief/{id}` deletes the caller's identity id plus the path id. It does not delete an `OrderRow` or `ProductRow` item.

The client still has note screens that call these routes. **Implemented** mismatch between create and get.

## Integrations

| System | Direction | Trigger | What moves | Auth in this repo | Failure behaviour |
|---|---|---|---|---|---|
| Amazon API Gateway | Inbound | HTTP events in `serverless.yml` | JSON body or path `id` | `aws_iam`, except the two FTP routes | Gateway 4XX/5XX add CORS headers. Handler failures are HTTP 500 |
| DynamoDB | Read and write | Every handler except billing and `hello` | Items in the table named by `tableName` | Lambda role, `dynamodb` item APIs on `arn:aws:dynamodb:eu-west-1:*:*` | Catch returns `{ status: false }` except where the call is not awaited |
| Order FTP | Read, then write `processed/` and delete | `GET /ftptos3` | Pipe-text files | Username and password from env | Errors are logged. The handler does not return `failure()` |
| Invoice FTP | Write | `GET /invoicetoftp/{id}` when the buffer is longer than 10 | One `.csv` of pipe lines | Username and password from env | `throw` inside the FTP callback. Not mapped to the HTTP response |
| Amazon S3 | Write | Each imported file | JSON string of the file text, bucket `ohlief-app-uploads` | Env access key and secret on the S3 client | Logged |
| Amazon SES | Write | Order import, and invoice export when the buffer is long enough | HTML and text body, configuration set `info`, source `accounts@ohlief.com` | Static credentials in `invoicetoftp.js`. Env credentials in `ftptos3.js` | Logged. HTTP still returns success if the scan itself succeeded |
| Stripe | Write | `POST /billing` | Charge, currency `usd`, description `Scratch charge` | `STRIPE_SECRET_KEY` | HTTP 500 `{ message: e.message }` |

Region on the FTP handlers is `eu-west-1`. `filetoDDb.js` sets `ap-southeast-2` and is not a deployed function.

## Constraints

- Runtime in the template is `nodejs10.x`. Stage defaults to `prod`. Region is `eu-west-1`. Profile name is `ohlief`.
- Functions read table name `order`. The template's DynamoDB resource is named `${stage}-ohlief`. Both are in the same file. Which one a deployment actually uses is **Unknown / Requires Confirmation**.
- Scans and the product lookup do not follow `LastEvaluatedKey`. One response page is returned.
- `invoiceQty` of `0` is stored as `null` because of `||`.
- VAT factor `0.21` is a literal in `invoicetoftp.js`. No configuration or comment names it as a tax rate. **Inferred** that it is 21% VAT.
- Money on the invoice file is computed in this API. The client computes a different total (round of quantity × unit price × pack, no VAT) for display. Both are implemented in their own repositories.
- CORS success responses send `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true` together.
- No rate limit, captcha, or request schema is defined in this repository.

## Functional requirements

### FR-001 — Create a note

`POST /ohlief`, IAM authorizer, JSON body.

The system reads `content` and `attachment`, generates two UUID v1 values (`noteId` and `transactionId`), sets `userId` to `event.requestContext.identity.cognitoIdentityId` and `createdAt` to `Date.now()`, and puts the item.

Success returns the item, HTTP 200. A put error returns HTTP 500 `{ status: false }`. `JSON.parse` sits outside `try`. Invalid JSON is not turned into `failure()`.

Evidence: `create.js`, `serverless.yml`.

Implementation: **Implemented.** Confidence: High.

### FR-002 — Create a product

`POST /createProduct`, IAM authorizer, JSON body.

The system puts an item with `userId` `ProductRow`, `type` `product`, new `noteId` and `transactionId`, `createdAt`, and these body fields: `productCode`, `barcode`, `packageBarcode`, `productDescription`, `unitPrice`, `quantityPerPack`, `sizeOfProduct`, `unit`, `rsp`.

No required-field check. Success returns the item.

Evidence: `createProduct.js`. The partition key changed from the caller identity to `ProductRow` in commit `a031ae1`.

Implementation: **Implemented.** Confidence: High.

### FR-003 — Read one order line

`GET /ohlief/{id}`, IAM authorizer.

The system gets key (`OrderRow`, path `id` as `transactionId`). A hit returns the item. A miss returns HTTP 500 `{ status: false, error: "Item not found." }`, not HTTP 404.

Evidence: `get.js`.

Implementation: **Implemented.** Confidence: High.

### FR-004 — Read one product

`GET /getproduct/{id}`, IAM authorizer.

Same as FR-003 with partition `ProductRow`.

Evidence: `getproduct.js`.

Implementation: **Implemented.** Confidence: High.

### FR-005 — List orders

`GET /ohlief`, IAM authorizer.

The system scans where `type` equals `order` and returns `Items`. Rows with another `type`, including notes from FR-001, are not included. One scan page only.

Evidence: `list.js`.

Implementation: **Implemented.** Confidence: High.

### FR-006 — List invoices

`GET /listinvoices`, IAM authorizer.

The system scans where `type` equals `invoice` and returns `Items`.

Evidence: `listinvoices.js`.

Implementation: **Implemented.** Confidence: High.

### FR-007 — List products

`GET /listProducts`, IAM authorizer.

The system scans where `type` equals `product` and returns `Items`.

Evidence: `listProducts.js`.

Implementation: **Implemented.** Confidence: High.

### FR-008 — List the OrderRow partition

`GET /listOrders`, IAM authorizer.

The system queries `userId` = `OrderRow` and returns `Items`. It does not filter `type`, so order rows and invoice rows in that partition both match.

The client document does not call this path. It is still declared.

Evidence: `listOrders.js`, `serverless.yml`.

Implementation: **Implemented.** Confidence: High.

### FR-009 — Mark an order number invoiced

`GET /invoiceupdate/{id}`, IAM authorizer.

The path value is compared to `orderNumber`. Each matching item that has a `transactionId` is updated at (`OrderRow`, `transactionId`) so that `type` becomes `invoice`.

The HTTP result does not wait for those updates. Body `{ status: true }`.

Evidence: `invoiceupdate.js`.

Implementation: **Implemented.** Confidence: High.

### FR-010 — Update an order line

`PUT /ohlief/{id}`, IAM authorizer, JSON body.

The system updates (`OrderRow`, path `id`) and sets `content`, `attachment`, and `invoiceQty` from the body, with `|| null`.

Success body `{ status: true, table: process.env.tableName }`.

Evidence: `update.js`.

Implementation: **Implemented.** Confidence: High.

### FR-011 — Update a product

`PUT /updateProduct/{id}`, IAM authorizer, JSON body.

The system updates (`ProductRow`, path `id`) and sets `barcode`, `productCode`, `packageBarcode`, `productDescription`, `unitPrice`, `quantityPerPack`, `sizeOfProduct`, `unit`, and `rsp`, each with `|| null`.

`type` is not changed.

Success body matches FR-010.

Evidence: `updateProduct.js`.

Implementation: **Implemented.** Confidence: High.

### FR-012 — Delete by caller identity

`DELETE /ohlief/{id}`, IAM authorizer.

The system deletes key (`cognitoIdentityId`, path `id`). Success `{ status: true }`.

An order or product row, whose partition key is `OrderRow` or `ProductRow`, is not the key this function uses.

Evidence: `delete.js`.

Implementation: **Implemented.** Confidence: High.

### FR-013 — Stripe storage charge

`POST /billing`, IAM authorizer, JSON body `{ storage, source }`.

The system computes an amount with `calculateCost` and creates a Stripe charge in `usd` described as `Scratch charge`.

`calculateCost`: rate `4` when `storage <= 10`, else rate `2` when `storage <= 100`, else rate `1`. Amount is `rate * storage * 100`.

Success `{ status: true }`. Stripe errors return HTTP 500 `{ message }` from the exception.

The client document does not call this path. The description string is the starter wording.

Evidence: `billing.js`, `libs/billing-lib.js`.

Implementation: **Implemented.** Confidence: High. Whether it is still a product requirement: **Unknown / Requires Confirmation.**

### FR-014 — Import FTP order files

`GET /ftptos3`. The authorizer line is commented out. No schedule is active.

Behaviour is workflow 1. Field mapping is BR-007 and BR-017.

The function does not call `success()` or `failure()`.

Evidence: `ftptos3.js`, `serverless.yml`.

Implementation: **Implemented.** Confidence: High.

### FR-015 — Export unprocessed invoices to FTP

`GET /invoicetoftp/{id}`. The authorizer line is commented out. The path id is ignored. No schedule is active.

Behaviour is workflow 4. Column order and the `0.21` factor are BR-014 and BR-010.

Evidence: `invoicetoftp.js`.

Implementation: **Implemented.** Confidence: High.

### FR-016 — HTTP envelope

Successful handler responses use HTTP 200. Caught handler errors use HTTP 500. Both send `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Credentials: true`. The body is `JSON.stringify` of the value passed to `success` or `failure`.

`invoicetoftp.js` passes the caught exception object as `status`, so a failure body is not a stable message string.

API Gateway `DEFAULT_4XX` and `DEFAULT_5XX` add `Access-Control-Allow-Origin: *` and `Access-Control-Allow-Headers: *`.

Evidence: `libs/response-lib.js`, `resources/api-gateway-errors.yml`.

Implementation: **Implemented.** Confidence: High.

### FR-017 — Route protection

Every HTTP event except `ftptos3` and `invoicetoftp` sets `authorizer: aws_iam` and `cors: true`.

Those two events set `cors: true` and leave the authorizer commented out.

Evidence: `serverless.yml`.

Implementation: **Implemented.** Confidence: High. Whether the open routes are intentional: **Unknown / Requires Confirmation.**

## Client paths this API does not implement differently

The client calls the paths in FR-001 through FR-007 and FR-009 through FR-012. This API implements them as above. Two client assumptions are not server rules:

- The client disables Save when `content` is empty. `update.js` does not.
- The client displays totals without the `0.21` factor. `invoicetoftp.js` applies that factor.

`GET /listOrders` and `POST /billing` are deployed and are not in the client call list.
