# Progress report

Fresh project, started from an empty folder at your request. This report states what was **verified by running it** and what was not. Where something is only partly done it says so.

## Status at a glance

| Phase | Status |
| --- | --- |
| 1. Audit, reference research, plan | Inventory + plan done. **Reference research NOT done** (needs your login or screenshots; see below) |
| 2. Auth, tenancy, roles, permissions | Done. Committed migration files still missing (see "Database") |
| 3. Shared shell + four dashboards | Done |
| 4. Pages (versions, workflow, scheduling), block builder + renderer, navigation, media, public sites | Done |
| 5. Content libraries (10 types), events, tracks, sessions, speakers | Done |
| 6. Forms, submissions, spam protection, CSV export, retention, notification jobs | Done (email transport is a stub, see below) |
| 7. Site settings/SEO, redirects, reports, ticket workflow, organization management | Done, with the gaps listed under "Known gaps" |
| 8. Tests, security review, accessibility scan, docs | Done for what is listed; MFA, email password reset and CSP are not built |
| 9. Every remaining navigation entry (all four dashboards) | Done: no "Soon" entries remain. Some screens are intentionally read-only or honest about what is not built (see below) |
| 10. Page editor rebuilt (Sections → Articles → Blocks), Panels module, page-list upgrades | Done and verified live; needs one SQL script on your database (see "Editor rebuild") |
| 11. Editor, New Page and Pages list restyled to match the reference admin's layout and behaviour | Done and verified live (see "Reference-style editor") |

## Editor rebuild (phase 10)

Structure follows the reference admin's workflow (design reference only; no code, text or assets were copied).

- **Content model.** A page is sections; a section holds articles; an article holds blocks. Sections have title/show-title, width, inner width, margins, padding, active flag and an optional show-from / hide-after window. Articles have four styles (plain, boxed, bordered, boxed and bordered), title, active flag and their own window. Existing pages convert automatically on read (one section and article per old block), so nothing is migrated and old versions can still be restored.
- **Editor.** Layout tree with Create-section between sections, drag handles plus Move up / Move down (keyboard friendly), Duplicate, Activate / Deactivate, Remove with confirmation, All / Active / Inactive filter, Section options (General, Settings, Publishing), Article options, and an "Available shortcuts" list. Page options has General (title, show title, parent page, URL, layout), SEO and Advanced (tags, show in search, visibility, assignee). Server validation errors jump to the section, article or block that needs fixing.
- **Shortcuts.** `{{sitename}}`, `{{siteurl}}`, `{{current_year}}` are replaced as plain text at render time. Unknown tags are left as typed.
- **Panels.** Header, Hero, Footer and Popup panels (Admin → Panels) use the same section/article editor, can be limited to selected pages, switched on or off, and reordered. Popups are dismissed per visitor in their browser. Requires `content.manage`.
- **Pages list.** Home icon, SEO state, drag/arrow reordering (one site selected, no filters), checkbox Copy pages (copies are drafts), status filter. Parent page is applied through the URL path (for example `about/team`).
- **Verified.** 173 automated tests pass (including new tenant-isolation tests for panels, page copy and reorder). Live checks: sections/articles/blocks create and save, merge tags on the public page, future show-from hides a section, expiry-before-start is rejected and pointed at the section, panels appear on the right site only and a panel limited to one page is absent elsewhere, popup stays dismissed after reload, copy and reorder (arrows and drag) persist, parent URL and tags save, unknown hostname returns 404, axe-core reports 0 violations on the editor, panels, pages list and the public page (desktop and 390 px, popup open and closed).
- **Not done / honest limits.** Drag-and-drop works within the same parent only (use Move to change order across lists; moving an article to another section is not built). Pages-list reorder is limited to one page of results (20). Panels have no version history or draft state (saving makes the change live when the panel is active).

## Reference-style editor (phase 11)

The page editor, the New Page screen and the Pages list were rebuilt to follow the reference admin's look and workflow (design reference only; no code, text, logo or illustrations were copied, and the empty-state artwork is original).

- **One editor, two modes.** New Page is the same screen as Edit Page, in create mode. Nothing is stored until you click Create Page; the page, its SEO and its content are saved in one step, and if anything fails the half-created page is removed again. With several websites and none chosen in the top bar you first pick the website.
- **Inline editing.** Sections are cards with the title typed in place, "Add New Article +" and "Section Options" buttons, and round icon buttons for move up/down, drag, duplicate and delete (delete asks first). Articles sit side by side (up to 4 columns, on the public site too) with their own title, move left/right, duplicate, options and delete (delete only when the section has more than one article). Text is written straight into the article: click inside any article (even an empty one) and the dark two-row formatting bar floats above it (bold, italic, underline, strikethrough, clear formatting, numbered and bulleted lists, four alignments, link and unlink, live preview, horizontal rule, table, image from a web address, Widget, Button, Button Group, insert shortcut, Styles, hide). Esc or the × hides it. Other widgets show as dark bars and open an "Edit widget" dialog. "Create New Section" opens the Content Section Layout chooser (1–4 columns).
- **Dialogs.** Section Options (General, Settings, Publishing), Article Options (General, Content, Publishing), Edit widget (Content, Appearance), Select Widget (Content, Social, Your show, View all) and Page Options (General, SEO, Publishing, Advanced, Versions). A server error opens the tab or widget that needs fixing, and the page shows a "needs attention" list with Fix buttons.
- **Bottom bar.** Delete, "Last Edited … by …" (UTC), Active Yes/No (publishes the saved page or returns it to draft), Page Options, View (preview draft, view live, publish latest changes) and Save.
- **Text markup.** The Text widget accepts a small safe markup (bold, italic, underline, strikethrough, links, buttons, headings, lists, rules, tables, images by web address, alignment). It is parsed into elements, never inserted as HTML, and unsafe link targets are dropped.
- **Pages list.** Big title, Add Page / Copy Pages buttons, search with clear button, Any Status filter, one card per page (home icon, drag handle with arrow buttons, checkbox, status, SEO indicator, Edit Page with Duplicate / Preview menu).
- **Verified.** tsc and eslint clean; 184 tests pass (unit + MariaDB integration, including create-from-editor and tenant checks); live flows: create a two-column page with inline text and a widget, SEO and URL, save, make it Active, public page shows bold text, lists and both columns, errors reopen the right dialog, validation fix and re-save, Active No, delete, Panels editor, pages list actions; axe-core reports 0 violations on the Pages list (with its menu), New Page, the editor and every dialog/tab, the error state and the Panels editor; no horizontal scroll at 390 px.
- **Honest limits.** This is not a full WYSIWYG: widgets show as bars and text previews rather than a pixel-exact page, and the editing panel uses a plain-text field with a formatting toolbar (markup) instead of a rich-text engine. Drag-and-drop stays within one parent (articles move with the left/right buttons). Dialogs apply changes immediately; Submit just closes them and Save writes to the server. The live preview opens in a new tab (last saved draft) instead of an in-page preview pane. Not built from the reference toolbar: text colour, "AI Rewrite", the symbol button, and images from the media library inside text (use the Text and image widget for library images). The artwork, icons, colours and wording are our own approximations of the reference style.

