# PRD — orginal_code

Evidence date: 2026-10-08.

Source: `git@bitbucket.org:overdrive/orginal_code.git`, `main` at `18caf8e` (2024-11-12).

There is no prior PRD in the source repository or in Silicon Ark. Requirements below are recovered from the tree at that revision. Provenance is labelled. Secrets are omitted: the tracked `.env` was not copied, and hardcoded API and SMTP credentials are named by file only.

## Product purpose

**Inferred.** Operate one golf course: ingest location fixes, 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** only by `README.md` (Node version, three processes, a course-change URL, and a sample fix payload). There is no product brief.

**Implemented** behaviour is the Express API in `app.js`, the round loop in `grinder.js`, and the cron jobs in `schedule.js`.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Course staff using a browser client | CORS allow-list defaults to `http://localhost:3000` and `https://localhost:3000`. Dashboard routes require a JWT. | **Inferred** caller. No UI is in this repo |
| User record | `models/user.js`: `externalId`, `role` (string), `permissions[]`, `coursePermissions[]` | **Implemented** storage. The role catalogue is not in this repo |
| Laravel (or similar) user service | `process.env.LOCALHOST` paths such as `/api/roles`, `/api/users/:id/permissions` in `controllers/modules/user.js` | **Implemented** HTTP client. The service itself is not in this repo |
| 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` | **Implemented**. Those GPS routes do not use the auth middleware |
| Central server | `middleware/server.js` allows `SERVER_IP` or `ENV=local` for `/token`, `/user-server`, device delete, and caddie delete | **Implemented** |
| Operator who starts the three Node processes | README commands | **Documented** |

`middleware/auth.js` verifies the JWT 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 are allowed to call which routes. `Config.pageAccess` is an untyped array with the comment `TODO: fix this`.

## Systems the product talks to

1. MongoDB at `DATABASE_URL`. Example database name `tagmarshal`.
2. Legacy Tagmarshal HTTP API at `https://{course}.tagmarshal.com` during course import (`controllers/course.js`, `modules/migration.js`). Ongoing legacy fixes use `Config.legacyBaseUrl` (`modules/fixes.js` via `schedule.js`).
3. User/role HTTP API at `LOCALHOST` (`controllers/modules/user.js`). `.env.example` defines `LARAVEL_AUTH_SERVER` and the JavaScript that was searched does not read that variable.
4. Tee-sheet providers selected by `Config.teesheetIntegration.provider` (file name under `teetimes/providers/`). Jonas uses SOAP. The others use HTTP.
5. Golf Genius when `Config.golfGeniusConfig.apiKey` is set (`teetimes/providers/golfgenius.js`, `modules/golfGenius.js`).
6. European Tour / ETX tournament helpers when the provider is `etx` (`modules/europeanTour.js`), on the midnight job.
7. Firebase Cloud Messaging. Service-account JSON 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`: `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, `MAILGUN_FROM_EMAIL`).
9. MessageBird (`modules/messagebird.js`: `MESSAGEBIRD_ACCESS_KEY`).
10. Gmail SMTP from `controllers/modules/trackmap.js` when a round is shared. Credentials are hardcoded. The password is omitted.
11. 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.

No queue broker, object store, or metrics agent is in the repository.

## Major workflows

### WF-1 — Start the API

**Documented** in the README. **Implemented** in `app.js`.

1. Load `.env` from the process directory and from the parent directory.
2. Connect to MongoDB. On failure, log and `process.exit(0)`.
3. Split `ALLOWED_ORIGINS` on commas. A browser `Origin` in that list is reflected. Any other origin is refused by the CORS callback.
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.

### WF-2 — Import a course

**Documented** as `GET /course/change/:course` with the example `steeberg`. **Implemented** in `controllers/course.js`.

1. No auth middleware on `/course`.
2. Base URL is `https://{course}.tagmarshal.com`.
3. Always starts import of config, sections, holes, advertisements, widgets, and devices.
4. Optional path segment `importAll` also starts tee times, rounds, fixes, geofences, goal times, and marshal notes (`modules/migration.js` `start`).
5. The handler calls `res.end()` immediately. The imports continue in axios callbacks. Axios retries three times (`axiosRetry` in `modules/migration.js`).
6. Config flags such as `teetimesActive` and `foodAndBeveragesActive` are copied from the legacy payload. Missing legacy flags become true except where the code checks `=== false`.

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

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

