# PRD — nataliehirschman

Evidence date: 2026-10-09. Source: `git@bitbucket.org:overdrive/nataliehirschman.git` at `f9248b83` (`master`).

There is no prior PRD in the repository or in Silicon Ark. Every requirement below was recovered from the code and the public HTML. Provenance is marked on each item.

**Branch rule.** `master` is the only branch. Requirements below are from that tree.

## Product purpose

Give the public a site for Natalie Hirschman Fine Art: home, biography, CV, a portfolio of works, a contact form, and an email-list form. Give staff an admin that stores the biography, CV, and artwork the public pages load.

Provenance: **Documented** in page titles and navigation (`index.html`). The admin purpose is **Inferred** from `Api`, `Images`, `Contact`, and `Newsletter`.

The public chrome is a FASO export. Dynamic body content for About, CV, and Works is **Implemented** as calls to `https://admin.nataliehirschman.com/api/...` (`admin/js/ajaxcalls.js`).

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Public visitor | Opens HTML pages with no account. About, CV, and Works then request the admin API | Root HTML, `ajaxcalls.js` | Implemented |
| Enquirer | Submits name, email, phone, and comments on the contact form | `contact.html`, `contact.php` | Implemented |
| Subscriber | Submits an email on the newsletter page | `email-newsletter.html`, `newsletter.php` | Implemented |
| Admin user | Signs in with username and password. Sees menus allowed for their role | `Welcome`, `Authentication`, `Authorization` | Implemented |
| Self-registered user | Opens Register from the login page. Account is stored with role id `1` | `Welcome::registration`, login view link | Implemented. The seed names role `1` "Developer" |
| Developer (seed role) | Menu seed grants Dashboard, Users, Menu Management, and CRUD Generator | `UserRole`, `UserMenu`, `UserAccess` seeds | Implemented as seed data. Whether those rows exist in production is Unknown |

There is no separate "artist" role in code. Staff are rows in `users`.

The sidebar label is "Nathalie Hirschman". The public site uses "Natalie". Provenance: **Implemented**. Which spelling is correct is **Unknown / Requires Confirmation**.

## Main workflows

### W1 — Read the public site

Trigger: visitor opens the site.

Desktop navigation in `index.html`:

1. Home → `index.html`.
2. About → Biography (`about.html`) and CV (`cv.html`).
3. Works → Figurative and Portraits, both `href="works.html"` with no category argument. Landscape and Still life are `href="#"` with grey text and `cursor: default`.
4. Contact → `contact.html`.
5. Galleries → `href="#"` with the same disabled styling. The file `galleries.html` still exists.
6. Email Newsletter → `email-newsletter.html`.

Mobile navigation in the same file still links Galleries to `galleries.html` and does not show the CV item or the disabled Works categories.

Provenance: **Implemented**. The desktop and mobile menus disagree.

Home still contains a FASO hero image and heading ("A sculpture dreams - Oil") in the HTML, plus a newsletter widget that posts to `https://nataliehirschman.com/webadmin/confirm`. That widget does not use `newsletter.php`. Provenance: **Implemented**.

### W2 — Read biography or CV

Trigger: `about.html` calls `getPage("about")`. `cv.html` calls `getPage('cv')`.

1. `getPage` GETs `https://admin.nataliehirschman.com/api/{pageName}`.
2. `Api::index` switches on the second URI segment: `about`, `works`, `contact`, `cv`.
3. About uses `ApiModel::getAboutContent`, which joins `pages` to `images` where `page_name = 'About'`.
4. CV uses `getPageContent('CV')`, the same join where `page_name = 'CV'`.
5. The script writes `response[0].page_title` into `#page_title` and `response[0].body` into `#article_body`.

`about.html` also contains a readonly textarea of static biography text. The script overwrites `#article_body` when the API returns a row. If the response is empty, `response[0]` is undefined and the static text is left in place only if the script throws before writing. Provenance: **Implemented**. There is no documented fallback requirement.

The join is inner: a page with no `images` row returns an empty array. Provenance: **Implemented** (`join`, not `left` join).