## What works (each item below was exercised against a running app + MariaDB)

- **Auth/tenancy:** credentials login, lockout, session revocation, per-request DB-derived permissions, tenant filters on every query. Cross-tenant ids return 404.
- **Pages:** drafts, immutable versions, restore, duplicate, workflow (draft → in review → scheduled/published → archived), optimistic concurrency, scheduled publishing (`/api/cron/publish`), signed draft preview links (24 h).
- **Page builder:** 14 validated block types, generic field editor, real public renderer, media usage tracking (media in use cannot be deleted; private media cannot be placed on pages).
- **Public sites:** host → site resolution, private/draft/scheduled pages are 404, redirects, sitemap, robots, JSON-LD (escaped), analytics (GA4/Plausible), theme.
- **Navigation:** header/footer menus, nesting, page/path/external links with scheme validation.
- **Media:** magic-byte validation, size/quota limits, private assets with signed URLs, replace-in-place.
- **Libraries/events:** versions, bulk actions, editor vs manager limits; events with time zones, venue/speaker conflict detection, public schedule block.
- **Forms:** builder (10 field types), public submit endpoint, honeypot, minimum-fill time, link-spam heuristics (stored as SPAM, no notification), per-form and per-IP rate limits, same-origin + host-ownership checks, 64 KB body cap, consent text, retention purge job, CSV export (formula-injection guard, audited by count only), background notification jobs with retry.
- **Site admin:** settings (title, suffix, descriptions, logo/favicon/social image, theme colours, indexing switch, analytics, social links) and redirects (validation, loop detection, reserved-path guard) with audit events.
- **Reports:** counts straight from the database (pages, library content, submissions per day, media storage, tickets, mean resolution time) as accessible SVG charts + CSV export. No traffic numbers are shown because the platform does not collect them.
- **Tickets:** raise requests, workflow transitions with required resolution notes, assignment limited to eligible staff, internal vs customer-visible comments (customers never see internal notes), time logging, escalation, in-app notifications, SLA display.
- **Super Admin:** create organizations, suspend/reactivate/archive (nothing deleted; access stops immediately), add websites with a primary domain, add/remove organization admins (existing users only; the last admin cannot be removed).

## Added in the "build every item" pass

- **Super Admin:** Websites (all sites, domains, counts), Users (create, suspend/reactivate, unlock, sign out everywhere, reset password; cannot act on yourself; cannot suspend the last super admin), Roles matrix, Platform configuration, Feature modules, Storage overview, Maintenance queue (failed jobs with retry, locked accounts, SLA breaches), Integrations, System health, Backup & recovery guidance, Security posture, My profile.
- **Admin:** Websites, Users & permissions (grant/revoke Editor or Maintenance on a site for existing users; never Organization Admin), My profile.
- **Maintenance:** Assigned websites, Incident reports, Maintenance history, Deployment status, Application logs, Documentation, Notifications inbox (mark read / all), My profile.
- **Editor:** Content preview, Revision history, My profile.
- **Two real cross-tenant bugs found and fixed while writing the tests for this pass:** (1) the new Incident query let its own `OR` replace the tenant filter (caught before release by an integration test); (2) the **existing** pages list let a search term replace the tenant filter, so an organization admin searching pages could see other tenants' page titles. Both now combine conditions with `AND`, and regression tests cover them (I confirmed the pages test fails on the old code).

## Tests actually run (last full run)

| Check | Result |
| --- | --- |
| `tsc --noEmit` (strict) | pass |
| `eslint .` | pass |
| `vitest run` (unit) | 129 passed, 11 skipped (integration, need a DB) |
| `vitest run` with `TEST_DATABASE_URL` set | **154 passed (134 unit + 20 integration)**; earlier run was 140, including 11 MariaDB integration tests for tenant isolation and role limits. I confirmed they can fail by temporarily disabling the permission guard: 8 of them failed, then I restored it |
| `next build` | pass (run with `PRISMA_SCHEMA_ENGINE_BINARY=/bin/true` in the authoring sandbox, see Database) |
| Live HTTP + browser flows (Playwright, curl with Host headers) | Pass for every phase: tenant isolation, preview tokens, draft/scheduled 404s, content/event workflows, form submit/spam/rate-limit/CSV/jobs, settings/redirects/reports, ticket lifecycle, organization management |
| Automated accessibility scan (axe-core, WCAG 2.0/2.1 A + AA) | 0 violations on login, 19 admin screens, 3 super-admin screens, and the public home page (after fixing a contrast failure in status badges); **plus 0 violations on all 30 screens added in the last pass** (12 super-admin, 3 admin, 8 maintenance, 3 editor, and admin users after a grant) |

**Not run:** manual screen-reader testing, keyboard-only walkthrough of every screen, dark-mode contrast scan, load/performance tests, HTTPS cookie behaviour, behaviour behind a real reverse proxy, a real SMTP send, browsers other than Chromium.

## Security decisions