1. `POST /token` looks up a user by `body.userId` (`externalId`), else a caddie by `caddieId` or email, else a player by `playerId` or email.
2. If none exist, respond `401` `{ valid: false }`.
3. Otherwise sign a JWT with `JWT_SECRET_KEY` and `JWT_EXPIRES_IN` (example `7d`). Payload includes `userId` or `caddieId` or `playerId`, `name`, and `expiresAt`.
4. `GET /token` runs auth then `verifyToken`, which only sends `{ valid: true }`. The auth middleware has already rejected a bad token.
5. `GET /token/refresh` verifies the JWT itself. If `expiresAt` is missing or is not more than 7 days ahead, it issues a new token. The 7-day window is hardcoded (`ms('7d')`) and is not read from `JWT_EXPIRES_IN`.

When `ENV` is `local`, the server middleware allows any caller to reach `POST /token`.

### WF-4 — Ingest a fix and build a round

**Documented** sample: `POST /device/fixes/:imei` with an array of fixes (`accuracy`, `speed`, `source`, `battery`, `hole`, `section`, `number`, `lat`, `lng`, `date`).

**Implemented** ingest: `routes/device.js`. `POST /device/fix/:imei`, `POST /device/fixes/:imei`, `POST /device/fixes/cached/:imei`, and `GET /device/fix/:imei/:lat/:lon/...` do not use `auth`. Tracker reads under `/device/tracker` do.

**Implemented** processing: `grinder.js` every 5 seconds.

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 the last processed id, limited by `fixLimit` (schema default 750). `.env.example` `FIX_LIMIT` is not read here.
4. Skip devices with `marshal`, `super`, or `drinks` true. A comment says to remove that special-case later.
5. Match `fix.position` to a section polygon with `$near` and `$maxDistance: 1` on a `2dsphere` index (`models/section.js`). For that index MongoDB measures distance in metres, so a point inside the polygon matches and a point more than one metre outside does not.
6. If the device has an open round today and the section is the next section (or a skip, or a targeted hop), update location, pace, play time, and projected goal time.
7. If the section means a new round (`isNext == 'new-round'`), start one. Shotgun helpers in `modules/shotgun.js` can override that.
8. A `busy` flag drops overlapping ticks unless the batch was full, in which case `grind(true)` runs again immediately.

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

**Implemented** only in `schedule.js`. Not described in the README beyond "cronjobs".

Jobs use `Config.timezone` captured at process start. A timezone change needs a process restart.

| Schedule (node-cron, 6-field) | Behaviour |
|---|---|
| `*/5 * * * * *` | If `legacyBaseUrl` is set, `getLegacyFixes()` |
| `0 */5 * * * *` | If `teesheetIntegration.provider` is set, `require` that provider and `processTeetimes`. If `golfGeniusConfig.apiKey` is set, also run the Golf Genius provider |
| `0 0 0 * * *` | Set device `assignment` from `temporary` to `permanent`. The call passes `{ marshal: false }` as the third argument, which is the options object, not the 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 provider is `etx`, refresh tournaments. If Golf Genius has an API key, `getEvents` |
| `10 0 * * *` | If today has no tee times and `defaultTeetimes` is set, load defaults |
| `5 0 * * *` | Apply the pin template whose schedule covers today, or clear hole pins |

The first cron expression includes a seconds field and fires every 5 seconds, not every 5 minutes. The comment "every 5 minutes" belongs to the second job.

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

**Implemented** at `/two-ways` behind `middleware/twoway.js`.

1. Header `DeviceIMEI` is required (`422` if missing).
2. `Device.deviceId` must match (`404` "Device not found" otherwise).
3. The device can read menu, tee times, messages, food menu, holes, advertisements, and its round; post fixes, scores, orders, and data entry; rate a round; accept a disclaimer.

Feature flags on `Config` (`messagingActive`, `scoringActive`, `drinksActive`, `foodAndBeveragesActive`, and others) are selected for the device menu in `controllers/modules/twoway.js`. Whether every route checks the matching flag was not verified line by line.

### WF-7 — Staff dashboard

**Implemented** at `/dashboard` behind auth. The router exposes active rounds, tee times, widgets (daily info, goals, time schedule, gap-time monitor), map bounds, marshal notes, manual round start, and round end. Widget layout is stored on `User.settings.widgetPositionSettings`.

There is no HTML in this repository. The responses are JSON for a client that is not here.

## Functional requirements

### FR-001 — Three processes

