# PRD — tom

Evidence date: 2026-10-01. Source: `git@bitbucket.org:overdrive/tom.git`, `master` at `0ceed11`.

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

No formal PRD existed in the repository or in `silicon_ark/tom/` before this file. Requirements below are recovered from the theme, plugins, and `wp-config.php`. They are not a new product decision.

Secret values that appear in source (database passwords, WordPress salts, AWS keys, API bearer tokens, a commented Google Maps key) are omitted on purpose.

## Product purpose

**Documented** in the theme header (`wp-content/themes/omtom/style.css`):

> The OMTOM Theme is developed by Hero Strategic Marketing for use by Old Mutual Two Oceans Marathon.

**Implemented:** a WordPress site whose custom theme publishes event information, editorial content, galleries, sponsors, contact details, a newsletter signup, and route maps for that marathon.

The repository slug is `tom`. The product name used in the theme is OMTOM / Two Oceans Marathon / Old Mutual Two Oceans Marathon.

## Users and roles

| Role | What they do | Provenance |
|---|---|---|
| Public visitor | Reads pages, events, news, maps; may subscribe or send a contact message; sees a cookie notice | **Implemented** in public templates and unauthenticated AJAX (`wp_ajax_nopriv_*`) |
| WordPress administrator / editor | Creates the custom post types, sets theme options, uploads media | **Inferred** from WordPress admin screens and `capability_type` default `post`. No custom roles are registered |
| Newsletter operator | Downloads a CSV of subscriptions for a date range | **Implemented** in `classes/reporting/reporting.php` and `classes/theme-options/options.newsletter.php`. The AJAX action is also registered for logged-out users |
| Repository authors | Jaco Brits, Rachel Mogano, liam-ovd | **Implemented** as git metadata, not application roles |

WordPress roles beyond the core `post` capability type are **Unknown**. The comments admin menu is removed (`omtom_remove_menus` in `functions.php`).

Hardcoded WordPress post IDs are treated as pages by the theme. Whether those rows exist is **Unknown / Requires Confirmation** without the database.

| ID | How the theme uses it | File |
|---|---|---|
| 3 | "Legal & Disclaimer" and "cookie policy" permalink | `footer.php` |
| 31 | Event entry info URL passed to front-end JS | `functions.php` (`eventEntryInfoURL`) |
| 76 | Prizes and medals URL | `functions.php` (`prizesMedalsURL`) |
| 79 | Race day URL | `functions.php` (`raceDayURL`) |
| 85 | Route info URL | `functions.php` (`routeInfoURL`) |
| 97 | T-shirts URL | `functions.php` (`tshirtsURL`) |
| 2594 | Reporting page permalink for admin JS | `functions.php` (`reportingURL`) |

## Major workflows

### FR-001 — Browse the public site

- **Who:** visitor.
- **Trigger:** request a WordPress URL.
- **Behaviour:** WordPress selects an OMTOM template. The theme supports a title tag, post thumbnails, HTML5 gallery/caption/search, RSS feed links, and two nav menus: `main` and `eyebrow`.
- **Output:** HTML page. Theme CSS and JS are enqueued from `//d19jcneif5xmtf.cloudfront.net/wp-content/themes/omtom/...`, not from the local theme directory.
- **Provenance:** **Implemented** (`functions.php` `omtom_setup`, `omtom_scripts`).
- **Unknown:** which menu items and pages exist in production.

### FR-002 — Home page

- **Who:** visitor.
- **Trigger:** the front page template `front-page.php`.
- **Behaviour:**
  - Lists up to 20 published `homepage_slider` posts (`menu_order` ASC). A slide is rendered when it has a featured image. Optional video, content image, and button.
  - Lists up to 100 published top-level `events` (`post_parent` 0, `menu_order` ASC). Each card shows title, optional sub-heading, optional event date, featured image, icon, and a colour from post meta `color-1`.
  - If meta `tickets-available` is set, the card status is "Entries Open". Otherwise, if meta `no-tickets` has text, that text is the status.
  - Section "Keeping you posted" renders up to three featured blocks from options `ps-1-settings`, `ps-2-settings`, `ps-3-settings` when background image, heading, and button URL are all set. A query for the three latest published posts is also prepared in the same template.
