# Business rules — lambda_grinder

Evidence date: 2026-10-10.

Source: `main` at `ffa0b1e0`.

Rules below are taken from code that enforces them. Each rule is labelled. Nothing in the repository is a signed business-rules document. Do not treat the Lambda grind rules as a replacement for the polling grinder until a person confirms which path is live. They contradict each other.

## Course and configuration

**BR-001 — One configuration document.** **Implemented.** `app.js`, `grinder.js`, `schedule.js`, and `grind/index.js` all call `Config.findOne()`.

**BR-002 — Timezone comes from that document.** **Implemented.** `schedule.js` reads `Config.timezone` once at startup and passes it to node-cron. A change of timezone is not picked up until the process starts again.

**BR-003 — Feature flags live on Config.** **Implemented** as schema defaults in `models/config.js`, including `teetimesActive` (default true), `foodAndBeveragesActive` (default false), `messagingActive`, `scoringActive`, `drinksActive`, `leaderboardActive`, `calibrationMode` (default false), `canAutoAssign` (default true), `maintenance` (default false). Which flags each controller checks was not re-read for every route.

**BR-004 — Map corner typo is repaired once.** **Implemented.** If `northEastLatitude` is missing and `northEastLatitute` is present, startup copies the misspelled field. Comment: remove later.

**BR-005 — Page access is unfinished.** **Implemented** storage only. `pageAccess: Array` with comment `TODO: fix this`. No rule for what the array means was found.

## Devices and who is a playing group

**BR-006 — A fix must belong to a known device on the Express path.** **Implemented.** `controllers/device.js` `insertFix` returns 404 `Unkown device` when `deviceId` does not match the IMEI.

**BR-007 — The Lambda intake does not apply BR-006.** **Implemented.** `FixInsert` queues any path `device_id`. `grind/index.js` drops the message later when `Device.find` returns nothing. The HTTP response is still 200 if SQS accepts the message.

**BR-008 — Marshal, super, and drinks tags are not playing groups.** **Implemented** in both `grinder.js` and `grind/index.js`. Those boolean flags skip round updates. `grinder.js` comments that this special case should be removed. **Requires confirmation** before treating the skip as a permanent rule.

**BR-009 — Device identity for the two-way API is the IMEI header.** **Implemented.** `DeviceIMEI` must match `Device.deviceId`. Missing header is 422. Unknown device is 404.

**BR-010 — Temporary assignments become permanent at local midnight.** **Implemented** as an update of `assignment` from `temporary` to `permanent`. The third argument `{ marshal: false }` is the Mongoose options object, so this job does not clear the marshal flag.

## Fixes and geofences

**BR-011 — Express single-fix POST stores a Fix and the device's lastFix.** **Implemented** in `insertFix`.

**BR-012 — A breach creates a GeofenceBreach and staff notifications.** **Implemented** when `is_geobreach` is true and a geofence was found. Users are selected with permission name `Geofence Breaches` and `active: true`. Notification id 7, 8, and 9 choose the low, medium, and high sentence. The text includes the device tag and the geofence name.

**BR-013 — If the client does not say whether a breach happened, the server computes it.** **Implemented.** `insertFix` calls `geoModule.checkIntersection` when `is_geobreach` is null or undefined.

**BR-014 — Lambda fixes copy breach, accuracy, speed, battery, source, date, and position from the form body.** **Implemented** in `fixFunction` inside `grind/index.js`. Section name, number, hole, hole section, and goal time come from the polygon match, not from the body fields that `FixInsert` also parses (`hole`, `number`, `goalTime`).

**BR-015 — Express section match and Lambda section match differ.** **Implemented.**

- `grinder.js` uses `$near` with `$maxDistance: 1` on the section polygon.
- `grind/index.js` uses `$geoIntersects` plus Turf `booleanPointInPolygon`, and keeps the first containing section.

**Inferred.** A point can be a fix in one path and have no section in the other. **Requires confirmation** of which match is the rule.

**BR-016 — Fix batch size for the poller is configuration, not the env example.** **Implemented.** `grinder.js` uses `Config.fixLimit` (schema default 750). `FIX_LIMIT` in `.env.example` is 1 and is not read by `grinder.js`.

## Rounds — polling grinder

These rules are **Implemented** in `grinder.js` `handleFix` and the modules it calls.

**BR-017 — An open round is today's incomplete round for that device.** The query is `_id` greater than the local start-of-day ObjectId, `device` equal to the tag, `complete: false`.

**BR-018 — The next section extends the round.** `roundModule.isNextSection` returning `true`, `skipped-stop`, or an object with `target` updates current section, hole, pace, play time, projected goal time, and last fix.

**BR-019 — A stop completes the round.** Completion is set when the result is `skipped-stop`, or the section trigger is `stop` and the round is not a shotgun round, or `isNext.lastSection` is set. `Device.round` is cleared. End time is the fix time for a skipped stop. Otherwise end time is the fix time plus the section goal time times `goalTimeFraction`.

**BR-020 — A new round can attach to a shotgun or to the closest tee time.** Shotgun fields copied onto the round include `goalTime`, `goalTimeFraction`, `isNineHole`, and `currentNine` (1 when nine holes, else 2). Closest tee time uses `CLOSEST` or 120. The unit of that number was not re-derived beyond the comparison in `grinder.js`.