The system runs as three Node entry points: `app.js`, `grinder.js`, and `schedule.js`. Each connects to the same `DATABASE_URL`.

- Trigger: an operator starts each process.
- Output: HTTP on `PORT`; a 5-second grind loop; cron jobs.
- Evidence: `README.md`, the three files.
- Implementation: **Implemented**.
- Confidence: High.

### FR-002 — Single course configuration

The process treats one `Config` document as the course: bounds, timezone, feature flags, tee-sheet settings, thresholds, kitchen hours, and disclaimer.

- Evidence: `models/config.js`, `Config.findOne()` in `app.js`, `grinder.js`, `schedule.js`.
- Implementation: **Implemented**.
- Unknown: whether one deployment is one course. The import URL is per course name, and nothing in the schema partitions data by course id.

### FR-003 — Course import

`GET /course/change/:course/:importAll?` pulls legacy JSON from `https://{course}.tagmarshal.com` and upserts local documents.

- Auth: none on the `/course` mount.
- Validation: the course param is interpolated into the host. No allow-list is in `change`.
- Error: axios failures are not returned to the client. The HTTP response has already ended.
- Evidence: `controllers/course.js`, `modules/migration.js`.
- Implementation: **Implemented**.
- Confidence: High for the trigger. Low for which legacy fields are still required.

### FR-004 — JWT session

A token is issued only for a user, caddie, or player that already exists. Protected routes reject a missing or invalid token with `401` `{ valid: false }`.

- Header: `TOKEN_HEADER_KEY` (example `UserToken`), else `Bearer`, else `authorization` with the word `Bearer` removed.
- If `authorization` is missing and the named header is missing, `req.header('authorization').replace` throws and the catch returns 401.
- Evidence: `middleware/auth.js`, `controllers/token.js`.
- Implementation: **Implemented**.
- The middleware does not authorise by role. **Inferred** that enforcement, if any, is in the unseen client or in the `LOCALHOST` user service.

### FR-005 — User directory is remote

Creating users, listing roles, and saving permissions call `LOCALHOST` and then copy the response onto the local `User` document.

- Example paths: `POST /api/users/:id`, `GET /api/roles`, `POST /api/users/:externalId/permissions`, `GET /api/permissions/:externalId`, `DELETE /api/users/:externalId`.
- Local routes that trigger this live under `/user` (auth required). `POST /user-server/postUserToAssociates` is behind the server middleware only.
- Evidence: `controllers/modules/user.js`, `routes/modules/user.js`, `routes/modules/user-server.js`.
- Implementation: **Implemented**.
- `LARAVEL_AUTH_SERVER` in `.env.example` is unused by these calls. **Requires Confirmation** that `LOCALHOST` is that service.

### FR-006 — Device location ingest

A device identified by IMEI can post one fix, a batch, or a cached batch. A GET form also accepts lat, lon, accuracy, speed, battery, source, and date in the path.

- Auth: not required on those four routes.
- Evidence: `routes/device.js`, README sample.
- Implementation: **Implemented**.
- Confidence: High for the route contract. The controller's field checks were not fully enumerated.

### FR-007 — Round construction

The grinder turns fixes into open rounds for non-special devices, using section polygons and goal times.

- Special devices (`marshal`, `super`, `drinks`) are skipped. Comment marks this as temporary.
- `turnTime` defaults to 1800 seconds.
- Evidence: `grinder.js`, `modules/rounds.js`, `modules/shotgun.js`, `modules/pace.js`.
- Implementation: **Implemented**.
- Whether skipping marshal carts is still wanted is **Requires Confirmation**.

### FR-008 — Pace labels

Pace in whole minutes is "On - Pace" when `pace >= 0` and "Slow" when `pace < 0`. If the location is holding another round, the label is "Delayer". If it is held up, the label is "Delayed".

- Evidence: `modules/pace.js`.
- Schema defaults that feed related monitors: `slowPlayMin` 600, `paceThreshold` -600, `positionalGap` 600, `gapTimeThreshold` 1080 (`models/config.js`). Seconds are the stored unit in those fields. **Inferred** from the names and from `paceInMin` dividing by 60.
- Implementation: **Implemented** labels. **Requires Confirmation** that the thresholds are the approved business values.

### FR-009 — Midnight round close

At local midnight the scheduler closes open rounds whose `_id` is after yesterday's local start-of-day, with `isIncomplete: true` and reason `Closed at midnight`. It clears the round pointer on every device, and sets `assignment` from `temporary` to `permanent`. `{ marshal: false }` is passed as the third argument of `updateMany` (the options object), so it does not change the `marshal` field.

