Every module is opt-in per estate via Settings → Special Access Modules. Estates that only want resident gate codes leave everything off and see no UI change. End-to-end functional + integration tests for the new code paths: 61/61 pass.
barcode_scan)/code, /guest-code and /household (new household pass celebration sheet) now render a scannable QR code AND a 1D Code 128 barcode beneath the 6 digits. Same code, two more input channels.Scan code button (only when flag on). Tapping opens an in-page camera scanner via html5-qrcode (lazy-loaded). On a successful read, the digits drop into the keypad and the existing verify-code endpoint fires — no new server path.verify-code accepts a via: 'camera' hint and writes a separate barcode.scanned audit row so admins can see scan vs typed entries.qrcode.js, jsbarcode, html5-qrcode) are CDN-loaded ONLY when the flag is on — zero added bytes for estates that don't enable it.business_visitors)For estates with a church, hotel, pharmacy, event centre or shop inside the gates. Solves the "I'm going to the hotel" cover-story problem.
businesses table. Admin adds each business (name, type, contact phone) at /businesses. Each business gets one phone+PIN login, mirroring the resident/security identity pattern. New /business-detail admin page edits, resets PIN, disables, deletes, and shows the visitor history.resolve_phone() and current_portal_user() extended to also try the businesses table. Login form + JSON portal login both auto-route businesses to /business with the same lockout / force-pin-change logic the residents and guards already use.Visitor code button on the keypad opens a sheet: pick the business from a dropdown, type the visitor's phone (required) + name (optional). Server validates the business is active, generates a 6-digit business_entry code that's marked used-at-issue (it's an audit record, not a re-verifiable code — the visitor walks in immediately) and writes an entry_log row. Visitor gets the digits + QR + 1D on screen if the barcode module is also on./business, sees active visitors, taps Release on a row OR types the phone. Server uses phone_variants() to find the most-recent matching open business_entry for that business; if the phone doesn't match anything, the server rejects with a friendly error AND audits business_visitor.phone_mismatch so admins can spot abuse patterns. On match, generates a business_exit code linked to the entry.verify-code now picks up business_exit codes, marks them single-use, logs entry_log with direction='out', and returns a business_exit resident-shape so the green takeover reads "Leaving Golden Pearls Pharmacy".business_codes table (separate from access_codes so the resident-NOT-NULL constraint on access_codes stays clean). em_unique_code() now checks both tables so the 6-digit code namespace stays globally unique while live.event_mass_codes, requires business_visitors)For churches running Sunday service, event centres hosting one-day events — one code, hundreds of guests, configurable window.
/business → Events tab → "Generate code". Sheet captures label ("Sunday Service"), start, end. Server validates end > start and the window ≤ event_code_max_days (default 7 days, admin-configurable). Returns code + QR + 1D the business can screenshot to WhatsApp.verify-code recognises code_type='event_mass': multi-use (no used_at flip), each scan logged in new event_code_uses table + entry_log. Pre-event scans return "Event hasn't started yet". After expires_at or revoked_at → reject.event_visitor_phone_required setting. When on, verify-code returns valid: 'needs_visitor_info' so the gate keypad prompts for visitor phone before logging the entry. Default off — high-throughput Sunday services don't want to type 300 phone numbers.Every new action calls em_audit():
barcode.scanned (with code_type)business.create / update / reset_pin / set_status / deletebusiness_visitor.entry_issued / exit_issued / exit_used / phone_mismatchevent_code.created / used / revokedAll visible in /audit for admins to audit who came in, when, by which guard, via which business, and which phone — including failed phone-match attempts.
businesses (id, name, business_type, contact_name, phone UNIQUE, pin_hash, force_pin_change, …)business_codes (code, code_type, business_id, visitor_*, label, event_start_at, expires_at, linked_entry_code_id, issued_by_*, used_at, used_by_*, revoked_at, …) + partial unique index on live codesevent_code_uses (business_code_id, visitor_name, visitor_phone, vehicle_plate, security_staff_id, entry_log_id, used_at)entry_log.business_code_id (new column — additive; migrator adds it automatically)New "Special Access Modules" collapsible section in Settings:
features_barcode_scan togglefeatures_business_visitors toggle + business_visitor_ttl_seconds (30 min–24 h, default 6 h) + business_exit_ttl_seconds (5 min–1 h, default 15 min) + link to /businessesfeatures_event_mass_codes toggle (greyed out unless business_visitors is on) + event_code_max_days (1–30, default 7) + event_visitor_phone_required toggleNew: api/businesses.php, views/businesses.php, views/business-detail.php, views/public/business.php. Modified: schema.sql, app.json, seeds/fresh.sql, seeds/demo.sql, helpers.php, api/portal.php, api/settings.php, views/public/login.php, views/public/_layout.php, views/public/security.php, views/public/code.php, views/public/guest-code.php, views/public/household.php, views/partials/settings-sections.php.
Rewrote every piece of customer-facing marketing copy so it matches the real, gate-focused, Nigerian-context product instead of the generic placeholder that still said "run a residential estate end-to-end".
description, marketplace.why_use, benefits, features — refocused on the one-tap-code-at-the-gate experience, Pa/Ma/gateman wording, household passes, in-app SOS, Paystack inside, no SMS bill for onboarding.PSP.api was using the browser's default fetch cache, so a refresh sometimes returned a stale empty response from before any rows were added. Set cache: 'no-store' + credentials: 'same-origin' on every portal API call.inline-flex + nowrap so the lucide icon and text sit side-by-side on one line.The lazy schema migrator had three compounding bugs that, together, left installs permanently stuck after any partial migration:
1. Unconditional hash stamping in _lazy_migrate_if_stale(). The function stamped the new schema hash after updater_apply_scope() returned, regardless of whether the apply had errors or skipped statements. So a migration that failed halfway through still stamped the hash, and every subsequent request saw $haveHash === $wantHash and skipped the retry — even after the migrator code itself was fixed. 2. No staleness self-check. The lazy migrator's only "is this DB stale?" signal was the schema hash. If the hash was wrongly stamped (per #1), the DB was permanently marked "up to date" even with missing tables. 3. updater_apply_scope() ran the whole schema as one big $db->exec($sql) call. A single failing statement (typically a partial UNIQUE INDEX that conflicts with pre-existing duplicate rows) would roll back the entire transaction — taking every new table in the same schema down with it.
Fixes:
_lazy_migrate_if_stale() no longer stamps the hash at all; that's owned by updater_apply_scope(), which only stamps after a clean(-enough) commit._lazy_has_missing_tables() — a cheap defensive check that scans the schema's declared CREATE TABLE names and verifies each exists in sqlite_master. Returns true if any are missing, which forces the lazy migrator to re-run regardless of the cached hash. Strips SQL comments first so doc strings like "the CREATE TABLE above" don't false-positive.updater_apply_scope() now executes the schema statement by statement (via new updater_split_statements() that respects strings, parens, line + block comments) with per-statement try/catch. A failing CREATE UNIQUE INDEX no longer kills new CREATE TABLEs.skipped items so the migrator doesn't loop forever retrying when the only failure is an index that genuinely can't be created (data violates the constraint).updater_scan_app_users() and updater_apply_app_users() — the bulk path that update.php uses for "Update all" actions.End-to-end verified: an existing install with panic_events / dependents / diesel_log missing AND the new hash already stamped will self-heal on the next page request. Resident data, access codes (including any duplicates), and existing tables are preserved exactly as they were.
white-space: nowrap and font-size: clamp(11px, 3.4vw, 14px), so the subtitle stays on one line on actual mobile devices without overflowing.panic_events / dependents / diesel_log not getting created on installs with duplicate live access codes.onclick= handlers were unreliable across the bottom-sheet rendering path. Refactored to addEventListener + event delegation on the row actions list. Empty-name input gets validated client-side. New pass codes also now show in a celebration sheet with Copy / Share buttons, not just a toast.Driver,Househelp,Nanny,Family,Gardener,Cook,Other). Residents see this exact list in the dropdown when adding a household pass — admin can append cleaner, family-member roles, security guards, etc.core/lib/updater.php: new updater_split_statements() helper, top-level statement loop with per-statement try/catch.portal/relationships (resident-side, reads settings.dependent_relationships).dependent_relationships setting wired into [seeds/fresh.sql](apps/estatemax/seeds/fresh.sql), [seeds/demo.sql](apps/estatemax/seeds/demo.sql), and [app.json](apps/estatemax/app.json) defaults.PSP.openSheet() with proper event handlers and a confirmation sheet that surfaces the new pass code prominently.panic_events, dependents, diesel_log) down with it. Now columns get added on existing tables before the schema executes. Fixes "no such table: panic_events" when raising the SOS button, "could not load fees" with templates missing, and the empty /household page.America/New_York. New helper em_format_local_time() parses the stored UTC string and renders in Africa/Lagos — fixes the "Valid until 4:01 AM" issue on the live-code strip, plus history, visitor pre-approvals, and the broadcast timestamp./dues "Could not load fees" fixed. The fetch URL had a .. segment that some browsers didn't normalize. Now uses an absolute portal URL./household "Add" button now works. The bottom-sheet helper wasn't defined on the portal SDK — built PSP.openSheet() / PSP.closeSheet() / PSP.confirm() to match the admin SDK's shape. Household passes, dependent forms, and confirm dialogs all use it.08… form throughout (Nigeria-only app — never show the 234 country code on screen)./manifest.webmanifest) and a service worker (/sw.js), both scoped to the portal path. Chrome / Edge surfaces the native install prompt; the login page also shows an "Install this app on your home screen" pill (and an iOS-specific Share-sheet hint for Safari, since Safari doesn't fire beforeinstallprompt). Once installed, the app opens straight to the login page on the resident's home screen.service_charges.paystack_reference; verify looks up the charge by reference and refuses any reference that doesn't match. Closes a hole where one resident could credit another resident's successful Paystack reference to their own dues.javascript: and data: links are refused at save time — only http:///https:// links pass.run_estatemax_cron() into the platform cron runner — every active EstateMax install ticks each hour. Sends "3 days before due", "due today", and "3 days overdue" reminders, flips overdue status, and cleans up expired access codes.broadcast_recipients with status='pending' and drained by the cron tick. The first 20 recipients still go out immediately so the admin sees progress; the rest fan out in the background. List view now shows "sent · still queued · failed" breakdowns.080… everywhere, not +234… / 234…. Storage stays in 234 form for stable joins, but every screen — residents list, resident detail, security staff, complaints, visitor log — renders in the Nigerian local form residents actually read. Form placeholders updated to "08012345678".resolve_phone() resolver that tries all four NG variants. Residents whose phone was stored as 08… no longer fail to find their own row./diesel admin page tracks fuel refills (liters · price/liter · total · supplier · optional generator-hours-run) with 90-day spend totals. "Bill residents" wizard one-taps a per-house diesel levy into service_charges (deduped by period). New "Diesel" quick-action tile on the admin home./security) and the admin dashboard (/, /panic) poll for open events every 8 seconds and pop a sticky red banner with a Web Audio siren, an animated icon, vibration on capable devices, and an optional browser desktop notification (one-time permission ask). The banner offers "Call resident" (tel: link) and "Mark responded" buttons; acknowledging from either screen clears it everywhere. New /panic admin view shows the full event history. Rate-limited to one event per resident per minute. Zero ₦ per alert./household portal page. Each resident is capped at 10 active passes.paystack_reference + paystack_amount_kobo columns on service_charges; partial unique index idx_access_codes_live_uniq on (code) WHERE used_at IS NULL AND revoked_at IS NULL; new tables diesel_log, panic_events, dependents; entry_log.dependent_id column.broadcast_recipients.status gained pending / sending values; new idx_broadcast_recipients_pending index.apps/estatemax/helpers.php: em_display_phone(), em_drain_broadcast_queue(), em_unique_dependent_code(), em_dependent_allowed_now().cron_estatemax_tick() lives in core/cron/tasks.php; runs run_estatemax_cron() and em_drain_broadcast_queue() against every active install.app.json: stripped vestigial roles block (pre-pivot shared-DB leftover), flipped ready to true, registered /diesel + /household routes + diesel API.api/portal.php, helpers.php header, api/staff.php.beginTransaction + try/catch with a rollback on failure.service-charges.php had a stray no-op UPDATE access_codes SET id = id WHERE 1=0 removed.resident-detail.php already had the correct slashes in PS.post('residents/...') — left as-is.require_tenant_member() / require_tenant_admin() gates rewritten to require_auth() as a placeholder. apps/estatemax/api/members.php and the members api-route removed.tenant_members / tenant_invites CREATE TABLE statements stripped (comments remain). identity block removed from app.json. The existing residents / security_staff tables (already app-managed with pin_hash) are untouched./guest-code page: one tap generates a 6-digit code for a visitor, valid 6 hours by default (admin-configurable). Big "Share via WhatsApp / SMS" button (uses the Web Share API where available, WhatsApp deep-link as fallback) and a "Copy" button. Optional guest details (name / phone / vehicle plate) live in a collapsed <details> panel so the one-tap path stays clean./history page lists the resident's last 20 codes with status — Used / Revoked / Expired / Live — and the guard's name where applicable./dues page. Each unpaid charge gets a "Pay ₦X online" button; tapping opens the Paystack popup. On success, the payment is server-verified against Paystack's API (no client-side trust) and recorded with method paystack. Charge status flips to paid once the balance hits zero. Existing manual cash / transfer / POS flows are unchanged.guest_code_ttl_seconds (default 21600 = 6 hours, min 5 min, max 7 days).sms_on_code_used (default 0).paystack_enabled, paystack_public_key, paystack_secret_key (default disabled, empty keys).access_codes gains code_type, revoked_at, revoked_by_role, revoked_by_user_id, guest_name, guest_phone, guest_plate, used_vehicle_plate. New index idx_access_codes_type. Per CLAUDE.md the platform's lazy migrator picks these up from the CREATE TABLE diff.helpers.php: em_active_guest_codes, em_issue_guest_code, em_revoke_code, em_unique_code, em_notify_code_used, em_guest_code_ttl_seconds. em_issue_code is now scoped to code_type = 'self' (guest codes are untouched when a new self-code is issued).lib/payments.php with em_is_payment_enabled, em_payment_currency, em_verify_paystack_payment (pattern lifted from PropertyPro).get-guest-code, revoke-code, my-codes, recent-uses, paystack-init, paystack-verify. New admin actions: residents/revoke-code, residents/active-codes.EstateMax is now a full app. The marquee surface is the one-tap gate-code flow; the rest of estate management sits behind it.
1234; admin can change it in Settings.0000.gate.yourestate.com to the resident portal at /admin/domains.residents, security_staff, access_codes, entry_log, service_charges, service_charge_payments, complaints, complaint_messages, broadcasts, broadcast_recipients, meeting_minutes, notification_log, reminder_log, verify_attempts, tenant_invites.helpers.php: portal session auth (require_resident, require_security), em_issue_code, default-PIN hashing, CSV parsers, SMS wrapper, cron runner.PSP SDK (not the platform PS) because residents have no Pancho session.apps/accesspass/ folder. Pre-launch — no migration needed.tenant_members table to schema.sql (backed by the platform's _per_user_app_bootstrap_owner() hook).estatepro → estatemax).subscriptions.app_id value now uses the new slug. Platform had not gone live yet, so old URLs are not preserved.All notable changes to this app are recorded here. Newest entries on top.