**BR-021 — Turn time defaults to 1800 seconds.** **Implemented** when `Config.turnTime` is unset (`grinder.js` and the schema default).

**BR-022 — Midnight closes leftover rounds.** **Implemented.** Open rounds after yesterday's local start-of-day become `complete: true`, `isIncomplete: true`, `reason: 'Closed at midnight'`. Every device's `round` is set to null.

## Rounds — Lambda grind

These rules are **Implemented** in `grind/index.js`. They are **not** the same rules as BR-017 to BR-022.

**BR-023 — The Lambda path keeps a single round named Steenberg.** `Round.findOne({ course: 'Steenberg' })`. Config `courseName` is loaded and not used for this query.

**BR-024 — More than 100 locations deletes every round.** When the Steenberg round is missing or `locations.length > 100`, the handler calls `Round.deleteMany({})` and then creates one round. The comment calls it a test round.

**BR-025 — The created round is forced to nine holes.** `isNineHole: true`. Start hole and start section come from `all_sections[0]`, not from the fix being processed when the index is not zero. Start time is the fix date minus that first section's `goalTime` seconds.

**BR-026 — Negative pace looks for a group being held up.** **Implemented** when `paceInMin(location.pace) < 0`, using `roundModule.getHoldingUp`. The holding round's last location is updated with `holdingUp` set to this round's id.

**BR-027 — Lambda messages are deleted by the function.** **Implemented.** After the Mongo writes, `deleteMessageBatch` runs. A thrown error is retried three times inside the handler and then rethrown. The queue then retries up to 5 receives before the DLQ.

**Requires confirmation:** BR-023 and BR-024 look like test behaviour (literal course, delete all, "test round" comment). They are what `index.handler` does.

## Tee times and pins

**BR-028 — A configured provider is polled every 5 minutes.** **Implemented.** `schedule.js` requires `./teetimes/providers/${provider}` and calls `processTeetimes`. An unknown provider name throws on require. That failure mode is **Inferred** from `require`.

**BR-029 — Golf Genius is additional, not a replacement.** **Implemented.** The same 5-minute job runs the Golf Genius provider when `golfGeniusConfig.apiKey` is set, including when another tee-sheet provider also ran.

**BR-030 — Default tee times load only when today has none.** **Implemented** in the `10 0 * * *` job, and only when `defaultTeetimes` is set.

**BR-031 — Pin of the day.** **Implemented.** If a `PinSchedule` covers today and has a template, hole pins are replaced from that template. Otherwise every hole's `pin` is set to null.

## Access

**BR-032 — Staff routes trust a signed JWT, not a role check.** **Implemented.** `middleware/auth.js` verifies the signature and stores ids. It does not load `User.role` or `permissions` before `next()`.

**BR-033 — Token issue is limited to the server IP unless the environment is local.** **Implemented** in `middleware/server.js` for `/token` and `/user-server`.

**BR-034 — Some device-admin routes have neither JWT nor the server gate.** **Implemented.** Examples: `GET /device/getAllDevices`, `POST /device/postAssignment`, `POST /device/sendAction`, and the whole `/course` mount. **Requires confirmation** that this is intentional.

**BR-035 — Geofence-breach notification recipients are permission-based.** **Implemented** only on the Express `insertFix` path, using the permission name in BR-012. The Lambda grind does not create those notifications.

## Pace thresholds stored on Config

**Implemented** as defaults, not re-traced through every caller:

| Field | Default | File |
|---|---|---|
| `slowPlayMin` | 600 | `models/config.js` |
| `paceThreshold` | -600 | `models/config.js` |
| `positionalGap` | 600 | `models/config.js` |
| `gapTimeThreshold` | 1080 | `models/config.js` |
| `fixHdopThreshold` | 1.2 | `models/config.js` |
| `fixSnrThreshold` | 17.5 | `models/config.js` |
| `geoHdopThreshold` | 0.8 | `models/config.js` |
| `geoSnrThreshold` | 21 | `models/config.js` |
| `softGeofenceTimer` | 10 | `models/config.js` |

**Unknown / Requires Confirmation:** the unit (seconds versus minutes) for each of these, except where a caller was read. `turnTime` is used as seconds in `grinder.js`.

## Conflicts

| Id | Polling grinder | Lambda grind |
|---|---|---|
| Which round | Device's open round today | One document with `course: 'Steenberg'` |
| When a round ends | Stop section, skip, or last section | Not applied. A new round starts after 100 locations, and all rounds are deleted |
| Course | `Config.courseName` is loaded | Literal `Steenberg` |
| Section test | `$near` / 1 | Polygon intersection and Turf |
| Geofence notifications | Express insert creates them | Lambda path does not |

## Unknown / Requires Confirmation

- Whether BR-023 and BR-024 are allowed to run against a shared database.
- Whether marshal, super, and drinks tags should stay excluded.
- The role and permission matrix. It is not in this repository.
- Which tee-sheet provider is the business default. The code supports every file under `teetimes/providers/`.