- Evidence: `schedule.js`.
- Implementation: **Implemented**. Not stated in the README. Treat as inferred product behaviour until someone confirms it.

### FR-010 — Tee times

Staff can list, pin, assign tags and players, upload a CSV, clear the day, toggle auto-assign, and switch provider. Routes are under `/teetime` behind auth.

- CSV upload uses `middleware/csvprocessor.js`: multipart field `file`, header row dropped, remaining rows split on commas.
- A missing file returns `400` `{ message }` from that middleware.
- Every five minutes, when a provider name is configured, `schedule.js` loads `teetimes/providers/{provider}` and calls `processTeetimes`.
- Providers present: `clubcaddie`, `clubessential`, `clubmaster`, `clubprophets`, `dgs`, `etx`, `ezlinks`, `foretees`, `foreup`, `gggolf`, `goClub`, `golfgenius`, `golfmanager`, `golfspain`, `jonas`, `jonas-legacy`, `lightspeed`, `membersfirst`, `membersport`, `northstar`, `teeOn`, `teecontrol`, `totalunity`, `whoosh`.
- Implementation: **Implemented**. Provider credentials live on `Config.teesheetIntegration`, not in env files. Their exact key names differ per file.

### FR-011 — Shotgun starts

Staff can create a shotgun, a retroactive shotgun, and a B group, and can clear or delete tee times. Routes are under `/shotgun` behind auth.

- Evidence: `routes/modules/shotgun.js`, `models/teetime.js` `shotgun` subdocument (`goalTime`, `startSection`, `stopSection`, `isRetro`, `group`).
- Implementation: **Implemented**.

### FR-012 — Geofences

Staff can create groups, geofences, and presets, toggle them, and list breaches. Routes are under `/geofence` behind auth.

- `softGeofenceTimer` defaults to 10 on `Config`.
- `Teetime.isIgnoreGeofence` can be set from `POST /teetime/ignoreGeofence`.
- Device menu can request geofences via `POST /two-ways/geofences`.
- Implementation: **Implemented**.

### FR-013 — Food and beverage

Staff manage groups, items, modifiers, options, kitchen hours, and order status under `/foodandbev` (auth). A device places an order at `POST /two-ways/fnb/createOrder` or the legacy `POST /two-ways/fnb/legacy`.

- Kitchen hours are stored on `Config.kitchenOperatingHours` (`startTime`, `endTime`, `days`).
- `foodAndBeveragesActive` and `drinksActive` are course flags.
- Implementation: **Implemented**.

### FR-014 — On-course messaging and notifications

Staff send device messages under `/messaging` (auth). In-app notifications live under `/usernotification`. Push uses Firebase (`modules/notifications.js`).

Notification types that attach buttons (`getButtonsForType`): `2Way Message (In)`, `Request for Support`, `Request For Drinks Cart`, `2Way Message Out`, `Geofence Breach Low|Medium|High`, `Interaction Recorded`, `Problem Group`, `Pace Notification`, `Food Order Placed`, `Round Rating`, `System Notification`.

A user notification is active when the user's permission is active and either `alwaysOn` is set or the current time falls in `activeDays`. Otherwise it is stored already dismissed.

- Implementation: **Implemented**.

### FR-015 — Caddies and players

Caddie routes are under `/caddietag` (mostly auth). Delete by email is server-gated. Player upload, fix insert, and an experience tracker are under `/players` (auth).

Caddie email uses Mailgun and includes `ANDROID_LINK`, `IOS_LINK`, and `WEB_LINK`.

- Implementation: **Implemented**.

### FR-016 — Reports, analytics, replay

- Round report and CSV: `/roundreport` (auth).
- Player report and caddie report (same controller, different mount): `/playerreport` and `/caddie`.
- Analytics hub: `/analyticshub` (pace distribution, hole stats, conditions, average round time, groups tracked, interactions).
- Round replay: `POST /rounds` (auth) to `roundReplay`.
- Rerun: `POST /rerun` (auth).
- Implementation: **Implemented** as routes. The report definitions inside the controllers were not re-specified field by field.

### FR-017 — Golf Genius and tournaments

`/golfgenius` (auth) lists events and rounds, syncs events, and reads leaderboards. `/holetimes` (auth) manages tournament templates and archives a tournament.

