# PRD — lambda_grinder

Evidence date: 2026-10-10.

Source: `git@bitbucket.org:overdrive/lambda_grinder.git`, `main` at `ffa0b1e0` (2025-03-22).

There is no prior PRD in the source repository or in Silicon Ark for this directory. Requirements below were read from this revision. Provenance is labelled. Secrets are omitted: tracked `.env` values were not copied, and hardcoded API and SMTP credentials are named by file only.

`README.md` describes three Lambda functions. `template.yaml` deploys two, with different triggers. Where they disagree, both are recorded. The code is the implemented behaviour.

## Product purpose

**Inferred.** Operate one golf course: ingest location fixes from tags, build rounds, compare them with goal times, and let staff manage tee times, devices, players, caddies, geofences, food and beverage, and on-course messages.

**Documented** in `README.md` as a modular Lambda version of `grinder.js` for "the golf round tracking system."

**Implemented** behaviour is three Node entry points plus a SAM application:

- `app.js` — HTTP and Socket.IO.
- `grinder.js` — polls MongoDB and builds rounds.
- `schedule.js` — cron.
- `template.yaml` — `FixInsert` and `GrindFunction`.

The database name in the hardcoded connection strings is `tagmarshal`.

## Users and interactions

| Actor | Evidence | Status |
|---|---|---|
| Course staff using a browser client | CORS allow-list in `.env.example` is `http://localhost:3000,https://localhost:3000`. Dashboard routes use the JWT middleware | **Inferred** caller. No UI is in this repo |
| User record | `models/user.js`: `externalId`, `role` (string), `permissions[]` | **Implemented** storage. The role catalogue is fetched from `LOCALHOST` `/api/roles`, not defined here |
| Laravel-style user service | `controllers/modules/user.js` calls `process.env.LOCALHOST` | **Implemented** HTTP client. The service is not in this repo. `LARAVEL_AUTH_SERVER` is in `.env.example` and is not read by the JavaScript that was searched |
| Caddie | `models/caddie.js`, routes under `/caddietag` | **Implemented** |
| Player | `models/player.js`, routes under `/players` and two-way data entry | **Implemented** |
| On-course device | Header `DeviceIMEI` on `/two-ways`. GPS posts to `/device/fix` and `/device/fixes` without the JWT middleware | **Implemented** |
| Tag posting to API Gateway | `POST /device/fix/{device_id}` on `FixInsert`. No authorizer is set on that event | **Implemented** in the SAM template |
| Central server | `middleware/server.js` allows `SERVER_IP` or `ENV=local` | **Implemented** |
| Operator | README does not document `node app.js`. The three process files are still startable | **Implemented** as files. The operating procedure is **Unknown** |

`middleware/auth.js` verifies a JWT with `JWT_SECRET_KEY` and copies `userId`, `caddieId`, `playerId`, and `name` onto the request. It updates `User.lastSeen` when `userId` is present. It does not read `role` or `permissions`.

**Unknown / Requires Confirmation:** which roles exist, and which screens may call which routes. `Config.pageAccess` is an untyped array with the comment `TODO: fix this`.

## Systems the product talks to