### W3 — Browse works

Trigger: `works.html` calls `getWorksPage()` on load. Filter links call `getWorksPage('Originals')`, `getWorksPage('Limited Editions')`, and `getWorksPage('Open Editions')`.

1. GET `https://admin.nataliehirschman.com/api/works` or `.../api/works/{filter}`.
2. With no filter, `getWorksImages` returns `pages` joined to `images` where `page_name = 'Works'`, ordered by `` `order` ASC ``.
3. With a filter, `getWorksImage` keeps `content_type = "image"` and `category = {filter}` OR `images.id = {filter}`.
4. The script builds three masonry columns and a carousel. Image URLs are `https://admin.nataliehirschman.com/` plus `thumbnail_path` or `image_path`.
5. An empty list sets the heading to "Portfolio of Works" and renders no items.

Admin category options are Figurative, Portraits, Landscape, and StillLife (`imageList.php`). Public filter strings are Originals, Limited Editions, and Open Editions. The Works menu items Figurative and Portraits do not pass those words to `getWorksPage`. Provenance: **Implemented** mismatch. Whether production data uses either set of names is **Unknown / Requires Confirmation**.

`content_type` stored by the admin form is `Image` or `Content`. The filtered API compares to lowercase `image`. Provenance: **Implemented**. A case-sensitive collation would return no filtered rows.

`Active` is stored on insert and update. The API queries do not filter on it. Provenance: **Implemented**.

### W4 — Open one artwork from the zoom page

`workszoom/index.html` calls `getImage(id)`.

GET `https://admin.nataliehirschman.com/api/works/{id}` and fills `#page_title`, `#size`, `#medium`, and `#artwork-viewer`. The viewer zooms by moving the background position. A hard-coded `title` attribute in the generated markup still says "A sculpture dreams by Natalie Hirschman, Oil, 120cm x 80cm" regardless of the API row. Provenance: **Implemented**.

Static per-work HTML under `workszoom/{id}/` and `workszoom-all/{id}/` remains in the repository and links to `inquire/{id}.html`. Those pages are the FASO snapshot, not the API viewer.

### W5 — Contact the artist

Trigger: visitor submits `contact.html`.

1. The form POSTs to `contact.php` with `Name`, `Email`, `Phone`, `Comments`, a hidden reCAPTCHA token, `_subject` ("A Client Contact From Your Website"), and `_requiredFields` (`Email`).
2. Labels say Name, Email, and Comments are required. The inputs in the excerpt do not use the HTML `required` attribute. `contact.php` does not check those fields.
3. `contact.php` verifies the token with Google reCAPTCHA. The secret is hardcoded. It is omitted from this PRD.
4. On `success == true`, it POSTs `from`, `email`, `phone`, and `comments` to `https://admin.nataliehirschman.com/public/contact` and redirects to `contact.html`.
5. On failure, the script produces no body and no redirect.

Admin `Contact::create` inserts those four fields into `contacts` and returns HTTP 201 with "contact created successfully". The `sendMail(...)` call under that insert is commented out. Provenance: **Implemented**.

`Contact::index` renders `contacts/contactList` for a signed-in user who can open the `contact` menu. The filters exempt every `contact` URI from login, so the resource routes (list, show, update, delete) are reachable without a session. Provenance: **Implemented**. Whether that is intended is **Unknown / Requires Confirmation**.

### W6 — Join the email list from the newsletter page

Trigger: visitor submits `email-newsletter.html`.

1. The form POSTs to `newsletter.php` with `Subscriber` and a reCAPTCHA token.
2. On reCAPTCHA success, `newsletter.php` POSTs `email` to `https://admin.nataliehirschman.com/public/newsletter` and redirects to `email-newsletter.html`.
3. `Newsletter::create` inserts `email` and `created_at` into `newsletter` and returns 201 "email added successfully". No format check, no duplicate check.