- **Provenance:** **Implemented** in `front-page.php`.
- **Countdown, urgent notification, homepage notification, logo, and advertising** are theme options (see FR-010). Whether the front page renders all of them was not fully traced line by line beyond the events list and the "Keeping you posted" section. **Requires Confirmation** if a tester needs every homepage region enumerated from a running site.

### FR-003 — Event detail

- **Who:** visitor.
- **Trigger:** a single `events` post (`single-events.php`).
- **Inputs:** post content and meta, including `tickets-available`.
- **Behaviour:** the template includes "ENTER NOW / via" links with CSS class `webtickets` and an empty `href`.
- **Output:** the control is visible in markup. It does not navigate anywhere based on the committed template.
- **Provenance:** **Implemented**.
- **Inferred:** Webtickets was the intended entry vendor (class name, `assets/images/icons/webtickets-*.png`, commented icon in `template.payment-options.php`). The live entry URL is **Unknown**.

### FR-004 — Route map

- **Who:** visitor on a page that loads `maps.js`.
- **Trigger:** `omtom_maps_js` is enqueued on the public site. The script reads `wp.gmapsAPIKey` from the `api-google-maps` option.
- **Behaviour:** `page_id_mapping` in `assets/js/maps.js` selects a route object:

| Map page id | Object name |
|---|---|
| 1 | `ultra_marathon` |
| 2 | `half_marathon` |
| 3 | `trail` |
| 4 | `funrun` |
| 5 | `friendshiprun` |
| 6 | `shuttle` |

KML files on disk: `ultra_marathon_route.kml`, `half_marathon_route.kml`, `trail_12km_route.kml`, `trail_24km_route.kml`, `funrun_2.1km_route.kml`, `funrun_5.6km_route.kml`, `friendship_route.kml`, `shuttle_route.kml`. Matching JSON files exist under `assets/objects/`. Marker art exists for start/finish of those distances and for cutoff, kilometre, medical, parking, refill, refreshment, shuttle, and supporter points.

- **Provenance:** **Implemented** as assets and map script. Which WordPress page id binds to map page id 1–6 is **Unknown** (the mapping is inside `maps.js`, not a WordPress ID).
- **Constraint:** KML URLs must be absolute. The script comment says files must point to a full URL. `kml_file_location` is `wp.themeURL + '/assets/kml/'`, and `themeURL` is the CloudFront host.

### FR-005 — Newsletter subscription

- **Who:** visitor (logged in or not).
- **Trigger:** AJAX action `handle_subscriptions` (`admin-ajax.php`). The footer includes a "Subscribe to Newsletter" control. `assets/js/functions.js` can show `#omtom-newsletter-popup`.
- **Inputs:** `subscription-name`, `subscription-email` (posted as a serialized form in `data`).
- **Validation:** name must be non-empty. Email must be non-empty and pass `FILTER_VALIDATE_EMAIL`. No nonce check is present in `save_subscription`. No duplicate-email check is present.
- **Behaviour on success:**
  1. Insert `name` and `email` into table `wp_omtom_subscriptions`.
  2. HTTP POST JSON to `https://api.touchbasepro.com/email/subscribers/{list id}` with `Resubscribe` true, `ConsentToTrack` `Yes`, and `RestartSubscriptionBasedAutoresponders` true.
- **Output:** JSON. After the remote call, the local success array is replaced by the decoded Touchbase body.
- **Errors:** missing fields set `success` false and list the field name. If the database insert works and Touchbase fails, the row can already exist. **Inferred** from statement order.
- **Provenance:** **Implemented** in `classes/forms/forms.php`.
- **Unknown:** whether table `wp_omtom_subscriptions` exists in the target database. No migration is in git. The bearer token in source must not be copied into tests or docs.

