# Changelog

All notable changes to the Laravel port of SocietyHub Pro are documented here, milestone by milestone.

## v1.1.0 — Events, Community Updates, Media Optimization, mobile UX pass

A full activity-infrastructure milestone on top of v1.0.0's RC1 baseline: a complete Events system
(calendar, registration, attendance, reminders), Community Updates/Announcements, an automated media
optimization pipeline, an upcoming-birthday community reminder, and a round of mobile UX/bug fixes.

### Added

- **Events** — full event lifecycle (Draft → Published → Cancelled/Completed → Archived), event
  categories, physical/online/hybrid locations, public/members-only/private visibility, and a
  member-facing calendar (month grid + list view) alongside the admin management screens. Events reuse
  the existing Media platform service for banners rather than a separate image pipeline.
- **Event registration & attendance** — capacity-limited registration with row-level locking so two
  concurrent sign-ups for the last open seat can't both succeed, member self-registration/cancellation,
  and admin attendance marking (Registered/Attended/No-show/Cancelled).
- **Event notifications & reminders** — registrants are notified immediately on cancellation or a
  meaningful schedule/location change (not on every edit), plus an automatic reminder a configurable
  number of days before the event, through the same channel toggles (in-app/email/SMS) and
  once-per-day dedup logging as every other reminder type.
- **Community Updates / Announcements** — admin-authored announcements with a draft/published
  lifecycle, Public/Members-only/Admin-only visibility, priority levels, an optional banner image, and
  expiry. Publishing notifies eligible members automatically; the member-facing feed and detail pages
  exclude drafts, admin-only, and expired announcements.
- **Media Optimization Engine** — uploaded images are automatically converted into responsive WebP (and
  AVIF where the server supports it) variants at four sizes, with a same-size fallback in the original
  format. Processing runs in small batches on a scheduled tick (`media:process-pending`, every 5
  minutes) or its HTTP cron fallback, never inline on the upload request, so uploads stay fast.
  Existing media from before this feature can be migrated into the pipeline in controlled batches from
  the admin Media Library, with per-item status, size-savings reporting, and a retry action for
  anything that fails — never processed automatically as a side effect of upgrading.
- **Community-wide birthday reminders** — in addition to the existing same-day "Happy Birthday" message
  to the member themselves, everyone else now gets a heads-up a configurable number of days before a
  member's birthday (default 3), so the organization can plan around it.
- **Media detail panel** — clicking any Media Library item opens a detail view with type, dimensions,
  original/optimized size, savings %, WebP/AVIF availability, variant count, and processing status,
  alongside Copy URL, Reprocess, Replace (swaps the underlying file in place and re-optimizes
  automatically), and Delete actions.
- **Redesigned transactional emails** — a shared layout with a scannable metadata panel (event
  date/time/venue, payment details) and semantic-colored alert callouts (info/warning/danger/success)
  replace plain-paragraph email bodies. Applied to event registration confirmation, cancellation, and
  update notices (each naming specifically what changed — date, time, or venue — rather than a generic
  "event updated"), and the event-reminder path.
- **SasuSync SMS integration** — a fourth SMS provider (alongside Hubtel/Arkesel/Twilio) for the
  existing single-send notification paths (payments, reminders, notifications, registration, password
  reset), plus a full tracked bulk-sending system for deliberate organization-wide SMS: an audience-based
  composer (all active members, event registrants, campaign donors, or a custom selection) with a
  mandatory preview-and-confirm step, message-part/character counting, scheduled sends, a delivery
  dashboard and per-message recipient-level detail (phone numbers masked), reusable templates, and
  webhook-driven delivery tracking (idempotent, out-of-order-safe). SasuSync credentials are
  server-only (`.env`), never exposed to the browser. Six new granular permissions
  (`sms.view`/`send`/`send_urgent`/`schedule`/`manage_templates`/`manage_settings`) gate access, and a
  per-member SMS opt-out preference is respected except for explicitly-confirmed urgent sends.

### Improved