1. MongoDB. `app.js`, `grinder.js`, and `schedule.js` connect to `mongodb://tagmarshal-poc-data-config-nlb-3d7a4f2e6b8260e7.elb.us-east-2.amazonaws.com:27017/tagmarshal`. `GrindFunction` uses `mongodb://${process.env.mongoqrnlb}:27017/tagmarshal`. The template sets `mongoqrnlb` to that same host. `.env` contains `DATABASE_URL`. Those three processes do not read it.
2. Legacy Tagmarshal HTTP API at `https://{course}.tagmarshal.com` during course import (`controllers/course.js`). Ongoing legacy fixes use `Config.legacyBaseUrl` (`modules/fixes.js` via `schedule.js`).
3. User and role HTTP API at `LOCALHOST` (`controllers/modules/user.js`).
4. Tee-sheet providers selected by `Config.teesheetIntegration.provider`. The file name under `teetimes/providers/` must match. Each file exports `processTeetimes`.
5. Golf Genius when `Config.golfGeniusConfig.apiKey` is set.
6. European Tour / ETX helpers when the provider is `etx`, on the midnight job (`modules/europeanTour.js`).
7. Firebase Cloud Messaging. Service-account paths come from `VIRTUALTAG_FIREBASE` and `TMAPP_FIREBASE` (`modules/notifications.js`). Those files are not in git.
8. Mailgun for caddie messages (`modules/caddie.js`).
9. MessageBird (`modules/messagebird.js`).
10. Gmail SMTP from `controllers/modules/trackmap.js` when a round is shared. Credentials are hardcoded. The password is omitted.
11. AWS, from `template.yaml`: API Gateway (regional, stage `prod`, X-Ray on), Lambda `nodejs18.x`, SQS, a dead-letter queue, CloudWatch alarm `ApiGateway5xxErrors`, X-Ray, VPC. IAM policies on the functions are the managed full-access policies named in the template (`AmazonSQSFullAccess` on both functions; `AmazonDynamoDBFullAccess` and `AmazonSSMFullAccess` on `GrindFunction`). No DynamoDB table and no SSM parameter are defined in the template.
12. Monday.com `https://api.monday.com/v2` is written in `controllers/course.js` after a `return`, so it does not execute. The bearer token is omitted.

## Major workflows

### WF-1 — Start the API

**Implemented** in `app.js`. Not described in `README.md`.

1. Load `.env` from the process directory and from the parent directory.
2. Connect to the hardcoded MongoDB URL. On failure, `config/database.js` logs and `process.exit(0)`.
3. Split `ALLOWED_ORIGINS` on commas. A browser `Origin` in that list is reflected. Any other origin is refused.
4. If `ALLOWED_ORIGINS` is missing, `.split` throws before listen.
5. Listen on `PORT`.
6. On startup, if the single `Config` document has no `northEastLatitude`, copy `northEastLatitute` into it. The comment says to remove this later.
7. Socket.IO listens on the same HTTP server. `handlers/teetimes.js` handles `teetimes:assignPlayer` and `teetimes:assignTag` and emits `teetimes:teetime` with an empty object.

### WF-2 — Import a course

**Implemented** in `controllers/course.js`. `GET /course/change/:course/:importAll?`. No auth middleware on `/course`.

1. Base URL is `https://{course}.tagmarshal.com`.
2. Always starts import of config, sections, holes, advertisements, widgets, and devices.
3. Optional path segment `importAll` also starts the broader import in `modules/migration.js` `start`, after devices resolve.
4. The handler `return res.end()` immediately. The imports continue in the background.

### WF-3 — Issue and check a token

**Implemented** in `routes/token.js`. Mounted at `/token` behind `middleware/server.js`.

1. `POST /token` is `tokenCtrl.genToken`.
2. `GET /token` runs auth, then `verifyToken`.
3. `GET /token/refresh` is `refreshToken` and is not behind the auth middleware in the route file. It is behind the server middleware because the mount is `app.use('/token', server, token)`.
4. When `ENV` is `local`, the server middleware allows any caller.

JWT settings in `.env.example`: header `UserToken`, expiry example `7d`. The auth middleware also accepts `Bearer` or an `authorization` header with the word `Bearer` removed.

### WF-4 — Ingest a fix through Express and build a round in grinder.js

**Implemented** ingest in `routes/device.js` and `controllers/device.js`. These routes do not use `auth`:

- `POST /device/fix/:imei`
- `GET /device/fix/:imei/:lat/:lon/:accuracy/:speed/:battery/:source/:date/:geoBreach?`
- `POST /device/fixes/:imei`
- `POST /device/fixes/cached/:imei`

`POST /device/fix/:imei`:

1. Load the device by `deviceId` equal to the IMEI. If none, respond `404` with the text `Unkown device` (spelled that way).
2. If the body omits `is_geobreach`, call `geoModule.checkIntersection`.
3. Create a `Fix`, set `Device.lastFix`, and on a breach create a `GeofenceBreach` and user notifications for users whose permissions include an active entry named `Geofence Breaches`. Notification ids 7, 8, and 9 map to low, medium, and high breach text.
4. Respond `OK`.
5. The breach record sets `location` from `locations[-1]`. In JavaScript that is not the last array element.

**Implemented** processing in `grinder.js`, `setInterval(grind, 5000)`, with a `busy` flag.

1. Load `Config` fields `fixLimit`, `tiedRoundTime`, `canAutoAssign`, `courseName`, `timezone`, `primary`, `turnTime` (default 1800 seconds when unset).
2. If there is no config, log `No config found.` and return.
3. Read fixes after `LastFix.fixId`, limited by `config.fixLimit` (schema default 750). `.env.example` `FIX_LIMIT=1` is not read here.
4. Skip devices with `marshal`, `super`, or `drinks` true. A comment says to remove that special case later.
5. Match the point to a section polygon with `$near` and `$maxDistance: 1` on the `2dsphere` index in `models/section.js`.
6. If the device has an open round today and the section is the next section, a skip, or a targeted hop, update location, pace, play time, and projected goal time.
7. A stop trigger, a skipped stop, or the last section sets `complete: true` and clears `Device.round`. Shotgun rounds are excluded from the ordinary stop trigger (`isShotgunRound`).
8. If the section means a new round, start one. Shotgun helpers in `modules/shotgun.js` can override that. Closest tee time uses `process.env.CLOSEST` or 120.

### WF-5 — Ingest a fix through API Gateway and GrindFunction

**Documented** in the README as three functions: Grind on a 5-minute schedule, Handle Fix on SQS, Get Raw Fixes on `POST /getRawFixes`.

**Implemented** in `template.yaml` and the two handlers. There is no schedule event and no `getRawFixes` resource. Commented outputs still name `HandleFixFunctionARN` and `GetRawFixesFunctionARN`.

`FixInsert` — `POST /device/fix/{device_id}` on the SAM API, stage `prod`:

1. If `event.body` is missing, return status 400 and `{ error: 'No body for payload found!' }`.
2. Parse the body as `application/x-www-form-urlencoded` with a local splitter. It does not parse JSON.
3. Coerce `accuracy`, `battery`, `goalTime`, `hole`, `lat`, `lng`, `speed`, and `number` with `Number`.
4. Set `device_id` from the path and `timestamp` from `new Date(payload.date).getTime()`.
5. Send one SQS message to `QUEUE_URL` (`FixProcessingQueue`). Attributes: `Source=api-gateway`, `DeviceId`.
6. On success, status 200 and the SQS message id. On throw, status 500.
7. The function does not check that the device exists. No API authorizer is configured.
8. Timeout 10 seconds, memory 512 MB. VPC: security group `sg-003babb1267989dfa`, subnets `subnet-0e60752ccfc80074f` and `subnet-069f4c1a796c82f05`.

`GrindFunction` — SQS event, batch size 10, batching window 5 seconds, timeout 120 seconds, reserved concurrency 300, memory 512 MB:

1. Connect with `layers/database/nodejs/bd/connection.js`, reusing a connection while `readyState === 1`. Pool max 50, min 5, server selection timeout 5000 ms.
2. Retry the batch up to 3 times (`async-retry`, 1s to 5s).
3. Load `Config` (one document) and the devices whose `deviceId` is in the batch.
4. Drop payloads whose device does not exist.
5. Find sections whose polygon `$geoIntersects` a `MultiPoint` of the coordinates, then keep the first Turf `booleanPointInPolygon` match per fix.
6. Insert `Fix` documents and set `Device.lastFix`.
7. Skip devices flagged `marshal`, `super`, or `drinks`, and skip fixes with no `sectionNumber`.
8. Load one round with `Round.findOne({ course: 'Steenberg' })`.
9. If that round is missing, or `locations.length > 100`, call `Round.deleteMany({})` and create one new round. The comment in `createNewRound` says "create new test round".
10. The new round sets `course: 'Steenberg'`, `isNineHole: true`, and a start time of the fix date minus `all_sections[0].properties.goalTime` seconds. Closest-tee-time code in that function is commented out.
11. Append a location and pace fields via `modules/rounds.js` and `modules/gaptime.js` from the database layer. If pace in minutes is negative, look up a holding-up round.
12. `bulkWrite` the round updates.
13. Delete the SQS messages with `deleteMessageBatch`. The event source mapping also deletes messages when the handler returns successfully. If the handler throws, the error comment says the message moves to the DLQ. The queue redrive policy is `maxReceiveCount: 5` onto `high-volume-dlq`.

