Compare commits

..

12 commits

Author SHA1 Message Date
ben
7b895ad785 docs: update Status.md with Zoho user sync production fixes
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
Documents the iterative debugging of user sync on production:
version constant mismatch, GET search criteria fix, error reporting
priority, phone/name validation, and spam account cleanup.
Final result: 64/64 active trainers syncing to Zoho CRM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:58:24 -04:00
ben
4c22b9db8e fix(zoho): Fix user sync search criteria and improve data validation
- Fix sync_users search: pass criteria in URL query string instead of
  as data parameter (GET requests ignore body data), which caused every
  user search to fail and fall through to create
- Improve validate_api_response to check Zoho-specific error codes
  before generic HTTP errors, and include field-level detail in messages
- Add Last_Name fallback to display_name/username when meta is empty
- Sanitize Phone to digits-only, require 10+ digits, omit if invalid
- Bump HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache

Result: 65/65 trainers now sync successfully (was 0/65).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:39:20 -04:00
ben
03b9bce52d fix(zoho): Fix silent sync failures with API response validation and hash reset
Zoho CRM sync appeared connected but silently failed to write data due to
unvalidated API responses. Sync methods now validate Zoho responses before
updating hashes, ensuring failed records re-sync on next run. Also fixes
staging detection to use wp_parse_url hostname parsing instead of fragile
strpos matching, adds admin UI for resetting sync hashes, and bumps
HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache for updated JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:25:26 -04:00
ben
4b53d3eab6 fix(find-training): Fix Near Me button mobile layout and add empty results notification
- Fixed CSS bug where Near Me button HTML was replaced without .hvac-btn-text
  wrapper class, causing layout issues on mobile when text became visible
- Applied fix to all 5 locations: loading state, success state, error reset,
  clear filters, and remove location filter
- Added notification when Near Me filter returns no results within 100km radius
  to improve UX feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:43:11 -04:00
ben
fcd55fd164 feat(find-training): Differentiate measureQuick Certified Champions from Trainers
Champions are identified by "Certified measureQuick Champion" certification.
Unlike Trainers, Champions do not offer public training, so they display
differently:

- White marker outline (vs green for Trainers)
- Show only state, not city, in sidebar and info windows
- No "View Profile" button or modal popup on click
- Sorted to end of trainer list (after all Trainers)
- Non-clickable card styling

Code review fixes (Gemini 3):
- Fixed location formatting to handle empty city gracefully
- Added secondary sort by name for stable ordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 02:01:15 -04:00
ben
d2a43bfd9b docs: update Status.md with tabbed interface implementation
- Document Trainers/Venues/Events tab navigation
- Document venue cards, event cards, info modal
- Document CDN cache issue and version bump fix
- Add CAPTCHA implementation as planned next session task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:56:28 -04:00
ben
ea3031528e chore: bump HVAC_VERSION to 2.2.6 for CDN cache bust
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:48:35 -04:00
ben
17dd3c9bdb feat(find-training): Add tabbed interface for Trainers, Venues, and Events
Replace single trainer list with a tabbed sidebar interface:
- Three tabs with dynamic counts for each category
- Venue cards with icon, name, location, and upcoming events count
- Event cards with date badge, title, venue, and cost
- Visibility toggles moved from map overlay to sidebar header
- Context-aware search placeholder based on active tab
- Client-side filtering for instant search results
- Info button and modal with usage instructions and map legend
- Keyboard navigation for tabs (arrow keys)
- All lists sync with map viewport on pan/zoom

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:36:17 -04:00
ben
5c15b27935 feat(find-training): measureQuick Approved Training Labs implementation
Add venue taxonomies and filter /find-training to show only approved labs:

- Create venue_type, venue_equipment, venue_amenities taxonomies
- Filter venue markers by mq-approved-lab taxonomy term
- Add equipment and amenities badges to venue modal
- Add venue contact form with AJAX handler and email notification
- Include POC (Point of Contact) meta for each training lab

9 approved training labs configured:
- Fast Track Learning Lab, Progressive Training Lab, NAVAC Technical Training Center
- Stevens Equipment Phoenix/Johnstown, San Jacinto College, Johnstone Supply
- TruTech Tools Training Center (new), Auer Steel & Heating Supply (new)

Note: Venues not displaying on map - to be debugged next session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:36:06 -04:00
ben
19147d978e feat(find-training): Add viewport sync and marker hover interactions
- Add viewport sync: sidebar shows only trainers visible in map area
- Add mouseover event on markers showing info window on hover
- Set optimized:false on markers for reliable hover events
- Add legacy URL redirects (/find-a-trainer → /find-training)
- Remove deprecated find-a-trainer page from Page Manager
- Update Status.md with session changes
- Bump version to 2.2.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:42:45 -04:00
ben
21c908af81 feat(find-training): New Google Maps page replacing buggy MapGeo implementation
Implements /find-training page with Google Maps JavaScript API:
- Interactive map showing trainers (teal) and venues (orange) markers
- MarkerClusterer for dense areas
- Filter by State, Certification, Training Format
- Search by name/location
- "Near Me" geolocation with proximity filtering
- Trainer profile modal with contact form
- Venue info modal with upcoming events
- 301 redirect from /find-a-trainer to /find-training
- Auto-geocoding for new TEC venues via Google API

Multi-model code review fixes (GPT-5, Gemini 3, Zen MCP):
- Added missing contact form AJAX handler with rate limiting
- Fixed XSS risk in InfoWindow (DOM creation vs inline onclick)
- Added caching for filter dropdown queries (1-hour TTL)
- Added AJAX abort handling to prevent race conditions
- Replaced alert() with inline error notifications

New files:
- includes/find-training/class-hvac-find-training-page.php
- includes/find-training/class-hvac-training-map-data.php
- includes/find-training/class-hvac-venue-geocoding.php
- templates/page-find-training.php
- assets/js/find-training-map.js
- assets/js/find-training-filters.js
- assets/css/find-training-map.css
- assets/images/marker-trainer.svg
- assets/images/marker-venue.svg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 23:20:34 -04:00
ben
9f4667fbb4 fix(security): Multi-model code review - 12 security and architecture fixes
Comprehensive code review using GPT-5, Gemini 3, Kimi K2.5, and Zen MCP tools
across 11 critical files (~9,000 lines). Identified and fixed issues by
consensus prioritization.

CRITICAL fixes:
- Strip passwords from transients in registration error handling
- Rewrite O(3600) token verification loop to O(1) with embedded timestamp

HIGH fixes:
- Replace remove_all_actions() with targeted hook removal (breaks WP isolation)
- Prefer wp-config.php constant for encryption key storage
- Add revocation check before generating certificate download URLs
- Fix security headers condition to apply to AJAX requests
- Add zoho-config.php to .gitignore

MEDIUM fixes:
- IP spoofing: only trust proxy headers when behind configured trusted proxies
- Remove unsafe-eval from CSP (keep unsafe-inline for compatibility)
- Remove duplicate Master Trainer component initialization
- Remove file-scope side-effect initialization in profile manager
- Use WordPress current_time() for consistent timezone in cert numbers

Validated as non-issues:
- Path traversal (token-based system prevents)
- SQL injection (proper $wpdb->prepare throughout)
- OAuth CSRF (correctly implemented with hash_equals)

All 7 modified PHP files pass syntax validation (php -l).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:06:43 -04:00
27 changed files with 9161 additions and 190 deletions

3
.gitignore vendored
View file

@ -30,6 +30,7 @@
/includes/*
!/includes/admin/
!/includes/zoho/
!/includes/find-training/
!/includes/**/*.php
!/templates/
/templates/*
@ -186,7 +187,7 @@
# **/.env.*
.auth/
# **/.auth/
# **/zoho-config.php
**/zoho-config.php
# **/wp-config.php
# **/wp-tests-config*.php
memory-bank/mcpServers.md

View file