### FR-006 — Contact message

- **Who:** visitor.
- **Trigger:** AJAX action `handle_contacts`. Template `template.contact.php` is "Contact Us".
- **Inputs:** `contact-name`, `contact-email`, `contact-message`, optional `contact-subject`.
- **Validation:** name, email, and message required. Email must pass `FILTER_VALIDATE_EMAIL`. No nonce check in `send_contact_message`.
- **Behaviour:** recipient is option `contact-details-settings` field `form-email`. Subject defaults to `Two Oceans Marathon Contact Form Message`. Body is HTML, including a logo image from `https://s3-eu-west-1.amazonaws.com/twooceans-marathon/wp-content/themes/omtom/assets/images/logos/omtom.png`. Sent with PHP `mail()`, not `wp_mail()`.
- **Errors:** field messages "Field Required" or "Invalid Email". If `mail()` returns false, the response becomes the string `Failed to send email`.
- **Provenance:** **Implemented**.

### FR-007 — Cookie notice

- **Who:** visitor.
- **Trigger:** every front-end page. Banner markup is in `footer.php` (`#omtom-cookie-notification`). It is not shown until script runs.
- **Behaviour:**
  - `assets/js/functions.js` `check_cookie_state` shows the banner when cookie `OMTOMDisclaimer` is absent. The CLOSE control calls `hide_cookie_disclaimer`, which fades the banner out and does not itself write the cookie.
  - `cookie_notice` in `functions.php` is hooked to `wp_footer`. On `window.onload` it always writes `OMTOMDisclaimer=1` with expiry `3600 * 1000 * 24 * 365 * 10` milliseconds (10 years) and `path=/`.
  - `handle_cookies()` is called from `header.php` and its body is empty. An older PHP `setcookie` path for `OMTOMInitialLoad` and `OMTOMDisclaimer` (2 years) is commented out.
- **Copy:** the banner says cookies are necessary, points at the cookie policy (permalink of post 3), and says that closing the banner, clicking a link, or continuing to browse agrees to cookies.
- **Provenance:** **Implemented**. The 2022-09-30 commits `2f1a52b` and `0ceed11` are this behaviour. Setting the cookie on load, rather than on CLOSE, means a later page view hides the banner even if the visitor did not press CLOSE. **Inferred** from the two scripts together.
- **Unknown:** whether post 3 is the legal page in the database.

### FR-008 — Charity application

- **Who:** visitor.
- **Trigger:** page template "Charity Applications" (`template.charity-applications.php`).
- **Behaviour:**
  - If the page has `page-buttons` meta, page content and a Google Forms iframe are rendered, and the buttons are a sidebar.
  - If there are no buttons, the iframe is rendered only when post meta `enable_application_form` is set.
- **Embed:** `https://docs.google.com/forms/d/e/1FAIpQLSellH8dgHMjS1BlgR9Dp_CfMxLfUfZYa6Cb2-CQ1EyN97yg5A/viewform?embedded=true`.
- **Provenance:** **Implemented**. Form questions and where responses are stored are **Unknown** (they live in Google Forms, not in this repo).
- **Inferred inconsistency:** the buttons branch embeds the form without checking `enable_application_form`.

`?panel` on that template skips header and footer. Provenance: **Implemented**. Purpose of `panel` is **Inferred** as an embed/partial view.

### FR-009 — Newsletter report export

- **Who:** any caller who can POST to `admin-ajax.php`, including logged-out users (`wp_ajax_nopriv_newsletter_reporting`).
- **Trigger:** action `newsletter_reporting` with `data.fromDate` and `data.toDate`.
- **Behaviour:** selects rows from `wp_omtom_subscriptions` where `created` is between those two values, writes a CSV under the WordPress uploads path, and returns the filesystem path as JSON. The date values are concatenated into the SQL string.
- **Provenance:** **Implemented** in `classes/reporting/reporting.php`.
- **Unknown:** whether the admin screen that posts these dates is still linked in the live admin menu. The newsletter options class is constructed (`options.newsletter.php`). The older `options.reporting.php` include is commented out in `functions.php`.