The template output `ApiEndpoint` is `https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/prod/messages`. The only API event path is `/device/fix/{device_id}`. There is no `/messages` method in the template.

### WF-6 — Midnight close and tee-sheet sync

**Implemented** in `schedule.js`. The cron timezone is `Config.timezone` captured when the process starts.

| Schedule (node-cron, 6-field) | Behaviour |
|---|---|
| `*/5 * * * * *` | If `legacyBaseUrl` is set, `getLegacyFixes()`. This expression fires every 5 seconds |
| `0 */5 * * * *` | If `teesheetIntegration.provider` is set, `require` that provider and `processTeetimes`. If `golfGeniusConfig.apiKey` is set, also run `teetimes/providers/golfgenius.js` |
| `0 0 0 * * *` | Set device `assignment` from `temporary` to `permanent`. The call passes `{ marshal: false }` as the third argument, which Mongoose treats as options, not as an update. Mark open rounds whose `_id` is after yesterday's local start-of-day `complete: true`, `isIncomplete: true`, `reason: 'Closed at midnight'`. Clear `Device.round`. If the provider is `etx`, refresh tournaments and the active tournament. If Golf Genius has an API key, `getEvents` |
| `10 0 * * *` | Five-field expression. If today has no tee times and `defaultTeetimes` is set, load defaults. node-cron accepts both 5-field and 6-field expressions |
| `5 0 * * *` | Apply the pin template whose schedule covers today, or clear hole pins |

The second job's comment says "every 5 minutes". The first job's comment says "get fixes from legacy" and does not say 5 seconds.

### WF-7 — Two-way device session

**Implemented** mount `app.use('/two-ways', twoway, twowayRoutes)`.

`middleware/twoway.js` requires header `DeviceIMEI`. Unknown device: status 404 `{ message: "Device not found" }`. Missing header: status 422 `{ message: "Device imei is required" }`. It does not check a JWT.

The route file exposes menu, tee times, messages, food menu, device info, round info, fix insert, ratings, Golf Genius round, geofences, orders, drinks cart, advertisements, scores, holes, data entry, and disclaimer acceptance. See `features.md` for the path list.

## Functional requirements

### FR-001 — Express API process

The system serves the mounts in `app.js` on `PORT` after connecting to MongoDB.

- Actor: operator process, then HTTP clients.
- Trigger: process start.
- Validation: CORS allow-list; JWT on the mounts that pass `auth`; server IP or `ENV=local` on `/token` and `/user-server`.
- Error: Mongo failure exits the process. Missing `ALLOWED_ORIGINS` throws. Bad JWT returns `401` `{ valid: false }`.

Evidence: `app.js`, `middleware/auth.js`, `middleware/server.js`. Implementation: **Implemented**. Confidence: high.

### FR-002 — Device fix persistence on the Express path

A known device can store a fix by JSON POST, by URI GET, by batch POST, or by cached batch POST. An unknown IMEI on the single-fix POST returns 404.

Evidence: `routes/device.js`, `controllers/device.js`. Implementation: **Implemented**. Confidence: high.

### FR-003 — Round building on the polling grinder

`grinder.js` turns stored fixes into open rounds for the device, using section polygons, goal time, pace, gap time, and shotgun rules. Marshal, super, and drinks devices are skipped.