@ -0,0 +1,260 @@
# Multi-Agent, Multi-Model Code Review Report
**Plugin:** HVAC Community Events WordPress Plugin
**Date:** 2026-01-31
**Models Used:** GPT-5 (Codex), Gemini 3, Kimi K2.5, Zen MCP (secaudit, analyze, codereview)
**Files Reviewed:** 11 critical components (~9,000 lines)
---
## Executive Summary
**Overall Security Posture: MODERATE**
The HVAC Community Events plugin demonstrates solid security foundations with centralized security helpers, proper nonce verification, prepared SQL statements, and well-designed certificate download security. However, several issues require attention, particularly in sensitive data handling and cryptographic key management.
**Context:** This is a small local WordPress environment with a single user. Security and scalability concerns are appropriately deprioritized in favor of functionality and maintainability. Findings are rated with this context in mind.
---
## Findings by Consensus Level
### CONSENSUS (3+ Tools Agree) - Highest Priority
| ID | Severity | Issue | Location | Tools |
|----|----------|-------|----------|-------|
| C1 | **CRITICAL** | Plaintext passwords stored in transients | `class-hvac-registration.php:98,196-199` | GPT-5, Zen OWASP, Zen Synthesis |
| C2 | **HIGH** | Encryption key stored in database | `class-hvac-secure-storage.php:30-40` | Kimi K2.5, Zen OWASP, Zen Synthesis |
| C3 | **HIGH** | IP spoofing undermines rate limiting | `class-hvac-security.php:186-198` | Gemini 3, Zen OWASP, Zen Synthesis |
| C4 | **MEDIUM** | Singleton API inconsistency | `class-hvac-plugin.php`, multiple | GPT-5, Zen Architecture, Zen Synthesis |
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php:724-735,900-915` | GPT-5, Zen Architecture |
### MAJORITY (2 Tools Agree) - High Priority
| ID | Severity | Issue | Location | Tools | Dissent |
|----|----------|-------|----------|-------|---------|
| M1 | **HIGH** | Weak CSP (unsafe-inline/unsafe-eval) | `class-hvac-ajax-security.php:88-97` | Gemini 3, Zen OWASP | Zen Synthesis: defer |
| M2 | **HIGH** | OAuth refresh token race condition | `class-zoho-crm-auth.php:156-163` | Kimi K2.5, Zen OWASP | Low risk for local |
| M3 | **MEDIUM** | Revoked certificates still downloadable | `class-certificate-manager.php:843-855` | Kimi K2.5, Zen Synthesis | |
| M4 | **MEDIUM** | Non-atomic Zoho sync operations | `class-zoho-sync.php` | Gemini 3, Zen Architecture | Self-healing |
| M5 | **MEDIUM** | Certificate number race condition | `class-certificate-manager.php:61-74` | Kimi K2.5, Zen Architecture | Very low risk |
### UNIQUE INSIGHTS - Model-Specific Findings
| ID | Severity | Issue | Location | Model |
|----|----------|-------|----------|-------|
| U1 | **CRITICAL** | O(expiry) token verification loop (DoS) | `class-hvac-ajax-security.php:498-513` | Zen Synthesis |
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php:1047-1054` | Zen Architecture |
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php:85-103` | Zen Synthesis |
| U4 | **HIGH** | Config file fallback credential exposure | `zoho-config.php` | Kimi K2.5 |
| U5 | **MEDIUM** | AES-256-CBC without MAC (no integrity) | `class-hvac-secure-storage.php:54-92` | Zen OWASP |
| U6 | **MEDIUM** | Audit log injection via filenames | `class-hvac-security-helpers.php:571` | Gemini 3 |
| U7 | **MEDIUM** | Roles treated as capabilities | `class-hvac-ajax-handlers.php:76-81` | GPT-5 |
| U8 | **MEDIUM** | Log sanitization regex gaps | `class-zoho-crm-auth.php:504-520` | Kimi K2.5 |
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php:1235` | Zen Architecture |
| U10 | **LOW** | PII (email) written to error logs | `class-hvac-ajax-handlers.php:1016-1026` | Zen OWASP |
| U11 | **LOW** | Timezone inconsistency at year boundary | `class-certificate-manager.php:70` | Kimi K2.5 |
### VALIDATED AS NON-ISSUES
| Original Concern | Status | Reason |
|------------------|--------|--------|
| Path traversal in certificate downloads | **NOT EXPLOITABLE** | Token-based system stores file_path in transients, not user input |
| SQL injection in `compile_trainer_stats()` | **SECURE** | Proper `$wpdb->prepare()` usage confirmed |
| OAuth state CSRF protection | **PROPERLY IMPLEMENTED** | Uses `hash_equals()` with single-use tokens |
---
## OWASP Top 10 Mapping
| Category | Status | Key Issues |
|----------|--------|------------|
| A01 - Broken Access Control | SECURE | Minor: roles as capabilities |
| A02 - Cryptographic Failures | **VULNERABLE** | C1 (passwords), C2 (key storage), U5 (no MAC) |
| A03 - Injection | SECURE | Prepared statements throughout |
| A04 - Insecure Design | Minor | U1 (O(n) loop) |
| A05 - Security Misconfiguration | **VULNERABLE** | M1 (weak CSP) |
| A06 - Vulnerable Components | Not Assessed | |
| A07 - Authentication Failures | **VULNERABLE** | C3 (IP spoofing) |
| A08 - Software/Data Integrity | Minor | M2 (token race) |
| A09 - Logging Failures | Minor | U6 (log injection), U10 (PII in logs) |
| A10 - SSRF | SECURE | |
---
## Positive Security Patterns Identified
All models identified strong security practices:
1. **Centralized AJAX Security** - `HVAC_Ajax_Security::verify_ajax_request()` enforces login + nonce + capability
2. **Consistent Input Sanitization** - `sanitize_text_field()`, `absint()`, `wp_kses_post()` throughout
3. **Prepared SQL Statements** - `$wpdb->prepare()` with proper placeholders
4. **Certificate Download Security** - Time-limited, random tokens with one-time-use
5. **OAuth CSRF Protection** - Timing-safe `hash_equals()` comparison
6. **Comprehensive Audit Logging** - Security events logged with 30-day retention
7. **WordPress Native Password Functions** - No custom password hashing
8. **Modern PHP 8+ Features** - Strict types, generators, match expressions
---
## Recommended Action Plan
### IMMEDIATE (This Session) - Critical Impact - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 1 | C1 | Strip password fields before transient storage | `class-hvac-registration.php` | ✅ FIXED |
| 2 | U1 | Rewrite token verification to O(1) | `class-hvac-ajax-security.php` | ✅ FIXED |
| 3 | U2 | Replace `remove_all_actions()` with targeted removal | `class-hvac-plugin.php` | ✅ FIXED |
### SHORT-TERM (Before Production) - High Impact - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 4 | C2 | Move encryption key to wp-config.php constant | `class-hvac-secure-storage.php` | ✅ FIXED |
| 5 | M3 | Add revoked check to `get_certificate_url()` | `class-certificate-manager.php` | ✅ FIXED |
| 6 | U3 | Fix security headers condition for AJAX | `class-hvac-ajax-security.php` | ✅ FIXED |
| 7 | U4 | Ensure `zoho-config.php` is in .gitignore | `.gitignore` | ✅ FIXED |
### MEDIUM-TERM - Incremental Improvement - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 8 | C3 | Fix IP spoofing with trusted proxy validation | `class-hvac-security.php` | ✅ FIXED |
| 9 | C5 | Remove duplicate init from `initializeFindTrainer()` | `class-hvac-plugin.php` | ✅ FIXED |
| 10 | U9 | Remove file-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` | ✅ FIXED |
| 11 | M1 | Remove `unsafe-eval` from CSP | `class-hvac-ajax-security.php` | ✅ FIXED |
| 12 | U11 | Fix timezone inconsistency in certificate numbers | `class-certificate-manager.php` | ✅ FIXED |
### DEFERRED (Low Priority for Local Environment)
- U5: Upgrade to AES-GCM authenticated encryption
- M2: OAuth refresh token locking mechanism
- M4: Transaction wrapper for sync operations
- M5: Atomic certificate number generation
- C4: Standardize singleton API to `::instance()` (code style only)
- U6: Audit log injection via filenames
- U7: Roles treated as capabilities
- U8: Log sanitization regex gaps
- U10: PII (email) written to error logs
---
## Code Fixes Reference
### Fix C1: Password Transient Storage
```php
// File: class-hvac-registration.php, around line 98
$submitted_data = $_POST;
// ADD THESE LINES:
unset(
$submitted_data['user_pass'],
$submitted_data['confirm_password'],
$submitted_data['current_password'],
$submitted_data['new_password'],
$submitted_data['hvac_registration_nonce']
);
```
### Fix U1: O(1) Token Verification
```php
// File: class-hvac-ajax-security.php, replace lines 498-513
public static function generate_secure_token($action, $user_id) {
$ts = time();
$salt = wp_salt('auth');
$sig = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, $salt);
return $ts . '.' . $sig;
}
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
$parts = explode('.', $token, 2);
if (count($parts) !== 2) return false;
[$ts, $sig] = $parts;
$ts = (int) $ts;
if (!$ts || empty($sig) || (time() - $ts) > $expiry) {
return false;
}
$expected = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, wp_salt('auth'));
return hash_equals($expected, $sig);
}
```
### Fix U2: Targeted Hook Removal
```php
// File: class-hvac-plugin.php, around line 1052
// REPLACE:
remove_all_actions('template_redirect', 10);
// WITH:
// Remove only specific HVAC auth callbacks that block registration
remove_action('template_redirect', [$this, 'hvac_auth_redirect'], 10);
// Or use early-return guard in the auth callback itself
```
### Fix C2: Encryption Key in wp-config.php
```php
// Add to wp-config.php:
define('HVAC_ENCRYPTION_KEY', base64_encode(random_bytes(32)));
// Modify class-hvac-secure-storage.php lines 30-40:
private static function get_encryption_key() {
if (!defined('HVAC_ENCRYPTION_KEY')) {
// Fallback for migration - log warning
error_log('HVAC: HVAC_ENCRYPTION_KEY not defined in wp-config.php');
$key = get_option('hvac_encryption_key');
if (!$key) {
$key = base64_encode(random_bytes(32));
update_option('hvac_encryption_key', $key);
}
return base64_decode($key);
}
return base64_decode(HVAC_ENCRYPTION_KEY);
}
```
---
## Files Reviewed
### Security-Critical (6 files, ~4,000 lines)
| File | Lines | Focus |
|------|-------|-------|
| `class-hvac-security.php` | 234 | Nonce, rate limiting, IP detection |
| `class-hvac-ajax-security.php` | 517 | AJAX auth, audit logging, CSP |
| `class-hvac-ajax-handlers.php` | 1030 | AJAX endpoints, input validation |
| `class-hvac-security-helpers.php` | 644 | Sanitization, file upload validation |
| `class-hvac-registration.php` | ~1000 | User registration, file uploads |
| `class-zoho-crm-auth.php` | 574 | OAuth2 tokens, credential storage |
### Business Logic (5 files, ~5,000 lines)
| File | Lines | Focus |
|------|-------|-------|
| `class-hvac-plugin.php` | 1,457 | Main controller, 50+ components |
| `class-hvac-event-manager.php` | 1,057 | Event CRUD, TEC integration |
| `class-hvac-trainer-profile-manager.php` | 1,236 | Profiles, taxonomies |
| `class-zoho-sync.php` | ~1,400 | CRM sync, pagination, hashing |
| `class-certificate-manager.php` | 906 | Certificate generation, files |
---
## Review Methodology
1. **Phase 1 (Parallel):** Security review with GPT-5, Gemini 3, Kimi K2.5
2. **Phase 2 (Parallel):** Business logic review with GPT-5, Gemini 3, Kimi K2.5
3. **Phase 3 (Parallel):** Zen OWASP audit, Architecture analysis, Code review synthesis
4. **Phase 4:** Consolidation by consensus level
**Total Models:** 4 (GPT-5, Gemini 3, Kimi K2.5, Zen MCP)
**Total Agents:** 9 background agents
**Consensus Methodology:** Issues flagged by 3+ tools prioritized highest
---
*Report generated by multi-agent code review system*

621
Status.md
View file

@ -1,12 +1,623 @@
# HVAC Community Events - Project Status
**Last Updated:** January 9, 2026
**Current Session:** Master Trainer Profile Edit Enhancement - Complete
**Version:** 2.1.12 (Deployed to Production)
**Last Updated:** February 6, 2026
**Current Session:** Zoho CRM Sync Production Fix
**Version:** 2.2.11 (Deployed to Production)
---
## 🎯 CURRENT SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
## 🎯 NEXT SESSION - CAPTCHA IMPLEMENTATION
### Status: 📋 **PLANNED**
**Objective:** Add CAPTCHA to all user-facing forms to prevent spam and bot submissions.
**Forms to Update:**
- Training login form
- Trainer registration form
- Contact forms (trainer, venue)
- Any other public-facing forms
---
## 🎯 CURRENT SESSION - ZOHO CRM SYNC PRODUCTION FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - 64/64 Trainers Syncing to Zoho CRM**
**Objective:** Fix Zoho CRM user sync that was silently failing on production - 0/65 users syncing despite "connection successful" status.
### Issues Found & Fixed (iterative production debugging)
1. **Version Constant Mismatch** (`includes/class-hvac-plugin.php`)
- `HVAC_PLUGIN_VERSION` stuck at `'2.0.0'` while `HVAC_VERSION` was `'2.2.10'`
- Admin JS used `HVAC_PLUGIN_VERSION` for cache busting, so browser served old JS without reset handler
- Fix: Bumped both constants to `'2.2.11'`
2. **GET Request Search Criteria Ignored** (`includes/zoho/class-zoho-sync.php`)
- `sync_users()` passed search criteria as `$data` parameter to `make_api_request()`
- But `make_api_request()` ignores `$data` for GET requests (line 345 only includes body for POST/PUT/PATCH)
- Every Zoho contact search returned unfiltered results, causing all syncs to fail
- Fix: Moved criteria into URL query string: `/Contacts/search?criteria=(Email:equals:...)`
3. **Error Reporting Priority** (`includes/zoho/class-zoho-sync.php`)
- `validate_api_response()` checked generic `error` key before Zoho-specific `data[0]` errors
- Users saw "API error with status code 400" instead of actionable field-level errors
- Fix: Reordered to check Zoho `data[0]` errors first, added field-level detail (api_name, expected_data_type, info)
4. **Phone Field Validation** (`includes/zoho/class-zoho-sync.php`)
- Zoho rejects invalid phone formats with HTTP 400
- Fix: Strip non-digit chars, require 10+ digits, only include Phone field when valid
5. **Last_Name Required Field** (`includes/zoho/class-zoho-sync.php`)
- Zoho Contacts module requires Last_Name but some WP users had empty last names
- Fix: Fallback chain: `last_name` meta → `display_name``user_login`
6. **Spam Account Cleanup** (production WP-CLI)
- User 14 (`rigobertohugo19`, `info103@noreply0.com`) was a spam/bot registration with no name
- Demoted from `hvac_trainer` to `subscriber`
### Production Sync Results (iterative)
| Attempt | Synced | Failed | Issue |
|---------|--------|--------|-------|
| 1st (pre-fix) | 0 | 65 | Search criteria not sent to Zoho API |
| 2nd (search fix) | 63 | 2 | Generic "status code 400" errors |
| 3rd (error detail) | 0 | 2 | User 57: invalid Phone, User 14: missing Last_Name |
| 4th (data validation) | 31 | 1 | User 57 phone still failing (7-digit threshold too low) |
| 5th (10-digit threshold) | 26 | 0 | **All 65 trainers synced** |
| Final (User 14 demoted) | **64/64** | **0** | All active trainers syncing |
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-plugin.php` | Synced `HVAC_PLUGIN_VERSION` to `'2.2.11'` (was stuck at `'2.0.0'`) |
| `includes/zoho/class-zoho-sync.php` | Search criteria in URL, error priority fix, phone sanitization, Last_Name fallback |
### Git Commits
- `03b9bce5` - fix(zoho): Fix silent sync failures with API response validation and hash reset
- `4c22b9db` - fix(zoho): Fix user sync search criteria and improve data validation
---
## 📋 PREVIOUS SESSION - ZOHO CRM SYNC ARCHITECTURE FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Fix Zoho CRM integration that appeared connected but was silently failing to sync data (events, attendees, ticket sales not appearing in Zoho CRM).
### Root Cause Analysis (4-model consensus: GPT-5, Gemini 3, Zen Code Review, Zen Debug)
The sync pipeline had a "silent failure" architecture:
1. **Connection test only uses GET** - bypasses staging write block, giving false confidence
2. **Staging mode returned fake `'status' => 'success'`** for blocked writes - appeared successful
3. **Sync methods unconditionally updated `_zoho_sync_hash`** after any "sync" - poisoned hashes
4. **Subsequent syncs compared hashes, found matches, skipped all records** - permanent data loss
5. **No API response validation** - even real Zoho errors were silently ignored
### Fixes Implemented
1. **API Response Validation** (`includes/zoho/class-zoho-sync.php`)
- Added `validate_api_response()` helper method
- Checks for WP_Error, staging mode blocks, HTTP errors, Zoho error codes
- Confirms success with ID extraction before treating a sync as successful
2. **Hash-Only-On-Success** (`includes/zoho/class-zoho-sync.php`)
- All 5 sync methods (events, users, attendees, rsvps, purchases) rewritten
- `_zoho_sync_hash` only updates when Zoho API confirms the write succeeded
- Failed syncs increment `$results['failed']` with error details
- Changed `catch (Exception)` to `catch (\Throwable)` for comprehensive error handling
3. **Staging Mode Detection Fix** (`includes/zoho/class-zoho-crm-auth.php`)
- Replaced fragile `strpos()` substring matching with `wp_parse_url()` hostname comparison
- Production (`upskillhvac.com` / `www.upskillhvac.com`) explicitly whitelisted
- All other hostnames default to staging mode
- `HVAC_ZOHO_PRODUCTION_MODE` / `HVAC_ZOHO_STAGING_MODE` constants can override
- Staging fake responses now return `'skipped_staging'` instead of misleading `'success'`
4. **Admin UI: Hash Reset & Staging Warning** (`includes/admin/class-zoho-admin.php`, `assets/js/zoho-admin.js`)
- Added `reset_sync_hashes()` AJAX handler - clears all `_zoho_sync_hash` from postmeta and usermeta
- Added "Force Full Re-sync (Reset Hashes)" button with confirmation dialog
- Connection test now surfaces staging warning when staging mode is active
5. **Staging Environment Fix** (staging `wp-config.php`)
- Removed `HVAC_ZOHO_PRODUCTION_MODE` constant from staging wp-config.php
- Staging now correctly blocks all Zoho write operations via hostname detection
- GET requests (reads) still pass through for testing
### Files Modified
| File | Change |
|------|--------|
| `includes/zoho/class-zoho-sync.php` | Added `validate_api_response()`, rewrote all 5 sync methods for validated hashing |
| `includes/zoho/class-zoho-crm-auth.php` | Rewrote `is_staging_mode()` with hostname parsing, changed fake response status |
| `includes/admin/class-zoho-admin.php` | Added `reset_sync_hashes()` handler, reset button HTML, staging warning in connection test |
| `assets/js/zoho-admin.js` | Added reset hashes button handler, staging warning display in connection test |
---
## 📋 PREVIOUS SESSION - NEAR ME BUTTON MOBILE FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging**
**Objective:** Fix mobile layout issue where the "Near Me" button caused the search bar to shrink when location was granted.
### Issues Found & Fixed
1. ✅ **CSS/HTML Button Structure Bug** (`assets/js/find-training-filters.js`)
- When the Near Me button state changed, the HTML was replaced without the `.hvac-btn-text` wrapper class
- On mobile, CSS hides `.hvac-btn-text`, but unwrapped text was visible causing layout issues
- Fixed in 5 locations: loading state, success state, error reset, clear filters, remove location filter
2. ✅ **No Feedback for Empty Results** (`assets/js/find-training-filters.js`)
- When "Near Me" filter returned no results within 100km, user saw empty map with no explanation
- Added notification: "No trainers, venues, or events found within 100km of your location. Try removing the 'Near Me' filter to see all results."
### Files Modified
| File | Change |
|------|--------|
| `assets/js/find-training-filters.js` | Fixed button HTML to preserve `.hvac-btn-text` wrapper; added empty results notification |
### Code Changes
**Button HTML Fix (5 locations):**
```javascript
// Before (broken on mobile):
$button.html('<span class="dashicons dashicons-yes-alt"></span> Near Me');
// After (correct):
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
```
**Empty Results Notification:**
```javascript
if (self.userLocation) {
const totalResults = HVACTrainingMap.trainers.length +
HVACTrainingMap.venues.length +
HVACTrainingMap.events.length;
if (totalResults === 0) {
self.showLocationError('No trainers, venues, or events found within 100km...');
}
}
```
### Mobile Testing Performed
- ✅ 375x812 (iPhone X) - Map and markers display correctly
- ✅ 320x568 (iPhone SE) - Map and markers display correctly
- ✅ Cluster markers expand on click
- ✅ Individual trainer markers clickable
- ✅ Info windows display correctly
- ✅ Profile modal opens and displays correctly
---
## 📋 PREVIOUS SESSION - CHAMPION DIFFERENTIATION ON FIND TRAINING (Feb 2, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Differentiate measureQuick Certified Champions from Trainers on the Find Training map. Champions do not offer public training, so they should be displayed differently.
### Changes Made
1. ✅ **Backend Data** (`includes/find-training/class-hvac-training-map-data.php`)
- Added `is_champion` flag for trainers with "Certified measureQuick Champion" certification
- Champions return empty `city` field (only state shown)
2. ✅ **Champion Marker Icon** (`assets/js/find-training-map.js`)
- Added `getChampionIcon()` method with white outline (vs green for Trainers)
- Champions use distinct visual appearance on map
3. ✅ **Sidebar Cards** (`assets/js/find-training-map.js`)
- Champions show only state (e.g., "Ohio" not "Canton, Ohio")
- Champions have non-clickable cards (no modal popup)
- Added `hvac-champion-card` CSS class
4. ✅ **Info Windows** (`assets/js/find-training-map.js`)
- Champions show only state in location
- No "View Profile" button for Champions
- Fixed location formatting to handle empty city gracefully
5. ✅ **Sorting** (`assets/js/find-training-map.js`)
- Champions sorted to end of trainer list
- Secondary sort by name for stable ordering
- Applied to both `loadMapData` and `loadTrainerDirectory`
6. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
- Champion cards have non-clickable appearance
- No hover effects (cursor: default, no transform/shadow)
### Files Modified
| File | Change |
|------|--------|
| `includes/find-training/class-hvac-training-map-data.php` | Added `is_champion` flag, empty city for champions |
| `assets/js/find-training-map.js` | Champion icon, card display, click prevention, sorting |
| `assets/css/find-training-map.css` | Champion card non-clickable styling |
### Verification (Staging)
- ✅ API returns `is_champion: true` for 18 champions
- ✅ Champions have empty city in API response
- ✅ Champions sorted to end of list
- ✅ Champion cards show only state
- ✅ Clicking champion card does NOT open modal
- ✅ Clicking trainer card DOES open modal (regression test)
### Code Review Findings (Gemini 3)
- **Fixed:** Location formatting bug - empty city could show ", California"
- **Fixed:** Unstable sort order - added secondary sort by name
---
## 📋 PREVIOUS SESSION - TABBED INTERFACE FOR FIND TRAINING (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Refactor the Find Training page sidebar from a single trainer list to a tabbed interface with Trainers, Venues, and Events tabs.
### Changes Made
1. ✅ **Tab Navigation System** (`templates/page-find-training.php`)
- Replaced "42 trainers" header with three tabs: Trainers | Venues | Events
- Each tab displays dynamic count in parentheses
- ARIA accessibility attributes (role="tablist", role="tab", aria-selected)
- Keyboard navigation (arrow keys, Home, End)
2. ✅ **Visibility Toggles Relocated**
- Moved from map overlay to sidebar header
- Colored dots (teal/orange/purple) match marker colors
- Only control map marker visibility, not tab content
3. ✅ **Venue Cards** (`assets/js/find-training-map.js`)
- Name with building icon
- City, State location
- "X upcoming events" count
4. ✅ **Event Cards** (`assets/js/find-training-map.js`)
- Date badge showing month/day
- Event title
- Venue name
- Cost display
- "Past" badge for past events
5. ✅ **Info Modal** (`templates/page-find-training.php`)
- "What is Upskill HVAC?" section
- "How to Use" instructions
- Map Legend (trainer/venue/event markers)
6. ✅ **Context-Aware Search** (`assets/js/find-training-filters.js`)
- Placeholder changes based on active tab
- Client-side filtering for instant results (150ms debounce)
- Falls back to server-side when filters active
7. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
- Tab navigation container and button styles
- Venue card and event card layouts
- Info button and modal styling
- Responsive adjustments for tablet/mobile
### Files Modified
| File | Change |
|------|--------|
| `templates/page-find-training.php` | Tab navigation, panels, info modal |
| `assets/js/find-training-map.js` | Tab switching, card creators, grid renderers |
| `assets/js/find-training-filters.js` | Context-aware search, client-side filtering |
| `assets/css/find-training-map.css` | ~300 lines new CSS for tabs, cards, modal |
| `includes/class-hvac-plugin.php` | Version bump 2.2.5 → 2.2.6 (CDN cache bust) |
### Issue Resolved: CDN Cache
**Problem:** After deployment, browser loaded old JavaScript without new `initTabs` method.
**Root Cause:** CDN cached old assets. Deploy script clears WP/OPcache but not CDN edge cache.
**Fix:** Bumped `HVAC_VERSION` from 2.2.5 to 2.2.6, changing script URL query string to force cache refresh.
### Verified on Staging
- ✅ 42 trainers tab count
- ✅ 9 venues tab count
- ✅ 8 events tab count
- ✅ Tab switching works with proper ARIA attributes
- ✅ Venue cards display correctly
- ✅ Event cards with date badges display correctly
- ✅ Info modal opens and displays content
- ✅ Visibility toggles control map markers
---
## 📋 PREVIOUS SESSION - MEASUREQUICK APPROVED TRAINING LABS (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging, Venues Displaying Correctly**
**Objective:** Transform /find-training to showcase only measureQuick Approved Training Labs with venue categories, equipment/amenities tags, and contact forms.
1. ✅ **Venue Taxonomies Created** (`includes/class-hvac-venue-categories.php` NEW)
- `venue_type` - For lab classification (e.g., "measureQuick Approved Training Lab")
- `venue_equipment` - Furnace, Heat Pump, AC, Mini-Split, Boiler, etc.
- `venue_amenities` - Coffee, Water, Projector, WiFi, Parking, etc.
- All taxonomies registered on `tribe_venue` post type
2. ✅ **9 Approved Training Labs Configured** (via WP-CLI script)
- Fast Track Learning Lab (ID: 6631) - Joe Medosch
- Progressive Training Lab (ID: 6284) - Samantha Brazie
- NAVAC Technical Training Center (ID: 6476) - Andrew Greaves
- Stevens Equipment Supply - Phoenix (ID: 6448) - Robert Cone
- San Jacinto College South Campus (ID: 6521) - Terry McWilliams
- Johnstone Supply - Live Fire Training Lab (ID: 4864) - Dave Petz
- Stevens Equipment Supply - Johnstown (ID: 1648) - Phil Sweren
- TruTech Tools Training Center (NEW) - Val Buckles
- Auer Steel & Heating Supply (NEW) - Mike Breen
3. ✅ **Map Data Filtered by Taxonomy**
- `get_venue_markers()` now includes `tax_query` for `mq-approved-lab` term
- Only approved training labs appear as venue markers
- Equipment, amenities, and POC data included in venue info
4. ✅ **Venue Modal Enhanced**
- Equipment badges (teal outline chips)
- Amenities badges (gray outline chips)
- Contact form with name, email, phone, company, message
- POC receives email notification on submission
5. ✅ **6 POC Trainer Accounts Created**
- Samantha Brazie, Andrew Greaves, Terry McWilliams
- Phil Sweren, Robert Cone, Dave Petz
- All with `hvac_trainer` role and approved status
### Files Created
| File | Description |
|------|-------------|
| `includes/class-hvac-venue-categories.php` | Venue taxonomy registration (singleton) |
| `scripts/setup-approved-labs.php` | WP-CLI script to configure all labs |
| `scripts/check-venue-coordinates.php` | Diagnostic script to verify venue coordinates |
| `scripts/geocode-approved-labs.php` | Geocode venues missing coordinates |
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-plugin.php` | Load venue categories class |
| `includes/find-training/class-hvac-training-map-data.php` | Add tax_query filter, include equipment/amenities |
| `includes/class-hvac-ajax-handlers.php` | Add venue contact form AJAX handler |
| `templates/page-find-training.php` | Add equipment/amenities badges, contact form |
| `assets/js/find-training-map.js` | Render badges, bind contact form handler |
| `assets/css/find-training-map.css` | Equipment/amenities badge styles |
### Issue Resolved: Venues Now Displaying on Map ✅
**Root Cause:** `venue_type` taxonomy wasn't being registered when `get_venue_markers()` ran.
`HVAC_Venue_Categories::instance()` was instantiated at 'init' priority 5, but then tried to add hooks to 'init' priority 5 for taxonomy registration. Since WordPress was already processing priority 5 handlers, the hook never fired.
**Fix Applied:**
1. **`includes/class-hvac-venue-categories.php`** - Added `did_action('init')` check in constructor
- If 'init' has already fired, call `register_taxonomies()` and `create_default_terms()` directly
- Otherwise, use the standard hook approach
2. **`includes/find-training/class-hvac-training-map-data.php`** - Fixed HTML entity encoding
- Venue names now use `html_entity_decode()` to properly display `&` and `` characters
- Event titles also fixed
**Result:**
- ✅ All 9 approved training labs now display as orange venue markers on the map
- ✅ Venue names display correctly (e.g., "Auer Steel & Heating Supply" not "Auer Steel &#038;...")
- ✅ Marker clustering works with mixed trainer/venue markers
---
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE ENHANCEMENTS (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Improve Find Training page UX with viewport sync and marker hover interactions.
### Changes Made
1. ✅ **Viewport Sync** - Sidebar now shows only trainers visible in current map area
- Added `visibleTrainers` array to track filtered trainers
- Added `syncSidebarWithViewport()` method filtering by map bounds
- Map `idle` event triggers sync on pan/zoom
- Count shows "X of Y trainers" when zoomed in
2. ✅ **Marker Hover Interaction** - Info window appears on hover
- Added `mouseover` event listener to trainer/venue markers
- Set `optimized: false` on markers for reliable hover events
- Hover shows info window preview with "View Profile" button
- Click on "View Profile" opens full modal with contact form
3. ✅ **Legacy URL Redirects**
- `/find-a-trainer/``/find-training/` (301 redirect)
- `/find-trainer/``/find-training/` (301 redirect)
- Removed old page from Page Manager
### Files Modified
| File | Change |
|------|--------|
| `assets/js/find-training-map.js` | Added viewport sync, hover events, optimized:false |
| `assets/js/find-training-filters.js` | Updated filter handler for visibleTrainers |
| `includes/class-hvac-route-manager.php` | Added legacy URL redirects |
| `includes/class-hvac-page-manager.php` | Removed find-a-trainer page definition |
| `includes/class-hvac-plugin.php` | Version bumped to 2.2.4 |
### Verified Behavior
- ✅ Hover over marker → Info window appears immediately
- ✅ Click "View Profile" → Full modal with trainer details + contact form
- ✅ Pan/zoom map → Sidebar updates to show visible trainers only
- ✅ Legacy URLs redirect to new page
---
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API.
### Why This Change
The existing IGM/amCharts implementation had a fundamental bug that corrupted marker coordinates (longitude gets overwritten with latitude). After multiple fix attempts, building fresh with Google Maps API provides:
- Full control over marker data
- No third-party plugin dependencies
- Better long-term maintainability
- Ability to show both trainers AND venues
### Files Created (8 new files)
| File | Description |
|------|-------------|
| `includes/find-training/class-hvac-find-training-page.php` | Main page handler (singleton), AJAX endpoints, asset enqueuing |
| `includes/find-training/class-hvac-training-map-data.php` | Data provider for trainer/venue markers with caching |
| `includes/find-training/class-hvac-venue-geocoding.php` | Auto-geocoding for TEC venues via Google API |
| `templates/page-find-training.php` | Page template with map, filters, modals |
| `assets/js/find-training-map.js` | Google Maps initialization, markers, clustering |
| `assets/js/find-training-filters.js` | Filter handling, geolocation, AJAX |
| `assets/css/find-training-map.css` | Complete responsive styling |
| `assets/images/marker-trainer.svg` | Teal person icon for trainers |
| `assets/images/marker-venue.svg` | Orange building icon for venues |
### Files Modified (3 files)
| File | Change |
|------|--------|
| `includes/class-hvac-page-manager.php` | Added find-training page definition |
| `includes/class-hvac-plugin.php` | Load new find-training classes |
| `includes/class-hvac-ajax-handlers.php` | Added contact form AJAX handler |
### Multi-Model Code Review Findings & Fixes
Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found and fixed 6 issues:
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| 1 | **CRITICAL** | Missing `hvac_submit_contact_form` AJAX handler | Added full handler with rate limiting, validation, email |
| 2 | **HIGH** | XSS risk in InfoWindow onclick handlers | Replaced with DOM creation + addEventListener |
| 3 | **MEDIUM** | Uncached filter dropdown SQL queries | Added wp_cache with 1-hour TTL |
| 4 | **MEDIUM** | AJAX race condition on rapid filters | Added request abort handling |
| 5 | **LOW** | Hardcoded `/trainer/registration/` URL | Changed to `site_url()` |
| 6 | **LOW** | `alert()` for geolocation errors | Added inline dismissible notification |
### Features Implemented
- ✅ Google Maps with custom trainer/venue markers
- ✅ MarkerClusterer for dense areas
- ✅ Filter by State, Certification, Training Format
- ✅ Search by name/location
- ✅ "Near Me" geolocation button
- ✅ Trainer/Venue toggle switches
- ✅ Trainer profile modal with contact form
- ✅ Venue info modal with upcoming events
- ✅ Trainer directory grid below map
- ✅ 301 redirect from `/find-a-trainer` to `/find-training`
- ✅ Auto-geocoding for new venues
- ✅ Rate-limited batch geocoding for existing venues
### Deployment Status
- ✅ Deployed to staging
- ✅ Map loads with markers and clustering
- ✅ Filters working (state, certification, format)
- ✅ Contact form functional
- ✅ Deployed to production
---
## 📋 PREVIOUS SESSION - E2E TESTING & BUG FIXES (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging, Ready for Production**
**Objective:** Deploy security fixes to staging, run E2E tests, fix discovered bugs, and validate all functionality.
### Deployment & Testing Summary
1. ✅ **Deployed to Staging** - All 12 security fixes from previous session
2. ✅ **E2E Tests Passed** - Master trainer pages, security endpoints verified
3. ✅ **Discovered & Fixed 2 Critical Bugs** - Trainers table, event pages
4. ✅ **Created E2E Testing Skill** - `.claude/commands/e2e-visual-test.md`
5. ✅ **Added Staging Email Filter** - Prevents accidental user spam
### Bugs Found & Fixed
| Bug | Severity | Root Cause | Fix |
|-----|----------|------------|-----|
| **Trainers table empty** | HIGH | `ajax_filter_trainers()` used complex SQL that returned empty; `count_trainers_by_status()` only queried `hvac_trainer` role | Rewrote to use `get_trainers_table_data()` (same as working dashboard); fixed role query to include both roles |
| **Event pages blank** | HIGH | Template path mismatch: code referenced `page-trainer-event-manage.php` but file is `page-manage-event.php` | Fixed path in `class-hvac-event-manager.php:138` |
### Files Modified (4 files)
1. **`includes/class-hvac-master-trainers-overview.php`**
- Rewrote `ajax_filter_trainers()` to use reliable `get_trainers_table_data()`
- Fixed `count_trainers_by_status()` to include `hvac_master_trainer` role
- Now shows 53 trainers, 5 active (was showing 0)
2. **`includes/class-hvac-event-manager.php`**
- Fixed template path: `page-trainer-event-manage.php``page-manage-event.php`
- Event creation form now fully functional
3. **`hvac-community-events.php`**
- Added staging email filter (only `ben@tealmaker.com` receives emails)
- Protects real users from test emails during development
4. **`.claude/commands/e2e-visual-test.md`** (new)
- Created E2E visual testing skill for Playwright MCP browser tools
- Documents login procedure, test sequence, credentials
### E2E Test Results
| Feature | Status | Notes |
|---------|--------|-------|
| Master Trainer Login | ✅ PASS | Custom `/training-login/` works |
| Master Dashboard | ✅ PASS | Stats, tables, AJAX functional |
| Trainers Table | ✅ PASS | 53 trainers displayed correctly |
| Announcements | ✅ PASS | Modal opens, form accessible |
| Event Creation | ✅ PASS | Full form with all TEC fields |
| Certificate Reports | ✅ PASS | Empty state (needs events) |
| Security Endpoints | ✅ PASS | 4/4 properly return 401/400 |
### Staging Email Protection
Emails on staging are now filtered:
- **Allowed:** `ben@tealmaker.com`, `ben@measurequick.com`
- **Blocked:** All other recipients (logged for debugging)
- **Subject Prefix:** `[STAGING]` added to allowed emails
### Next Steps
1. ⏳ Deploy to production: `./scripts/deploy.sh production`
2. ⏳ Verify production functionality
3. ⏳ Monitor for any issues
---
## 📋 PREVIOUS SESSION - MULTI-MODEL SECURITY CODE REVIEW (Jan 31, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging**
**Objective:** Comprehensive security and business logic code review using 4 AI models (GPT-5, Gemini 3, Kimi K2.5, Zen MCP) across 11 critical files (~9,000 lines).
### Critical Issues Found & Fixed (12 total)
| ID | Severity | Issue | File |
|----|----------|-------|------|
| C1 | **CRITICAL** | Passwords stored in transients | `class-hvac-registration.php` |
| U1 | **CRITICAL** | O(3600) token verification loop (DoS) | `class-hvac-ajax-security.php` |
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php` |
| C2 | **HIGH** | Encryption key in same database as data | `class-hvac-secure-storage.php` |
| M3 | **HIGH** | Revoked certificates still downloadable | `class-certificate-manager.php` |
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php` |
| C3 | **MEDIUM** | IP spoofing undermines rate limiting | `class-hvac-security.php` |
| M1 | **MEDIUM** | Weak CSP with `unsafe-eval` | `class-hvac-ajax-security.php` |
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php` |
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` |
| U11 | **LOW** | Timezone inconsistency in cert numbers | `class-certificate-manager.php` |
| U4 | **HIGH** | zoho-config.php not in .gitignore | `.gitignore` |
### Deliverables
- ✅ **Full Report:** `MULTI-MODEL-CODE-REVIEW-REPORT.md`
- ✅ **12 Security Fixes:** All implemented and deployed to staging
---
## 📋 PREVIOUS SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
@ -436,7 +1047,7 @@ DISPLAY=:1 HEADLESS=false node test-comprehensive-validation.js
### Production Environment
**URL:** https://upskillhvac.com
**Version:** 2.1.8 (latest)
**Version:** 2.2.11 (latest)
**Server:** Cloudways Shared VPS
---

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

View file

@ -0,0 +1,604 @@
/**
* Find Training Filters
*
* Handles filtering, searching, and geolocation functionality
* for the Find Training page.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
(function($) {
'use strict';
// Namespace for filters module
window.HVACTrainingFilters = {
// Debounce timer for search
searchTimer: null,
// Current AJAX request (for aborting)
currentRequest: null,
// Active filters
activeFilters: {
state: '',
certification: '',
training_format: '',
search: '',
include_past: false
},
// User location (if obtained)
userLocation: null,
/**
* Initialize filters
*/
init: function() {
this.bindEvents();
this.initMobileFilterToggle();
},
/**
* Bind event handlers
*/
bindEvents: function() {
const self = this;
// Search input with debounce - client-side filtering for instant results
$('#hvac-training-search').on('input', function() {
clearTimeout(self.searchTimer);
const value = $(this).val().toLowerCase().trim();
self.searchTimer = setTimeout(function() {
self.activeFilters.search = value;
// For empty search or server-side filters, use AJAX
if (!value || self.hasActiveServerFilters()) {
self.applyFilters();
} else {
// Client-side filtering for instant results
self.filterActiveTabList(value);
}
}, 150); // Faster for client-side
});
// State filter
$('#hvac-filter-state').on('change', function() {
self.activeFilters.state = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Certification filter
$('#hvac-filter-certification').on('change', function() {
self.activeFilters.certification = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Training format filter
$('#hvac-filter-format').on('change', function() {
self.activeFilters.training_format = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Include past events checkbox
$('#hvac-include-past').on('change', function() {
self.activeFilters.include_past = $(this).is(':checked');
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Mobile include past events checkbox
$('#hvac-include-past-mobile').on('change', function() {
const checked = $(this).is(':checked');
$('#hvac-include-past').prop('checked', checked);
self.activeFilters.include_past = checked;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Near Me button
$('#hvac-near-me-btn').on('click', function() {
self.handleNearMeClick($(this));
});
// Clear all filters
$('.hvac-clear-filters').on('click', function() {
self.clearAllFilters();
});
// Remove individual filter
$(document).on('click', '.hvac-active-filter button', function() {
const filterType = $(this).parent().data('filter');
self.removeFilter(filterType);
});
},
/**
* Apply current filters
*/
applyFilters: function() {
const self = this;
// Abort any pending request to prevent race conditions
if (this.currentRequest && this.currentRequest.readyState !== 4) {
this.currentRequest.abort();
}
// Build filter data
const filterData = {
action: 'hvac_filter_training_map',
nonce: hvacFindTraining.nonce,
state: this.activeFilters.state,
certification: this.activeFilters.certification,
training_format: this.activeFilters.training_format,
search: this.activeFilters.search,
show_trainers: $('#hvac-show-trainers').is(':checked'),
show_venues: $('#hvac-show-venues').is(':checked'),
show_events: $('#hvac-show-events').is(':checked'),
include_past: this.activeFilters.include_past
};
// Add user location if available
if (this.userLocation) {
filterData.lat = this.userLocation.lat;
filterData.lng = this.userLocation.lng;
filterData.radius = 100; // km
}
// Send filter request and store reference
this.currentRequest = $.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: filterData,
success: function(response) {
if (response.success) {
// Update map data
HVACTrainingMap.trainers = response.data.trainers || [];
HVACTrainingMap.venues = response.data.venues || [];
HVACTrainingMap.events = response.data.events || [];
// Reset visible arrays to all items
HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice();
HVACTrainingMap.visibleVenues = HVACTrainingMap.venues.slice();
HVACTrainingMap.visibleEvents = HVACTrainingMap.events.slice();
// Reset displayed counts
HVACTrainingMap.displayedCounts = { trainers: 0, venues: 0, events: 0 };
// Update map markers
HVACTrainingMap.updateMarkers();
// Update all counts
HVACTrainingMap.updateAllCounts();
// Render the active tab
HVACTrainingMap.renderActiveTabList();
// Show notification if "Near Me" filter returned no results
if (self.userLocation) {
const totalResults = HVACTrainingMap.trainers.length +
HVACTrainingMap.venues.length +
HVACTrainingMap.events.length;
if (totalResults === 0) {
self.showLocationError('No trainers, venues, or events found within 100km of your location. Try removing the "Near Me" filter to see all results.');
}
}
// Note: syncSidebarWithViewport will be called by map 'idle' event
}
},
complete: function() {
self.currentRequest = null;
}
});
// Show/hide clear button
this.updateClearButtonVisibility();
},
/**
* Handle Near Me button click
*/
handleNearMeClick: function($button) {
const self = this;
// Show loading state
$button.prop('disabled', true);
$button.html('<span class="dashicons dashicons-update-alt hvac-spin"></span><span class="hvac-btn-text">Locating...</span>');
// Clear any previous error message
this.clearLocationError();
// Get user location
HVACTrainingMap.getUserLocation(function(location, error) {
if (location) {
self.userLocation = location;
// Center map on user location
HVACTrainingMap.centerOnLocation(location.lat, location.lng, 9);
// Apply filters with location
self.applyFilters();
// Update button state
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
$button.addClass('active');
// Add to active filters display
self.addActiveFilter('location', 'Near Me');
} else {
// Show inline error instead of alert
self.showLocationError(error || 'Unable to get your location. Please check browser permissions.');
// Reset button
$button.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>');
$button.prop('disabled', false);
}
});
},
/**
* Show location error message inline
*/
showLocationError: function(message) {
// Remove any existing error
this.clearLocationError();
// Create error element
const $error = $('<div class="hvac-location-error">' +
'<span class="dashicons dashicons-warning"></span> ' +
this.escapeHtml(message) +
'<button type="button" class="hvac-dismiss-error" aria-label="Dismiss">&times;</button>' +
'</div>');
// Insert after Near Me button
$('#hvac-near-me-btn').after($error);
// Auto-dismiss after 5 seconds
setTimeout(function() {
$error.fadeOut(300, function() { $(this).remove(); });
}, 5000);
// Click to dismiss
$error.find('.hvac-dismiss-error').on('click', function() {
$error.remove();
});
},
/**
* Clear location error message
*/
clearLocationError: function() {
$('.hvac-location-error').remove();
},
/**
* Clear all filters
*/
clearAllFilters: function() {
// Reset filter values
this.activeFilters = {
state: '',
certification: '',
training_format: '',
search: '',
include_past: false
};
// Reset user location
this.userLocation = null;
// Reset form elements
$('#hvac-filter-state').val('');
$('#hvac-filter-certification').val('');
$('#hvac-filter-format').val('');
$('#hvac-training-search').val('');
$('#hvac-include-past').prop('checked', false);
$('#hvac-include-past-mobile').prop('checked', false);
// Reset Near Me button
$('#hvac-near-me-btn')
.removeClass('active')
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
.prop('disabled', false);
// Clear active filters display
$('.hvac-active-filters').empty();
// Hide clear button
$('.hvac-clear-filters').hide();
// Reset map to default view
HVACTrainingMap.map.setCenter(HVACTrainingMap.config.defaultCenter);
HVACTrainingMap.map.setZoom(HVACTrainingMap.config.defaultZoom);
// Reload data without filters
HVACTrainingMap.loadMapData();
},
/**
* Remove a specific filter
*/
removeFilter: function(filterType) {
switch (filterType) {
case 'state':
this.activeFilters.state = '';
$('#hvac-filter-state').val('');
break;
case 'certification':
this.activeFilters.certification = '';
$('#hvac-filter-certification').val('');
break;
case 'training_format':
this.activeFilters.training_format = '';
$('#hvac-filter-format').val('');
break;
case 'search':
this.activeFilters.search = '';
$('#hvac-training-search').val('');
break;
case 'location':
this.userLocation = null;
$('#hvac-near-me-btn')
.removeClass('active')
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
.prop('disabled', false);
break;
case 'include_past':
this.activeFilters.include_past = false;
$('#hvac-include-past').prop('checked', false);
$('#hvac-include-past-mobile').prop('checked', false);
break;
}
this.applyFilters();
this.updateActiveFiltersDisplay();
},
/**
* Update active filters display
*/
updateActiveFiltersDisplay: function() {
const $container = $('.hvac-active-filters');
$container.empty();
// State filter
if (this.activeFilters.state) {
this.addActiveFilter('state', `State: ${this.activeFilters.state}`);
}
// Certification filter
if (this.activeFilters.certification) {
this.addActiveFilter('certification', this.activeFilters.certification);
}
// Training format filter
if (this.activeFilters.training_format) {
this.addActiveFilter('training_format', this.activeFilters.training_format);
}
// Search filter
if (this.activeFilters.search) {
this.addActiveFilter('search', `"${this.activeFilters.search}"`);
}
// Location filter
if (this.userLocation) {
this.addActiveFilter('location', 'Near Me');
}
// Include past events filter
if (this.activeFilters.include_past) {
this.addActiveFilter('include_past', 'Including Past Events');
}
this.updateClearButtonVisibility();
},
/**
* Add an active filter chip
*/
addActiveFilter: function(type, label) {
const $container = $('.hvac-active-filters');
const $chip = $(`
<span class="hvac-active-filter" data-filter="${type}">
${this.escapeHtml(label)}
<button type="button" aria-label="Remove filter">&times;</button>
</span>
`);
$container.append($chip);
},
/**
* Update clear button visibility
*/
updateClearButtonVisibility: function() {
const hasFilters = this.activeFilters.state ||
this.activeFilters.certification ||
this.activeFilters.training_format ||
this.activeFilters.search ||
this.activeFilters.include_past ||
this.userLocation;
if (hasFilters) {
$('.hvac-clear-filters').show();
} else {
$('.hvac-clear-filters').hide();
}
},
/**
* Check if any server-side filters are active
*/
hasActiveServerFilters: function() {
return this.activeFilters.state ||
this.activeFilters.certification ||
this.activeFilters.training_format ||
this.activeFilters.include_past ||
this.userLocation;
},
/**
* Filter the active tab's list client-side for instant results
*/
filterActiveTabList: function(searchTerm) {
const activeTab = HVACTrainingMap.activeTab;
let items, filterFn;
switch (activeTab) {
case 'trainers':
items = HVACTrainingMap.visibleTrainers.length > 0
? HVACTrainingMap.trainers
: HVACTrainingMap.trainers;
filterFn = (trainer) => {
const searchFields = [
trainer.name,
trainer.city,
trainer.state,
trainer.company,
...(trainer.certifications || [])
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
case 'venues':
items = HVACTrainingMap.venues;
filterFn = (venue) => {
const searchFields = [
venue.name,
venue.city,
venue.state,
venue.address
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
case 'events':
items = HVACTrainingMap.events;
filterFn = (event) => {
const searchFields = [
event.title,
event.venue_name,
event.venue_city,
event.venue_state
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
default:
return;
}
// Apply filter
const filteredItems = searchTerm ? items.filter(filterFn) : items;
// Update the visible items for the active tab
switch (activeTab) {
case 'trainers':
HVACTrainingMap.visibleTrainers = filteredItems;
HVACTrainingMap.displayedCounts.trainers = 0;
HVACTrainingMap.updateTrainerGrid();
break;
case 'venues':
HVACTrainingMap.visibleVenues = filteredItems;
HVACTrainingMap.displayedCounts.venues = 0;
HVACTrainingMap.updateVenueGrid();
break;
case 'events':
HVACTrainingMap.visibleEvents = filteredItems;
HVACTrainingMap.displayedCounts.events = 0;
HVACTrainingMap.updateEventGrid();
break;
}
// Update all counts
HVACTrainingMap.updateAllCounts();
},
/**
* Escape HTML for safe output
*/
escapeHtml: function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Initialize mobile filter toggle
*/
initMobileFilterToggle: function() {
const self = this;
// Mobile filter panel toggle
$(document).on('click', '.hvac-mobile-filter-toggle', function() {
const $toggle = $(this);
const $panel = $('#hvac-mobile-filter-panel');
const isExpanded = $toggle.attr('aria-expanded') === 'true';
if (isExpanded) {
$panel.attr('hidden', '');
$toggle.attr('aria-expanded', 'false');
} else {
$panel.removeAttr('hidden');
$toggle.attr('aria-expanded', 'true');
}
});
// Sync mobile filter selects with desktop selects
$('#hvac-filter-state-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-state').val(value);
self.activeFilters.state = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-certification-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-certification').val(value);
self.activeFilters.certification = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-format-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-format').val(value);
self.activeFilters.training_format = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Also sync desktop to mobile when desktop changes
$('#hvac-filter-state').on('change', function() {
$('#hvac-filter-state-mobile').val($(this).val());
});
$('#hvac-filter-certification').on('change', function() {
$('#hvac-filter-certification-mobile').val($(this).val());
});
$('#hvac-filter-format').on('change', function() {
$('#hvac-filter-format-mobile').val($(this).val());
});
}
};
// Initialize when document is ready
$(document).ready(function() {
if ($('#hvac-training-map').length) {
HVACTrainingFilters.init();
}
});
})(jQuery);

File diff suppressed because it is too large Load diff

View file

@ -220,6 +220,13 @@ jQuery(document).ready(function ($) {
successHtml += '<p>Refresh Token: ❌ Missing (OAuth required)</p>';
}
// Show staging warning if present
if (response.data.staging_warning) {
successHtml += '<div style="margin-top: 10px; padding: 8px 12px; background: #fff8e5; border-left: 3px solid #ffb900;">';
successHtml += '<p style="margin: 0; color: #826200;"><strong>Warning:</strong> ' + response.data.staging_warning + '</p>';
successHtml += '</div>';
}
// Show debug info if available
if (response.data.debug) {
successHtml += '<details style="margin-top: 10px;">';
@ -622,6 +629,47 @@ jQuery(document).ready(function ($) {
});
});
// =====================================================
// Reset Sync Hashes Handler
// =====================================================
$('#reset-sync-hashes').on('click', function () {
if (!confirm('This will clear all sync hashes and force every record to re-sync on the next run. Continue?')) {
return;
}
var $button = $(this);
var $status = $('#reset-hashes-status');
$button.prop('disabled', true).text('Resetting...');
$status.text('');
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_reset_sync_hashes',
nonce: hvacZoho.nonce
},
success: function (response) {
if (response.success) {
$status.html(
'<span style="color: #46b450;">' +
response.data.posts_cleared + ' post hashes and ' +
response.data.users_cleared + ' user hashes cleared.</span>'
);
} else {
$status.html('<span style="color: #dc3232;">Error: ' + response.data.message + '</span>');
}
},
error: function () {
$status.html('<span style="color: #dc3232;">Network error - please try again</span>');
},
complete: function () {
$button.prop('disabled', false).text('Force Full Re-sync (Reset Hashes)');
}
});
});
// =====================================================
// Diagnostic Test Handler
// =====================================================

View file

@ -47,6 +47,8 @@ class HVAC_Zoho_Admin {
add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
// Add simple test handler
add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test'));
// Add hash reset handler
add_action('wp_ajax_hvac_zoho_reset_sync_hashes', array($this, 'reset_sync_hashes'));
// Add OAuth callback handler - only use one method to prevent duplicates
add_action('init', array($this, 'add_oauth_rewrite_rule'), 5);
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
@ -297,6 +299,13 @@ class HVAC_Zoho_Admin {
<div class="hvac-zoho-sync">
<h2>Data Sync</h2>
<div class="sync-maintenance" style="margin-bottom: 20px; padding: 12px 15px; background: #fff8e5; border-left: 4px solid #ffb900;">
<p style="margin: 0 0 8px 0;"><strong>Sync Maintenance</strong></p>
<p style="margin: 0 0 8px 0; font-size: 13px;">If records aren't syncing (e.g. after a failed sync or configuration change), reset sync hashes to force all records to re-sync on the next run.</p>
<button class="button" id="reset-sync-hashes">Force Full Re-sync (Reset Hashes)</button>
<span id="reset-hashes-status" style="margin-left: 10px;"></span>
</div>
<div class="sync-section">
<h3>Events Campaigns</h3>
<p>Sync events from The Events Calendar to Zoho CRM Campaigns</p>
@ -897,20 +906,28 @@ class HVAC_Zoho_Admin {
}
// Success!
// Success!
wp_send_json_success(array(
$mode_info = HVAC_Zoho_CRM_Auth::get_debug_mode_info();
$response_data = array(
'message' => 'Connection successful!',
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => true,
'is_staging' => $mode_info['is_staging'],
'mode_info' => $mode_info,
'credentials_status' => array(
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => true,
'api_working' => true
)
));
);
if ($mode_info['is_staging']) {
$response_data['staging_warning'] = 'WARNING: Staging mode is active. All write operations (sync) are blocked. Hostname: ' . ($mode_info['parsed_host'] ?? 'unknown');
}
wp_send_json_success($response_data);
} catch (Exception $e) {
$error_response = array(
'message' => 'Connection test failed due to exception',
@ -1072,5 +1089,42 @@ class HVAC_Zoho_Admin {
}
}
/**
* Reset all Zoho sync hashes to force a full re-sync
*/
public function reset_sync_hashes() {
check_ajax_referer('hvac_zoho_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized access'));
return;
}
global $wpdb;
// Delete all _zoho_sync_hash post meta
$posts_cleared = $wpdb->query(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_zoho_sync_hash'"
);
// Delete all _zoho_sync_hash user meta
$users_cleared = $wpdb->query(
"DELETE FROM {$wpdb->usermeta} WHERE meta_key = '_zoho_sync_hash'"
);
// Also clear last sync time so scheduled sync does a full run
delete_option('hvac_zoho_last_sync_time');
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info("Sync hashes reset: {$posts_cleared} post hashes, {$users_cleared} user hashes cleared", 'ZohoAdmin');
}
wp_send_json_success(array(
'message' => 'Sync hashes reset successfully',
'posts_cleared' => (int) $posts_cleared,
'users_cleared' => (int) $users_cleared,
));
}
}
?>

View file

@ -67,7 +67,10 @@ class HVAC_Certificate_Manager {
update_option('hvac_certificate_counter', $counter);
// Format: PREFIX-YEAR-SEQUENTIAL (e.g., HVAC-2023-00001)
$year = date('Y');
// FIX (U11): Use WordPress current_time() for consistent timezone handling
// Previous code used PHP date('Y') which uses server timezone, while
// date_generated uses WordPress timezone, causing year boundary inconsistencies
$year = date('Y', current_time('timestamp'));
$formatted_counter = str_pad($counter, 5, '0', STR_PAD_LEFT);
return $prefix . $year . '-' . $formatted_counter;
@ -836,11 +839,25 @@ class HVAC_Certificate_Manager {
/**
* Get certificate file URL.
*
* SECURITY FIX (M3): Check if certificate is revoked before generating URL.
* Revoked certificates should not be downloadable.
*
* @param int $certificate_id The certificate ID.
*
* @return string|false The file URL if found, false otherwise.
* @return string|false The file URL if found, false if not found or revoked.
*/
public function get_certificate_url($certificate_id) {
// Verify certificate exists and is not revoked
$certificate = $this->get_certificate($certificate_id);
if (!$certificate) {
return false;
}
// Check if certificate has been revoked
if (!empty($certificate->revoked)) {
return false;
}
// Create a secure URL with nonce for downloading
$url = add_query_arg(
array(

View file

@ -65,6 +65,14 @@ class HVAC_Ajax_Handlers {
// Password reset endpoint for master trainers
add_action('wp_ajax_hvac_send_password_reset', array($this, 'send_password_reset'));
add_action('wp_ajax_nopriv_hvac_send_password_reset', array($this, 'unauthorized_access'));
// Contact trainer form (Find Training page)
add_action('wp_ajax_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
add_action('wp_ajax_nopriv_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
// Contact venue form (Find Training page - Approved Labs)
add_action('wp_ajax_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
add_action('wp_ajax_nopriv_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
}
/**
@ -1024,6 +1032,343 @@ class HVAC_Ajax_Handlers {
wp_send_json_success('Password reset email sent to ' . $user->user_email);
}
/**
* Handle trainer contact form submission from Find Training page
*
* Sends an email to the trainer with the visitor's inquiry.
* Available to both logged-in and anonymous users.
*/
public function submit_trainer_contact_form() {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token'], 403);
return;
}
// Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip();
$rate_key = 'hvac_contact_rate_' . md5($ip);
$submissions = get_transient($rate_key) ?: 0;
if ($submissions >= 5) {
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
return;
}
// Validate required fields
$required_fields = ['first_name', 'last_name', 'email', 'trainer_id'];
foreach ($required_fields as $field) {
if (empty($_POST[$field])) {
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
return;
}
}
// Sanitize inputs
$first_name = sanitize_text_field($_POST['first_name']);
$last_name = sanitize_text_field($_POST['last_name']);
$email = sanitize_email($_POST['email']);
$phone = sanitize_text_field($_POST['phone'] ?? '');
$city = sanitize_text_field($_POST['city'] ?? '');
$state = sanitize_text_field($_POST['state_province'] ?? '');
$company = sanitize_text_field($_POST['company'] ?? '');
$message = sanitize_textarea_field($_POST['message'] ?? '');
$trainer_id = absint($_POST['trainer_id']);
$profile_id = absint($_POST['trainer_profile_id'] ?? 0);
// Validate email
if (!is_email($email)) {
wp_send_json_error(['message' => 'Invalid email address'], 400);
return;
}
// Get trainer data
$trainer = get_userdata($trainer_id);
if (!$trainer) {
wp_send_json_error(['message' => 'Trainer not found'], 404);
return;
}
// Get trainer's display name from profile if available
$trainer_name = $trainer->display_name;
if ($profile_id) {
$profile_name = get_post_meta($profile_id, 'trainer_display_name', true);
if ($profile_name) {
$trainer_name = $profile_name;
}
}
// Build email content
$subject = sprintf(
'[Upskill HVAC] Training Inquiry from %s %s',
$first_name,
$last_name
);
$body = sprintf(
"Hello %s,\n\n" .
"You have received a training inquiry through the Upskill HVAC directory.\n\n" .
"--- Contact Details ---\n" .
"Name: %s %s\n" .
"Email: %s\n" .
"%s" . // Phone (optional)
"%s" . // Location (optional)
"%s" . // Company (optional)
"\n--- Message ---\n%s\n\n" .
"---\n" .
"This message was sent via the Find Training page at %s\n" .
"Please respond directly to the sender's email address.\n",
$trainer_name,
$first_name,
$last_name,
$email,
$phone ? "Phone: {$phone}\n" : '',
($city || $state) ? "Location: " . trim("{$city}, {$state}", ', ') . "\n" : '',
$company ? "Company: {$company}\n" : '',
$message ?: '(No message provided)',
home_url('/find-training/')
);
// Email headers
$headers = [
'Content-Type: text/plain; charset=UTF-8',
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
];
// Send email to trainer
$sent = wp_mail($trainer->user_email, $subject, $body, $headers);
if (!$sent) {
// Log failure
if (class_exists('HVAC_Logger')) {
HVAC_Logger::error('Failed to send trainer contact email', 'AJAX', [
'trainer_id' => $trainer_id,
'sender_email' => $email
]);
}
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
return;
}
// Update rate limit
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
// Log success
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Trainer contact form submitted', 'AJAX', [
'trainer_id' => $trainer_id,
'sender_email' => $email,
'has_message' => !empty($message)
]);
}
// Store lead if training leads system exists
if (class_exists('HVAC_Training_Leads')) {
$leads = HVAC_Training_Leads::instance();
if (method_exists($leads, 'create_lead')) {
$leads->create_lead([
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
'phone' => $phone,
'city' => $city,
'state' => $state,
'company' => $company,
'message' => $message,
'trainer_id' => $trainer_id,
'source' => 'find_training_page'
]);
}
}
wp_send_json_success([
'message' => 'Your message has been sent to the trainer.',
'trainer_name' => $trainer_name
]);
}
/**
* Handle venue contact form submission from Find Training page
*
* Sends an email to the venue POC (Point of Contact) with the visitor's inquiry.
* Available to both logged-in and anonymous users.
*/
public function submit_venue_contact_form() {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token'], 403);
return;
}
// Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip();
$rate_key = 'hvac_venue_contact_rate_' . md5($ip);
$submissions = get_transient($rate_key) ?: 0;
if ($submissions >= 5) {
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
return;
}
// Validate required fields
$required_fields = ['first_name', 'last_name', 'email', 'venue_id'];
foreach ($required_fields as $field) {
if (empty($_POST[$field])) {
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
return;
}
}
// Sanitize inputs
$first_name = sanitize_text_field($_POST['first_name']);
$last_name = sanitize_text_field($_POST['last_name']);
$email = sanitize_email($_POST['email']);
$phone = sanitize_text_field($_POST['phone'] ?? '');
$company = sanitize_text_field($_POST['company'] ?? '');
$message = sanitize_textarea_field($_POST['message'] ?? '');
$venue_id = absint($_POST['venue_id']);
// Validate email
if (!is_email($email)) {
wp_send_json_error(['message' => 'Invalid email address'], 400);
return;
}
// Get venue data
$venue = get_post($venue_id);
if (!$venue || $venue->post_type !== 'tribe_venue') {
wp_send_json_error(['message' => 'Venue not found'], 404);
return;
}
$venue_name = $venue->post_title;
// Get POC information from venue meta
$poc_user_id = get_post_meta($venue_id, '_venue_poc_user_id', true);
$poc_email = get_post_meta($venue_id, '_venue_poc_email', true);
$poc_name = get_post_meta($venue_id, '_venue_poc_name', true);
// Fallback to post author if no POC meta
if (empty($poc_user_id)) {
$poc_user_id = $venue->post_author;
}
if (empty($poc_email)) {
$author = get_userdata($poc_user_id);
if ($author) {
$poc_email = $author->user_email;
$poc_name = $poc_name ?: $author->display_name;
}
}
if (empty($poc_email)) {
wp_send_json_error(['message' => 'Unable to find contact for this venue'], 500);
return;
}
// Build email content
$subject = sprintf(
'[Upskill HVAC] Training Lab Inquiry - %s',
$venue_name
);
$body = sprintf(
"Hello %s,\n\n" .
"You have received an inquiry about your training lab through the Upskill HVAC directory.\n\n" .
"--- Training Lab ---\n" .
"%s\n\n" .
"--- Contact Details ---\n" .
"Name: %s %s\n" .
"Email: %s\n" .
"%s" . // Phone (optional)
"%s" . // Company (optional)
"\n--- Message ---\n%s\n\n" .
"---\n" .
"This message was sent via the Find Training page at %s\n" .
"Please respond directly to the sender's email address.\n",
$poc_name ?: 'Training Lab Contact',
$venue_name,
$first_name,
$last_name,
$email,
$phone ? "Phone: {$phone}\n" : '',
$company ? "Company: {$company}\n" : '',
$message ?: '(No message provided)',
home_url('/find-training/')
);
// Email headers
$headers = [
'Content-Type: text/plain; charset=UTF-8',
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
];
// Send email to POC
$sent = wp_mail($poc_email, $subject, $body, $headers);
if (!$sent) {
// Log failure
if (class_exists('HVAC_Logger')) {
HVAC_Logger::error('Failed to send venue contact email', 'AJAX', [
'venue_id' => $venue_id,
'poc_email' => $poc_email,
'sender_email' => $email
]);
}
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
return;
}
// Update rate limit
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
// Log success
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Venue contact form submitted', 'AJAX', [
'venue_id' => $venue_id,
'venue_name' => $venue_name,
'poc_email' => $poc_email,
'sender_email' => $email,
'has_message' => !empty($message)
]);
}
// Store lead if training leads system exists
if (class_exists('HVAC_Training_Leads')) {
$leads = HVAC_Training_Leads::instance();
if (method_exists($leads, 'create_lead')) {
$leads->create_lead([
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
'phone' => $phone,
'company' => $company,
'message' => $message,
'venue_id' => $venue_id,
'venue_name' => $venue_name,
'source' => 'find_training_venue_contact'
]);
}
}
wp_send_json_success([
'message' => 'Your message has been sent to the training lab.',
'venue_name' => $venue_name
]);
}
/**
* Get client IP address safely
*
* @return string IP address
*/
private function get_client_ip(): string {
// Use REMOTE_ADDR only to prevent IP spoofing
return sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1');
}
}
// Initialize the handlers

View file

@ -81,11 +81,17 @@ class HVAC_Ajax_Security {
/**
* Send security headers
*
* SECURITY FIX (U3): Fixed condition to apply headers to AJAX requests.
* is_admin() returns true for admin-ajax.php, so previous condition never matched.
* Also removed 'unsafe-eval' from CSP (M1) - rarely needed and weakens security.
*/
public function send_security_headers() {
if (!is_admin() && wp_doing_ajax()) {
// Content Security Policy
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';");
// Apply to AJAX requests (wp_doing_ajax covers admin-ajax.php)
// Skip if headers already sent
if (wp_doing_ajax() && !headers_sent()) {
// Content Security Policy - removed unsafe-eval (M1 fix)
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';");
// Additional security headers
header('X-Content-Type-Options: nosniff');
@ -478,39 +484,60 @@ class HVAC_Ajax_Security {
*
* @param string $action Action identifier
* @param int $user_id User ID
* @return string Secure token
* @return string Secure token with embedded timestamp (format: timestamp.signature)
*/
public static function generate_secure_token($action, $user_id) {
$salt = wp_salt('auth');
$data = $action . '|' . $user_id . '|' . time();
return hash_hmac('sha256', $data, $salt);
$timestamp = time();
$data = $action . '|' . $user_id . '|' . $timestamp;
$signature = hash_hmac('sha256', $data, $salt);
// Return timestamp.signature format for O(1) verification
return $timestamp . '.' . $signature;
}
/**
* Verify secure token
* Verify secure token - O(1) complexity
*
* @param string $token Token to verify
* SECURITY FIX (U1): Rewritten to O(1) verification instead of O(expiry) loop.
* Previous implementation looped up to 3600 times computing HMACs, creating
* a DoS vulnerability. Now extracts timestamp from token for single HMAC check.
*
* @param string $token Token to verify (format: timestamp.signature)
* @param string $action Action identifier
* @param int $user_id User ID
* @param int $expiry Token expiry in seconds (default 1 hour)
* @return bool
*/
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
$salt = wp_salt('auth');
// Generate tokens for last $expiry seconds
$current_time = time();
for ($i = 0; $i <= $expiry; $i++) {
$data = $action . '|' . $user_id . '|' . ($current_time - $i);
$expected_token = hash_hmac('sha256', $data, $salt);
if (hash_equals($expected_token, $token)) {
return true;
}
}
// Parse token format: timestamp.signature
$parts = explode('.', $token, 2);
if (count($parts) !== 2) {
return false;
}
list($timestamp, $signature) = $parts;
$timestamp = (int) $timestamp;
// Validate timestamp exists and signature is not empty
if (!$timestamp || empty($signature)) {
return false;
}
// Check if token has expired
$current_time = time();
if (($current_time - $timestamp) > $expiry) {
return false;
}
// Single HMAC computation for verification
$salt = wp_salt('auth');
$data = $action . '|' . $user_id . '|' . $timestamp;
$expected_signature = hash_hmac('sha256', $data, $salt);
// Timing-safe comparison
return hash_equals($expected_signature, $signature);
}
}
// Initialize the security handler

View file

@ -31,9 +31,10 @@ class HVAC_Page_Manager {
'parent' => null,
'capability' => null
],
'find-a-trainer' => [
'title' => 'Find a Trainer',
'template' => 'page-find-trainer.php',
// Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager)
'find-training' => [
'title' => 'Find Training',
'template' => 'page-find-training.php',
'public' => true,
'parent' => null,
'capability' => null

View file

@ -112,10 +112,10 @@ final class HVAC_Plugin {
*/
private function defineConstants(): void {
if (!defined('HVAC_PLUGIN_VERSION')) {
define('HVAC_PLUGIN_VERSION', '2.0.0');
define('HVAC_PLUGIN_VERSION', '2.2.11');
}
if (!defined('HVAC_VERSION')) {
define('HVAC_VERSION', '2.1.7');
define('HVAC_VERSION', '2.2.11');
}
if (!defined('HVAC_PLUGIN_FILE')) {
define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php');
@ -176,6 +176,9 @@ final class HVAC_Plugin {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-browser-detection.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-find-trainer-assets.php';
// reCAPTCHA integration for contact forms
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-recaptcha.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-safari-debugger.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-shortcodes.php';
// DISABLED - Using TEC Community Events 5.x instead
@ -264,6 +267,16 @@ final class HVAC_Plugin {
'class-hvac-mapgeo-safety.php', // MapGeo safety wrapper
];
// Find Training feature files (Google Maps based - replaces MapGeo)
$findTrainingFiles = [
'find-training/class-hvac-find-training-page.php',
'find-training/class-hvac-training-map-data.php',
'find-training/class-hvac-venue-geocoding.php',
];
// Venue Categories (taxonomies for training labs)
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
// Load feature files with memory-efficient generator
foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) {
if ($status === 'loaded') {
@ -278,6 +291,13 @@ final class HVAC_Plugin {
}
}
// Load Find Training feature files (Google Maps based)
foreach ($this->loadFeatureFiles($findTrainingFiles) as $file => $status) {
if ($status === 'loaded') {
$this->componentStatus["find_training_{$file}"] = true;
}
}
// Load community system files
$communityFiles = [
'community/class-login-handler.php',
@ -579,6 +599,11 @@ final class HVAC_Plugin {
HVAC_Venues::instance();
}
// Initialize venue categories (taxonomies for training labs)
if (class_exists('HVAC_Venue_Categories')) {
HVAC_Venue_Categories::instance();
}
// Initialize trainer profile manager
if (class_exists('HVAC_Trainer_Profile_Manager')) {
HVAC_Trainer_Profile_Manager::get_instance();
@ -877,12 +902,12 @@ final class HVAC_Plugin {
* Loads trainer directory functionality with proper error handling.
*/
public function initializeFindTrainer(): void {
// Initialize Find a Trainer page
// Initialize Find a Trainer page (legacy MapGeo-based)
if (class_exists('HVAC_Find_Trainer_Page')) {
HVAC_Find_Trainer_Page::get_instance();
}
// Initialize MapGeo integration
// Initialize MapGeo integration (legacy)
if (class_exists('HVAC_MapGeo_Integration')) {
HVAC_MapGeo_Integration::get_instance();
}
@ -897,22 +922,31 @@ final class HVAC_Plugin {
HVAC_Trainer_Directory_Query::get_instance();
}
// Initialize master trainer manager components
if (class_exists('HVAC_Master_Trainers_Overview')) {
HVAC_Master_Trainers_Overview::instance();
// Initialize Find Training page (new Google Maps-based)
if (class_exists('HVAC_Find_Training_Page')) {
HVAC_Find_Training_Page::get_instance();
}
if (class_exists('HVAC_Announcements_Manager')) {
HVAC_Announcements_Manager::get_instance();
// Initialize Training Map Data provider
if (class_exists('HVAC_Training_Map_Data')) {
HVAC_Training_Map_Data::get_instance();
}
if (class_exists('HVAC_Master_Pending_Approvals')) {
HVAC_Master_Pending_Approvals::instance();
// Initialize Venue Geocoding service
if (class_exists('HVAC_Venue_Geocoding')) {
HVAC_Venue_Geocoding::get_instance();
}
if (class_exists('HVAC_Master_Events_Overview')) {
HVAC_Master_Events_Overview::instance();
}
// ARCHITECTURE FIX (C5): Master Trainer components are already initialized
// in initializeSecondaryComponents() at priority 5. Removed duplicate
// initialization here (priority 20) to prevent confusion and potential
// double hook registration issues.
//
// Components initialized in initializeSecondaryComponents():
// - HVAC_Master_Events_Overview
// - HVAC_Master_Pending_Approvals
// - HVAC_Master_Trainers_Overview
// - HVAC_Announcements_Display / HVAC_Announcements_Admin
// Fix master trainer pages if needed
if (class_exists('HVAC_Master_Pages_Fixer')) {
@ -1048,8 +1082,14 @@ final class HVAC_Plugin {
// If we're on the trainer registration page, don't apply any authentication checks
$current_path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
if ($current_path === 'trainer/registration' || is_page('registration') || is_page('trainer-registration')) {
// Remove any potential authentication hooks that might be added by other code
remove_all_actions('template_redirect', 10);
// SECURITY FIX (U2): Remove only HVAC-specific auth redirects, not ALL hooks
// Previous code used remove_all_actions() which broke WordPress core,
// theme, and other plugin functionality at priority 10
remove_action('template_redirect', array($this, 'restrict_trainer_pages'), 10);
remove_action('template_redirect', array($this, 'check_trainer_access'), 10);
// If other HVAC auth hooks are added in the future, remove them here specifically
// DO NOT use remove_all_actions() as it breaks WordPress isolation
}
}

View file

@ -189,11 +189,23 @@ class HVAC_Registration {
* @param string $redirect_url The URL to redirect back to.
*/
private function redirect_with_errors($errors, $data, $redirect_url) {
$transient_id = uniqid(); // Generate unique ID for transient key
$transient_id = bin2hex(random_bytes(16)); // Cryptographically secure token
$transient_key = self::TRANSIENT_PREFIX . $transient_id;
// SECURITY FIX (C1): Strip password fields before storing in transient
// Passwords should never be persisted to database, even temporarily
$safe_data = $data;
unset(
$safe_data['user_pass'],
$safe_data['confirm_password'],
$safe_data['current_password'],
$safe_data['new_password'],
$safe_data['hvac_registration_nonce']
);
$transient_data = [
'errors' => $errors,
'data' => $data, // Store submitted data to repopulate form
'data' => $safe_data, // Store submitted data to repopulate form (sans passwords)
];
// Store for 5 minutes
set_transient($transient_key, $transient_data, MINUTE_IN_SECONDS * 5);

View file

@ -81,7 +81,8 @@ class HVAC_Route_Manager {
'communication-templates' => 'trainer/communication-templates',
'communication-schedules' => 'trainer/communication-schedules',
'trainer-registration' => 'trainer/registration',
'find-trainer' => 'find-a-trainer', // Fix E2E testing URL mismatch
'find-trainer' => 'find-training', // Legacy URL redirect
'find-a-trainer' => 'find-training', // Old page redirect to new Google Maps page
);
// Parent pages that redirect to dashboards

View file

@ -25,9 +25,24 @@ class HVAC_Secure_Storage {
/**
* Get encryption key
*
* SECURITY FIX (C2): Prefer wp-config.php constant over database storage.
* Storing encryption key in the same database as encrypted data is "key under doormat".
* Define HVAC_ENCRYPTION_KEY in wp-config.php for proper key separation.
*
* @return string
*/
private static function get_encryption_key() {
// Prefer wp-config.php constant (recommended for production)
if (defined('HVAC_ENCRYPTION_KEY') && HVAC_ENCRYPTION_KEY) {
return base64_decode(HVAC_ENCRYPTION_KEY);
}
// Fallback to database storage (legacy/development)
// Log warning in debug mode to encourage migration
if (WP_DEBUG) {
error_log('HVAC Security Warning: HVAC_ENCRYPTION_KEY not defined in wp-config.php. Using database-stored key (less secure).');
}
$key = get_option('hvac_encryption_key');
if (!$key) {

View file

@ -181,17 +181,41 @@ class HVAC_Security {
/**
* Get user IP address
*
* SECURITY FIX (C3): Only trust proxy headers when behind a known trusted proxy.
* Previous implementation trusted user-controllable headers unconditionally,
* allowing attackers to spoof IPs and bypass rate limiting.
*
* For most deployments, use REMOTE_ADDR directly. Only trust X-Forwarded-For
* when behind Cloudflare, AWS ALB, or other known reverse proxies.
*
* @return string
*/
public static function get_user_ip() {
$ip = '';
// Primary: Use REMOTE_ADDR (cannot be spoofed at network level)
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = $_SERVER['REMOTE_ADDR'];
// Only trust proxy headers if HVAC_TRUSTED_PROXIES is defined
// Define as comma-separated list of trusted proxy IPs in wp-config.php
// Example: define('HVAC_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
if (defined('HVAC_TRUSTED_PROXIES') && HVAC_TRUSTED_PROXIES) {
$trusted_proxies = array_map('trim', explode(',', HVAC_TRUSTED_PROXIES));
if (in_array($ip, $trusted_proxies, true)) {
// Behind trusted proxy - check forwarded headers
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Take first IP (original client) from comma-separated list
$forwarded = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$client_ip = trim($forwarded[0]);
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
$ip = $client_ip;
}
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$client_ip = $_SERVER['HTTP_CLIENT_IP'];
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
$ip = $client_ip;
}
}
}
}
return sanitize_text_field($ip);

View file

@ -1232,5 +1232,9 @@ class HVAC_Trainer_Profile_Manager {
}
}
// Initialize the manager
HVAC_Trainer_Profile_Manager::get_instance();
// ARCHITECTURE FIX (U9): Removed file-scope side-effect initialization.
// Component initialization should be controlled by HVAC_Plugin orchestrator,
// not triggered automatically at file-include time. HVAC_Plugin already calls
// get_instance() at line 584 during initializeSecondaryComponents().
//
// Previous code: HVAC_Trainer_Profile_Manager::get_instance();

View file

@ -0,0 +1,282 @@
<?php
/**
* Venue Categories - Taxonomy Registration for Training Labs
*
* Registers custom taxonomies for TEC venues:
* - venue_type: Training lab types (e.g., "measureQuick Approved Training Lab")
* - venue_equipment: Equipment available at venues
* - venue_amenities: Amenities available at venues
*
* @package HVAC_Community_Events
* @since 2.3.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Venue_Categories
*
* Manages venue taxonomies for categorizing training labs.
*/
class HVAC_Venue_Categories {
/**
* Singleton instance
*
* @var HVAC_Venue_Categories|null
*/
private static ?self $instance = null;
/**
* Get singleton instance
*
* @return HVAC_Venue_Categories
*/
public static function instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
add_action('init', [$this, 'register_taxonomies'], 5);
add_action('init', [$this, 'create_default_terms'], 10);
}
/**
* Register venue taxonomies
*/
public function register_taxonomies(): void {
// Venue Type taxonomy (e.g., "measureQuick Approved Training Lab")
register_taxonomy('venue_type', 'tribe_venue', [
'labels' => [
'name' => __('Venue Types', 'hvac-community-events'),
'singular_name' => __('Venue Type', 'hvac-community-events'),
'search_items' => __('Search Venue Types', 'hvac-community-events'),
'all_items' => __('All Venue Types', 'hvac-community-events'),
'parent_item' => __('Parent Venue Type', 'hvac-community-events'),
'parent_item_colon' => __('Parent Venue Type:', 'hvac-community-events'),
'edit_item' => __('Edit Venue Type', 'hvac-community-events'),
'update_item' => __('Update Venue Type', 'hvac-community-events'),
'add_new_item' => __('Add New Venue Type', 'hvac-community-events'),
'new_item_name' => __('New Venue Type Name', 'hvac-community-events'),
'menu_name' => __('Venue Types', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-type'],
]);
// Venue Equipment taxonomy
register_taxonomy('venue_equipment', 'tribe_venue', [
'labels' => [
'name' => __('Equipment', 'hvac-community-events'),
'singular_name' => __('Equipment', 'hvac-community-events'),
'search_items' => __('Search Equipment', 'hvac-community-events'),
'all_items' => __('All Equipment', 'hvac-community-events'),
'parent_item' => __('Parent Equipment', 'hvac-community-events'),
'parent_item_colon' => __('Parent Equipment:', 'hvac-community-events'),
'edit_item' => __('Edit Equipment', 'hvac-community-events'),
'update_item' => __('Update Equipment', 'hvac-community-events'),
'add_new_item' => __('Add New Equipment', 'hvac-community-events'),
'new_item_name' => __('New Equipment Name', 'hvac-community-events'),
'menu_name' => __('Equipment', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-equipment'],
]);
// Venue Amenities taxonomy
register_taxonomy('venue_amenities', 'tribe_venue', [
'labels' => [
'name' => __('Amenities', 'hvac-community-events'),
'singular_name' => __('Amenity', 'hvac-community-events'),
'search_items' => __('Search Amenities', 'hvac-community-events'),
'all_items' => __('All Amenities', 'hvac-community-events'),
'parent_item' => __('Parent Amenity', 'hvac-community-events'),
'parent_item_colon' => __('Parent Amenity:', 'hvac-community-events'),
'edit_item' => __('Edit Amenity', 'hvac-community-events'),
'update_item' => __('Update Amenity', 'hvac-community-events'),
'add_new_item' => __('Add New Amenity', 'hvac-community-events'),
'new_item_name' => __('New Amenity Name', 'hvac-community-events'),
'menu_name' => __('Amenities', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-amenities'],
]);
}
/**
* Create default taxonomy terms
*/
public function create_default_terms(): void {
// Only run once
if (get_option('hvac_venue_categories_initialized')) {
return;
}
// Venue Types
$venue_types = [
'measureQuick Approved Training Lab' => 'mq-approved-lab',
];
foreach ($venue_types as $name => $slug) {
if (!term_exists($slug, 'venue_type')) {
wp_insert_term($name, 'venue_type', ['slug' => $slug]);
}
}
// Equipment
$equipment = [
'Furnace',
'Heat Pump',
'Air Conditioner',
'Mini-Split',
'Boiler',
'Gas Meter',
'TrueFlow Grid',
'Flow Hood',
];
foreach ($equipment as $name) {
$slug = sanitize_title($name);
if (!term_exists($slug, 'venue_equipment')) {
wp_insert_term($name, 'venue_equipment', ['slug' => $slug]);
}
}
// Amenities
$amenities = [
'Coffee',
'Water',
'Tea',
'Soda',
'Snacks',
'Projector',
'Whiteboard',
'WiFi',
'Parking',
'Vending Machines',
];
foreach ($amenities as $name) {
$slug = sanitize_title($name);
if (!term_exists($slug, 'venue_amenities')) {
wp_insert_term($name, 'venue_amenities', ['slug' => $slug]);
}
}
update_option('hvac_venue_categories_initialized', true);
}
/**
* Get venues by type
*
* @param string $type_slug Type slug (e.g., 'mq-approved-lab')
* @return array Venue IDs
*/
public function get_venues_by_type(string $type_slug): array {
$query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'fields' => 'ids',
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => $type_slug,
],
],
]);
return $query->posts;
}
/**
* Check if venue is an approved training lab
*
* @param int $venue_id Venue post ID
* @return bool
*/
public function is_approved_training_lab(int $venue_id): bool {
return has_term('mq-approved-lab', 'venue_type', $venue_id);
}
/**
* Get venue equipment
*
* @param int $venue_id Venue post ID
* @return array Equipment names
*/
public function get_venue_equipment(int $venue_id): array {
$terms = wp_get_post_terms($venue_id, 'venue_equipment', ['fields' => 'names']);
return is_wp_error($terms) ? [] : $terms;
}
/**
* Get venue amenities
*
* @param int $venue_id Venue post ID
* @return array Amenity names
*/
public function get_venue_amenities(int $venue_id): array {
$terms = wp_get_post_terms($venue_id, 'venue_amenities', ['fields' => 'names']);
return is_wp_error($terms) ? [] : $terms;
}
/**
* Set venue as approved training lab
*
* @param int $venue_id Venue post ID
* @return bool|WP_Error
*/
public function set_as_approved_lab(int $venue_id) {
return wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false);
}
/**
* Set venue equipment
*
* @param int $venue_id Venue post ID
* @param array $equipment Equipment slugs or names
* @return bool|WP_Error
*/
public function set_venue_equipment(int $venue_id, array $equipment) {
return wp_set_post_terms($venue_id, $equipment, 'venue_equipment', false);
}
/**
* Set venue amenities
*
* @param int $venue_id Venue post ID
* @param array $amenities Amenity slugs or names
* @return bool|WP_Error
*/
public function set_venue_amenities(int $venue_id, array $amenities) {
return wp_set_post_terms($venue_id, $amenities, 'venue_amenities', false);
}
}

View file

@ -0,0 +1,517 @@
<?php
/**
* Find Training Page Handler
*
* Manages the Find Training page with Google Maps integration
* showing trainers and training venues on an interactive map.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Find_Training_Page
*
* Main controller for the Find Training page functionality.
* Uses singleton pattern consistent with other HVAC plugin classes.
*/
class HVAC_Find_Training_Page {
/**
* Singleton instance
*
* @var HVAC_Find_Training_Page|null
*/
private static ?self $instance = null;
/**
* Page slug
*
* @var string
*/
private string $page_slug = 'find-training';
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Get singleton instance
*
* @return HVAC_Find_Training_Page
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load Google Maps API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
}
/**
* Initialize WordPress hooks
*/
private function init_hooks(): void {
// Page registration
add_action('init', [$this, 'register_page'], 15);
// Asset enqueuing
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
// Body classes
add_filter('body_class', [$this, 'add_body_classes']);
// AJAX handlers
add_action('wp_ajax_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_nopriv_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_nopriv_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_nopriv_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
add_action('wp_ajax_nopriv_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
// Redirect from old page
add_action('template_redirect', [$this, 'maybe_redirect_from_old_page']);
}
/**
* Register the Find Training page
*/
public function register_page(): void {
$page = get_page_by_path($this->page_slug);
if (!$page) {
$this->create_page();
}
}
/**
* Create the Find Training page in WordPress
*/
private function create_page(): void {
$page_data = [
'post_title' => 'Find Training',
'post_name' => $this->page_slug,
'post_content' => '<!-- This page uses a custom template -->',
'post_status' => 'publish',
'post_type' => 'page',
'post_author' => 1,
'comment_status' => 'closed',
'ping_status' => 'closed',
'meta_input' => [
'_wp_page_template' => 'page-find-training.php',
'ast-site-content-layout' => 'page-builder',
'site-post-title' => 'disabled',
'site-sidebar-layout' => 'no-sidebar',
'theme-transparent-header-meta' => 'disabled'
]
];
$page_id = wp_insert_post($page_data);
if ($page_id && !is_wp_error($page_id)) {
update_option('hvac_find_training_page_id', $page_id);
}
}
/**
* Check if current page is the Find Training page
*
* @return bool
*/
public function is_find_training_page(): bool {
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id'));
}
/**
* Enqueue page assets
*/
public function enqueue_assets(): void {
if (!$this->is_find_training_page()) {
return;
}
// Enqueue CSS
wp_enqueue_style(
'hvac-find-training',
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
['astra-theme-css'],
HVAC_VERSION
);
// Enqueue Google Maps API with MarkerClusterer
if (!empty($this->api_key)) {
wp_enqueue_script(
'google-maps-api',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
[],
null,
true
);
// MarkerClusterer library
wp_enqueue_script(
'google-maps-markerclusterer',
'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js',
['google-maps-api'],
'2.5.3',
true
);
}
// Enqueue main map JavaScript
wp_enqueue_script(
'hvac-find-training-map',
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
['jquery', 'google-maps-api', 'google-maps-markerclusterer'],
HVAC_VERSION,
true
);
// Enqueue filter JavaScript
wp_enqueue_script(
'hvac-find-training-filters',
HVAC_PLUGIN_URL . 'assets/js/find-training-filters.js',
['jquery', 'hvac-find-training-map'],
HVAC_VERSION,
true
);
// Localize script with data
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_find_training'),
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key
'map_center' => [
'lat' => 39.8283, // US center
'lng' => -98.5795
],
'default_zoom' => 4,
'cluster_zoom' => 8,
'messages' => [
'loading' => __('Loading...', 'hvac-community-events'),
'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events')
],
'marker_icons' => [
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg'
]
]);
}
/**
* Add body classes for the page
*
* @param array $classes Existing body classes
* @return array Modified body classes
*/
public function add_body_classes(array $classes): array {
if ($this->is_find_training_page()) {
$classes[] = 'hvac-find-training-page';
$classes[] = 'hvac-full-width';
$classes[] = 'hvac-page';
}
return $classes;
}
/**
* Redirect from old /find-a-trainer page to /find-training
*/
public function maybe_redirect_from_old_page(): void {
if (is_page('find-a-trainer')) {
wp_safe_redirect(home_url('/find-training/'), 301);
exit;
}
}
/**
* AJAX: Get all map data (trainers and venues)
*/
public function ajax_get_map_data(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainers = $data_provider->get_trainer_markers();
$venues = $data_provider->get_venue_markers();
wp_send_json_success([
'trainers' => $trainers,
'venues' => $venues,
'total_trainers' => count($trainers),
'total_venues' => count($venues)
]);
}
/**
* AJAX: Filter map markers
*/
public function ajax_filter_map(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$filters = [
'state' => sanitize_text_field($_POST['state'] ?? ''),
'certification' => sanitize_text_field($_POST['certification'] ?? ''),
'training_format' => sanitize_text_field($_POST['training_format'] ?? ''),
'search' => sanitize_text_field($_POST['search'] ?? ''),
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
];
$data_provider = HVAC_Training_Map_Data::get_instance();
$result = [
'trainers' => [],
'venues' => []
];
if ($filters['show_trainers']) {
$result['trainers'] = $data_provider->get_trainer_markers($filters);
}
if ($filters['show_venues']) {
$result['venues'] = $data_provider->get_venue_markers($filters);
}
$result['total_trainers'] = count($result['trainers']);
$result['total_venues'] = count($result['venues']);
$result['filters_applied'] = array_filter($filters, function($v) {
return !empty($v) && $v !== true;
});
wp_send_json_success($result);
}
/**
* AJAX: Get trainer profile for modal
*/
public function ajax_get_trainer_profile(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$profile_id = absint($_POST['profile_id'] ?? 0);
if (!$profile_id) {
wp_send_json_error(['message' => 'Invalid profile ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
if (!$trainer_data) {
wp_send_json_error(['message' => 'Trainer not found']);
return;
}
// Generate modal HTML
ob_start();
$this->render_trainer_modal_content($trainer_data);
$html = ob_get_clean();
wp_send_json_success([
'trainer' => $trainer_data,
'html' => $html
]);
}
/**
* AJAX: Get venue info for info window
*/
public function ajax_get_venue_info(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$venue_id = absint($_POST['venue_id'] ?? 0);
if (!$venue_id) {
wp_send_json_error(['message' => 'Invalid venue ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$venue_data = $data_provider->get_venue_full_info($venue_id);
if (!$venue_data) {
wp_send_json_error(['message' => 'Venue not found']);
return;
}
wp_send_json_success(['venue' => $venue_data]);
}
/**
* Render trainer modal content
*
* @param array $trainer Trainer data
*/
private function render_trainer_modal_content(array $trainer): void {
?>
<div class="hvac-training-modal-header">
<h2><?php echo esc_html($trainer['name']); ?></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-training-modal-body">
<div class="hvac-training-profile-section">
<div class="hvac-training-profile-header">
<?php if (!empty($trainer['image'])): ?>
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
<?php else: ?>
<div class="hvac-training-profile-avatar">
<span class="dashicons dashicons-businessperson"></span>
</div>
<?php endif; ?>
<div class="hvac-training-profile-info">
<p class="hvac-training-location">
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
</p>
<?php if (!empty($trainer['certifications'])): ?>
<div class="hvac-training-certifications">
<?php foreach ($trainer['certifications'] as $cert): ?>
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['company'])): ?>
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
<?php endif; ?>
<p class="hvac-training-events-count">
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
</p>
</div>
</div>
<?php if (!empty($trainer['training_formats'])): ?>
<div class="hvac-training-detail">
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['upcoming_events'])): ?>
<div class="hvac-training-events">
<h4>Upcoming Events</h4>
<ul class="hvac-events-list">
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
<li>
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
<?php echo esc_html($event['title']); ?>
</a>
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="hvac-training-contact-section">
<h4>Contact Trainer</h4>
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
<div class="hvac-form-row">
<input type="text" name="first_name" placeholder="First Name" required>
<input type="text" name="last_name" placeholder="Last Name" required>
</div>
<div class="hvac-form-row">
<input type="email" name="email" placeholder="Email" required>
<input type="tel" name="phone" placeholder="Phone Number">
</div>
<div class="hvac-form-row">
<input type="text" name="city" placeholder="City">
<input type="text" name="state_province" placeholder="State/Province">
</div>
<div class="hvac-form-field">
<input type="text" name="company" placeholder="Company">
</div>
<div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-message hvac-form-success" style="display: none;">
Your message has been sent! Check your inbox for more details.
</div>
<div class="hvac-form-message hvac-form-error" style="display: none;">
There was an error sending your message. Please try again.
</div>
</div>
</div>
<?php
}
/**
* Get filter options for dropdowns
*
* @return array Filter options
*/
public function get_filter_options(): array {
$data_provider = HVAC_Training_Map_Data::get_instance();
return [
'states' => $data_provider->get_state_options(),
'certifications' => $data_provider->get_certification_options(),
'training_formats' => $data_provider->get_training_format_options()
];
}
/**
* Get page slug
*
* @return string
*/
public function get_page_slug(): string {
return $this->page_slug;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,517 @@
<?php
/**
* Venue Geocoding Service
*
* Handles geocoding for TEC venues to add lat/lng coordinates.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Venue_Geocoding
*
* Manages geocoding of venue addresses using Google Maps Geocoding API.
* Auto-geocodes new venues and provides batch processing for existing venues.
*/
class HVAC_Venue_Geocoding {
/**
* Singleton instance
*
* @var HVAC_Venue_Geocoding|null
*/
private static ?self $instance = null;
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Rate limit per minute
*
* @var int
*/
private int $rate_limit = 50;
/**
* Cache duration for geocoding results
*
* @var int
*/
private int $cache_duration = DAY_IN_SECONDS;
/**
* Get singleton instance
*
* @return HVAC_Venue_Geocoding
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
}
/**
* Initialize hooks
*/
private function init_hooks(): void {
// Auto-geocode new venues on save
add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2);
// Register geocoding action for async processing
add_action('hvac_geocode_venue', [$this, 'geocode_venue']);
// Admin action for batch geocoding
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
// Clear venue coordinates when address changes
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
}
/**
* Maybe geocode venue on save
*
* @param int $venue_id Venue post ID
* @param WP_Post $post Post object
*/
public function maybe_geocode_venue(int $venue_id, WP_Post $post): void {
// Skip autosaves and revisions
if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) {
return;
}
// Check if coordinates already exist
$has_coords = $this->venue_has_coordinates($venue_id);
if (!$has_coords) {
// Schedule geocoding to avoid blocking save
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]);
}
}
/**
* Check if venue already has coordinates
*
* @param int $venue_id Venue post ID
* @return bool
*/
public function venue_has_coordinates(int $venue_id): bool {
// Check custom coordinates
$lat = get_post_meta($venue_id, 'venue_latitude', true);
$lng = get_post_meta($venue_id, 'venue_longitude', true);
if (!empty($lat) && !empty($lng)) {
return true;
}
// Check TEC built-in coordinates
$tec_lat = get_post_meta($venue_id, '_VenueLat', true);
$tec_lng = get_post_meta($venue_id, '_VenueLng', true);
return !empty($tec_lat) && !empty($tec_lng);
}
/**
* Geocode a venue
*
* @param int $venue_id Venue post ID
* @return bool Success
*/
public function geocode_venue(int $venue_id): bool {
// Rate limiting check
if (!$this->check_rate_limit()) {
// Reschedule
wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]);
return false;
}
// Build address
$address = $this->build_venue_address($venue_id);
if (empty($address)) {
update_post_meta($venue_id, '_venue_geocoding_status', 'no_address');
return false;
}
update_post_meta($venue_id, '_venue_geocoding_attempt', time());
// Check cache first
$cache_key = 'venue_geo_' . md5($address);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $this->save_coordinates($venue_id, $cached);
}
// Make API request
$result = $this->geocode_address($address);
if ($result && isset($result['lat'], $result['lng'])) {
// Cache result
set_transient($cache_key, $result, $this->cache_duration);
return $this->save_coordinates($venue_id, $result);
}
// Handle failure
$this->handle_geocoding_failure($venue_id, $result);
return false;
}
/**
* Build venue address string from TEC meta
*
* @param int $venue_id Venue post ID
* @return string Full address
*/
private function build_venue_address(int $venue_id): string {
$parts = [];
$address = get_post_meta($venue_id, '_VenueAddress', true);
$city = get_post_meta($venue_id, '_VenueCity', true);
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
$zip = get_post_meta($venue_id, '_VenueZip', true);
$country = get_post_meta($venue_id, '_VenueCountry', true);
if (!empty($address)) {
$parts[] = $address;
}
if (!empty($city)) {
$parts[] = $city;
}
if (!empty($state)) {
$parts[] = $state;
}
if (!empty($zip)) {
$parts[] = $zip;
}
if (!empty($country)) {
$parts[] = $country;
}
return implode(', ', $parts);
}
/**
* Make geocoding API request
*
* @param string $address Address to geocode
* @return array|null Result with lat/lng or null on failure
*/
private function geocode_address(string $address): ?array {
if (empty($this->api_key)) {
return ['error' => 'No API key configured'];
}
$url = 'https://maps.googleapis.com/maps/api/geocode/json';
$params = [
'address' => $address,
'key' => $this->api_key,
'components' => 'country:US|country:CA' // Restrict to North America
];
$response = wp_remote_get($url . '?' . http_build_query($params), [
'timeout' => 10,
'user-agent' => 'HVAC Training Directory/1.0'
]);
if (is_wp_error($response)) {
return ['error' => $response->get_error_message()];
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
return ['error' => $data['status'] ?? 'Unknown error'];
}
$location = $data['results'][0]['geometry']['location'];
return [
'lat' => $location['lat'],
'lng' => $location['lng'],
'formatted_address' => $data['results'][0]['formatted_address'],
'place_id' => $data['results'][0]['place_id'] ?? ''
];
}
/**
* Save coordinates to venue
*
* @param int $venue_id Venue post ID
* @param array $result Geocoding result
* @return bool Success
*/
private function save_coordinates(int $venue_id, array $result): bool {
if (!isset($result['lat']) || !isset($result['lng'])) {
return false;
}
// Save to our custom meta
update_post_meta($venue_id, 'venue_latitude', $result['lat']);
update_post_meta($venue_id, 'venue_longitude', $result['lng']);
// Also update TEC's meta for compatibility
update_post_meta($venue_id, '_VenueLat', $result['lat']);
update_post_meta($venue_id, '_VenueLng', $result['lng']);
if (!empty($result['formatted_address'])) {
update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']);
}
update_post_meta($venue_id, '_venue_geocoding_status', 'success');
update_post_meta($venue_id, '_venue_geocoding_date', time());
return true;
}
/**
* Handle geocoding failure
*
* @param int $venue_id Venue post ID
* @param array|null $result Error result
*/
private function handle_geocoding_failure(int $venue_id, ?array $result): void {
$error = $result['error'] ?? 'Unknown error';
update_post_meta($venue_id, '_venue_geocoding_status', 'failed');
update_post_meta($venue_id, '_venue_geocoding_error', $error);
// Handle specific errors
switch ($error) {
case 'OVER_QUERY_LIMIT':
// Retry in 1 hour
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]);
break;
case 'ZERO_RESULTS':
// Try fallback with less specific address
$this->try_fallback_geocoding($venue_id);
break;
default:
// Log error
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}");
}
}
}
/**
* Try fallback geocoding with city/state only
*
* @param int $venue_id Venue post ID
*/
private function try_fallback_geocoding(int $venue_id): void {
$city = get_post_meta($venue_id, '_VenueCity', true);
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
$country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA';
if (empty($city) && empty($state)) {
return;
}
$address = implode(', ', array_filter([$city, $state, $country]));
$result = $this->geocode_address($address);
if ($result && isset($result['lat'], $result['lng'])) {
$this->save_coordinates($venue_id, $result);
update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback');
}
}
/**
* Check rate limiting
*
* @return bool Can make request
*/
private function check_rate_limit(): bool {
$rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i');
$current = get_transient($rate_key) ?: 0;
if ($current >= $this->rate_limit) {
return false;
}
set_transient($rate_key, $current + 1, 60);
return true;
}
/**
* Clear coordinates when venue address changes
*
* @param int $meta_id Meta ID
* @param int $post_id Post ID
* @param string $meta_key Meta key
* @param mixed $meta_value Meta value
*/
public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void {
if (get_post_type($post_id) !== 'tribe_venue') {
return;
}
$address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip'];
if (in_array($meta_key, $address_fields, true)) {
// Address changed - clear coordinates to force re-geocoding
delete_post_meta($post_id, 'venue_latitude');
delete_post_meta($post_id, 'venue_longitude');
delete_post_meta($post_id, '_venue_geocoding_status');
// Schedule re-geocoding
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]);
}
}
/**
* AJAX handler for batch geocoding venues
*/
public function ajax_batch_geocode(): void {
// Check permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Permission denied']);
return;
}
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$limit = absint($_POST['limit'] ?? 10);
$result = $this->batch_geocode_venues($limit);
wp_send_json_success($result);
}
/**
* Batch geocode venues without coordinates
*
* @param int $limit Maximum venues to process
* @return array Results
*/
public function batch_geocode_venues(int $limit = 10): array {
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => $limit,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results = [
'processed' => 0,
'success' => 0,
'failed' => 0,
'remaining' => 0
];
foreach ($venues as $venue) {
if (!$this->check_rate_limit()) {
break;
}
$results['processed']++;
if ($this->geocode_venue($venue->ID)) {
$results['success']++;
} else {
$results['failed']++;
}
}
// Count remaining
$remaining_query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results['remaining'] = $remaining_query->found_posts;
return $results;
}
/**
* Get geocoding status for a venue
*
* @param int $venue_id Venue post ID
* @return array Status info
*/
public function get_geocoding_status(int $venue_id): array {
return [
'has_coordinates' => $this->venue_has_coordinates($venue_id),
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
];
}
/**
* Clear coordinates for a venue
*
* @param int $venue_id Venue post ID
*/
public function clear_coordinates(int $venue_id): void {
delete_post_meta($venue_id, 'venue_latitude');
delete_post_meta($venue_id, 'venue_longitude');
delete_post_meta($venue_id, '_VenueLat');
delete_post_meta($venue_id, '_VenueLng');
delete_post_meta($venue_id, '_venue_formatted_address');
delete_post_meta($venue_id, '_venue_geocoding_status');
delete_post_meta($venue_id, '_venue_geocoding_error');
delete_post_meta($venue_id, '_venue_geocoding_date');
}
}

View file

@ -222,18 +222,21 @@ class HVAC_Zoho_CRM_Auth {
return true;
}
// 3. Parse hostname from site URL for accurate comparison
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
// 3. Check for specific staging domains or keywords
if (strpos($site_url, 'staging') !== false ||
strpos($site_url, 'dev') !== false ||
strpos($site_url, 'test') !== false ||
strpos($site_url, 'cloudwaysapps.com') !== false) {
return true;
if (empty($host)) {
return true; // Can't determine host, default to staging for safety
}
// 4. Default check: Production only on upskillhvac.com
return strpos($site_url, 'upskillhvac.com') === false;
// 4. Production: upskillhvac.com or www.upskillhvac.com
if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
return false;
}
// 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.)
return true;
}
/**
@ -242,8 +245,12 @@ class HVAC_Zoho_CRM_Auth {
* @return array Debug information
*/
public static function get_debug_mode_info() {
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
$info = array(
'site_url' => get_site_url(),
'site_url' => $site_url,
'parsed_host' => $host,
'is_staging' => self::is_staging_mode(),
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
@ -255,20 +262,12 @@ class HVAC_Zoho_CRM_Auth {
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
} elseif ($info['forced_staging']) {
$info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
} elseif (empty($host)) {
$info['detection_logic'][] = 'STAGING: Could not parse hostname from URL';
} elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
$info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com';
} else {
$site_url = $info['site_url'];
if (strpos($site_url, 'staging') !== false) $info['detection_logic'][] = 'Matched "staging" in URL';
if (strpos($site_url, 'dev') !== false) $info['detection_logic'][] = 'Matched "dev" in URL';
if (strpos($site_url, 'test') !== false) $info['detection_logic'][] = 'Matched "test" in URL';
if (strpos($site_url, 'cloudwaysapps.com') !== false) $info['detection_logic'][] = 'Matched "cloudwaysapps.com" in URL';
if (empty($info['detection_logic'])) {
if (strpos($site_url, 'upskillhvac.com') === false) {
$info['detection_logic'][] = 'Default STAGING: URL does not contain "upskillhvac.com"';
} else {
$info['detection_logic'][] = 'Default PRODUCTION: URL contains "upskillhvac.com"';
}
}
$info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com';
}
return $info;
@ -283,7 +282,7 @@ class HVAC_Zoho_CRM_Auth {
// In staging mode, only allow read operations, no writes
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
$this->log_debug('STAGING MODE: Simulating ' . $method . ' request to ' . $endpoint);
$this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint);
return array(
'data' => array(
array(
@ -291,8 +290,8 @@ class HVAC_Zoho_CRM_Auth {
'details' => array(
'message' => 'Staging mode active. Write operations are disabled.'
),
'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint,
'status' => 'success'
'message' => 'Blocked ' . $method . ' request to: ' . $endpoint,
'status' => 'skipped_staging'
)
)
);

View file

@ -56,6 +56,99 @@ class HVAC_Zoho_Sync {
return !HVAC_Zoho_CRM_Auth::is_staging_mode();
}
/**
* Validate a Zoho API response to determine if the operation succeeded
*
* @param mixed $response Response from make_api_request()
* @return array ['success' => bool, 'id' => string|null, 'error' => string|null]
*/
private function validate_api_response($response) {
// Check for WP_Error
if (is_wp_error($response)) {
return array(
'success' => false,
'id' => null,
'error' => $response->get_error_message()
);
}
// Check for staging mode simulation
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'STAGING_MODE') {
return array(
'success' => false,
'id' => null,
'error' => 'Staging mode: write operations blocked'
);
}
// Check for Zoho API error codes FIRST (more specific than generic HTTP errors)
if (isset($response['data'][0]['code']) && !in_array($response['data'][0]['code'], array('SUCCESS', 'DUPLICATE_DATA'))) {
$error_msg = isset($response['data'][0]['message']) ? $response['data'][0]['message'] : $response['data'][0]['code'];
// Include field-level details if available
if (isset($response['data'][0]['details'])) {
$details = $response['data'][0]['details'];
if (isset($details['api_name'])) {
$error_msg .= ' (field: ' . $details['api_name'] . ')';
}
if (isset($details['expected_data_type'])) {
$error_msg .= ' (expected: ' . $details['expected_data_type'] . ')';
}
if (isset($details['info'])) {
$error_msg .= ' - ' . $details['info'];
}
}
return array(
'success' => false,
'id' => null,
'error' => $error_msg
);
}
// Check for HTTP-level errors (generic fallback)
if (isset($response['error'])) {
return array(
'success' => false,
'id' => null,
'error' => $response['error']
);
}
// Check for successful response with ID
if (isset($response['data'][0]['details']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['id'],
'error' => null
);
}
// Check for duplicate record handling (also a success case)
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'DUPLICATE_DATA'
&& isset($response['data'][0]['details']['duplicate_record']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['duplicate_record']['id'],
'error' => null
);
}
// Check for success status without ID (e.g., PUT updates)
if (isset($response['data'][0]['status']) && $response['data'][0]['status'] === 'success') {
return array(
'success' => true,
'id' => isset($response['data'][0]['details']['id']) ? $response['data'][0]['details']['id'] : null,
'error' => null
);
}
// Unknown response structure - treat as failure
return array(
'success' => false,
'id' => null,
'error' => 'Unexpected API response: ' . json_encode(array_slice($response, 0, 3))
);
}
/**
* Generate a hash for sync data to detect changes
*
@ -210,6 +303,7 @@ class HVAC_Zoho_Sync {
}
$campaign_id = null;
$sync_succeeded = false;
// FIRST: Check if we already have a stored Zoho Campaign ID
$stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true);
@ -219,20 +313,24 @@ class HVAC_Zoho_Sync {
$update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($update_response);
// Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
if (!$validated['success'] && isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
// Fallback: Create new campaign
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
if (!empty($create_response['data'][0]['details']['id'])) {
$campaign_id = $create_response['data'][0]['details']['id'];
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
}
} else {
} elseif ($validated['success']) {
$campaign_id = $stored_campaign_id;
$sync_succeeded = true;
$results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response);
}
} else {
@ -240,25 +338,32 @@ class HVAC_Zoho_Sync {
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
// Extract campaign ID from create response
if (!empty($create_response['data'][0]['details']['id'])) {
$campaign_id = $create_response['data'][0]['details']['id'];
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
}
}
if ($sync_succeeded) {
$results['synced']++;
// Update event meta with Zoho Campaign ID and sync hash
// Only update hash and Zoho ID on confirmed success
if (!empty($campaign_id)) {
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
}
update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data));
} catch (Exception $e) {
} else {
$results['failed']++;
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $e->getMessage());
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('Event %s: [%s] %s', $event->ID, get_class($e), $e->getMessage());
}
}
@ -358,10 +463,14 @@ class HVAC_Zoho_Sync {
continue;
}
$contact_id = null;
$sync_succeeded = false;
// Check if contact already exists in Zoho
$search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array(
'criteria' => "(Email:equals:{$contact_data['Email']})"
));
$search_response = $this->auth->make_api_request(
'/Contacts/search?criteria=(Email:equals:' . urlencode($contact_data['Email']) . ')',
'GET'
);
if (!empty($search_response['data'])) {
// Update existing contact
@ -369,28 +478,38 @@ class HVAC_Zoho_Sync {
$update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
'data' => array($contact_data)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new contact
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
'data' => array($contact_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if (!empty($create_response['data'][0]['details']['id'])) {
$contact_id = $create_response['data'][0]['details']['id'];
if ($validated['success'] && $validated['id']) {
$contact_id = $validated['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Update user meta with Zoho ID and sync hash
if (isset($contact_id)) {
// Only update hash and Zoho ID on confirmed success
if (!empty($contact_id)) {
update_user_meta($user->ID, '_zoho_contact_id', $contact_id);
}
update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data));
} catch (Exception $e) {
} else {
$results['failed']++;
$results['errors'][] = sprintf('User %s: %s', $user->ID, $e->getMessage());
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('User %s: %s', $user->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('User %s: [%s] %s', $user->ID, get_class($e), $e->getMessage());
}
}
@ -496,6 +615,9 @@ class HVAC_Zoho_Sync {
continue;
}
$invoice_id = null;
$sync_succeeded = false;
// Check if invoice already exists in Zoho (by WordPress Order ID)
$search_response = $this->auth->make_api_request(
'/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')',
@ -505,31 +627,41 @@ class HVAC_Zoho_Sync {
if (!empty($search_response['data'])) {
// Update existing invoice
$invoice_id = $search_response['data'][0]['id'];
$this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
$update_response = $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new invoice
$create_response = $this->auth->make_api_request('/Invoices', 'POST', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if (!empty($create_response['data'][0]['details']['id'])) {
$invoice_id = $create_response['data'][0]['details']['id'];
if ($validated['success'] && $validated['id']) {
$invoice_id = $validated['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Update order meta with Zoho ID and sync hash
if (isset($invoice_id)) {
// Only update hash and Zoho ID on confirmed success
if (!empty($invoice_id)) {
update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id);
}
update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data));
} catch (Exception $e) {
} else {
$results['failed']++;
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $e->getMessage());
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $error_msg);
}
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('Order %s: [%s] %s', $order->ID, get_class($e), $e->getMessage());
}
}
@ -687,8 +819,18 @@ class HVAC_Zoho_Sync {
$results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug);
}
// If contact creation failed, count as failed and skip hash update
if (!$contact_id) {
$results['failed']++;
$error_detail = $this->last_contact_error ?: 'Unknown error';
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
continue;
}
$attendee_sync_ok = true;
// Step 2: Create Campaign Member (link Contact to Campaign)
if ($contact_id && !empty($attendee_data['event_id'])) {
if (!empty($attendee_data['event_id'])) {
$campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
// Debug: Log event_id and campaign_id for troubleshooting
@ -732,9 +874,6 @@ class HVAC_Zoho_Sync {
} else {
$results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']);
}
} elseif (!$contact_id) {
$error_detail = $this->last_contact_error ?: 'Unknown error';
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
} elseif (empty($attendee_data['event_id'])) {
// Debug: Log when event_id is missing
if (count($results['responses']) < 10) {
@ -742,9 +881,10 @@ class HVAC_Zoho_Sync {
}
}
// Contact was created/found successfully - count as synced
$results['synced']++;
// Update attendee meta with Zoho Contact ID and sync hash
// Only update hash on confirmed contact success
update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data));
@ -866,12 +1006,17 @@ class HVAC_Zoho_Sync {
// Step 1: Create/Update Lead
$lead_id = $this->ensure_lead_exists($rsvp_data);
if ($lead_id) {
$results['leads_created']++;
if (!$lead_id) {
$results['failed']++;
$results['errors'][] = sprintf('RSVP %s: Failed to create/find lead', $rsvp->ID);
continue;
}
$results['leads_created']++;
// Step 2: Create Campaign Member (link Lead to Campaign)
if ($lead_id && !empty($rsvp_data['event_id'])) {
if (!empty($rsvp_data['event_id'])) {
$campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true);
if ($campaign_id) {
$assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
@ -892,13 +1037,13 @@ class HVAC_Zoho_Sync {
$results['synced']++;
// Update RSVP meta with Zoho Lead ID and sync hash
// Only update hash on confirmed lead creation success
update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id);
update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data));
} catch (Exception $e) {
} catch (\Throwable $e) {
$results['failed']++;
$results['errors'][] = sprintf('RSVP %s: %s', $rsvp->ID, $e->getMessage());
$results['errors'][] = sprintf('RSVP %s: [%s] %s', $rsvp->ID, get_class($e), $e->getMessage());
}
}
@ -1203,11 +1348,25 @@ class HVAC_Zoho_Sync {
$role = 'Trainee';
}
return array(
// Last_Name is required by Zoho - fallback to display_name or username
$last_name = html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (empty(trim($last_name))) {
$last_name = $user->display_name ?: $user->user_login;
}
// Sanitize phone: strip to digits and leading +, require 10+ digits for validity
$raw_phone = get_user_meta($user->ID, 'phone_number', true);
$phone_digits = preg_replace('/\D/', '', $raw_phone);
$phone = '';
if (strlen($phone_digits) >= 10) {
// Preserve leading + if present, otherwise just use digits
$phone = (strpos(trim($raw_phone), '+') === 0 ? '+' : '') . $phone_digits;
}
$data = array(
'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Last_Name' => html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Last_Name' => $last_name,
'Email' => $user->user_email,
'Phone' => get_user_meta($user->ID, 'phone_number', true),
'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Lead_Source' => 'HVAC Community Events',
@ -1217,6 +1376,13 @@ class HVAC_Zoho_Sync {
'Years_Experience' => get_user_meta($user->ID, 'hvac_years_experience', true),
'Certification' => get_user_meta($user->ID, 'hvac_certifications', true)
);
// Only include Phone if we have a valid value (10+ digits)
if (!empty($phone)) {
$data['Phone'] = $phone;
}
return $data;
}
/**

View file

@ -0,0 +1,409 @@
<?php
/**
* Template Name: Find Training
*
* Google Maps-style full-screen layout with left sidebar panel
* for trainer directory and compact filter toolbar.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
defined('ABSPATH') || exit;
define('HVAC_IN_PAGE_TEMPLATE', true);
get_header();
// Get page handler instance
$find_training = HVAC_Find_Training_Page::get_instance();
$filter_options = $find_training->get_filter_options();
$api_key_configured = $find_training->is_api_key_configured();
?>
<div class="hvac-find-training-page">
<!-- Skip link for accessibility -->
<a href="#hvac-trainer-grid" class="hvac-skip-link">Skip to trainer results</a>
<!-- Compact Filter Bar -->
<div class="hvac-filter-bar" role="search" aria-label="Filter trainers and venues">
<div class="hvac-filter-bar-inner">
<!-- Search -->
<div class="hvac-filter-item hvac-filter-search">
<span class="dashicons dashicons-search" aria-hidden="true"></span>
<input type="text" id="hvac-training-search" placeholder="Search trainers..." aria-label="Search trainers">
</div>
<!-- Info Button -->
<button type="button" id="hvac-info-btn" class="hvac-info-btn" aria-label="How to use this page">
<span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
</button>
<!-- Filter Dropdowns (hidden on mobile, shown in panel) -->
<div class="hvac-filter-dropdowns">
<select id="hvac-filter-state" class="hvac-filter-select" aria-label="Filter by state">
<option value="">All States</option>
<?php foreach ($filter_options['states'] as $state): ?>
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
<?php endforeach; ?>
</select>
<select id="hvac-filter-certification" class="hvac-filter-select" aria-label="Filter by certification">
<option value="">All Certifications</option>
<?php foreach ($filter_options['certifications'] as $cert): ?>
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
<?php endforeach; ?>
</select>
<select id="hvac-filter-format" class="hvac-filter-select" aria-label="Filter by training format">
<option value="">All Formats</option>
<?php foreach ($filter_options['training_formats'] as $format): ?>
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Near Me Button -->
<button type="button" id="hvac-near-me-btn" class="hvac-near-me-btn">
<span class="dashicons dashicons-location-alt" aria-hidden="true"></span>
<span class="hvac-btn-text">Near Me</span>
</button>
<!-- Include Past Events Checkbox -->
<label class="hvac-filter-checkbox">
<input type="checkbox" id="hvac-include-past">
<span>Include Past</span>
</label>
<!-- Clear Filters -->
<button type="button" class="hvac-clear-filters" style="display: none;">
Clear
</button>
<!-- Mobile Filter Toggle -->
<button type="button"
class="hvac-mobile-filter-toggle"
aria-expanded="false"
aria-controls="hvac-mobile-filter-panel">
<span class="dashicons dashicons-filter" aria-hidden="true"></span>
<span class="hvac-btn-text">Filters</span>
</button>
</div>
<!-- Active Filters Chips -->
<div class="hvac-active-filters" aria-live="polite"></div>
<!-- Mobile Filter Panel (hidden by default) -->
<div id="hvac-mobile-filter-panel" class="hvac-mobile-filter-panel" hidden>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-state-mobile">State / Province</label>
<select id="hvac-filter-state-mobile" class="hvac-filter-select" aria-label="Filter by state">
<option value="">All States</option>
<?php foreach ($filter_options['states'] as $state): ?>
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-certification-mobile">Certification</label>
<select id="hvac-filter-certification-mobile" class="hvac-filter-select" aria-label="Filter by certification">
<option value="">All Certifications</option>
<?php foreach ($filter_options['certifications'] as $cert): ?>
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-format-mobile">Training Format</label>
<select id="hvac-filter-format-mobile" class="hvac-filter-select" aria-label="Filter by training format">
<option value="">All Formats</option>
<?php foreach ($filter_options['training_formats'] as $format): ?>
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label class="hvac-filter-checkbox">
<input type="checkbox" id="hvac-include-past-mobile">
<span>Include Past Events</span>
</label>
</div>
</div>
</div>
<!-- Main Content: Sidebar + Map -->
<div class="hvac-map-layout">
<!-- Left Sidebar -->
<aside class="hvac-sidebar" role="region" aria-label="Training directory">
<div class="hvac-sidebar-header">
<!-- Tab Navigation -->
<div class="hvac-sidebar-tabs" role="tablist" aria-label="Browse by category">
<button role="tab" class="hvac-tab active" data-tab="trainers" aria-selected="true" aria-controls="hvac-panel-trainers">
Trainers (<span data-count="trainers">0</span>)
</button>
<button role="tab" class="hvac-tab" data-tab="venues" aria-selected="false" aria-controls="hvac-panel-venues">
Venues (<span data-count="venues">0</span>)
</button>
<button role="tab" class="hvac-tab" data-tab="events" aria-selected="false" aria-controls="hvac-panel-events">
Events (<span data-count="events">0</span>)
</button>
</div>
<!-- Visibility Toggles (moved from map overlay) -->
<div class="hvac-visibility-toggles">
<label class="hvac-visibility-toggle" title="Show trainers on map">
<input type="checkbox" id="hvac-show-trainers" checked>
<span class="hvac-toggle-dot hvac-toggle-trainer"></span>
</label>
<label class="hvac-visibility-toggle" title="Show venues on map">
<input type="checkbox" id="hvac-show-venues" checked>
<span class="hvac-toggle-dot hvac-toggle-venue"></span>
</label>
<label class="hvac-visibility-toggle" title="Show events on map">
<input type="checkbox" id="hvac-show-events" checked>
<span class="hvac-toggle-dot hvac-toggle-event"></span>
</label>
</div>
<!-- Mobile collapse toggle -->
<button type="button"
class="hvac-sidebar-toggle"
aria-expanded="true"
aria-controls="hvac-sidebar-content"
aria-label="Toggle directory list">
<span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
</button>
</div>
<div id="hvac-sidebar-content" class="hvac-sidebar-content">
<!-- Trainers Panel -->
<div role="tabpanel" id="hvac-panel-trainers" class="hvac-tab-panel active" aria-labelledby="tab-trainers">
<div id="hvac-trainer-grid" class="hvac-item-list">
<div class="hvac-grid-loading">
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
Loading trainers...
</div>
</div>
</div>
<!-- Venues Panel -->
<div role="tabpanel" id="hvac-panel-venues" class="hvac-tab-panel" aria-labelledby="tab-venues" hidden>
<div id="hvac-venue-grid" class="hvac-item-list"></div>
</div>
<!-- Events Panel -->
<div role="tabpanel" id="hvac-panel-events" class="hvac-tab-panel" aria-labelledby="tab-events" hidden>
<div id="hvac-event-grid" class="hvac-item-list"></div>
</div>
<!-- Load More Button -->
<div class="hvac-load-more-wrapper" style="display: none;">
<button type="button" id="hvac-load-more" class="hvac-btn-secondary">
Load More
</button>
</div>
<!-- CTA -->
<div class="hvac-sidebar-cta">
<p>Want to be listed in our directory?</p>
<a href="<?php echo esc_url(site_url('/trainer/registration/')); ?>" class="hvac-btn-primary hvac-btn-small">
Become A Trainer
</a>
</div>
</div>
</aside>
<!-- Map Container -->
<div class="hvac-map-container" role="region" aria-label="Training locations map">
<div id="hvac-training-map" class="hvac-google-map">
<?php if (!$api_key_configured): ?>
<div class="hvac-map-error">
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
<p><strong>Map Configuration Required</strong></p>
<p>The Google Maps API key is not configured. <?php if (current_user_can('manage_options')): ?>Please configure it in <a href="<?php echo admin_url('admin.php?page=hvac-trainer-profile-settings'); ?>">Trainer Profile Settings</a>.<?php else: ?>Please contact the site administrator.<?php endif; ?></p>
</div>
<?php else: ?>
<div class="hvac-map-loading">
<span class="dashicons dashicons-location" aria-hidden="true"></span>
<p>Loading map...</p>
</div>
<?php endif; ?>
</div>
<!-- Map Legend Overlay -->
<div class="hvac-map-legend">
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-trainer" aria-hidden="true"></span>
<span>Trainer</span>
</div>
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-venue" aria-hidden="true"></span>
<span>Venue</span>
</div>
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-event" aria-hidden="true"></span>
<span>Event</span>
</div>
</div>
</div>
</div>
</div>
<!-- Trainer Profile Modal -->
<div id="hvac-trainer-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trainer-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content">
<div class="hvac-modal-loading">
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
Loading...
</div>
<div class="hvac-modal-body"></div>
</div>
</div>
<!-- Venue Info Modal -->
<div id="hvac-venue-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="venue-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content hvac-venue-modal-content">
<div class="hvac-venue-modal-header">
<h2 id="venue-modal-title"></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-venue-modal-body">
<p class="hvac-venue-address"></p>
<p class="hvac-venue-phone"></p>
<p class="hvac-venue-capacity"></p>
<div class="hvac-venue-description"></div>
<!-- Equipment Badges -->
<div class="hvac-venue-equipment" style="display: none;">
<h4>Equipment</h4>
<div class="hvac-badge-list hvac-equipment-badges"></div>
</div>
<!-- Amenities Badges -->
<div class="hvac-venue-amenities" style="display: none;">
<h4>Amenities</h4>
<div class="hvac-badge-list hvac-amenities-badges"></div>
</div>
<!-- Upcoming Events -->
<div class="hvac-venue-events">
<h4>Upcoming Events</h4>
<ul class="hvac-venue-events-list"></ul>
<p class="hvac-venue-no-events" style="display: none;">No upcoming events scheduled.</p>
</div>
<!-- Actions -->
<div class="hvac-venue-actions">
<a href="#" class="hvac-venue-directions hvac-btn-secondary" target="_blank" rel="noopener">
<span class="dashicons dashicons-location" aria-hidden="true"></span>
Get Directions
</a>
</div>
<!-- Contact Form -->
<div class="hvac-venue-contact-section">
<h4>Contact This Training Lab</h4>
<form class="hvac-venue-contact-form" data-venue-id="">
<div class="hvac-form-row">
<div class="hvac-form-group">
<label for="venue-contact-first-name">First Name <span class="required">*</span></label>
<input type="text" id="venue-contact-first-name" name="first_name" required>
</div>
<div class="hvac-form-group">
<label for="venue-contact-last-name">Last Name <span class="required">*</span></label>
<input type="text" id="venue-contact-last-name" name="last_name" required>
</div>
</div>
<div class="hvac-form-row">
<div class="hvac-form-group">
<label for="venue-contact-email">Email <span class="required">*</span></label>
<input type="email" id="venue-contact-email" name="email" required>
</div>
<div class="hvac-form-group">
<label for="venue-contact-phone">Phone</label>
<input type="tel" id="venue-contact-phone" name="phone">
</div>
</div>
<div class="hvac-form-group">
<label for="venue-contact-company">Company</label>
<input type="text" id="venue-contact-company" name="company">
</div>
<div class="hvac-form-group">
<label for="venue-contact-message">Message</label>
<textarea id="venue-contact-message" name="message" rows="4" placeholder="Tell us about your training needs..."></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-success" style="display: none;">
<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
<p>Your message has been sent! The training lab will be in touch soon.</p>
</div>
<div class="hvac-form-error" style="display: none;">
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
<p>There was a problem sending your message. Please try again.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Info Modal -->
<div id="hvac-info-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="info-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content hvac-info-modal-content">
<div class="hvac-info-modal-header">
<h2 id="info-modal-title">Find Training Near You</h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-info-modal-body">
<section class="hvac-info-section">
<h3>What is Upskill HVAC?</h3>
<p>Upskill HVAC is a community of certified trainers and training facilities dedicated to advancing skills in the HVAC industry. Our platform connects technicians with professional training opportunities across the country.</p>
</section>
<section class="hvac-info-section">
<h3>How to Use This Page</h3>
<ul class="hvac-info-list">
<li><strong>Browse by Category:</strong> Use the tabs above the list to switch between Trainers, Venues, and Events</li>
<li><strong>Search:</strong> Type in the search bar to filter results within the current tab</li>
<li><strong>Filter:</strong> Use the dropdown filters to narrow by state, certification, or format</li>
<li><strong>Near Me:</strong> Click the "Near Me" button to find training options close to your location</li>
<li><strong>Map Markers:</strong> Click on any marker to see details, or hover to preview</li>
<li><strong>Toggle Visibility:</strong> Use the colored dots in the header to show/hide marker types on the map</li>
</ul>
</section>
<section class="hvac-info-section">
<h3>Map Legend</h3>
<div class="hvac-info-legend">
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-trainer"></span>
<div>
<strong>Trainer</strong>
<p>Certified HVAC trainers who conduct training sessions</p>
</div>
</div>
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-venue"></span>
<div>
<strong>Training Lab</strong>
<p>measureQuick Approved facilities with professional equipment</p>
</div>
</div>
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-event"></span>
<div>
<strong>Event</strong>
<p>Scheduled training sessions you can register for</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<?php get_footer(); ?>