# ICSSPK Events — Phase 1: Architecture & Implementation Plan

Status: **Approved** (24 Sep 2026) · Phase 2 implemented — see
[Phase 2 implementation notes](#phase-2-implementation-notes) at the end.

This document is the Phase 1 deliverable. It freezes the architecture, schema,
lifecycles, cPanel deployment model and the template-integration strategy.

### Approved decisions (locked)

1. Panelists are public profiles only in v1: no login and no scoring.
2. The shortlist minimum is a hard floor of 20. A category may require more,
   never fewer.
3. Nominations are free.
4. Only accepted, shortlisted and published nominees are publicly visible.
5. Third-party nominations need administrator-confirmed nominee consent
   (`consent_confirmed_at`) before the profile can be published.
6. Branding: primary `#294455`, accent `#ED1B24`. Template gold is not a
   brand colour.
7. News & Events, Downloads, Careers and the insurance Partners page are
   dropped from v1.
8. The partners strip is kept as an optional Sponsors & Partners component,
   shown only when sponsor data exists.
9. The production domain is configurable (`APP_URL`), never hard-coded.
10. The supplied Patekit template is the visual source of truth.

The one rule every section serves: **NO VERIFIED PAYMENT = NO VOTE.**

---

## 1. Final architecture

```
Browser (mobile-first, supplied pk- template)
   │ HTTPS
   ▼
public/index.php  ── single front controller (Apache + .htaccess rewrite)
   │
   │ Middleware pipeline (in order)
   │   ForceHttps → SecurityHeaders → StartSession → VerifyCsrf
   │   → RateLimit(route-specific) → RequireAdmin → RequirePermission
   ▼
Router ─► Controllers  (thin: parse input → call service → render view / JSON)
            Public\*   Admin\*   Webhook\IntaSendWebhookController
              │
              ▼
          Services  (all business rules live here, nowhere else)
            AwardService · CategoryService · NominationService · ShortlistService
            PanelService · VotingService · PaymentService
            PaymentConfirmationService  ← ONLY writer of confirmed votes
            RefundService · VoteAdjustmentService · ResultsService
            ReconciliationService · ReportService · MediaService
            BrandingService · AuditLogger · RateLimiter · AdminAuthService
              │                                │
              ▼                                ▼
          Repositories (PDO, prepared)     Payments\PaymentGateway (interface)
              │                                └─ IntaSend\IntaSendGateway
              ▼                                     └─ IntaSendClient (cURL)
          MySQL/MariaDB (InnoDB, utf8mb4, UTC)
```

**Key decisions**

| Decision | Why |
|---|---|
| Plain PHP 8.3, no framework, no runtime packages | Brief requirement; nothing to install on cPanel |
| Own PSR-4 autoloader (`app/autoload.php`) | Works when Composer can't run on the server. `composer.json` exists for dev tools (PHPUnit) only — production never needs `vendor/` |
| Front controller + router | One place for middleware/CSRF/headers, clean SEO URLs, only `public/` exposed |
| Views = plain PHP templates with mandatory `e()` escaping | Direct port of the supplied template, no templating engine |
| Settings-driven branding | Organisation name, logo, colours etc. live in `settings`, not in code |
| Cron-backed, not daemon-backed | cPanel: no Supervisor/Redis/systemd. Webhook is primary; cron is the safety net |
| MySQL for sessions? **No** — file sessions in `storage/sessions` | Simple, works on shared hosting, outside web root |
| MySQL-backed rate limiter | No Redis on cPanel |

**Rewrite fallback.** The template deliberately avoided `.htaccess` rewriting. This
app needs a front controller, so it relies on `mod_rewrite` (enabled on
virtually every cPanel Apache). If a host ever disables it, the router also
accepts `/index.php/awards/...` (PATH_INFO), so the site degrades to uglier URLs
but never breaks.

---

## 2. Final folder structure

```
icsspk-events/                     ← project root, OUTSIDE public_html
├── public/                        ← the ONLY web-exposed directory (document root)
│   ├── index.php                  front controller (reads ../bootstrap/paths.php)
│   ├── .htaccess                  rewrite, headers, deny dotfiles, caching
│   ├── robots.txt
│   ├── assets/                    ← template assets, kept intact
│   │   ├── css/custom.css         pk- design system (vars now fed by settings)
│   │   ├── css/vendor/ css/plugins/   bootstrap, swiper, aos, fontawesome
│   │   ├── css/app.css            NEW components only (vote selector, results bars…)
│   │   ├── css/admin.css          admin shell built on the same tokens
│   │   ├── fonts/                 Poppins self-hosted (removes Google Fonts dependency)
│   │   ├── js/site.js             template JS, unchanged
│   │   ├── js/vote.js             quantity selector, payment status polling
│   │   ├── js/admin.js
│   │   └── images/                logo, favicon, placeholders
│   └── media/                     uploaded images (awards, categories, nominees,
│       └── .htaccess              panelists, branding) — script execution OFF
├── bootstrap/
│   ├── paths.php                  APP_ROOT resolution (the cPanel fallback hook)
│   └── app.php                    env → config → error handler → container
├── app/
│   ├── autoload.php               PSR-4 autoloader (namespace App\ → app/)
│   ├── Core/                      Env, Config, Database, Router, Request, Response,
│   │                              Session, Csrf, View, Validator, Logger,
│   │                              ErrorHandler, Clock (UTC store / Nairobi display)
│   ├── Http/
│   │   ├── Controllers/Public/    Home, Award, Category, Nominee, Nomination, Vote,
│   │   │                          PaymentStatus, Results, Panel, Page, Contact, Sitemap
│   │   ├── Controllers/Admin/     Auth, Dashboard, Awards, Categories, Nominations,
│   │   │                          Shortlist, Nominees, Panelists, Votes, Adjustments,
│   │   │                          Payments, Refunds, Reconciliation, Reports,
│   │   │                          AuditLog, Admins, Settings, Media
│   │   ├── Controllers/Webhook/   IntaSendWebhookController
│   │   └── Middleware/
│   ├── Services/                  (see §1)
│   ├── Payments/
│   │   ├── PaymentGateway.php     interface: initiate(), fetchStatus(), refund()*
│   │   ├── GatewayResult.php, ProviderState.php
│   │   └── IntaSend/              IntaSendClient, IntaSendGateway,
│   │                              WebhookVerifier, StateMapper
│   ├── Repositories/              one per aggregate
│   ├── Domain/                    enums + value objects: PaymentStatus, VoteStatus,
│   │                              NominationStatus, ShortlistStatus, Money, KenyanPhone
│   ├── Notifications/             MailerInterface (+ PhpMailFunction, LogMailer),
│   │                              SmsChannelInterface (no v1 implementation)
│   └── Support/                   Slug, Ulid, helpers.php (e(), url(), asset(), csrf_field())
├── config/                        app.php, database.php, payments.php, security.php,
│                                  routes.php, permissions.php, media.php
├── database/
│   ├── migrations/                001_…sql … 0NN_…sql (forward-only, versioned)
│   └── seeders/                   roles_permissions.sql (prod) + demo_*.sql (DEV ONLY)
├── resources/views/
│   ├── layouts/                   main.php (public), admin.php, auth.php, email.php
│   ├── partials/                  header, topbar, navbar, footer, cta, breadcrumbs,
│   │                              page-banner, flash, pagination, seo-meta, schema
│   ├── components/                award-card, category-card, nominee-card,
│   │                              panelist-card, vote-selector, stat, results-table,
│   │                              shortlist-meter, status-badge, form-field
│   ├── public/                    home, awards, award, categories, category, nominee,
│   │                              vote, payment-status, results, panel, nominate,
│   │                              nominate-thanks, about, contact, terms, privacy,
│   │                              voting-rules, faq
│   ├── admin/                     one folder per admin module
│   ├── errors/                    404, 403, 419 (CSRF), 429, 500, maintenance
│   └── emails/
├── bin/                           CLI (php-cli only, refuse web SAPI)
│   ├── migrate.php  seed.php  create-admin.php
│   ├── cron-webhooks.php          retry failed/unprocessed webhook events
│   ├── cron-payments.php          expire stale PROCESSING (after querying IntaSend)
│   ├── cron-reconcile.php         discrepancy sweep + cached-total verification
│   ├── cron-maintenance.php       prune rate_limits/login_attempts/old logs
│   └── anonymise-voters.php       manual, policy-driven PII anonymisation
├── storage/                       logs/, cache/, sessions/, tmp/  (writable, not public)
├── tests/                         Unit/, Integration/, fixtures/ (PHPUnit, dev only)
├── docs/                          this file + SECURITY, DEPLOYMENT, DATABASE,
│                                  PAYMENTS, ADMIN_GUIDE, TESTING
├── .env.example                   committed, no values
├── .env                           NOT committed, lives here (outside web root)
├── composer.json                  dev tooling only
└── README.md
```

---

## 3. Entity relationship overview

```
                  ┌──────────── PANELIST ─┐
                  │  * AWARD_PANELIST *    │
AWARD 1──* CATEGORY 1──* NOMINEE (prospect → shortlisted)
  │            │             ▲
  │            │             │ 0..1 (linked on acceptance; many nominations
  │            └──* NOMINATION ┘   can point at the same nominee = duplicates merged)
  │                    └──* NOMINATION_REVIEW (notes + status changes)
  └──* AWARD_VOTE_PACKAGE

VOTER 1──* PAYMENT *──1 NOMINEE (composite FK also pins category + award)
            │ 1──0..1 VOTE          (created only by PaymentConfirmationService)
            │ 1──*    PAYMENT_STATUS_HISTORY
            │ 1──0..1 PAYMENT_REVERSAL (refund record)
            └ *──*    WEBHOOK_EVENT  (via api_ref / invoice_id)

NOMINEE 1──* VOTE_ADJUSTMENT *──1 ADMIN
NOMINEE 1──1 NOMINEE_VOTE_TOTAL   (cache, rebuildable)
ROLE 1──* ADMIN ; ROLE *──* PERMISSION
MEDIA  ← referenced by awards, categories, nominees, panelists, branding settings
```

Audit trail for every paid vote:
**Vote → Payment → IntaSend invoice + M-Pesa ref → webhook events + status
history → Voter → Nominee → Category → Award**, all browsable from one admin
"Payment detail" screen.

---

## 4. Final database schema plan

Conventions: InnoDB · utf8mb4_unicode_ci · all timestamps `DATETIME` in **UTC**
(connection runs `SET time_zone = '+00:00'`) · money `DECIMAL(12,2)` ·
`BIGINT UNSIGNED` IDs on high-volume tables · soft archive via `status`, never
hard delete for business records.

### 4.1 Access control
```
roles              id, slug UQ (super_admin|admin|editor), name
permissions        id, slug UQ, description
role_permissions   role_id, permission_id  PK(both)
admins             id, role_id FK, name, email UQ, password_hash,
                   status(active|disabled), session_version INT (bump = log out everywhere),
                   totp_secret NULL, totp_enabled BOOL  (2FA seam, unused in v1),
                   last_login_at, last_login_ip, password_changed_at, created_at, updated_at
login_attempts     id, email_hash, ip VARBINARY(16), success, attempted_at
                   IDX(email_hash, attempted_at), IDX(ip, attempted_at)
rate_limits        bucket_key PK, hits, window_started_at
```

**Permission catalogue and default role mapping** (editable in DB; super_admin
bypasses checks):

| Permission | Admin | Editor |
|---|:-:|:-:|
| awards.content (text, images) | ✓ | ✓ |
| awards.configure (price, dates, packages, limits, visibility, publish/close/archive) | ✓ | |
| categories.content | ✓ | ✓ |
| categories.configure (min shortlist, voting config) | ✓ | |
| nominations.view | ✓ | ✓ |
| nominations.review (accept/reject, notes) | ✓ | |
| shortlist.manage (shortlist / not shortlist) | ✓ | |
| voting.open_close (per category) | ✓ | |
| nominees.content (profiles, photos, order) | ✓ | ✓ |
| panel.manage | ✓ | ✓ |
| media.upload | ✓ | ✓ |
| votes.view · payments.view · reports.view · reconciliation.view | ✓ | |
| payments.export · votes.export | ✓ | |
| results.publish | ✓ | |
| votes.adjust · payments.refund · payments.review_resolve | super only | |
| settings.branding | ✓ | |
| settings.payments · settings.security | super only | |
| admins.manage · audit.view | super only | |

### 4.2 Branding, content, media
```
settings           setting_key PK, value TEXT, value_type(string|int|bool|json|media),
                   is_public BOOL, updated_by FK admins NULL, updated_at
                   keys: org.name, org.short_name, org.tagline, org.logo_media_id,
                   org.logo_white_media_id, org.favicon_media_id, brand.primary,
                   brand.secondary, brand.accent, contact.email, contact.phone,
                   contact.whatsapp, contact.address, org.website, social.* ,
                   footer.credit, votes.default_price, votes.default_min, votes.default_max,
                   payments.grace_minutes_default, payments.mobile_tariff (BUSINESS-PAYS),
                   payments.stk_timeout_minutes, retention.* , shortlist.floor (20)
media              id, disk_path UQ, public_url, purpose(award_logo|award_cover|
                   category|nominee|panelist|branding), mime, width, height, bytes,
                   sha256, uploaded_by FK, created_at
```

### 4.3 Awards, categories, nominations, nominees, panel
```
awards             id, slug UQ, name, short_description, description,
                   logo_media_id, cover_media_id,
                   event_start_at, event_end_at,
                   nominations_start_at, nominations_end_at,
                   voting_start_at, voting_end_at,
                   status(draft|published|closed|archived),
                   price_per_vote DECIMAL(12,2), currency CHAR(3) 'KES',
                   min_votes_per_txn INT 1, max_votes_per_txn INT 1000,
                   allow_custom_quantity BOOL 1,
                   payment_grace_minutes INT 5,
                   results_visibility(hidden|live|after_close),
                   results_published_at NULL, results_published_by NULL,
                   show_vote_counts BOOL 0,
                   collect_email(off|optional|required) 'optional',
                   terms TEXT, privacy_url, meta_title, meta_description,
                   created_by, created_at, updated_at
award_vote_packages id, award_id FK, quantity INT, sort_order, is_active
                   UQ(award_id, quantity)          -- price ALWAYS = qty × price_per_vote
categories         id, award_id FK, slug, name, short_description, description,
                   eligibility_criteria TEXT, image_media_id,
                   min_shortlist INT 20  CHECK(min_shortlist >= 20 via service + floor setting),
                   voting_state(closed|open) 'closed',
                   voting_opened_at, voting_opened_by, voting_closed_at, voting_closed_by,
                   status(draft|published|archived), sort_order,
                   UQ(award_id, slug), UQ(id, award_id)
nominees           id, award_id, category_id, slug UQ,
                   nominee_type(individual|organisation), name, organisation_name NULL,
                   position_title NULL, short_bio, description,
                   photo_media_id, website_url, social_links JSON,
                   contact_email NULL, contact_phone NULL        (private, admin-only)
                   source(nomination|admin), created_reason NULL (required if source=admin),
                   shortlist_status(prospect|shortlisted|not_shortlisted|withdrawn),
                   shortlisted_at, shortlisted_by, consent_confirmed_at NULL,
                   is_published BOOL, sort_order, created_at, updated_at
                   FK(category_id, award_id) → categories(id, award_id)
                   UQ(id, category_id, award_id)
                   IDX(category_id, shortlist_status)
nominations        id BIGINT, public_ref CHAR(26) UQ, award_id, category_id,
                   FK(category_id, award_id) → categories(id, award_id),
                   nominee_type(individual|organisation), nominee_name,
                   nominee_organisation NULL, nominee_position NULL,
                   nominee_email NULL, nominee_phone NULL, nominee_website NULL,
                   nomination_type(self|third_party),
                   nominator_name, nominator_phone, nominator_email NULL,
                   nominator_relationship NULL,
                   reason TEXT NOT NULL, supporting_links JSON NULL,
                   status(pending_review|under_review|accepted|rejected),
                   nominee_id NULL FK nominees  (set on acceptance / merge),
                   reviewed_by NULL, reviewed_at NULL,
                   submitter_ip VARBINARY(16), submit_token UQ, created_at, updated_at
                   IDX(category_id, status)
nomination_reviews id, nomination_id FK, admin_id FK, from_status, to_status NULL,
                   note TEXT, created_at          -- append-only notes/decisions
shortlist_decisions id, nominee_id FK, admin_id FK, from_status, to_status,
                   reason TEXT, created_at        -- append-only
panelists          id, slug UQ, name, position_title, organisation, biography,
                   photo_media_id, linkedin_url, website_url, social_links JSON,
                   sort_order, status(draft|published|archived), created_at, updated_at
award_panelists    award_id, panelist_id, panel_role NULL (Chair, Judge…), sort_order
                   PK(award_id, panelist_id)
```

The nomination list filters "Shortlisted" and "Not shortlisted" come from the
linked nominee's `shortlist_status`. They are not duplicated on the nomination.

### 4.4 Voting and money
```
voters             id, phone_e164 UQ (2547…/2541…), full_name, email NULL,
                   anonymised_at NULL, created_at, updated_at
payments           id BIGINT, public_ref CHAR(26) UQ, api_ref VARCHAR(64) UQ,
                   provider VARCHAR(20) 'intasend', method 'MPESA_STK',
                   provider_invoice_id VARCHAR(64) UQ NULL, mpesa_reference NULL IDX,
                   voter_id FK, award_id, category_id, nominee_id,
                   FK(nominee_id, category_id, award_id) → nominees(id, category_id, award_id),
                   vote_quantity, unit_price, amount, currency, mobile_tariff,
                   phone_e164, payer_name, payer_email NULL,
                   status(PENDING|PROCESSING|SUCCESS|FAILED|CANCELLED|EXPIRED|REVIEW|REFUNDED),
                   review_reason NULL, provider_state NULL, failure_reason NULL,
                   verification_source(webhook|poll|cron|admin) NULL,
                   submit_token UQ, ip VARBINARY(16), user_agent_hash,
                   initiated_at, expires_at, paid_at, verified_at,
                   provider_response JSON (redacted), created_at, updated_at
                   IDX(status, created_at), IDX(award_id, status), IDX(voter_id)
payment_status_history id BIGINT, payment_id FK, from_status, to_status,
                   source(system|webhook|poll|cron|admin), admin_id NULL, note, created_at
votes              id BIGINT, payment_id FK UQ ◄── one payment → at most one vote row
                   voter_id, award_id, category_id, nominee_id,
                   FK(nominee_id, category_id, award_id) → nominees(...),
                   quantity, amount, status(CONFIRMED|REVERSED),
                   confirmed_at, reversed_at NULL, reversal_id NULL, created_at
                   IDX(nominee_id, status), IDX(award_id, confirmed_at)
payment_reversals  id, payment_id FK UQ, vote_id FK NULL, admin_id FK, reason TEXT,
                   original_amount, currency, provider_invoice_id, mpesa_reference,
                   vote_quantity_reversed, provider_refund_ref NULL (for future API
                   refunds), created_at
vote_adjustments   id, award_id, category_id, nominee_id (composite FK), delta INT ≠ 0,
                   reason TEXT NOT NULL, admin_id FK, total_before, total_after, created_at
nominee_vote_totals nominee_id PK, confirmed_votes, adjustment_votes, updated_at
                   (cache; `cron-reconcile` verifies it matches SUM(votes)+SUM(adjustments))
```

### 4.5 Integration, audit, system
```
webhook_events     id BIGINT, provider, event_key CHAR(64) UQ
                   (sha256 of invoice_id|state|updated_at), invoice_id IDX, api_ref IDX,
                   state, payload JSON (challenge stripped), payload_hash,
                   challenge_valid BOOL, source_ip,
                   processing_status(received|processed|ignored|failed),
                   attempts, last_error, received_at, processed_at
audit_logs         id BIGINT, actor_type(admin|system|webhook|cron), actor_id NULL,
                   action, entity_type, entity_id, before_state JSON, after_state JSON,
                   reason NULL, ip, user_agent, created_at      (append-only)
contact_messages   id, name, email, phone NULL, subject, message, ip, status, created_at
notification_outbox id, channel(email|sms|whatsapp), recipient, template, payload JSON,
                   status(pending|sent|failed|skipped), attempts, sent_at, created_at
cron_runs          job PK, last_started_at, last_finished_at, last_status, last_message
                   (dashboard shows "cron last ran 2 min ago" — catches mis-set cPanel crons)
schema_migrations  version PK, applied_at, checksum
```

**Database-level integrity guarantees**
- Composite foreign keys make it physically impossible for a nomination, nominee,
  payment or vote to point at a category from another award, or a nominee from
  another category.
- `UNIQUE(votes.payment_id)` is the last line of defence against double-crediting.
- `UNIQUE(payments.submit_token)` and `UNIQUE(nominations.submit_token)` stop
  double-submits from refreshes and double taps.
- The app DB user needs normal DML, but `audit_logs` is written only by
  `AuditLogger`, and no route updates or deletes it. On cPanel you can't grant
  per-table privileges easily through the UI, so this is enforced in code. The
  deployment guide shows the optional SQL GRANT for hosts that allow it.

---

## 5. Request lifecycle

```
1  Apache: public/.htaccess → rewrite to index.php (static files served directly)
2  index.php → bootstrap/paths.php (locate APP_ROOT) → bootstrap/app.php
3  Env::load(APP_ROOT/.env) → Config → ErrorHandler (logs, never displays in prod)
   → refuse to boot if APP_ENV/production uses a test key or vice-versa
4  Request::fromGlobals() (method, path, query, body, files, client IP)
5  Router::match()  → 404 view if no route; 405 if wrong method
6  Middleware pipeline (route declares which apply):
     ForceHttps → SecurityHeaders(nonce) → StartSession → VerifyCsrf
     → RateLimit → RequireAdmin → RequirePermission
7  Controller: validate input with Validator → call Service(s)
8  Service: business rules, DB transaction(s), AuditLogger
9  Response: View::render(layout, view, data) | JSON | Redirect (internal only)
10 Response::send() → headers + body. Exceptions → logged with a request ID;
   user sees a friendly error page quoting the request ID for support.
```

Webhook requests take the same path but with a minimal pipeline (no session,
no CSRF, route-specific rate limit and body-size cap).

---

## 6. Authentication architecture (admin)

- **Login:** `/admin/login` uses email + password with a CSRF-protected form.
  `password_verify()` runs against `password_hash(PASSWORD_ARGON2ID)`, falling
  back to BCRYPT if Argon2 isn't compiled in (cPanel builds vary). Hashes are
  re-hashed transparently on login when `password_needs_rehash()` says so.
- **Throttling:**
  - 5 failures per account in 15 min causes a progressive delay and then a
    15-minute lock.
  - 20 failures per IP in 15 min are blocked.
  - Errors are always generic ("Invalid credentials").
- **Session:**
  - `session_regenerate_id(true)` runs on login and on role change.
  - Cookies are `Secure`, `HttpOnly`, `SameSite=Strict` for admin (a separate
    cookie name from the public session) and `use_strict_mode=1`.
  - Idle timeout is 30 min and absolute timeout is 8 h.
  - The session stores `admin_id`, `session_version` and a UA-hash
    fingerprint. Every request re-checks that the admin is still active and the
    session version still matches, so a disabled admin or a password change
    logs them out everywhere immediately.
- **Authorisation:** checked twice. `RequirePermission('x')` is declared on
  the route, and the service method re-asserts the permission (defence in
  depth against a mis-declared route).
- **Privilege escalation guard:** only `admins.manage` can change roles. Nobody
  can edit their own role, and the last active super admin can't be disabled
  or demoted.
- **2FA seam:** `totp_secret`/`totp_enabled` columns exist, plus an
  `AdminAuthService::requiresSecondFactor()` hook. It is not built in v1.
- **Bootstrap:** the first admin is created via `bin/create-admin.php` (CLI).
  If there's no SSH, a one-time setup route is enabled only while
  `SETUP_TOKEN` is set in `.env`; it refuses to run once any admin exists and
  logs a warning until the token is removed.
- **Audited:** login success and failure, logout, lockout, password change,
  role change.

---

## 7. Payment lifecycle

### 7.1 Verified IntaSend facts in use (from the official developer docs)
- STK Push: `POST /api/v1/payment/mpesa-stk-push/`. The body takes `amount`,
  `phone_number` (required), `api_ref`, and `mobile_tarrif`
  (`BUSINESS-PAYS`|`CUSTOMER-PAYS`). Auth is `Authorization: Bearer <secret key>`.
- Status: `POST /api/v1/payment/status/` with `{invoice_id}`. It returns
  `invoice.state` ∈ PENDING, PROCESSING, FAILED, CANCELED, PARTIAL, COMPLETE,
  RETRY, plus `value`, `currency`, `api_ref`, `mpesa_reference`, `net_amount`
  and `charges`.
- Webhook: a JSON POST on every state change, carrying the `challenge` string
  you set in the dashboard. There is no HMAC signature. The webhook is
  deactivated after more than 20 consecutive failed deliveries and only
  support can re-enable it.
- **The base URL is configuration** (`INTASEND_BASE_URL`). The docs conflict
  (`api.intasend.com` vs `payment.intasend.com` / `sandbox.intasend.com`), so
  Phase 8 starts by verifying against the sandbox. Nothing is hard-coded.

`.env` keys actually needed: `INTASEND_ENV`, `INTASEND_BASE_URL`,
`INTASEND_SECRET_KEY`, `INTASEND_WEBHOOK_CHALLENGE`. The publishable key is not
used by server-side STK Push.

### 7.2 State machine

| Status | Meaning | Entered from | IntaSend trigger |
|---|---|---|---|
| PENDING | Row created, STK not yet accepted | — | — |
| PROCESSING | STK accepted, customer deciding | PENDING | PENDING / PROCESSING / RETRY |
| SUCCESS | Verified; vote created in the same transaction | PROCESSING, EXPIRED, REVIEW (admin) | COMPLETE and every check passes |
| FAILED | Rejected / insufficient funds / STK call failed | PENDING, PROCESSING | FAILED |
| CANCELLED | Customer cancelled | PROCESSING | CANCELED |
| EXPIRED | No final state after the timeout (IntaSend queried first) | PROCESSING | — |
| REVIEW | Money moved but a rule failed. **No votes.** | PROCESSING, EXPIRED | PARTIAL, amount/currency mismatch, paid after the grace period, category/award closed |
| REFUNDED | Refunded manually in IntaSend and marked here; vote REVERSED | SUCCESS, REVIEW | admin action |

Any transition not listed is rejected by `PaymentStatus::canTransitionTo()`.
Every accepted transition writes `payment_status_history`.

### 7.3 Flow

```
Browser ── POST /vote/{nominee} (CSRF, submit_token)
   ▼
VotingService.initiate()  → payments row PENDING (amount computed server-side)
   ▼
PaymentService → PaymentGateway.initiate() → IntaSend STK Push
   │  ok → store invoice_id, PROCESSING     │ error → FAILED, friendly message
   ▼
Customer enters PIN on phone
   ▼
IntaSend ── webhook POST /webhooks/intasend  (signal only)
   │   1 challenge hash_equals → else 401 + alert log
   │   2 INSERT webhook_events (dup event_key → 200, stop)
   │   3 PaymentConfirmationService.confirm(invoice_id)
   │   4 return 200 once stored, even if step 3 failed (cron retries it)
   ▼
PaymentConfirmationService.confirm(invoice_id)
   1  fetchStatus(invoice_id) server-to-server  ← the ONLY source of truth
   2  api_ref must map to one of our payments
   3  BEGIN; SELECT payment … FOR UPDATE
   4  already SUCCESS/REFUNDED/REVIEW → COMMIT, return (idempotent)
   5  state COMPLETE? value == amount? currency == KES? invoice_id matches?
   6  initiated_at < voting_end AND verified_at ≤ voting_end + grace
      AND category voting_state was open at initiation → else REVIEW
   7  payment → SUCCESS (mpesa_reference, paid_at, verified_at, source)
   8  INSERT votes CONFIRMED (UNIQUE payment_id makes it once-only)
   9  UPDATE nominee_vote_totals; status history; audit log; outbox email
   10 COMMIT  (any exception → ROLLBACK, nothing partial)
   ▼
Browser status page polls GET /payments/{ref}/status.json (reads OUR DB only).
After ~20 s still PROCESSING → the server itself calls confirm() (throttled).
A browser refresh or redirect can never create a vote.
```

**Cron safety nets** (never the primary path):
- Every minute, failed or unprocessed webhook events are retried.
- Every 5 minutes, PROCESSING payments older than the STK timeout are checked
  with IntaSend and then either confirmed or moved to EXPIRED.
- Hourly, a reconciliation sweep runs.

**Refunds (v1, manual):** the admin refunds in the IntaSend dashboard, then
uses "Mark refunded" here and must give a reason. `RefundService` then does the
following in one transaction:
1. Sets the payment to REFUNDED.
2. Sets the vote to REVERSED (the row is kept).
3. Decrements the cached totals.
4. Writes `payment_reversals` (admin, reason, timestamp, original amount,
   IntaSend and M-Pesa refs, vote quantity).
5. Writes the status history and the audit log.

`PaymentGateway::refund()` exists in the interface and throws
`NotSupported` in v1.

---

## 8. Voting lifecycle

**A nominee can be voted for only when every one of these is true**, checked
server-side at initiation and re-checked at confirmation:
1. The award is `published` and `voting_start_at ≤ now < voting_end_at`.
2. The category is `published` **and** `voting_state = open`.
3. The nominee is `shortlisted` **and** `is_published`.
4. The nominee → category → award chain is consistent (also enforced by FKs).
5. The quantity is an integer with `min_votes_per_txn ≤ q ≤ max_votes_per_txn`
   (defaults 1–1,000). It must be a preset package unless
   `allow_custom_quantity` is on.
6. The phone number normalises to `2547XXXXXXXX` / `2541XXXXXXXX`. Accepted
   inputs are `07…`, `01…`, `+2547…` and `2547…`.
7. The rate limits pass: per IP, per phone (it throttles STK prompts to one
   number, which also stops people spamming strangers' phones), and a global
   circuit breaker.

`amount = quantity × award.price_per_vote`. **The request never carries a price.**
Vote rows exist only as CONFIRMED (created by `PaymentConfirmationService`) or
REVERSED. There are no PENDING vote rows, because the payment row represents
"pending". Results = Σ CONFIRMED votes + Σ adjustments. Reversed votes are
excluded.

**Closing:** when `voting_end_at` passes, the award is closed automatically for
new initiations; there is no need to wait for cron. Admins can also close a
single category early. Late confirmations follow the grace rule. Results
visibility follows `hidden | live | after_close`, and
`results_published_at` is set by an admin action with `results.publish`.

**Adjustments:** `VoteAdjustmentService` only (super admin by default). It
needs a reason and records the admin, the before and after totals and an audit
entry. It is shown as a separate line in the admin results. There is no
"add votes" button anywhere.

---

## 9. Nomination lifecycle

```
Award phase:  DRAFT → NOMINATIONS OPEN → REVIEW → VOTING → CLOSED → RESULTS → ARCHIVED
              (derived from the award's dates + status, shown on the dashboard)

Nomination:   pending_review ──► under_review ──► accepted ──► (links to a nominee prospect)
                       └──────────────┴────────► rejected
```

- **Public form** `/awards/{award}/nominate`. It is open only between
  `nominations_start_at` and `nominations_end_at`, and only for published
  categories. It captures:
  - nominee type (individual or organisation)
  - nominee name, organisation and position
  - optional nominee contact
  - self or third-party nomination
  - nominator name, phone, optional email and relationship
  - **reason for recognition** (required, with a minimum length)
  - optional supporting links
  - consent checkboxes (privacy policy; for a third-party nomination, the
    nominator confirms they believe the nominee would accept being contacted)
- **Protection:** CSRF, a honeypot, rate limits per IP and per phone, and a
  submit token. Every field is escaped and no HTML is allowed.
- **Review:** admins with `nominations.review` open a nomination, add notes
  (`nomination_reviews`, append-only) and move it to `under_review`,
  `accepted` or `rejected`. A rejection needs a note.
- **Acceptance** either creates a new nominee in `prospect` status, or
  **merges** the nomination into an existing prospect when several people
  nominated the same person. That shows as "3 nominations" on the prospect.
- Admins can also add a prospect directly (`source = admin`, reason required,
  audited).

## 10. Shortlisting lifecycle

```
Nominee:  prospect ──► shortlisted ──► (voting)        withdrawn (terminal, audited)
              └──────► not_shortlisted
```

- `ShortlistService` records every decision in `shortlist_decisions` (admin,
  reason, from/to status) and in the audit log.
- **The 20-prospect rule is a hard backend rule.**
  `CategoryService::openVoting($categoryId)` locks the category row and counts
  shortlisted, published nominees. It **refuses** (a domain exception, an
  audit entry and a clear message) if the count is below `min_shortlist`.
  `min_shortlist` can be raised per category but never below the system floor
  `shortlist.floor = 20`, which is validated in the service.
- **The rule stays true after opening.** While voting is open, un-shortlisting
  or unpublishing a nominee that would drop the count below the minimum is
  refused. Removing a nominee mid-vote requires `withdrawn` status, super admin,
  a reason and an audit entry, and it can't happen once the nominee has
  confirmed votes unless results handling is decided explicitly. Nominees with
  votes or payments can never change category.
- **Admin UI:** each category shows a readiness meter, for example
  `19 / 20 — NOT READY` (red) or `20 / 20 — READY FOR VOTING` (green). The
  "Open voting" button reflects this, but the server is what enforces it.

## 11. Panelist architecture

- `panelists` holds the profile: name, title, organisation, bio, photo,
  LinkedIn, website, socials, order and status (draft, published or archived).
  `award_panelists` optionally assigns panelists to awards with a role
  (Chair, Judge…) and a per-award order.
- The admin can add, edit, archive, reorder (up/down or drag, saved by a
  CSRF-protected POST), publish or unpublish, and upload or replace photos
  through `MediaService`.
- Public pages:
  - `/panel` lists all published panelists.
  - `/awards/{award}/panel` shows the award's panel.
  - The award page gets a panel strip.
  All of them use the template's `pk-leader-card` component (§13).
- In v1 panelists are **public profiles only; they don't log in**. The schema
  leaves room for a later "panelist scoring" module: a `panelist_scores` table
  and a `panelist` role.

---

## 12. cPanel deployment architecture

```
/home/CPUSER/
├── icsspk-events/            ← whole project (NOT inside public_html)
│   ├── app/ bootstrap/ config/ database/ resources/ storage/ bin/ .env …
│   └── public/               ← domain document root points HERE (preferred)
└── public_html/              ← untouched, or used by the fallback below
```

**Preferred:** in cPanel → Domains, set the domain's (or subdomain's) document
root to `/home/CPUSER/icsspk-events/public`. Current cPanel versions allow this
for addon domains, subdomains and, on most hosts, the primary domain.

**Fallback (the host forces `public_html`):**
1. Copy only the *contents* of `public/` into `public_html/`.
2. Edit `public_html/index.php`'s single include path so `bootstrap/paths.php`
   resolves `APP_ROOT=/home/CPUSER/icsspk-events`.
3. Nothing else in the project ever sits under `public_html`.

`app/`, `config/`, `database/`, `storage/` and `.env` stay unreachable by URL
because they are outside the web root. They are **not** merely hidden by
`.htaccess`.

**PHP:** use MultiPHP Manager / Select PHP Version to pick 8.3 and enable
`pdo_mysql`, `curl`, `gd`, `fileinfo`, `openssl`, `mbstring`, `json`,
`intl` (optional). Set `display_errors=Off`, `expose_php=Off`,
`upload_max_filesize=5M`, `post_max_size=8M` and `session.use_strict_mode=1`
in MultiPHP INI Editor.

**No SSH:** use File Manager / FTP upload with a pre-built zip. Migrations can
be run three ways:
- (a) phpMyAdmin → import `database/install.sql`, a concatenated build of all
  migrations generated by `bin/migrate.php --dump`;
- (b) a cPanel cron that runs `bin/migrate.php` once;
- (c) SSH, if it's available.

No Composer is needed in production.

**Crons (cPanel → Cron Jobs).** The PHP binary path varies by host, e.g.
`/usr/local/bin/php` or `/opt/cpanel/ea-php83/root/usr/bin/php`. The deployment
guide shows how to find it.

| Schedule | Command |
|---|---|
| `* * * * *` | `php /home/CPUSER/icsspk-events/bin/cron-webhooks.php` |
| `*/5 * * * *` | `php /home/CPUSER/icsspk-events/bin/cron-payments.php` |
| `15 * * * *` | `php /home/CPUSER/icsspk-events/bin/cron-reconcile.php` |
| `30 2 * * *` | `php /home/CPUSER/icsspk-events/bin/cron-maintenance.php` |

Every job uses a DB lock (`GET_LOCK`) so overlapping runs are impossible,
writes `cron_runs`, and redirects output to `storage/logs/cron.log`.

**Webhook URL for IntaSend:** `https://DOMAIN/webhooks/intasend`, with the
challenge set to the value of `INTASEND_WEBHOOK_CHALLENGE`. AutoSSL must be
active first, because IntaSend requires HTTPS.

**Needs server-level access?** Nothing in v1. Optional extras that do:
per-table MySQL GRANTs for `audit_logs`, and nginx rules. Both are documented
as optional.

---

## 13. Frontend-template integration strategy

### 13.1 What the template is
The Patekit site: plain PHP includes; Bootstrap 5 grid, Swiper, AOS and Font
Awesome shipped locally; and a `pk-` design system in `assets/css/custom.css`
(880 lines) driven by `:root` CSS variables. It uses Poppins. Its reusable
partials are `header.php` (SEO/OG/schema), `navbar.php` (topbar, header row,
animated nav band, mobile drawer), `footer.php`, `cta.php` and
`breadcrumbs.php`. `site.js` handles the mobile nav, hero Swiper, AOS, the
accordion, leader cards, back-to-top and the WhatsApp menu.

**Preserved as-is:** every `pk-*` class name, the CSS, the plugins, `site.js`,
the layout grid, the animations and the breakpoints. The class names stay
`pk-` to avoid a risky mass rename. They are internal and invisible to users.

**Rebranding with zero CSS rewrites:** the palette variables at the top of
`custom.css` are overridden from `settings` by a small `<style nonce>` block in
the layout. The defaults come from the logo:

| Variable | Template | ICSSPK default |
|---|---|---|
| `--pk-navy-dark`, `--pk-blue` (primary) | `#A02224` | `#294455` (logo navy) |
| `--pk-blue-dark` (hover) | `#78191B` | `#1C3140` |
| `--pk-cyan`, `--pk-gold` (accent) | `#FCAF19` | `#ED1B24` (logo red) |
| `--pk-cyan-light` | `#FEF5E3` | `#FDECEC` |

The hard-coded warm tints inside some rules (`#F0B9A0`, `#F5C9A8`,
`rgba(241,90,36,…)`) are converted to variables in `app.css` overrides, not by
editing the original file.

### 13.2 Mapping: template → application

| Template piece | Becomes | Data source |
|---|---|---|
| `includes/header.php` | `layouts/main.php` + `partials/seo-meta.php`, `partials/schema.php` | settings + per-page SEO vars; schema changes from InsuranceAgency to Organization / Event |
| `includes/navbar.php` | `partials/topbar.php`, `partials/navbar.php` | settings (socials, contact); nav items: Home, Awards▾ (live awards), Nominate, Panel, Results, About, Contact; CTA button → "Vote Now" |
| `includes/footer.php` | `partials/footer.php` | settings; WhatsApp menu driven by `contact.whatsapp`; credit line configurable |
| `includes/cta.php` | `partials/cta.php` | per-page vars ("Voting is open — cast your vote", "Nominate someone") |
| `includes/breadcrumbs.php` + `.pk-page-banner` | `partials/page-banner.php` | route data + BreadcrumbList schema |
| Home hero Swiper | home hero | one slide per featured published award (cover image, name, phase-aware CTA: Nominate / Vote / See results) |
| Home about split | home "About ICSSPK Awards" | settings / page content |
| Home `pk-card` grid | featured award & category cards (`components/award-card`, `category-card`) | awards / categories |
| Home 4-step `pk-card` row | "How it works: Nominate → Review → Vote → Results" | static copy |
| Home partners marquee `.pk-partners` | "Sponsors & partners" (optional, hidden when empty) | v1: settings JSON list; v2: table |
| Home `pk-contact-card` trio | contact strip | settings |
| `services/index.php` (grouped `pk-card` grid) | Awards listing, Award → categories listing | awards, categories |
| `services/service.php` (banner, `pk-feat` grid, sidebar card, related cards) | Category page: description, **eligibility criteria** sidebar, nominee grid, related categories; also the Award page layout | category + shortlisted nominees |
| `team/index.php` `pk-leader-card` | `components/nominee-card` **and** `components/panelist-card` | nominees (photo/initials avatar already supported), panelists |
| `about/index.php` (`pk-value-card`, vision/mission cards) | About page | settings/page content |
| `why-us/` + `.pk-accordion` | FAQ and Voting Rules | page content |
| `quote/index.php` (form + side cards, success/error cards) | Nomination form and **Vote page** (form column + summary side card) | award/category/nominee |
| `contact/index.php` (`pk-step-list`, form, map frame) | Contact | settings; messages saved to `contact_messages` + mail |
| `.table-pk`, `.pk-stat` | Results page and results tables | ResultsService |
| `404.php` | `errors/404.php` (+ 403/419/429/500 in the same style) | — |
| `news-events/`, `downloads/`, `careers/`, `partners/` | dropped from v1 (not in scope) | — |

### 13.3 New components (only where the template has nothing)
Built from existing tokens (`--pk-*`, `.btn-pk`, `.pk-card`, radius/shadow
variables) so they look native:
- **Vote selector:** the package chips (`1 · 5 · 10 · 25 · 50 · 100`), a `[-] n [+]`
  stepper, and a live "KSh 200" total. The JS total is display-only; the server
  recomputes it. There's a large full-width "Pay with M-Pesa" button.
- **Payment status screen:** "Check your phone" with a spinner, then a success
  state ("10 votes recorded for Jane Doe · Ref …"), plus failed, cancelled and
  expired states with a retry. It polls every 3 s for up to about 2 min and
  works without JS (meta refresh fallback).
- **Voting countdown:** display only; the server enforces the deadline.
- **Results bars / leaderboard** (when visible), **status badges**, and a
  **shortlist readiness meter** for the admin.
- **Admin shell:** the template has no admin area. `layouts/admin.php` uses the
  same Poppins font, palette and cards, with a sidebar, data tables
  (`.table-pk`), filters, pagination, charts (a small bundled Chart.js file
  served locally, no CDN) and forms.

### 13.4 Template issues fixed during conversion
- **Inline styles.** There are about 100 `style=""` attributes. They're kept
  where harmless, so the CSP allows `style-src 'self' 'unsafe-inline'` but
  **scripts stay strict** (`script-src 'self' 'nonce-…'`). They're moved into
  `app.css` whenever a view is touched.
- **Google Fonts.** Poppins is self-hosted, which removes a third-party
  request, simplifies the CSP and avoids sending visitor IPs to Google.
- **Session startup.** The template's `config.php` starts a session on every
  page. That's replaced by the middleware, so public pages that don't need a
  session don't set a cookie; the vote and nomination forms do.
- **Patekit leftovers.** Hard-coded names, phone numbers, WhatsApp contacts and
  the InsuranceAgency schema are all removed or moved to settings.

---

## 14. Security architecture

| Threat | Control |
|---|---|
| SQL injection | PDO, `ATTR_EMULATE_PREPARES=false`, prepared statements only; dynamic ORDER BY/columns only from allow-lists |
| XSS | `e()` on all output; JSON via `json_encode(JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT)`; no user HTML stored (plain text + nl2br); strict script CSP with nonces |
| CSRF | Per-session token, verified by middleware on every POST/PUT/PATCH/DELETE; webhook the only exemption |
| Session fixation / hijack | strict mode, regenerate on login/privilege change, Secure/HttpOnly/SameSite cookies, UA fingerprint, idle + absolute timeouts, session_version revocation |
| Brute force | login_attempts throttling per account + IP, generic errors |
| Rate abuse | MySQL `rate_limits`: vote initiation (per IP, per phone), nomination submit, contact form, status polling, webhook |
| Amount / quantity / ID tampering | price never read from the request; nominee loaded by slug and chain checked; composite FKs; amount re-verified against IntaSend `value` at confirmation |
| Duplicate payments / votes | submit_token UQ, row lock `FOR UPDATE`, idempotent confirm, `UNIQUE(votes.payment_id)` |
| Webhook spoofing / replay | challenge `hash_equals`; body treated as a signal only — truth comes from the server-to-server status call; event_key UQ dedupe; body size cap |
| Invalid late payments | grace window rule → REVIEW, never auto-credit |
| Admin privilege escalation | route + service permission checks, no self-role-edit, last-super-admin guard, all role changes audited |
| Uploads | size cap; extension allow-list; `finfo` MIME; `getimagesize` dimension limits; **GD re-encode** (strips EXIF & payloads) to WebP/JPEG; random filenames; `public/media/.htaccess` disables PHP/CGI and forces `X-Content-Type-Options` |
| Directory traversal / LFI / RFI | views resolved from a fixed map; no user input in `include` paths; `allow_url_include` off; media served by path from DB, never from query strings |
| Open redirect | redirects only to named internal routes / same-origin relative paths |
| Info leakage | prod: `display_errors=0`, generic error pages with request ID; `expose_php` off; no stack traces, SQL or paths in responses |
| Secrets | `.env` outside web root; secret key never reaches views/JS; logs redact `Authorization`, challenge, full phone numbers (2547****678) |
| Transport | HTTPS forced; HSTS once HTTPS confirmed (config flag) |
| Headers | CSP, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` (camera/mic/geolocation off), `frame-ancestors 'none'` + `X-Frame-Options: DENY` |
| Data protection (Kenya DPA 2019) | minimal fields; consent + privacy notice on forms; nominee contacts admin-only; configurable retention; manual `anonymise-voters.php` keeps financial records but nulls name/email and masks phone; access via roles; all admin access audited |

---

## 15. Phase-by-phase implementation plan

The approved scope adds nominations, shortlisting and panelists, so the plan
now has **12 phases**. Each phase ends with: files created or modified, DB
changes, how to test it, and what's outstanding.

| # | Phase | Delivers | Testable outcome |
|---|---|---|---|
| 1 | **Architecture** (this doc) | Design approval | — |
| 2 | Core infrastructure | autoloader, env/config, DB, router, request/response, sessions, CSRF, errors, logger, views, **template converted into layouts/partials** (static placeholder content), migration runner, all migrations, roles/permissions seed, demo seed | Home renders in the ICSSPK-branded template; `bin/migrate.php` builds the schema |
| 3 | Admin auth & audit | login/logout, throttling, roles/permissions, admin shell, audit logger, admin users mgmt, first-admin setup | Log in, see dashboard skeleton, lockout works, audit rows written |
| 4 | Settings, media, awards, categories, panel | branding settings (logo/colours/contacts), MediaService, awards CRUD + packages, categories CRUD + eligibility + min shortlist, panelists CRUD + assignments + ordering | Change brand colour → site updates; upload images safely |
| 5 | Nominations & shortlisting | public nomination form, admin review queue + notes, accept/reject/merge, prospects, shortlisting, readiness meter, **open-voting guard (20 rule)** | Category with 19 shortlisted cannot open voting (server returns error) |
| 6 | Public website | all public pages wired to real data, SEO meta/OG/canonical, sitemap.xml, robots, panel pages, static pages | Browse award → category → nominee on mobile |
| 7 | Voting engine | vote page + selector, VotingService validation, deadlines, rate limits, pending payment creation (gateway stubbed only in tests, not in production code) | Tampered price/quantity/nominee rejected |
| 8 | IntaSend integration | **sandbox verification first**, IntaSendClient, STK Push, status API, webhook + challenge, webhook_events idempotency, PaymentConfirmationService, status page + polling, cron-webhooks, cron-payments | End-to-end sandbox payment → votes credited exactly once |
| 9 | Results, reports, reconciliation | ResultsService + visibility/publish, admin reports + charts + CSV, reconciliation screens, refunds/reversals, vote adjustments, dashboard KPIs, cron-reconcile | Refund reverses votes; results exclude reversed |
| 10 | Security hardening | headers/CSP pass, rate-limit tuning, session review, input validation sweep, full security review → SECURITY.md | Checklist in §14 verified |
| 11 | Testing | PHPUnit unit + DB integration tests, concurrency test (parallel confirm calls), payment scenario matrix, TESTING.md | All brief §49 scenarios pass |
| 12 | cPanel deployment & docs | DEPLOYMENT.md (cPanel step-by-step, cron commands, webhook setup, no-SSH path, backups), DATABASE.md, PAYMENTS.md, ADMIN_GUIDE.md (incl. retention/anonymisation), README | Fresh cPanel install from the guide |

---

## 16. Assumptions to confirm before Phase 2

1. **Panelists are public profiles only in v1.** They don't log in or score
   nominations; shortlisting decisions are made by admins, and panel scoring
   can come later.
2. **The shortlist minimum is a floor of 20.** Each category can require more
   than 20, never fewer.
3. **Nominating is free.** There's no nomination fee.
4. **Only shortlisted, published nominees are public.** Prospects, rejected
   and not-shortlisted people are never visible on the site.
5. **Third-party nominations of individuals** are stored privately. Should an
   admin have to confirm the nominee's consent (`consent_confirmed_at`) before
   their profile and photo are published for voting? This is recommended under
   the Data Protection Act. Or is shortlisting enough?
6. **Branding colours:** primary is logo navy `#294455`, accent is logo red
   `#ED1B24`, and the template's gold is dropped. Please confirm, or give brand
   hex codes if ICSSPK has official ones.
7. **Pages dropped from the template:** News & Events, Downloads, Careers and
   the insurance Partners page. The partners marquee is repurposed as an
   optional "Sponsors" strip.
8. **Domain:** what is the live domain, e.g. `events.icsspk.org`? Can its
   document root be pointed at `/public` in your cPanel, or do we need the
   `public_html` fallback?

---

## Phase 2 implementation notes

What was built and where it differs from, or adds to, the plan above.

**Bootstrap & runtime**
- `public/index.php` → `bootstrap/app.php` → `App\Core\Application::boot()`.
  The steps run in order: autoloader, `.env`, config, logger, error handler,
  lazy database, router. Then the request is dispatched.
- `APP_ROOT` is derived from file locations, never from a cPanel username.
  The `public_html` fallback uses `app-root.php` (defaults to the sibling
  `icsspk-events` folder).
- Hosts without mod_rewrite: pages work at `/index.php/...`. Setting
  `APP_URL=https://domain/index.php` makes generated links use that form,
  while assets stay direct (`static_url()`).
- `/robots.txt` is generated (so the Sitemap line follows `APP_URL`), and
  non-production environments are always `noindex` / `Disallow: /`.
- `/health` returns only `ok` / `degraded` for deployment checks.

**Schema** (see `docs/DATABASE.md` for the full list of DB-enforced rules)
- The approved rules are CHECK constraints, not just service checks:
  the 20-shortlist floor, the publish-requires-shortlisted rule and the
  publish-requires-consent rule.
- `fk_votes_payment_match`: a vote row must carry exactly its payment's
  nominee, voter, quantity and amount.
- Additions: `nominees.consent_required`, `*.phone_masked`,
  `awards.is_featured`, `awards.event_venue`, `media.purpose = sponsor`,
  retry-scheduling columns. `votes.reversal_id` was dropped because it would
  create a circular FK.

**Frontend**
- Template files are unchanged (`custom.css`, plugins, `site.js`). ICSSPK
  colours re-point the template's CSS variables from settings.
- Two template bugs were fixed in `app.css` without touching the template:
  1. AOS slide-ins widened the page on phones (fixed with `overflow-x: clip`).
  2. The header row covered the top of the open mobile drawer (close button
     and first item). The original template has the same issue.
- Poppins is self-hosted in Regular, Italic, Medium and Bold. The server had
  no SemiBold/ExtraBold files, so the Bold file serves weights 600–900.
  Supplying SemiBold/ExtraBold `.woff` files later is a drop-in change.
- Insurance-specific stock photos were removed. Two neutral photos remain as
  placeholders until award cover images are uploaded.

**Phase numbering** (12 phases, as proposed in section 15):
1 Architecture ✔ · 2 Core infrastructure ✔ · 3 Admin auth & audit ·
4 Settings, media, awards, categories, panel · 5 Nominations & shortlisting ·
6 Public site completion · 7 Voting engine · 8 IntaSend · 9 Results, reports,
reconciliation · 10 Hardening · 11 Testing · 12 cPanel deployment & docs