Evidence: `grinder.js`, `modules/rounds.js`, `modules/shotgun.js`, `modules/gaptime.js`, `modules/pace.js`. Implementation: **Implemented**. Confidence: high for the control flow that was read; individual pace formulas inside `modules/rounds.js` were not re-derived line by line.

### FR-004 — Queued fix intake

API Gateway `POST /prod/device/fix/{device_id}` accepts a URL-encoded body and enqueues it. It does not write MongoDB.

Evidence: `FixInsert/index.js`, `template.yaml`. Implementation: **Implemented** in the template. Whether the stack is deployed: **Unknown**. Confidence: high for the source.

### FR-005 — Lambda grind

`GrindFunction` inserts fixes for known devices and updates a single round whose `course` is `Steenberg`, deleting all rounds when that round exceeds 100 locations.

Evidence: `grind/index.js`. Implementation: **Implemented** in source. This conflicts with FR-003. Confidence: high.

### FR-006 — Scheduled course jobs

While `schedule.js` is running, the jobs in WF-6 run in `Config.timezone`.

Evidence: `schedule.js`. Implementation: **Implemented**. Confidence: high.

### FR-007 — Staff dashboard and course admin API

JWT callers can use the dashboard, tee time, round, device tracker, geofence, goal, pin, analytics, Golf Genius, messaging, and food-and-beverage routes mounted behind `auth` in `app.js`.

Evidence: `app.js` and the route files under `routes/`. Implementation: **Implemented** as routes. Each controller was not re-tested. Confidence: high for the mount list; medium for every controller branch.

### FR-008 — On-course two-way API

A device identified by `DeviceIMEI` can call `/two-ways` as listed in `routes/modules/twoway.js`.

Evidence: `middleware/twoway.js`, `routes/modules/twoway.js`. Implementation: **Implemented**. Confidence: high for the gate; medium for each controller.

## Important constraints

- One `Config` document is assumed (`Config.findOne()`).
- Section matching on the polling grinder uses a 1-metre `$near` query. The Lambda grind uses polygon intersection and Turf.
- Lambda round updates ignore `courseName` from config and use the literal `Steenberg`.
- Global Lambda timeout in the template is 600 seconds. `FixInsert` overrides it to 10 seconds and `GrindFunction` to 120 seconds.
- SQS visibility timeout is 120 seconds. The YAML comment says "5 minutes".
- API throttling in the template is rate 10000 and burst 20000. The comment says "Allow up to 5000 requests per second".
- `ReservedConcurrentExecutions` is 300. The comment says "up to 900".
- Node 18 is the Lambda runtime (`template.yaml`) and the README dependency line. `package.json` has no `engines` field.
- Root `package.json` depends on `mongoose` 6. The Lambda node-modules layer depends on `mongoose` 8. The processes do not share one `node_modules` tree.

## Conflicts that require a decision

| Topic | README or other doc | Code |
|---|---|---|
| Function count | Three functions | Two resources in `template.yaml` |
| Grind trigger | Every 5 minutes | SQS, batch 10 |
| Handle Fix | SQS consumer that updates rounds | No such function. `GrindFunction` both inserts fixes and updates rounds |
| Get Raw Fixes | `POST /getRawFixes` | Not in the template. `getRawFixes` is a function inside `grinder.js` |
| API output URL | — | Output path `/prod/messages`; event path `/device/fix/{device_id}` |
| Round identity | — | `grinder.js` uses the device's open round today. `grind/index.js` uses one `Steenberg` round and can delete every round |
| Database URL | `.env` key `DATABASE_URL` | Entry points hardcode the POC NLB |

## Unknown / Requires Confirmation

- Which process is the production fix path.
- Whether `grind/index.js` is approved behaviour or a test handler left as `index.handler`.
- The public API domain, if any. The template has no custom domain.
- Whether the VPC, security group, and subnets in the template are still valid.
- The caller of `FixInsert` (tag firmware versus the Express app). The Express app does not publish to SQS.
- Product owner and the relationship to `orginal_code`.