- **Member dashboard simplified** — removed the redundant Membership Status and Latest Notification
  widgets (status is shown on Profile where it's relevant; the header's notification bell already
  carries a live unread-count badge), and the Profile Completion widget now hides itself once the
  profile is actually complete instead of sitting there empty. The Upcoming Reminder widget now links
  through to the payments page.
- **Mobile conversation view** — the message composer is now a genuine fixed panel pinned to the
  viewport bottom instead of a `calc(100vh-…)` estimate that drifted as mobile browser chrome
  shows/hides; the bottom tab bar is hidden on an open conversation so it no longer competes for the
  same space. The member bottom tab bar was trimmed from 6 tabs back to 5 to match its own stated
  design intent.
- **Tab bars on mobile** — a tab row wider than the screen (e.g. the admin member-detail page's 6 tabs)
  now scrolls within itself instead of dragging the entire page into horizontal scroll, which
  previously cut off content across the whole layout, not just the tab row.

### Fixed

- **Voice notes failing to send** — recorded voice notes were rejected with "that file type isn't
  supported." Server-side MIME detection sniffs actual file content rather than trusting the browser,
  and a WebM/Opus voice recording is frequently sniffed as `video/webm` instead of `audio/webm` since
  WebM is fundamentally a Matroska container. Now accepted specifically for voice-note uploads (regular
  file attachments still correctly reject video) and normalized back to `audio/webm` when stored, so
  playback isn't silently broken by the same mismatch.

### Notes for upgraders

- One additive migration adds processing-status columns to the `media` table; existing media rows are
  left untouched (`COMPLETED`, no variants) until an admin explicitly starts migrating them from the
  Media Library — nothing is auto-processed as a side effect of upgrading.
- A new `birthday_reminder_days_before` setting (Admin → Settings → Notifications, default 3) controls
  the new community birthday reminder; no action needed unless you want a different lead time.
- If your host's cron only runs Laravel's own scheduler entry (`php artisan schedule:run` via crontab),
  no configuration change is needed — media processing is already wired into `routes/console.php`
  alongside the existing reminders/backup entries. If you use the HTTP cron fallback instead, add
  `POST /cron/media-process` alongside your existing `/cron/reminders` and `/cron/backup` calls; see
  [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).

## v1.0.0 — Release Candidate complete

The full RC1 milestone: System Health Center, Security Center, White Label, Deployment Center,
performance/UX polish, a general codebase-wide improvements pass, and the complete documentation set.
See `docs/RC1_DELIVERABLE_REPORT.md` for the detailed module-by-module report from that milestone.

- **Security Center hardening** — the 2FA code-verification step had no rate limiting at all (a 6-digit
  code was brute-forceable); now throttled the same way the login form already was, keyed on user+IP and
  respecting the configured max-attempts/lockout-minutes. Also fixed inconsistent file-upload validation
  (CSV import, JSON config restore) and a client-trusted MIME-type check in messaging attachments (now
  server-side sniffed, matching the Media Library's existing correct approach).
- **Performance** — `AdminRole::hasPermission()` ran a fresh query on every permission check (`Gate::before`
  calls it on every ability check in a request); now memoized per-instance the same way `Settings::current()`
  already was. Bounded several previously-unbounded admin list queries (Members, Transactions) to a 2,000-row
  window with exports left uncapped, and fixed an N+1 in notification recipient counting.
- **UX polish** — fixed 7 places where a mutating admin action fired a success toast without checking the
  response actually succeeded (silent false-positive on a rejected request); added a missing empty state;
  mapped backend field-level validation errors onto their actual form fields instead of only a generic toast
  (new shared `applyServerErrors` helper); added missing `aria-label`s on icon-only controls.
- **Website Content module built, then removed** — a lightweight public-site CMS (Pages, Leadership roster,
  FAQ, Contact form + admin inbox) was built as part of this milestone, then fully removed at product-decision
  time before release: migrations rolled back, models/controllers/routes/frontend deleted, permission-catalog
  entries and the `PermissionCategory::Content` enum case removed, docs updated to no longer describe it.
- **Payment method selection fixed** — two real bugs: the selected payment method (Paystack/PayPal) had no
  visible selected-state (a barely-noticeable border-color change only); and both methods were always shown
  as options regardless of whether either was actually configured with real gateway credentials, contradicting
  documented behavior. Added `PaymentGatewayResolver::availableMethods()` (checks credential presence) wired
  into both the member Payments page and the public campaign donation page — zero configured shows a clear
  message, one configured auto-selects it, and the selected state now uses the same filled/checked treatment
  as the rest of the app.
- **Guest-donation transaction crash fixed** — several admin pages (Dashboard, Campaign detail, Transactions
  list + CSV export, Receipt email, Reports) unconditionally accessed `$transaction->user->name`/`->email`,
  crashing with a null-property error for any transaction from a guest (no-account) donation — a real,
  already-supported flow, not an edge case. Added `Transaction::payerEmail()` alongside the existing
  `payerName()` helper (both fall back to the donor name/email captured at guest checkout) and fixed every
  affected call site.
- **Birthday field + automatic birthday reminders** — members and admins can set their birthday on their own
  profile (admins can also set it for other members); the existing reminder engine
  (`AutomationService::runReminders`) gained a `BIRTHDAY` block that sends an automatic "Happy Birthday"
  notification/email on the day itself, reusing the same channel toggles and dedup logging as every other
  reminder type — no new settings or infrastructure.
- **Admin "Set new password"** — admins previously could only send a member a password-reset email; added a
  second option (manual entry or random-generate) available alongside it, not replacing it, so an admin can
  get a locked-out member back in immediately without relying on outbound email working.
- **Logo-fallback letter fixed** — the sidebar and login page both showed a hardcoded "S" as the logo
  placeholder instead of the organization's actual first letter, so white-labeled installs (any org name
  other than the default) still showed the wrong letter whenever no custom logo was uploaded. Also removed
  three decorative "carousel" dots on the login page that implied multiple rotating messages but never had
  more than one — dead UI.

## Milestone 5 — Licensing, Installation Wizard, Update Center, System Health Center, Backup & Restore, Security Center, White Label Completion, Public Website Management

- **Public Website Management** — a public, no-account-required donation surface at `/campaigns`
  (listing) and `/campaigns/{id}` (detail + donate), gated to `CampaignVisibility::Public` +
  `CampaignStatus::Active` campaigns — this enum value already existed on the `Campaign` model but had no
  route or page consuming it before now. Guest checkout reuses the existing Paystack/PayPal gateway
  services unchanged (they were never coupled to an authenticated `User` to begin with); the coupling
  lived entirely in `PaymentService`/`PaymentController`, so `transactions.user_id` is now nullable with
  new `donor_name`/`donor_email` columns for the guest case, and `ReceiptService`/`VerificationService`
  were updated to fall back to `donor_name` instead of assuming `transaction.user` always exists —
  receipts and the public `/verify` page both now work correctly for a donation with no account behind
  it. Admin campaign table gained a "copy public donation link" action for PUBLIC + ACTIVE campaigns.
  Every guest endpoint is deliberately scoped defensively: the status-polling endpoint only ever resolves
  `user_id IS NULL` transactions, so a guessed reference can never expose or act on an authenticated
  member's data. Verified end-to-end both locally and live on test.restrohq.org: visibility/status
  gating (hidden campaigns 404, public ones don't), a full guest-donation-to-receipt cycle (transaction
  creation → success confirmation → receipt PNG rendering → public verification page → campaign raised
  total updating), and confirmed zero regression to the existing authenticated member donation flow.
- **White Label Completion** — Two of the six white-label Settings fields (`white_label_company_name`,
  `white_label_login_title`) were already validated and round-tripped through the Settings API from an
  earlier pass, but had no UI input and were never actually read anywhere — dead fields. Added them to the
  White Label settings section, wired `companyName` and `loginTitle` into the shared `branding` Inertia
  prop, and put them to use: the login screen's marketing headline is now overridable per-install (falls
  back to the default copy when blank), and a new `white_label_hide_powered_by` toggle controls the
  sidebar's "Provided by {company}" attribution line (previously a hardcoded "SocietyHub Pro" with no way
  to turn it off). The 2FA QR enrollment issuer name (Module 6) also now reads the white-label app name
  instead of the raw `config('app.name')` env value, so authenticator apps show the reseller's branding,
  not the underlying product's. Verified end-to-end live on test.restrohq.org: custom app name, company
  name, and login headline all confirmed flowing through to the rendered `<title>` tag and shared branding
  prop via a real Settings API update, then reset back to defaults.