See [`SECURITY.md`](SECURITY.md). Highlights: authorization is server-side and re-derived per request; private/foreign resources are indistinguishable from missing ones (404); IP addresses are only trusted when `TRUST_PROXY=true` and are stored as keyed hashes; no password, token, secret or form payload is ever logged; uploads are validated by content, not by name.

## Known gaps (not built, not faked)

- **Reference dashboard (ShowOff) was never inspected.** It requires a login I do not have and I did not try to access it. `docs/REFERENCE-SPEC.md` does not exist. Screens were built from your written specification.
- **Accounts:** a super admin can create accounts (initial password chosen by them and shared out of band), suspend/reactivate, unlock, sign out everywhere and reset passwords; everyone can change their own password. There is **no MFA, no email-based reset/invite flow, and no forced password change on first sign-in** (needs the email transport below).
- **Email:** only a console/disabled mailer exists. Notification jobs run and retry, but nothing is sent until an SMTP/API transport is implemented behind the `Mailer` interface.
- **No Content-Security-Policy.** Other headers are set (nosniff, frame options, referrer policy, permissions policy, HSTS in production). A CSP needs nonce plumbing that I did not want to ship untested.
- **Rate limits are in memory** (per process). Use an edge/Redis limiter with several instances.
- **No committed Prisma migrations.** See Database.
- **Intentionally limited screens:** Roles & permissions is read-only (roles are defined in code). Platform configuration and Integrations show the running environment read-only (secrets only as set / not set). Feature modules records the selection per site but other screens do not hide a disabled module yet. Backup & recovery explains what you must run outside the app and shows database table sizes; the app takes and records no backups. Deployment status shows serving status and last publish activity because every site is served by one application (no per-site deployments exist). Incident reports are open tickets that are high/critical, security-related or escalated; there is no separate incident record. Maintenance "Application logs" shows redacted audit events and failed jobs, not raw server logs. System health is a live check with no history.
- **Not built:** custom-domain verification, image resizing/thumbnails, media folders UI beyond a single folder field, page templates, granting Organization Admin from the admin dashboard (super admin only), editing roles.
- **Cross-tenant editor URLs** return HTTP 200 with a "not found" body (streaming under `loading.tsx`); no data is exposed, but the status code is not 404.
- Escalation resets the SLA clock to the new priority's full window from the moment of escalation (a policy choice; change in `services/tickets.ts`).
- Visitor analytics are delegated to GA4/Plausible; nothing is collected by the platform.

## Database

Schema: 33 models. **No migration files are committed**, because Prisma's schema engine could not be downloaded in the authoring sandbox. For testing I applied throwaway DDL generated from the schema to a local MariaDB. On your machine, never accept a reset prompt. See `DEPLOYMENT.md` → "Safe schema changes".

Additive changes made after your first install (all nullable or defaulted): `BackgroundJob.payload`, `BackgroundJob.runAfter`, plus the models for navigation, categories/tags, content entries and versions, tracks, sessions, redirects, media usage, notifications, maintenance activity.

## Next steps

1. On your machine, baseline migrations safely (DEPLOYMENT.md), then `pnpm typecheck && pnpm lint && pnpm test`.
2. Give me reference access or screenshots to write `docs/REFERENCE-SPEC.md`, then reconcile screens.
3. MFA, SMTP transport with invite/reset by email and forced first-login password change, CSP, distributed rate limiting.
4. Enforce feature modules in navigation/routes.

## Role-based landing and project picker (Oct 2026)
- After login: super admin → Super Admin dashboard (unchanged, all access); editor → Editor dashboard (unchanged);
  org admin and maintenance → `/dashboard/projects` (`landingPath()` in `lib/authz/context.ts`).
- `/dashboard/projects` shows the website count for the organisation and one card per website. Org admins get an **Edit**
  button; maintenance users get **Open**. Both call `openProjectAction`, which re-checks the site against the user's
  accessible sites, sets the active-site cookie and opens `/dashboard/admin`.
- Maintenance now has a read-only Admin view of assigned projects: added `dashboard.admin`, `pages.read`, `media.read`
  to MAINTENANCE. Admin nav items carry a `perm` and are hidden when the user lacks it, so maintenance sees Dashboard,
  Websites, Pages (view only), Media library (view only), Maintenance requests and Profile. Forms, users, SEO, reports, etc. return 404.
- "All projects" in the top bar returns to the picker.
- Existing database: run `prisma/manual/2026-10-maintenance-admin-view.sql` (idempotent) or re-run the seed.

## Custom panel locations (Oct 2026)
- Super admin → **Panel locations**: add a location (name, where it renders: above/below page content, help text), choose all websites or specific ones, turn it off, or delete it when unused.
- A location appears in Panels (list and editor) only for websites that have access; those panels render on that website's pages. Built-in Header/Hero/Footer/Popup are unchanged.
- New table `PanelLocation`: run `prisma/manual/2026-10-panel-locations.sql`, then `pnpm prisma generate`.

## Single dashboard address space
Every dashboard page is now `/dashboard/<page>` (e.g. `/dashboard/sites`, `/dashboard/panels`, `/dashboard/overview`) for every role. `src/lib/dashboard-urls.ts` holds the page table; `src/proxy.ts` rewrites the public address to the internal tree (`super-admin`, `admin`, `maintenance`, `editor`) for the signed-in user (dashboard list stored in the JWT as `dk`, highest authority first) and redirects old `/dashboard/<tree>/...` addresses. The platform maintenance page is `/dashboard/system-maintenance`. Authorization is unchanged and still checked per page/action. The sidebar is one "cmsadmin" menu per user.