### FR-010 — Theme settings (editors)

Editors with access to the registered settings pages can store:

| Area | Option keys | Fields |
|---|---|---|
| Theme | countdown, urgent notification, homepage notification, logo | Enable countdown and datetime; urgent and homepage notices (heading, copy, button, colours); logo |
| Keeping you posted | `ps-1-settings` … `ps-3-settings` | Image, heading, button label, URL, target for three slots |
| Homepage links | four link groups | Image, icon, heading, copy, button |
| Advertising | `advert-settings` | Enable, image, heading, copy, button, colours |
| Vision / mission / values | VMV option | Vision, mission, values left, values right |
| Social URLs | `social-settings` | Facebook, Twitter, Instagram, YouTube. Empty URL hides the icon (admin description) |
| Contact directory | several `contact-*-settings` | Race/entries/seeding/results; reception; media; exhibition and sales; postal; physical; office hours; VAT number; form recipient email |
| API keys | `api-facebook`, `api-twitter`, `api-instagram`, `api-google-maps` | App id/secret, Twitter tokens, Instagram access token, Maps key |

Provenance: **Implemented** in `classes/theme-options/`. Stored values are in the database and were not inspected.

Admin copy for the API screens says Facebook settings enable reading posts from Two Oceans Marathon, Twitter settings enable tweets from `@2OceansMarathon`, and the Instagram token is generated via `http://instagram.pixelunion.net/`. Provenance: **Documented** in `options.api.php`.

`pull_instagram_feed()` calls `https://api.instagram.com/v1/users/self/media/recent` when an access token option is non-empty, and keeps items whose type is `image`. Provenance: **Implemented**. Whether that legacy API still responds is **Unknown**.

### FR-011 — Content model

Editors manage the custom post types and taxonomies registered in `functions.php`. See `features.md` for the catalogue. Meta boxes cover tickets, icons, dates, colours, galleries, maps, charity contact, T-shirts, history timeline, and related fields. Provenance: **Implemented** as PHP includes under `classes/meta-boxes/`. The exact field set per post type is the meta-box classes; activation of a box depends on each class's `$screen`. A full per-type matrix was not copied here. **Requires Confirmation** only if a field-level test matrix is needed beyond the class list.

### FR-012 — Sharing and shortcodes

- Open Graph and Twitter Card tags are printed in `header.php`. Twitter `twitter:site` is `@2OceansMarathon`. The OG image size requested is `pp_featured_image`. The sizes registered in `omtom_setup` are `omtom_featured_image` (1920 wide), `omtom_thumbnail` (780×530), and `omtom_gallery_thumbnail` (640×640). `pp_featured_image` is not registered. Provenance: **Implemented**. The OG image lookup is **Inferred** to fail or fall back unless another plugin registers that size.
- Facebook JS uses `wp.facebookAppID` and `FB.init` version `v2.7`, then `FB.ui` method `share`.
- Twitter widgets script is enqueued from `platform.twitter.com`.
- Shortcodes: `youtube`, `button`, `pdfLink`, `map` (`classes/shortcodes/shortcodes.php`), with TinyMCE buttons in `assets/js/tinymce/omtom-plugin.js`.

## Integrations