- **Security Center** — Admin → Settings → Security Center: recent security events (filtered from the
  existing audit trail — sign-ins, sign-outs, 2FA enabled/disabled, recovery-code sign-ins), an active
  sessions list with per-session revoke (self-revocation is blocked — log out normally instead), and
  IP allowlist/blocklist + lockout-duration settings. Real TOTP-based two-factor authentication, available
  to both admins and members from their Profile page: QR enrollment (hand-rolled RFC 6238 — HMAC-SHA1,
  30s windows, ±1 window clock-skew tolerance — rather than a new composer dependency, reusing the
  `endroid/qr-code` package already in use for membership cards) plus 8 one-time recovery codes shown once
  at setup. Login now runs through `Auth::validate()` instead of `Auth::attempt()` so a correct password
  alone never grants a session once 2FA is confirmed — a pending-2FA marker in the session gates a
  dedicated `/two-factor-challenge` step (TOTP code or a recovery code) before `Auth::login()` ever runs.
  Also wires up three Settings fields that already existed in the UI but were never actually enforced:
  `max_login_attempts` and a new configurable lockout window now really gate login attempts (previously
  Laravel's default `LoginRequest` hardcoded 5 attempts / 60 seconds regardless of what Settings showed),
  and new IP allow/block lists are checked before credentials on every login — both fail open (no
  restriction) if left blank, matching how every other optional Settings field in this app degrades.
  Adding a `security` permission category required widening `permissions.category`'s DB-level `ENUM`
  column, not just the PHP-side enum — a real gap in the seeder that would have hit anyone adding a new
  permission category, not just this one. Verified end-to-end both locally and live on test.restrohq.org.
- **Backup & Restore** — Admin → Settings → Backup & Restore: on-demand DATABASE (SQL dump), UPLOADS (media +
  message attachments as a ZIP), or COMPLETE (both) backups, each optionally excluding audit logs, media, or
  messages, stored under `storage/app/private/backups/` (never web-accessible — downloads go through an
  authenticated, audited controller action). A backup-history table (download/restore/delete, all audit-logged)
  and a scheduling card (frequency, type, excludes, retention count) round it out. Scheduled runs are driven by
  a daily `backup:run` Artisan command plus a shared-secret `/cron/backup` HTTP endpoint (same pattern as the
  existing reminders cron), both thin wrappers around `BackupService::runScheduled()`, which is itself
  due-gated against `Settings` so calling it more often than configured is harmless; pruning keeps only the
  configured retention count of scheduled backups. The dump routine (`DatabaseProbe::dumpSqlFromPdo()`) is
  shared with the Installation Wizard's pre-clear backup rather than duplicated. Found and fixed three real bugs
  during restore testing (two locally, one on the live test.restrohq.org server before this ever touched
  df.e-data.shop): PDO's default `FETCH_BOTH` mode was duplicating every column in each dumped row (silently
  doubling every `INSERT` value list, so a restore always failed with a column-count mismatch) — fixed via an
  explicit `FETCH_ASSOC`; a ZIP path off-by-one was chopping the first character off every archived filename —
  fixed by normalizing the source directory's trailing separator before computing relative paths; and the
  `backups` table itself was being included in every DATABASE/COMPLETE dump, so restoring a backup silently
  wiped the backup-history list back to the snapshot's moment (including the very row for the backup just
  restored, since it hadn't been created yet when its own dump ran) — fixed by always excluding `backups` from
  the dump, since it's operational metadata about what's on disk right now, not point-in-time app data. Verified
  end-to-end both locally and live on test.restrohq.org: all three backup types, restore-from-backup (row counts,
  a sample record, and backup-history survival all confirmed post-restore), delete, the scheduled command's
  same-day no-op guard and retention pruning, and the cron endpoint's shared-secret auth.