## Organization settings: members, slugs, translations
- URL names (slugs) are no longer typed: organizations and websites get one generated from the name (`-2`, `-3` on clashes); the org page no longer shows it.
- Organization page → "Members & access": pick a user and a role (Organization Admin = whole organization; Editor / Maintenance = all websites or one website). Removing access is per row.
- Organization page → "Translations" tab (modelled on ShowOff's client-level translate): languages, search, Module / Text / Type table, column options, per-language overrides. Overrides for the organization's default language are applied to the public form texts (`src/lib/i18n/catalog.ts` lists the wording that can be changed). Needs `prisma/manual/2026-10-org-translations.sql` + `pnpm prisma generate`; the public site falls back to the built-in text until then.

## Organisation settings tabs (ShowOff client-level settings)

The organisation page now has the same tabs ShowOff has at client level: Client level translate, DPA settings, Scheduled Tasks and Related Sites (plus our Overview).

- Client level translate: language tabs, search, sortable Module / Text / Type table with **paging (10/25/50/100 per page, "Showing x to y of n entries")**. The toolbar now has **Site-level Translations** and **Edit Countries** buttons instead of "Add language"; adding a language moved into *Translation Options*.
- Site-level Translations (`?tab=translations&view=site`): pick a website, reword text for that website only. Website wording wins over the organisation's (`messagesForSite`).
- Edit Countries (`?tab=translations&view=countries`): ~250 countries, hide one or change its display name.
- DPA settings: DPA groups, DPA questions (optionally in a group), and options (sectors, contact types, scopes).
- Scheduled Tasks: website + type (exhibitor export XML, visitor import XML/CSV/API). This records the task; nothing runs it yet.
- Related Sites: pair two or more websites with a description, active flag and sharing attributes.
- The built-in text list grew from 7 to 17 entries (site navigation labels, popup labels, schedule fallbacks, 404 page) and each is wired into the public site.
- Database: `prisma/manual/2026-10-org-settings.sql` (run once, then `pnpm prisma generate`). The screens show a notice instead of failing until it is run.
- All writes need `platform.organizations.manage` and reject websites that belong to another organisation. Covered by `src/integration/isolation.test.ts` ("organization settings tabs") and `src/lib/i18n/countries.test.ts`.

## Event settings tab (ShowOff "Event Settings")

Organisation page → **Event settings** tab: pick a website, then collapsible sections as in ShowOff.
- General: name (renames the website), cover picture, start and end date/time (in the website's time zone).
- Modules and Configuration: categories per library, DPA group, zone login (forgotten-password link), zone profile lock, tags, business sectors.
- Support: main contact (someone with access to the organisation).
- Single Sign-On: enable switch and shared secret. The secret is sealed (AES-256-GCM, `src/lib/security/secret-box.ts`) and never sent back to the browser; leaving the box empty keeps the current one, Generate makes a new one.
- Database: `prisma/manual/2026-10-site-event-settings.sql` (needs `2026-10-org-settings.sql` first), then `pnpm prisma generate`.
- These values are stored and editable; nothing on the public site reads them yet (SSO link generation, profile lock and the rest are future work).
- Tests: "event settings" in `src/integration/isolation.test.ts`, `secret-box.test.ts`.

## SEO & settings (ShowOff cmsadmin/settings, seoSettings, layout settings)

Site admin → **SEO & settings** now has tabs: SEO, Site settings, Design & appearance, Branding & analytics, Redirects, Domains & crawling. No new database tables: everything lives in `Site.settings` / `Site.theme` JSON (zod schemas in `src/lib/site-settings.ts`), saved per section by `saveSiteSection`.
- SEO: Google verification, social image, index/noindex, extra robots.txt lines, Event Schema (JSON-LD).
- Site settings: General (404 page, default layouts), Security (headers, blocked emails, reCAPTCHA keys), Third-party code (super admin only), Date/Time, Wishlist, Email.
- Design & appearance: direction, scrollbar, scroll-to-top, page-title style, 8-colour palette, fonts, element styling.
- Secrets (reCAPTCHA secret) are sealed and never returned to the browser; third-party code is stripped for non-super-admins.

Applied on the public site: Google verification, robots extras, Event JSON-LD, blocked emails (submissions marked SPAM), text direction, scrollbar, scroll-to-top, page title style, palette, fonts, link/muted colours, third-party CSS/JS.
Stored only (not applied yet): security headers (X-Frame-Options, Referrer-Policy, CSP, Cache-Control: apply at hosting layer), reCAPTCHA keys, 404 page and default layouts, wishlist, email settings, element styling (image ratio/radius, buttons).
Added afterwards (all ShowOff tabs now present):
- Design & appearance → **Layout**: order the page parts (header panels, hero, page title, panels above, content, panels below, footer panels) per page layout (Default, Homepage, Narrow). Applied on the public site and the draft preview.
- SEO & settings → **Site robots.txt**: edit the whole file (Reset to default). Written file is served; a noindex website always disallows everything. The old "extra lines" box on the SEO tab moved here.
- SEO & settings → **Related pages** (ShowOff "Page Relationships"): link a page to its counterpart on the organisation's other websites; published as hreflang links using each website's language. Only pages of the same organisation can be linked.
Not built: ShowOff's Advanced design tab (it is empty in ShowOff), Instagram integration (needs an OAuth connection to Instagram).

## Design comparison with ShowOff, and Instagram

- Duplicates removed: the Branding tab no longer has "Default social image" (SEO tab) or "Heading font" (Typography); the main **Text** colour moved from Branding to Design → Colours.
- Typography: each font family now has a directory (System, Google Fonts, Adobe, Custom), a name and regular/bold weights. Google fonts load from fonts.googleapis.com, Adobe from a project id, Custom from an https stylesheet. Older saved plain values still work (read as system fonts).
- General: page title gained padding-top and font family.
- Elements: images (aspect ratio, corner radius, border) and buttons (font, size, bold, uppercase, text, background, border, radius, hover colours) are now applied on the public site through the `site-img` and `site-btn` classes (rich-text images and buttons). ShowOff's separate Header/Body image sets and the "Display this element?" switches are not built.
- Instagram (SEO & settings → Instagram): save the Meta app id and secret (secret sealed), copy the shown redirect address into the Meta app, click Authorize. `/api/instagram/authorize` and `/api/instagram/callback` run the Instagram Login OAuth flow (signed 10-minute state tied to the signed-in user), exchange for a long-lived token (sealed, about 60 days), and store the username. Disconnect clears it. Uses Instagram API with Instagram Login (professional accounts), not the retired Basic Display API. The token is stored but nothing on the public site shows Instagram posts yet. It was not tested against real Instagram (needs a Meta app).

## Settings consolidation
- Design & appearance → Colours now holds Primary, Background and Text (removed from Branding & analytics).
- SEO tab now holds Page title suffix and Default meta description; the duplicate "Allow search engines to index" checkbox was removed from Branding (the index/noindex select on the SEO tab is the one control).
- Branding & analytics keeps: site title, tagline, footer text, logo, favicon, analytics and social links.

## Branding tab removed
- Site settings → General now holds Site title, Tagline, Footer text, Logo, Favicon (each with a visible title) and Social links.
- Site settings → Security settings now holds Analytics (provider and id) next to headers, blocked emails and reCAPTCHA.
- The "Branding & analytics" tab and its form were deleted. Colours are under Design & appearance, suffix/description/indexing/social image under SEO.

## Categories (ShowOff-style)

Dashboard → **Categories** (needs `content.manage`). Categories are now managed in one place and shared between libraries, instead of a flat list per library.

- **Groups** (top level): a code, a label per organisation language (default language = the name), and the libraries they are assigned to. **Categories** live inside a group and can be nested up to 5 levels, each with a code, labels and a "Category selectable" switch.
- Group page: Add Category, More menu (Edit, Order A-Z / Z-A, Translate, Import), Categories / Assigned To tabs, Up / Down reordering, Add Child Category, Edit, Delete (with confirmation), bulk delete.
- Import: one line per category, either `label` or `code,label` (up to 300 lines; duplicates under the same parent are skipped).
- Library lists and entry editors show only selectable categories below the groups assigned to that library (shown as "Parent › Child (CODE)"). The library sidebar filters by category and links to "Manage categories".
- Not built (differences from ShowOff): category icons, drag-and-drop ordering (Up/Down instead), and per-language labels are stored but not shown on the public site yet.

**To apply:** run `prisma/manual/2026-10-categories.sql` (idempotent; existing per-library categories become one group per library, e.g. "News categories", assigned to that library), then `pnpm prisma generate` and restart `pnpm dev`.

Removed: `src/components/features/library/categories-panel.tsx` (replaced; delete the old file if it is still in your copy).

## Approvals and Rapports (ShowOff `cmsadmin/approvals`, `cmsadmin/rapports`)

**To apply:** run `prisma/manual/2026-10-approvals-rapports.sql` (idempotent; two new tables), then `pnpm prisma generate` and restart `pnpm dev`.

- **Approvals** (dashboard → Approvals, needs `content.manage`): a moderation queue with filters for status, content type and exhibitor, search and paging. Any library entry has an **Approval** card under the editor (people with `content.edit`) with "Submit for approval" and an optional "on behalf of exhibitor". Reviewing offers **Approve and publish** (runs the normal publish validation), **Action required** and **Decline**; the last two need a moderation comment, which the author sees on the entry. One waiting request per entry; decisions are audited. The menu item has no live count badge (the count shows in the page title instead).
- **Rapports** (dashboard → Rapports, needs `reports.read` to view, `content.manage` to approve/decline/delete, `settings.manage` for Options): requests from visitors to meet an exhibitor or speaker, with Awaiting / Any / Approved filter, search, paging, and an Options tab (Enabled, moderation email notification, captcha). Export is **CSV** (opens in Excel) rather than XLS and is audited because it holds personal data.
- **Public form:** every website serves `/rapport` (optional `?to=<entry id>` to preselect someone; link to it from any page or menu). It posts to `/api/public/rapport`, which only accepts same-origin JSON, resolves the website from the host, is rate limited, stores a salted IP hash only, and answers "not found" when Rapport is disabled. "Captcha" means a hidden trap field, a signed page-load token and a minimum fill time; no third-party captcha. A page whose slug is `rapport` is shadowed by this route.
- **Notifications:** when enabled and a notification address is set (SEO & settings → Site settings → Email), a `rapport.notify` background job emails that a request is waiting. The email carries no visitor details.
- Not built: approvals submitted by exhibitors themselves from a separate exhibitor login (there is no exhibitor portal); the custom-fields editor ShowOff shows on Rapports.

## Media Manager (ShowOff `cmsadmin/mediamanager`)

**To apply:** run `prisma/manual/2026-10-media-folders.sql` (idempotent; one new table), then `pnpm prisma generate` and restart `pnpm dev`.

The Media library is now the **Media Manager**:
- **Nested folders** (up to 5 levels). Existing flat folder names become top-level folders automatically; empty folders are stored in the new `MediaFolder` table. New Folder, a Home / folder / breadcrumb, folder cards ("Contains N files / N folders").
- Grid or list view, Records per page (25 / 50 / 100 / 250), Sort by (Name / Type / Size / Date Last Modified, ascending or descending), live search (searches all folders), numbered paging over folders and files together.
- **Drag-and-drop upload** (or Select Files) into the folder you are in, with per-file progress. The old upload form with alt text / caption / credit fields is gone; those are edited by opening a file (click it), where Folder is now a path such as `branding/logos`.
- **Selection bar**: tick files or folders, then Rename (one item; a file keeps its extension), Download (files, up to 20 at once), Delete (files in use stay protected; folders must be empty) and Move (to any folder or Home; a folder cannot move into itself).
- **Folder Options**: X-Robots-Tag `noindex` for the non-image files in the selected folder(s) (or the current folder) and everything below; the media route then sends `X-Robots-Tag: noindex` for them.
- Permissions are unchanged: media.read to browse, media.upload to create / rename / move / upload, media.delete to delete. Everything is scoped to the active website and audited.
- Not built: "Clear Cache" (nothing is cached on our side), and per-file noindex (the option is per folder).

## Vercel Blob media storage

Set `STORAGE_PROVIDER="vercel-blob"` to store uploads in Vercel Blob (default stays `local`). Run `pnpm install` (adds `@vercel/blob`).

1. In Vercel: Storage → create a **Blob** store and connect it to the project; this adds `BLOB_READ_WRITE_TOKEN`. For local development copy that token into `.env`.
2. Set `BLOB_ACCESS` to the store's access level. `private` (default) means files are readable only with the server token, so they can only be fetched through `/api/media/<id>`, which keeps the existing private-file rules, noindex header and no-sniff/sandbox headers. Use `public` only if the store was created public (files then also have a direct Blob URL).
3. Each asset records the provider it was stored with (`storageProvider`), so existing local files keep working after the switch (they still need `STORAGE_DIR` to be readable) and new uploads go to Blob. Replace and delete use the asset's own provider.
4. **Size limit:** uploads go through our server and Vercel limits request bodies to 4.5 MB, so with Vercel Blob files are capped at 4.4 MB (the upload area shows the limit). For larger files, direct browser-to-Blob uploads would be needed; that is not built.
5. Not verified against a real Blob store from this environment (no token here): the provider is covered by unit tests with the SDK mocked. Please upload, view, replace and delete one image on a preview deployment before relying on it.

## Reports menu (ShowOff Reports with children)

**To apply:** run `prisma/manual/2026-10-reports.sql` (idempotent; four new tables), then `pnpm prisma generate` and restart `pnpm dev`. Schedule two jobs (see docs/DEPLOYMENT.md): `POST /api/cron/reports` hourly and `POST /api/cron/uptime` every 5-15 minutes, both with `Authorization: Bearer $CRON_SECRET`.

Reports is now a collapsible menu (needs `reports.read`) with four children:
- **Overview** (`/reports`): the existing counted figures page.
- **Scheduled Reports** (`/reports/scheduled`): "Kick-start your reports" picker of report types, then a report page with a table, **Edit Filters** (column / condition / value, up to 10), **Edit Layout** (choose and reorder columns, sort) and a **More** menu (Export CSV, Save filter, Schedule). Header buttons: **Saved Filters** (open / delete), **Saved Schedules** (Name, Entity, Date created, Schedule, Last ran; pause / resume / delete) and **Schedule History** (every run, with CSV download). Report types: Pages, one per content library, Form submissions, Rapport requests, Approvals. A person only sees the types their role may open (content.edit for libraries, pages.read, forms.submissions.read, content.manage for approvals); other tenants get 404.
- **Uptime** (`/reports/uptime`, the equivalent of ShowOff's Pingdom page, deliberately not branded as Pingdom): Start/End date + Apply Filters, **Refresh Data** (runs one check now, limited to one per minute), Website uptime %, Average response over the last 7 days (per day) and per week since the first check. Figures are only from checks this server recorded; with none it says "No checks recorded yet". Probes are one HTTPS GET to the primary domain, redirects are not followed, below HTTP 400 is up. Only public DNS names are probed (no IP literals, localhost, `.local`, `.internal`, etc.; resolved addresses that are private are refused), so local development domains show a note instead.
- **Insights** (`/reports/insights`): visitor traffic is not collected by this platform, so the page shows the analytics provider configured under SEO & settings (Google Analytics 4 or Plausible) with a link to open it, or explains how to connect one.

Differences from ShowOff / notes:
- Scheduled reports are stored in Schedule History. Email is a plain-text **notice with the row count only**: the mailer has no attachments, report data can contain personal information, and the transport is the console logger until you wire SMTP. Stored CSVs are capped at 2,000 rows; live pages and exports read up to 5,000 rows (a note appears if truncated). Scheduled runs use the saved filter as it is at run time; a schedule whose filter was deleted records a failed run.
- Exports (live and from history) are audited. Form submissions and Rapport requests contain personal data and need their own permissions.
- Schedules run in UTC; monthly runs use the same day clamped to the 28th. Not built: custom date-range presets, per-user (private) filters, XLS export (CSV opens in Excel).
- Tests: `src/lib/reports/query.test.ts`, `src/lib/uptime/uptime.test.ts`, integration "reports, saved filters, schedules and uptime" (tenant isolation, per-entity permissions, run recorded once, uptime guards).

## Modules, module builder and form templates

**To apply:** run `prisma/manual/2026-10-modules.sql` and `prisma/manual/2026-10-module-field-overrides.sql` `prisma/manual/2026-10-site-content-modules.sql` and `prisma/manual/2026-10-library-settings.sql` (all idempotent; the last one gives every existing website all built-in content modules so nothing disappears), then `pnpm prisma generate` and restart `pnpm dev`.

**Super admin (Platform menu)**
- **Feature modules** (`/dashboard/modules`) is now the module library: every built-in library plus custom modules, with field count, entry count, websites licensed and status. Built-ins open read-only (**View fields**: every field, its key and kind). **Add module** opens the builder: name (singular/plural), key (permanent), entry-title label, description, "categories" and "featured image" switches, and fields of these kinds: short text, long text, link, image/file, choice list, number, repeating group (one level, with its own fields). Edit keeps the key; **Deactivate** hides it without touching entries; **Delete** only works while no website has entries (and unlicenses it everywhere).
- **Field CRUD (custom modules, super admin):** the module page lists every field with **Add field**, **Edit**, **Delete** (two-step) and ↑/↓ reorder; each change saves immediately and is audited (`module.field.*`). Keys must be unique and the last field can't be deleted. **Built-in modules (News, Products, Sponsors…) have the same field CRUD**: changes are stored as an override (`ContentTypeOverride`) on top of the defaults in code, apply to every website, and **Reset to default fields** removes them. On a website's Modules page a super admin gets **Edit fields** (opens this editor) plus an Entries link; admins keep a single Edit button for entries and cannot change fields.
- **Show on websites (per module):** the custom module page has a tick list of every website with Select all / Clear and **Save websites**, so a super admin can add a module to any project from the module itself (audited as `module.license` / `module.unlicense`). The matrix below still works too.
- **Basic vs content modules:** Pages, Media Manager, Events and Forms are basic: every website has them and they are no longer switchable. All content modules, built-in (News, Products, Sponsors…) and custom, are added to / removed from each website by the super admin (Feature modules → *Modules per website*, or the module's own page → *Show on websites*). New websites start with all built-in modules. Unticking hides the module and 404s its lists; entries are kept. Org admins/editors see only what their website has, plus a read-only table of the content modules on each website in their organisation.
- **Licensing per website** (same page): tick which websites get each custom module. A custom module is invisible and 404 on a website that is not ticked (library list, entries, categories, reports, Modules page). Core modules are also hidden from the menu of a website that has Pages, Media, Events or Forms unticked, and their list screens return 404 (super admins always see everything). Built-in content libraries stay available everywhere.
- **Form templates** (`/dashboard/form-templates`): build reusable forms (fields, consent, success message). The same page has **Create a form on a website** so a super admin can create a form on any website from a template. Templates are copied, not linked: later template edits or deletion never change a website's form.

**Admin (Website menu)**
- **Modules** (`/dashboard/site-modules`, needs `content.edit`): ShowOff-style overview of the website's tools (Pages, Media Manager, Events, Forms: Enabled / Not licensed) and its content modules, each with an expandable field list, entry counts and one **Edit** button that opens the module's entries (add, edit and delete happen there). Fields are read-only for admins; changing them is a super-admin action.
- **Libraries menu (ShowOff style):** sidebar **Libraries** expands to *Manage*, *Add New Library* (super admin; opens the module builder) and one item per content module the website has. **Manage** (`/libraries`) has search, sortable Name / Entries / Type columns, a Status dot, rows-per-page (10/25/50) with pagination, and a **View Entries** split button. For a super admin its dropdown has **Edit** (module page), **Clone** (copies the library, built-in or custom, into a new custom module `<key>-copy` and adds it to this website) and **Delete** (two-step; removes the library from this website only, entries are kept). Not built: *View in site* (modules are not shown on the public site by design). Entry lists and editors are also reachable from the Modules page; the Editor menu has Libraries and Modules.
- **CRUD:** Create (New in the entries list), Read (Edit opens the list), Update (Edit per row), Delete (per-row two-step delete, or bulk). Delete needs `content.manage`.
- A licensed custom module behaves like any library: list with search/filters/paging, entry editor with its fields, status workflow, versions, categories (if switched on), approvals, media usage tracking and Reports.
- **Forms:** admins with `forms.manage` get **Add from template** next to "Create form".

### Library contents (ShowOff layout)
- **Entries list** (`/libraries/<key>`): *Add New Entry*, *More* (**Import Entries**: CSV with a `title` column, optional `slug` and field-key columns, up to 200 drafts, bad rows reported by line; **Export Entries**: CSV via the report exporter), search and status filter, columns Name / Date Created / Tagged / Status, an **Edit Entry** button with a dropdown (**Delete Entry**, two-step), bulk actions, "Showing x to y of z".
- **Bottom bar:** library key, **Active** Yes/No (only a super admin can set No, which removes the library from this website; it can be added back from Feature modules) and **Library Options** with *General* (read-only title, key, description; super admin links to the module page) and *Display* (**entries per page** 10/20/50/100 and **order by**, stored per website in `LibrarySetting`, needs `content.manage`).
- **Entry editor:** Title, Entry code (read-only id), the library's fields; fixed bottom bar with **Delete**, **Active** Yes/No (Yes = published, No = back to draft; managers only), **Entry Options** (*General*: page URL, category, tags, sort order, featured image, archive/restore; *SEO*; *Versions* with restore) and **Save**. The approval card stays below.
- Not built (public-site settings that have no effect here because modules are not listed on the public website): ShowOff's Library Options tabs Publishing, Advanced and Layout Settings, the list/search templates, Clone, and *Open in Site*.

Notes / not built:
- Public rendering: by design modules are not listed directly on the public website; entries are managed and reported on, and shown only where a page/block references them.
- Removing a field from a module hides it; values already saved for it are dropped the next time an entry is saved.
- Direct URLs to inner pages of an unlicensed core module (for example one specific page) are not individually blocked; the menu and list screens are. Custom modules are blocked everywhere.
- Tests: `src/lib/modules/schema.test.ts`, integration "custom modules, licensing and form templates".

## Menus (ShowOff-style; replaces Navigation)
- **Setup:** run `prisma/manual/2026-10-menus.sql` on the database (adds the Mailto link type and the `style` / `anchor` columns), then `pnpm prisma generate`.
- **Org admin** (`/dashboard/menus`): the sidebar *Menus* item expands to one entry per menu of the open website. The Menus page lists **Name / No. of Menu Items / Edit Menu Items**. The menu editor shows the items as draggable rows (drag to reorder siblings; *Move up/down* in the dropdown for keyboard use), an **Add Menu Item** button, and per row **Edit** with a dropdown (**Add Child Item**, Move up/down, **Delete**, two-step). One level of children. Needs `navigation.manage`.
- **Item dialog:** Title, Type (Page, Site path, Free link URL, Mailto), Style (preset CSS classes), Content (depends on Type), Page Anchor, Target (Same / New window) and a collapsible **Restrictions** section (Hide from the website). Validated server-side (https only, safe paths, listed styles, clean anchors, pages must belong to the website).
- **Super admin** (`/dashboard/menus`): every organisation's websites with their menus. **Add Menu** creates a menu with one name on any selection of websites (all, per organisation or individual; websites that already have that name are skipped). Rename / Delete per menu. Header and footer menus are built in (they feed the public layout) and cannot be renamed or deleted.
- Public site: header/footer render as before; Mailto links, anchors and style classes are applied. Extra menus are stored and managed; the public layout only shows header and footer.
- Not built: ShowOff's Library / Gallery / Media / Exhibitor link types and per-item login restrictions (no matching public pages here).
- Tests: `src/lib/navigation/schema.test.ts`, integration "menus".

## Pages and Panels CRUD for admins
- **Panels:** admins with `content.manage` can now **Add Panel**, **Copy** and **Delete** (single or bulk, with confirmation) on their own websites, as well as edit, activate and reorder. (Previously add/copy/delete were super-admin only.)
- **Pages:** the row dropdown now matches ShowOff: **Preview Page**, **Set as Homepage**, **Copy Page**, **Delete Page** (two-step). Set as Homepage and Delete need `pages.publish`; the old homepage keeps its content under a slug taken from its title, the promoted page's old address redirects to `/`, and the homepage itself cannot be deleted. A published page must be unpublished before it can be deleted.
- Not built: ShowOff's *Create Child Page* (pages have no parent/child hierarchy here).
- Tests: integration "panels" and "pages: set as homepage and delete".

## Panel positions for admins (ShowOff "Panel Positions")
- The Panels list now shows **Edit Panel Positions** (top) and **Edit Position** (each location group) to org admins too.
- **Panel Positions page** (`/dashboard/panels/positions`): Position / Panel Count / Type table for the open website, **Add Panel Position**, and Edit Position per row.
- **Setup:** run `prisma/manual/2026-10-panel-position-overrides.sql`, then `pnpm prisma generate`.
- Every position has **Edit Position**. Positions made for the website only can be fully edited (label, type, help text, on/off, delete). Built-in and shared positions can be renamed for that website only (label and help text, **Reset to default**); where they appear on the page is unchanged and other websites are not affected. Positions in use by panels cannot be deleted. Super admins keep the platform-wide management at Panel locations.
- Tests: integration "site panel positions".

## Consistent buttons (Oct 2026)
- `ui/button.tsx` is the single source: pill shape, h-10, bold text; variants primary / secondary (outline) / grey / ghost / danger / success driven by `--btn-*` tokens in `globals.css` (light, dark and `.ed`).
- `ed-ui` pills, split buttons (menus, libraries, menu items), entries table, categories, locations, websites and Instagram all use `buttonClass`/`Button`.

## Themes (super admin, Oct 2026)
- A theme is a folder: `src/themes/<key>/{theme.json,theme.css,README.md}`. Super admin > Themes: **Add theme** writes the folder (never overwrites; key `^[a-z][a-z0-9-]{1,39}$`, path confined to `src/themes`) and each theme card assigns it to websites (`Site.theme.themeKey`, no schema change).
- Add theme has no colour/font/CSS inputs of its own: the defaults are copied from a chosen website's **Site Settings** (all Design tabs: General, Layout, Colours, Typography, Elements) plus its active custom CSS.
- Theme = defaults, website settings override: `lib/themes/resolve.ts` merges theme.json over the site's own values (`resolveSiteTheme`) and the Site Settings forms store only values that differ from the theme (`themeToStore`), so later theme changes still flow to untouched fields.
- Every Design value is a CSS variable on the site root (`designVars` in `site-frame.tsx`: `--primary`, `--color-one..muted`, `--font-one/two`, `--btn-*`, `--img-*`, `--page-title-*`, `--scrollbar-*`, `--scroll-top-*`, `--direction`) and the design rules read those variables; palette classes `.site-color-<k>` / `.site-bg-<k>`.
- Needs a writable `src/themes` on the server; commit generated folders for production.

## Sidebar order, headings & website dropdown (Oct 2026)
- Super admin's sidebar follows the platform workflow: Dashboard, Organizations, Websites & projects, Roles & permissions, Users, Platform configuration, Feature modules, Themes, Menus, Form templates, Panel locations, Integrations, then operations (Storage, Audit, Maintenance, System health, Backup, Security, My profile). Admin keeps the ShowOff order.
- A super admin always sees the platform menu; the website menu (Pages, Panels, Libraries, Modules, Reports, Media, Approvals, Settings, Menus) is appended below it only when editing a particular website.
- No section titles for super admin; the "Website" title is for admins only.
- The **Website dropdown** (`ui/website-select.tsx`) lives inside the individual page and panel editors for super admins, not in the list views. On New Page / Add Panel it chooses the website the item is created for (reloads the editor for that website). Existing pages and panels do not show it (the breadcrumb already names the website).
- Super admin Menus page lists each website as an accordion (native `<details>`): collapsed by default, with menu count in the header; open when searching or when only one website matches.
- Inside a website, a super admin's sidebar ends with **Menus** (appended below the other website items) expanding to that website's menus (Header menu, Footer menu, ...); links stay in the website tree (`/dashboard/admin/menus/...`).
- Buttons (and other clickable controls: summary, select, checkboxes, radios, role=button) show `cursor: pointer` when enabled (`buttonClass` + a global rule in `globals.css`); disabled ones keep `not-allowed`.

## Super admin sidebar order (Oct 2026)
- One list, in this order: Dashboard, Organizations, Websites & projects, Pages, Panels, Menus, Libraries, Reports, Media Manager, Approvals, Settings, Themes, Feature modules, Form templates, Panel locations, Integrations, Security settings, My profile, Roles & permissions, Users, Maintenance, System health, Backup & recovery, Audit logs, Storage overview, Platform configuration (`SUPER_ORDER` in `app-shell.tsx`).
- Pages ... Settings (and the website Menus with that website's menus) appear only inside a particular website; outside one, Menus is the all-websites page. The website "Modules" item is omitted (Feature modules covers it).

## Integrations (per website)
- Website admins (`settings.manage`) add Webhook, Email (SMTP), Analytics & tracking and Social links integrations at `/dashboard/integrations`; the super admin cannot add or change them (server-enforced) and only views them per website.
- Approval is per type (super admin toggles it; defaults: webhook and SMTP need approval). Pending integrations stay inactive until approved or rejected with a note; changing an approved integration's settings asks for approval again. Turning a rule off activates the waiting ones.
- Webhooks: signed (HMAC-SHA256 `x-webhook-signature`), sent through the job queue with retries, events form.submitted / page.published / page.unpublished, private-network addresses refused, delivery log. SMTP replaces the console mailer for that website's notification emails. Analytics (GA4, GTM, Plausible, Meta Pixel) and social links render on the public site only while active. Secrets are sealed and never sent to the browser.
- Database: run `prisma/manual/2026-10-integrations.sql`; then `pnpm install` (nodemailer) and `pnpm prisma generate`.