The login filter's exception list includes `contact` and does not include `newsletter`. An unauthenticated POST to the `newsletter` route is redirected to the admin login before `create` runs. Provenance: **Implemented**. The public handler targets that route. Whether production actually stores subscribers is **Unknown / Requires Confirmation**.

Other newsletter widgets (home, artwork pages, `newsletter-archive.html`) POST to `https://nataliehirschman.com/webadmin/confirm` or `https://nataliehirschman.fineartstudioonline.com/aspwpadmin/confirm.asp`. Those are FASO endpoints, not `newsletter.php`. Provenance: **Implemented**.

`Newsletter::index` is an empty method. There is no admin screen in the views for the list. Show, update, and delete exist on the resource controller.

### W7 — Sign in and edit content

Trigger: staff opens the admin.

1. GET `/` shows `common/login` unless `isLoggedIn` is already true, in which case the user is sent to `home`.
2. POST `GetLogin` (and POST `/`, because `Welcome::index` handles both) reads `inputEmail` and `inputPassword`.
3. The username lookup uses `inputEmail`. Passwords are checked with `password_verify`.
4. Success stores `username`, `role`, and `isLoggedIn` and redirects to `articles`.
5. Failure sets flash text "Your ID or Password is Wrong" and redirects to `/`. The same text is used when the user does not exist.
6. `articles` is `Images::index`: the image list, page dropdown, and upload form.
7. Saving an image POSTs to `upload` or `articles/update`. Files are resized to a 500px thumbnail and stored under the admin public uploads path. Rows go to `images` with title, category, size, medium, page, order, content type, body, and paths.
8. Logout is `Welcome::logout`, which destroys the session. No route named `logout` is declared. Auto-routing is on (`setAutoRoute(true)`), so `Welcome/logout` can resolve. Provenance: **Implemented**. A menu URL of `logout` was not found in the seed.

`home` is commented out as a route (`Home::index`). The seed menu still points Dashboard at `home`. `Authorization` allows `home` without a menu check. Whether `/home` renders depends on auto-routing to `Home::index`. Provenance: **Implemented** in routes and seed; the live menu row is data.

### W8 — Register an admin account

Trigger: "Don't have an account? Register" on the login view.

1. GET `register` shows the form. The route is outside both auth filters.
2. POST checks `inputEmail` is unique in `users.username`, `inputPassword` is required, and `inputPassword2` matches.
3. The new row is role `1`, full name and email from the form, password hashed with `PASSWORD_DEFAULT`.
4. Success flash: "Registration Successfully! Please login with your account." The login view is returned directly (not a redirect).

Role `1` is the seeded Developer role. A new registrant receives that role id, not a limited role. Provenance: **Implemented**. No approval step exists.

### W9 — Users, menus, and the CRUD generator

Seed menus, for a role that has `user_access` rows:

- Dashboard (`home`)
- Users (`users`) — create, update, and delete users and roles; change menu permissions
- Menu Management (`menuManagement`) — create menu categories, menus, and submenus. Creating a menu attempts to create a controller and view file
- CRUD Generator (`crudGenerator`) — generates from a table name

`Authorization` loads `user_menu` by the first URI segment. No matching menu redirects to `/`. A menu the role cannot access redirects to `blocked`.

Contacts and articles are not in the menu seed. They can appear only if those `user_menu` rows were added in the database. Provenance: **Unknown / Requires Confirmation** for the production menu. The screens exist in code (`contactList`, `imageList`).

## Functional requirements

### FR-001 — Public pages without a login

The visitor can open Home, About, CV, Contact, Works, Galleries, Email Newsletter, and Newsletter Archive as HTML. Galleries is not offered as a working desktop nav item.

Evidence: root HTML files and the nav in `index.html`.

Implementation: Implemented.

Confidence: High.

### FR-002 — About and CV body come from the admin API

About and CV request `/api/about` and `/api/cv` and render `page_title` and `body` from the first JSON row.

Evidence: `ajaxcalls.js` `getPage`, `Api::getAboutPage`, `Api::getCvPage`.

Implementation: Implemented.

Confidence: High.

### FR-003 — Works portfolio comes from the admin API