- **System Health Center expansion** — 4 new health checks (18 total): SMS provider (Hubtel/Arkesel/Twilio
  credential check), Background Jobs Queue (pending/failed job counts on non-sync queue connections), Memory
  (peak usage vs `memory_limit`, warns past 90%), Disk Usage (free/total on the storage volume, warns past
  90%). Also added a "View Logs" action (gated behind `system.manage`, not the read-only `system.view`, since
  log lines can include IPs/emails/file paths) — tails the last 300 lines of `storage/logs/laravel.log`
  reading from the end of the file rather than loading the whole thing into memory.
- **Settings page nav** — desktop sidebar no longer stretches to match the settings form's height (was
  `align-items: stretch` from the grid, left a large empty box under a short nav); now `sticky` + `self-start`
  so it sizes to its own content and follows the page as you scroll. On mobile it's a horizontally-scrollable
  row instead of a tall stacked list pushing the form down.
- **Update Center** — Admin → Settings → Updates: installed version, last-checked latest version, current
  release notes (parsed live from `CHANGELOG.md`), a "Check for Updates" action, and an update-history table.
  No automatic updating yet (per spec — future-ready only): `UpdateService::checkForUpdate()` is the one
  method a future KMD Hub update-check API replaces, structured the same way `LicenseService` was before KMD
  Hub's licensing API existed. `config('app.version')` is now the single source of truth for the product
  version (previously three different hardcoded fallback strings had drifted out of sync with each other).
  `recordVersionIfChanged()` logs a `version_history` row whenever the running code's version doesn't match
  the last one on record, so the history reflects real deploys without needing an active update mechanism to
  report them. Verified end-to-end on real Hostinger infrastructure.