Midnight sync runs only when the corresponding config is present (WF-5).

- Implementation: **Implemented**.

### FR-018 — Pin sheets

`/pinlocations` (auth) stores templates and schedules and can save the current pins. The 00:05 cron applies today's scheduled template or nulls `Hole.pin`.

- Evidence: `routes/modules/pinlocations.js`, `schedule.js`.
- Implementation: **Implemented**.
- `controllers/modules/pinlocations.js` calls `mongoose.connect` at load.

### FR-019 — Support ticket

`POST /course/createSupportTicket` requires `subject`, `queryType`, `description`, `deviceType`, and `deviceNumber` (`validatorjs`). A failure returns `401` with the validator errors. Success saves `ContactSupport` and returns `200` with the message "Support ticket was successfully updated."

The Monday.com `fetch` is after that `return` and does not run. Its payload is the static item name "Hello, world!", not the ticket fields.

- Implementation: **Implemented** local save. Monday.com integration is **not executed**.

### FR-020 — Share a round by email

`POST /trackmap/postShareRoundInfo/:roundId/:email` (auth) sends mail through Gmail SMTP. The message includes course name, round time, tee time, player first names, and `https://{Config.URL}/round/{id}`.

- Implementation: **Implemented**. Credentials are hardcoded. Password omitted.

### FR-021 — Live tee-time socket

On Socket.IO `connection`, `handlers/teetimes.js` listens for `teetimes:assignPlayer` and `teetimes:assignTag`. Both log the payload and emit `teetimes:teetime` with `JSON.stringify({})`.

- Implementation: **Implemented** as a stub. No tee time is read or written.
- **Requires Confirmation** whether a client depends on this event.

### FR-022 — Automated test

`npm test` runs Jest. The only test is named `should run` and has the body `// TODO: write test`. It loads `app.js`, which connects to MongoDB and listens.

- Evidence: `package.json`, `tests/app.test.js`.
- Implementation: **Implemented** placeholder. It does not assert behaviour.

## Integrations

| System | Direction | Trigger | Failure behaviour seen in code |
|---|---|---|---|
| MongoDB | Out | Process start | API exits 0. Scheduler logs and stays up. Grinder has no error handler on `mongoose.connect` |
| Legacy Tagmarshal | In | Course change; legacy-fix cron | Course change does not report HTTP errors to the caller. Cron errors were not separately handled in `schedule.js` |
| `LOCALHOST` user API | Out | User admin routes | Catch returns `401` with a short message, or logs and still returns `200` on some permission updates |
| Tee-sheet provider | In | Every 5 minutes | Provider-specific. `totalunity.js` logs and continues when a course returns no data |
| Golf Genius | In | 5-minute job and midnight | Skipped when `apiKey` is absent |
| ETX tournaments | In | Midnight, provider `etx` | No extra guard in the cron beyond the provider check |
| Firebase | Out | Push and device actions | Returns `{ message: "No Devices Found." }` when no tokens. Missing service-account file would throw on `require` |
| Mailgun | Out | Caddie mail | Not separately reviewed beyond client setup |
| MessageBird | Out | Module init | Client is constructed at load with `MESSAGEBIRD_ACCESS_KEY` |
| Gmail SMTP | Out | Share round | Password hardcoded. Failure path not fully reviewed |
| Monday.com | None at runtime | Unreachable code | Does not run |

## Important constraints

- One MongoDB, one `Config` document assumed by `findOne`.
- CORS is an allow-list. There is no wildcard in the callback.
- `ENV=local` disables the IP gate.
- JWT secret, Firebase files, Mailgun, and MessageBird are environment configuration. Two credentials are also hardcoded (see project plan).
- README Node `^v16.19.0` is not enforced by `package.json`.
- No rate limit, captcha, or request schema shared across routes. Validation appears per controller via `validatorjs` where a controller chose to use it.

## Requirements that could not be confirmed

- The product name, owner, and whether `18caf8e` is what production runs.
- The meaning of `LOCALHOST` versus `LARAVEL_AUTH_SERVER`.
- The role and permission matrix. Names are stored; the catalogue is remote.
- Whether unauthenticated GPS ingest and course import are accepted operations.
- Whether marshal/super/drinks devices should stay outside the grinder.
- Whether midnight auto-close is an approved rule.
- Whether the empty Socket.IO payloads are a defect.
- Deployment host, process supervisor, and backup. None are in git.
- The scan-host files named at task creation that are absent from this commit.