The works page lists images for the page named `Works`, ordered by `order`, and can request one image or a category filter.

Evidence: `getWorksPage`, `getImage`, `ApiModel::getWorksImages`, `getWorksImage`.

Implementation: Implemented. Filter vocabulary does not match the admin category list. See W3.

Confidence: High for the mechanism. Low for which categories production uses.

### FR-004 — Artwork files are served from the admin host

Thumbnails and full images in the dynamic gallery are loaded from `https://admin.nataliehirschman.com/` plus the stored path.

Evidence: `ajaxcalls.js`. Upload writes `public/assets/uploads/` and `public/assets/uploads/img_thumbnail/`.

Implementation: Implemented.

Confidence: High.

### FR-005 — Contact form stores an enquiry after reCAPTCHA

A successful reCAPTCHA causes a POST that inserts `from`, `email`, `phone`, and `comments` into `contacts`. The visitor is sent back to `contact.html`. Mail is not sent.

Evidence: `contact.php`, `Contact::create`, commented `sendMail` in that method.

Implementation: Implemented. Email notification is present only as a commented call and as `mail_helper.php`.

Confidence: High.

### FR-006 — Newsletter page stores an email after reCAPTCHA

`newsletter.php` posts the address to the newsletter resource, which inserts `email` and `created_at`.

Evidence: `email-newsletter.html`, `newsletter.php`, `Newsletter::create`.

Implementation: Implemented in those files. The auth filter does not exempt `newsletter`, so the insert may never run for an anonymous caller.

Confidence: Medium. Needs a live check.

### FR-007 — Admin login

A user with a matching `users.username` and `password_verify` success gets a session and is sent to the articles screen.

Evidence: `Welcome::index`, `UserModel::getUser`.

Implementation: Implemented.

Confidence: High.

### FR-008 — Menu authorisation

After login, the first URI segment must match a `user_menu.url`, and `user_access` must allow that menu for the session role. Otherwise the user is sent to `/` or `blocked`.

Evidence: `Authorization`, `Filters.php`.

Implementation: Implemented. Exceptions are listed in `Filters.php` (`/`, `GetLogin`, `register`, `api`, `api/*`, `upload`, `contact`, and for the grant filter also `blocked`, `home`, `Welcome/*`).

Confidence: High.

### FR-009 — Staff can add and edit images and page content

The articles screen lists `images`, lets the user pick a page from `pages`, choose content type Image or Content, category, medium (Oil, Water, Carole), size, order, active flag, and optional file.

Evidence: `Images::index`, `imageList.php`, `Upload`, `ImageModel`.

Implementation: Implemented. The mime and 4096 KB rules are written and then not applied: the `if ($validateImage)` block is commented out, and a non-empty client filename is saved anyway.

Confidence: High.

### FR-010 — Staff can list contact rows

`Contact::index` renders every row from `contacts`.

Evidence: `ContactModel::getContact`, `contactList.php`.

Implementation: Implemented. The same controller's write and read endpoints are outside the login filter.

Confidence: High for the code. Low for whether the menu is granted in production.

### FR-011 — Public admin registration

Anyone who can open the login page can register a role `1` account if the email is not already a username.

Evidence: `common/login.php`, `Welcome::registration`.

Implementation: Implemented.

Confidence: High. Product intent is **Unknown / Requires Confirmation**.

### FR-012 — Analytics

Public pages that include the Matomo snippet record page views to `analytics.medmin.co.za` for site id 3, cookie domain `*.nataliehirschman.com`.

Evidence: `index.html` and the same block on `galleries.html`.

Implementation: Implemented on pages that contain the snippet. Not every HTML file was compared line by line.

Confidence: High for the home page.

## Integrations