- **Installation Wizard** — `/install` replaces the old "SSH in, hand-write `.env`, run `migrate`/`tinker`" deployment steps entirely. Guided 6-step flow: License (optional KMD Hub key, activated once a database exists) → Requirements → Database (test connection, create-if-missing, existing-table detection with a downloadable pre-clear SQL backup) → Environment (writes `.env`, runs migrations — only after they succeed, so a mid-request failure never leaves `.env` pointing at an unmigrated database) → Organization → Administrator. Self-locking two ways (a `storage/app/installed.lock` marker plus the existing-administrator check), and resilient to running before a database connection exists at all — session/cache fall back to the file driver and `APP_KEY` self-generates at the bootstrap level for a genuinely fresh, zero-config deploy. Seeds the permission catalog and default admin roles itself (`PermissionCatalogSeeder`, extracted from `DatabaseSeeder` so production installs get proper RBAC scaffolding without pulling in demo data) and assigns the new admin the built-in "Super Administrator" role, so the account has full access immediately instead of landing with zero permissions. Verified end-to-end against real Hostinger infrastructure (Apache/PHP-FPM/MariaDB), not just local testing.
- **Fixed a pre-existing, unrelated critical bug found via that testing**: `User`'s mass-assignment allowlist was missing `password_hash`, so registration and password reset silently discarded the new password app-wide (not specific to the installer). Also hardened `SettingsService::getExtendedSystemInfo()` (System Status page) against a missing `package.json` — a dev-only build manifest that not every deployment method ships to production.
- **Licensing** — adapter-based license system (`App\Services\License`) with a zero-config Development Mode
  adapter (shipped default — the app runs fully featured, clearly marked unlicensed) and a KMD Hub adapter
  shaped for their still-unpublished API (fails closed with a clear "not configured" result until
  `KMD_HUB_BASE_URL` is set — never a hard crash). License state lives on the `Settings` singleton plus a
  `license_activation_history` audit trail. Validation is interval-gated (`validateIfDue()`, hourly schedule
  entry, default 24h) and never blocks the app on a failed check — it falls back to a configurable offline
  grace period (default 7 days) before ever marking a license EXPIRED. Admin → License page: activate/refresh/
  deactivate, masked key display, grace-period countdown, and full activation history. Also added as the 14th
  System Status health check.