| System | Direction | Trigger | Data | Provenance |
|---|---|---|---|---|
| MySQL `twooceansmarathon` | App → DB | All WordPress requests | Content, options, users. Prefix `wp_` | **Implemented** in `wp-config.php` |
| Amazon S3 `twooceans-marathon` (`s3-eu-west-1`) | App → storage; browser → objects | Offload plugin; hardcoded logo URL in contact email | Media and at least the theme logo URL | **Implemented** URL; offload behaviour depends on plugin activation (**Unknown** whether active) |
| CloudFront `d19jcneif5xmtf.cloudfront.net` | Browser → CDN | Every public and admin theme asset enqueue | CSS, JS, theme URL passed to scripts | **Implemented** |
| Touchbase Pro | App → API | Successful newsletter validation | Name, email, list id, consent flags | **Implemented**. Token omitted |
| Google Forms | Browser → Google | Charity application template | Visitor completes the embedded form | **Implemented** embed. Response handling is outside this repo |
| Google Maps | Browser → Google | Map pages | API key from options; KML/JSON from theme URL | **Implemented** |
| Instagram API v1 | App → Instagram | `pull_instagram_feed()` when token option is set | Recent media for the token's user | **Implemented** |
| Facebook JS SDK v2.7 | Browser → Facebook | Pages with the theme scripts | App id; share dialog | **Implemented** |
| Twitter widgets | Browser → Twitter | Public pages | Widgets script; card site `@2OceansMarathon` | **Implemented** |
| PHP `mail()` | App → local mailer | Contact form | HTML message to the configured address | **Implemented**. Mail transport (sendmail, SMTP plugin) is **Unknown** |
| Wordfence | In-process if `auto_prepend_file` includes `wordfence-waf.php` | Each PHP request when the bootstrap file exists | Firewall | **Implemented** path `/var/www/html/twooceansmarathon/`. Whether auto_prepend is configured on the server is **Unknown** |
| Crazy Egg | Plugin can inject a tracking script | When the plugin is active | Page analytics | Plugin is vendored. Activation is **Unknown** |

Commented AWS settings in `wp-config.php` also show an RDS hostname `twooceans-rds.cn93a1lgmc5x.eu-west-1.rds.amazonaws.com` and database user `admin`. The active defines use `DB_USER` `root`, `DB_HOST` `localhost`, and an empty password. Provenance: **Implemented** as the committed file. Production credentials may differ and are **Unknown**.

`$_SERVER['HTTPS']` is forced to `on` in `wp-config.php`. `FS_METHOD` is `direct`. `WP_CACHE` is `false`.

## Constraints

- WordPress 4.9.21 and the vendored plugins are the versions in git. The theme README says "Requires at least: WordPress 4.9.8" and "Tested up to: WordPress 4.9.8-trunk". Core in the same tree is 4.9.21.
- Theme asset URLs are pinned to one CloudFront distribution. A local theme file change does not affect the browser until that CDN path is updated. **Inferred** from the enqueue URLs.
- Custom post type labels in `classes/post-types/post-type.php` use text domain `peaceparks`. The theme text domain is `omtom`. Provenance: **Implemented**. **Inferred** leftover from another Hero theme.
- `.gitignore` is the stock Bitbucket example (Node, Java, Python, media extensions). It does not ignore `wp-config.php`, uploads, or SQL dumps.
- There is no automated test suite in the repository.
- There is no Bitbucket Pipelines file.
- `phpinfo.php` calls `phpinfo()` and is tracked at the web root.

## Non-functional notes discovered in code

These are observations, not approved requirements:

- Newsletter and contact AJAX actions accept unauthenticated requests and do not check a WordPress nonce in the handlers that were read.
- The newsletter report query interpolates request dates into SQL and is registered for logged-out users.
- Secrets are stored in git. See technical documentation. Values are intentionally absent here.
- Cookie consent is recorded by setting `OMTOMDisclaimer` on `window.onload`, which runs even when the visitor has not pressed CLOSE.

## Planned behaviour

No TODO, roadmap, or open ticket inside the repository describes future work. The commented PHP cookie block and the commented reporting-options include are disabled code, not a written plan.

## Unknown / requires confirmation

- Live domain, hosting, and whether this tree is still what production runs.
- Plugin activation, including which SEO plugin is active.
- Existence and schema ownership of `wp_omtom_subscriptions` (columns used by code: `name`, `email`, `created`).
- Whether hardcoded post IDs still match the intended pages.
- Whether Webtickets (or another entrant system) is linked from content rather than from the empty anchors.
- Whether the Google Form and Touchbase list are still owned by the event.
- SonarQube files named by OVER-310 but absent from git.