| System | Direction | Trigger | Data | Auth | Failure |
|---|---|---|---|---|---|
| Admin API | Browser → admin | About, CV, Works, zoom | JSON rows from `pages` + `images` | None. CORS `Access-Control-Allow-Origin: *` | Script logs the status text. Page shell stays |
| Admin contact resource | `contact.php` → admin | reCAPTCHA success | `from`, `email`, `phone`, `comments` | None in the filter list | No visitor message. Redirect happens even without checking the admin response |
| Admin newsletter resource | `newsletter.php` → admin | reCAPTCHA success | `email` | Login filter is not exempt | Same: redirect does not depend on the admin response |
| Google reCAPTCHA | `contact.php` / `newsletter.php` → Google | Form POST | token, remote IP, secret | Secret in source | Non-success ends the script with an empty response |
| Matomo | Browser → `analytics.medmin.co.za` | Page load | page view, site id 3 | None in the snippet | `noscript` pixel only. No app-level retry |
| SMTP (Google) | Admin `Email` config and `mail_helper.php` | `sendMail($to,$subject,$message)` | From `nataliehirschmanweb@gmail.com`, display name "Nathalie Hirschman" | SMTP password in config (omitted here) | Helper prints the debugger. Contact create does not call it |
| MySQL | Admin → `sql11.cpt4.host-h.net` | Each request that hits a model | See technical documentation | Username in `Database.php`. Password omitted here | CI database errors. Exact visitor page is the framework error view |
| FASO / InformedCollector | Browser → fineartstudioonline.com | Static forms and "Get New Art Alerts" | FASO admin id `33778` | Not in this repo | Not handled here |
| FTP deploy | Bitbucket → `$FTP_HOST` `/public_html` | Was `master` pipeline | Working tree except vendor (git-ftp ignore) | `FTP_USERNAME` / `FTP_PASSWORD` variables | Pipeline is commented out as of `f9248b83`, so commits do not deploy |

## Constraints

- Public dynamic content depends on the admin host being reachable from the visitor's browser. The HTML does not proxy the API.
- Page names the API looks up are the literals `About`, `CV`, and `Works`. Other names return no branch in `Api::index` (null response).
- Image and page tables are not created by the migrations in git. A new environment that only runs `spark migrate` does not get them.
- `app.baseURL` in config is `http://admin.nataliehirschman.local`. The browser calls `https://admin.nataliehirschman.com`. Generated admin links use the configured base URL.
- `indexPage` is `index.php`. Apache rewrites in both `.htaccess` files send non-files to `index.php`.
- CSRF and honeypot filters are commented out. CORS allows any origin and GET, POST, OPTIONS, PUT, DELETE.
- `setAutoRoute(true)` is on.
- PHP `^8.0`. Locked framework `v4.6.3`.

## Non-functional observations

- Upload validation is present and bypassed. Max size constant in the unused rule is 4096 KB. Thumbnail resize is 500 by 500, maintain ratio on height, quality 100.
- `created_by` is set to the current date-time string, not a user id (`ImageModel`).
- API image lookup concatenates `$id` into SQL. Provenance: **Implemented** in `ApiModel::getWorksImage` and `checkContentPage`.
- `contact.php` and `newsletter.php` redirect on reCAPTCHA success without reading the admin HTTP status.
- Committed `env` sets `CI_ENVIRONMENT = development`. CodeIgniter reads `.env`. Whether production copied this file is **Unknown**.
- Tests under `admin.nataliehirschman.com/tests` are the framework examples (`HealthTest`, `ExampleSessionTest`, `ExampleDatabaseTest`), not tests of articles, contact, or the API.

## Open product questions

- Is the live site still `nataliehirschman.com`, and is the admin still `admin.nataliehirschman.com`?
- Should public registration remain, and should new users receive the Developer role?
- Should contact and upload stay outside the login filter?
- Which category names are real: Figurative / Portraits / Landscape / StillLife, or Originals / Limited Editions / Open Editions?
- Should inactive images be hidden on the public site?
- Should a contact submission email the artist? The helper exists and the call is commented out.
- Is the newsletter form expected to work for anonymous visitors given the auth filter?
- Are the FASO enquire pages and `webadmin/confirm` forms still supposed to be live?
- Is Galleries retired? Desktop nav disables it; the HTML file and the mobile link remain.
- What is the correct artist spelling for the admin chrome?