## Milestone 4 + Release Candidate 1 — Full module set, messaging overhaul, system status, production docs

- **Dashboard & profile regressions closed** — member and admin dashboards rebuilt to match the full source design (welcome card, quick balance, right-rail widgets, admin quick actions); member Profile page (photo upload, password change, Google account linking) built out from an unwired scaffold; status pills across the app switched to the shared color-coded `StatusBadge`; all four auth forms (login/register/forgot/reset) now show success/failure toasts.
- **Payment gateways** — live Paystack and PayPal integration, credentials resolved from Settings, webhook-verified (HMAC-SHA512 for Paystack, signature verification API for PayPal) so payment status is never trusted from client input. Payment callback page polls for confirmation with a "still processing" retry state.
- **Receipts** — GD-rendered PNG receipts (no Imagick dependency, matching the membership-card renderer), emailable from both the member and admin receipt views.
- **Reports & exports** — all 7 report types with CSV/Excel export and date-range filtering.
- **Notifications & automation** — email/SMS delivery channels per notification, idempotent reminder engine runnable via `php artisan reminders:send` (schedulable) or the `POST /cron/reminders` HTTP endpoint (for hosts without shell/cron access to Artisan).
- **Messaging overhaul** — member-to-member direct conversations (not just member↔admin support chat), file and voice-note attachments, message deletion, grouped-bubble/day-divider thread UI. Every conversation read/write is gated by participant-membership (or admin-on-support-conversation) — closes the gap where an admin could otherwise open a private conversation by guessing its ID. Attachments are served with their stored `Content-Type`, not one guessed from the file extension.
- **PWA** — web manifest, generated app icons (192/512/512-maskable/180), service worker with offline fallback and in-app update prompts, install prompt.
- **White-label wiring** — organization/white-label Settings fields (app name, browser tab title, favicon, login logo/background, privacy policy/terms links) now actually drive the rendered app instead of sitting unused in the settings form.
- **System Status** — 13 live health checks (application, database, authentication, Google auth, SMTP, Paystack, PayPal, Turnstile, storage, notifications, messaging, PWA, background jobs), 30-day uptime history, configuration warnings, and a diagnostics export — the RC1 flagship admin page.
- **Global search** — admin-wide search across members, transactions, campaigns, notifications, messages, and settings sections, permission-scoped per result group.
- **Public verification** — membership card and receipt QR codes now resolve to a public, no-login `/verify` page (this was a latent gap — the QR codes previously encoded the raw membership number/reference instead of a scannable link).
- **Card template picker fixed** — Admin → Settings → Membership's card template field was a free-text input with no indication of valid values; it's now a proper dropdown (Midnight/Ledger/Seal), and the default Settings value was corrected from a non-existent `"default"` template to `"midnight"`.
- **Production docs** — this README, CHANGELOG, LICENSE, and the full `docs/` guide set, rewritten for Laravel + MySQL + cPanel/shared-hosting deployment.

## Milestone 3 + 3.1 — Administration Portal & production polish

- Full admin portal: members, dues, campaigns, transactions, notifications, messaging inbox, roles & permissions, audit logs, settings (with export/import/backup/restore), reports scaffold.
- Role-based access control via a permission catalog and `Gate::before`, mirroring the source app's `getUserPermissions()` model.
- Hardening sweep: fixed a role-scoping gap in `MemberController::show()` (route-model binding resolved any user by ID regardless of role) and a silent `Settings::current()` creation failure.

## Milestone 2 — Communication and Payment Foundation

- Member↔admin support messaging (single-conversation-per-member model).
- Notification system (in-app), initial payment/dues tracking scaffolding.

## Milestone 1 — Foundation and Core Member Portal

- Laravel 12 + Inertia.js v2 + React 19 project scaffold, ULID-keyed schema, session-based auth, member dashboard, membership card generation (Intervention Image/GD + endroid QR code — chosen specifically over Imagick for shared-hosting compatibility).
