This commit is contained in:
2026-05-06 00:18:37 +02:00
parent 09e0ce8dc0
commit ea77c8ea35
25 changed files with 1996 additions and 34 deletions

View File

@@ -0,0 +1,130 @@
# Architecture
**Analysis Date:** 2026-05-05
## Pattern Overview
WordPress plugin with layered architecture: CPT-backed data model, REST API surface, Singleton orchestrators, static utility classes.
- Entry point bootstraps a single Singleton (`Yacht_Booking`) via `plugins_loaded`
- All public-facing state changes go through REST API (`/wp-json/yacht-booking/v1/`)
- Admin state changes use WordPress PRG pattern (form POST → redirect)
- External calendar sync handled by separate Integration namespaces
## Class Inventory
| Class | File | Pattern | Responsibility |
|-------|------|---------|---------------|
| `Yacht_Booking` | `includes/class-yacht-booking.php` | Singleton | Master orchestrator — wires all subsystems |
| `Yacht` | `includes/class-yacht.php` | Static utility | `yacht` CPT registration + meta accessors |
| `Booking` | `includes/class-booking.php` | Static utility | `yacht_booking` CPT + `create()` + status |
| `Inquiry` | `includes/class-inquiry.php` | Static utility | `yacht_inquiry` CPT + `send_emails()` |
| `Availability` | `includes/class-availability.php` | Static utility | All `wp_yacht_availability` table operations |
| `Rest_Controller` | `api/class-rest-controller.php` | Extends `WP_REST_Controller` | 7 REST endpoints, booking orchestration |
| `Settings` | `includes/class-settings.php` | Static utility | Typed `wp_options` accessors + formatting helpers |
| `Installer` | `includes/class-installer.php` | Plain class | DB table creation + default options on activation |
| `Email_Templates` | `includes/class-email-templates.php` | Static utility | Template storage, compilation, tag replacement |
| `Admin` | `admin/class-admin.php` | Singleton (admin-only) | Menu, form processing, CSV export, customer emails |
| `Booking_List_Table` | `admin/class-booking-list-table.php` | Extends `WP_List_Table` | Bookings admin table |
| `Yacht_List_Table` | `admin/class-yacht-list-table.php` | Extends `WP_List_Table` | Yachts admin table |
| `Inquiry_List_Table` | `admin/class-inquiry-list-table.php` | Extends `WP_List_Table` | Inquiries admin table |
| `Sync_Controller` | `integrations/google-calendar/class-sync-controller.php` | Singleton | GCal cron sync orchestration |
| `GCal_Service` | `integrations/google-calendar/class-gcal-service.php` | Plain class | Google Calendar API calls |
| `OAuth_Handler` | `integrations/google-calendar/class-oauth-handler.php` | Plain class | OAuth 2.0 token storage + refresh |
| `ICal_Feed` | `integrations/ical/class-ical-feed.php` | Static/plain | iCal feed generation + rewrite rule |
| `ICal_Import` | `integrations/ical/class-ical-import.php` | Static/plain | External iCal URL import + cron |
| `Shortcode` | `frontend/class-shortcode.php` | Singleton | `[yacht_calendar]` shortcode |
| `Calendar_Widget` | `frontend/class-calendar-widget.php` | Extends Elementor `Widget_Base` | Elementor `yacht-calendar` widget |
## Dependency Graph
```
yacht-booking-system.php
└── Yacht_Booking (Singleton)
├── Yacht (static)
├── Booking (static)
│ └── fires: yacht_booking_created
│ yacht_booking_status_changed
├── Availability (static)
├── Settings (static)
├── Email_Templates (static)
├── Rest_Controller
│ ├── Yacht, Booking, Availability, Settings, Inquiry
│ ├── Email_Templates
│ └── listens: yacht_booking_created → send_booking_notification (admin email)
├── Admin (Singleton, admin-only)
│ ├── Booking, Availability, Yacht, Settings, Email_Templates, Inquiry
│ ├── Booking_List_Table → Booking, Availability, Settings
│ ├── Yacht_List_Table, Inquiry_List_Table
│ └── listens: yacht_booking_status_changed → send_customer_notification
├── Sync_Controller (Singleton)
│ ├── GCal_Service → OAuth_Handler
│ └── writes: Availability::mark_as_blocked
├── ICal_Import → Availability::mark_as_blocked
└── ICal_Feed (feed output)
```
## WP Hook Map
| Hook | Type | Registered by | Handler |
|------|------|---------------|---------|
| `plugins_loaded` (p10) | action | bootstrap | `yacht_booking_init()``Yacht_Booking::get_instance()` |
| `init` (p10) | action | Yacht_Booking | `register_post_types()` |
| `init` (p15) | action | Yacht_Booking | `register_shortcodes()` |
| `init` | action | bootstrap | `yacht_booking_load_textdomain()` |
| `wp_enqueue_scripts` | action | Yacht_Booking | `enqueue_frontend_assets()` (conditional) |
| `admin_enqueue_scripts` | action | Yacht_Booking | `enqueue_admin_assets()` (conditional) |
| `rest_api_init` | action | Yacht_Booking | `register_rest_routes()` |
| `elementor/widgets/register` | action | Yacht_Booking | `register_elementor_widgets()` |
| `admin_init` | action | Yacht_Booking | `add_custom_capabilities()` |
| `admin_menu` (p9) | action | Admin | `register_admin_menu()` |
| `admin_init` | action | Admin | `process_bulk_actions()`, `process_booking_actions()`, `process_yacht_save()`, `process_settings_save()`, `process_export_download()` |
| `admin_notices` | action | Admin | `display_admin_notices()` |
| `yacht_booking_created` | custom action | Rest_Controller | `send_booking_notification()` (admin email) |
| `yacht_booking_status_changed` | custom action | Admin | `send_customer_notification()` |
| `before_delete_post` | action | Sync_Controller | `on_booking_deleted()` (GCal event delete) |
| `wp_ajax_yacht_booking_manual_sync` | action | Sync_Controller | `ajax_manual_sync()` |
| `yacht_booking_sync_to_gcal` | cron action | Sync_Controller | `sync_booking_to_gcal()` |
| `yacht_booking_pull_from_gcal` | cron action (hourly) | Sync_Controller | `pull_from_gcal()` |
| `yacht_booking_ical_import` | cron action (hourly) | ICal_Import | `run_import()` |
| `init` | action | ICal_Feed | `add_rewrite_rules()` |
| `template_redirect` | action | ICal_Feed | `handle_feed_request()` |
## Key Data Flows
### Booking Creation (Frontend → REST → DB)
1. User picks dates on FullCalendar → `GET /availability/{yacht_id}``Availability::get_availability_calendar()`
2. User submits form → `POST /bookings` with `X-WP-Nonce` header
3. `Rest_Controller::create_booking()`: nonce check → `is_booking_enabled()``Availability::is_available()`
4. Price: `count_days() × get_price_per_day()`
5. `Booking::create()` → inserts CPT post + meta → fires `yacht_booking_created`
6. `Availability::mark_as_booked()` → inserts rows in `wp_yacht_availability`
7. Admin email sent via `Email_Templates`; JSON response to frontend
### Admin Booking Management
1. Bookings list → `Booking_List_Table::prepare_items()` (WP_Query)
2. Row action URLs: nonce-protected GET `?action=approve_booking_{id}`
3. `Admin::process_booking_actions()``Booking::update_status()` → fires `yacht_booking_status_changed`
4. `Admin::send_customer_notification()` → customer email
5. On cancel/delete: `Availability::clear_booking_availability($booking_id)` deletes rows by booking_id
6. PRG redirect back to list
### GCal Sync
1. Hourly WP Cron: `pull_from_gcal()``GCal_Service` → Google API → `Availability::mark_as_blocked()`
2. On booking create: scheduled single cron `yacht_booking_sync_to_gcal` → push to GCal
3. OAuth token refresh: `OAuth_Handler::get_access_token()` → auto-refresh if expired
## Error Handling Strategy
- REST endpoints return `WP_Error` with HTTP status codes
- Admin operations: `wp_die()` on capability failure; `?error=1` redirect on soft errors
- `Booking::create()` returns `false` on failure — caller checks
- GCal API: `is_wp_error()` check only; HTTP 4xx/5xx responses silently ignored
- No exceptions used anywhere
## Custom Capabilities
Added once to `administrator` role via `Yacht_Booking::add_custom_capabilities()`:
- `yacht_booking_manage_yachts`
- `yacht_booking_manage_bookings`
- `yacht_booking_manage_settings`

189
.paul/codebase/concerns.md Normal file
View File

@@ -0,0 +1,189 @@
# Technical Concerns
**Analysis Date:** 2026-05-05
**Revised:** 2026-05-05 — verified call sites; demoted false positives (see CHANGELOG at bottom)
Prioritized list of issues found during codebase analysis.
---
## CRITICAL — Security / Data Loss Risk
### C1. Race condition on booking creation — no atomic lock
**Files:** `includes/class-availability.php` (`is_available`), `api/class-rest-controller.php` (`create_booking` lines 326362)
Flow: `is_available()``Booking::create()``mark_as_booked()` — three separate non-transactional operations. Two concurrent POST requests for the same yacht and overlapping dates will both pass the check before either writes. The `UNIQUE KEY yacht_date (yacht_id, date)` will reject the second `mark_as_booked()` iteration silently, but both `yacht_booking` posts are already created. Admin sees two confirmed bookings for the same dates.
**Fix:** Wrap in a DB transaction with `SELECT ... FOR UPDATE` on availability rows, or insert a sentinel lock row first and check the result before proceeding.
---
### C2. OAuth credentials stored unencrypted in `wp_options`
**File:** `integrations/google-calendar/class-oauth-handler.php` lines 185187, 213215
`yacht_booking_gcal_credentials` (client_id, client_secret) and `yacht_booking_gcal_tokens` (access_token, refresh_token) stored as plaintext serialized arrays. Any plugin or user with DB read access can extract a working Google Calendar refresh token. If the database is compromised (via SQL injection elsewhere on the WordPress install or hosting breach), the Google Calendar account is fully exposed.
**Fix:** Encrypt with WordPress secret keys (`AUTH_KEY` etc.) before storing; decrypt on read.
---
## HIGH — Reliability
### H1. GCal API HTTP errors silently ignored
**Files:** `integrations/google-calendar/class-gcal-service.php`, `class-oauth-handler.php`
Every API call checks `is_wp_error()` but not the HTTP response code. A `401`, `403`, `429`, or `500` from Google is treated as success — `json_decode` runs, expected fields like `$body['id']` or `$body['access_token']` are absent, function returns `false`. All errors only logged when `WP_DEBUG === true`. In production: complete silence.
---
### H2. Token refresh has no concurrency protection
**File:** `integrations/google-calendar/class-oauth-handler.php` lines 107128
Simultaneous WP-Cron events both finding the token expired will both call `refresh_access_token()`. Google may invalidate the first new token when the second refresh request arrives. Result: permanently broken GCal connection requiring manual reauth.
**Fix:** Wrap refresh in a transient-based lock (`set_transient('gcal_refresh_lock', 1, 60)`), or use `wp_cache_add()` semantics.
---
### H3. `register_cron_actions()` may not be called — cron hooks fire with no registered callbacks
**File:** `integrations/google-calendar/class-sync-controller.php` lines 141153
The constructor registers `on_booking_created` etc., but `register_cron_actions()` (which registers the `yacht_booking_sync_to_gcal` action itself) is a separate static method. Need to verify it is called on every `init` / `plugins_loaded` request. If not, scheduled single events silently fire with no handler.
---
### H4. Single calendar for all yachts — GCal events apply to every yacht
**File:** `integrations/google-calendar/class-gcal-service.php` lines 196272
`get_calendar_id()` returns one shared option (`yacht_booking_gcal_calendar_id`). `pull_from_gcal()` calls `sync_from_gcal($yacht->ID)` for each yacht but each call fetches all events from the same calendar and blocks that yacht for all those dates. Multi-yacht sites will incorrectly block all yachts for every GCal event.
---
### H5. `tmp-fix-polish.php` in project root reveals dev paths
**File:** `tmp-fix-polish.php`
Contains only one line: a path string to another local project (`c:\visual studio code\projekty\jachty.pagedev.pl\tmp-fix-polish.php`). No PHP tag, no code. If deployed via FTP, the raw path string is publicly accessible at the URL. Should be deleted and excluded from deployment.
---
## MEDIUM — Maintainability / Correctness
### M1. Date validation missing on booking creation
**File:** `api/class-rest-controller.php` lines 116122
`start_date`/`end_date` use only `sanitize_text_field` — no `validate_callback`. An invalid date string like `"not-a-date"` reaches `new \DateTime()` in `Availability::count_days()` without try/catch, producing an uncaught fatal error visible to the public. The `/availability` endpoint validates dates correctly; `/bookings` does not.
**Fix:** Add `validate_callback` checking `\DateTime::createFromFormat('Y-m-d', $param) !== false`.
---
### M2. No pagination on `get_yachts` and `get_bookings`
**File:** `api/class-rest-controller.php` lines 222245, 429462
Both endpoints use `posts_per_page => -1`. Will degrade progressively as bookings accumulate.
---
### M3. N+1 query pattern in booking and yacht lists
**File:** `api/class-rest-controller.php` lines 442458
For each booking: `get_post($yacht_id)` called individually inside loop. For each yacht: three separate `get_post_meta()` calls per row. With 20 bookings: 60+ queries per response.
---
### M4. iCal importer ignores `STATUS:CANCELLED` events
**File:** `integrations/ical/class-ical-import.php` `parse_ics()` method
Parser reads UID, SUMMARY, DTSTART, DTEND but ignores STATUS. Cancelled events in the iCal feed are imported as blocks. The GCal service correctly skips `status: cancelled` events (line 347 of `class-gcal-service.php`) — parity needed in iCal importer.
---
### M5. `mark_as_blocked()` passes `null` booking_id with `%d` format
**File:** `includes/class-availability.php` lines 98109
`$wpdb->replace()` receives `null` with `%d` format specifier. In older MySQL/MariaDB this may insert `0` instead of `NULL`, making it impossible to distinguish a blocked row from one linked to booking ID 0.
---
### M6. OAuth redirect URI hardcoded to production domain
**File:** `integrations/google-calendar/class-oauth-handler.php` lines 40, 72
`$redirect_uri = 'https://jachty.pagedev.pl/wp-admin/...'` — literal string, not `admin_url()`. OAuth fails completely on staging or local dev. Comment acknowledges this is intentional but makes testing and domain migration fragile.
---
## LOW — Cleanup / Polish
### L1. Dead code: `Availability::mark_as_available()` has bug but is never called
**File:** `includes/class-availability.php` lines 123149
The function ignores its `$start_date`/`$end_date` parameters and runs `$wpdb->delete()` filtered only by `yacht_id` — which would wipe the entire availability table for that yacht.
**Verification:** `grep` across the plugin shows zero call sites. All code that handles cancellation/deletion uses `clear_booking_availability($booking_id)` (in `Admin`, `Rest_Controller`, `GCal_Service`, `ICal_Import`), which correctly filters by `booking_id`.
**Risk:** Trap for a future developer who reads the method name and decides to use it. Recommend either deleting the function or replacing its body with the commented-out correct implementation that already lives below it (lines 137146).
---
### L2. Dead form field: `yacht_booking_nonce` from `wp_nonce_field` is never read
**Files:** `frontend/class-shortcode.php:329`, `frontend/class-calendar-widget.php:429`
The booking form renders `wp_nonce_field('yacht_booking_submit', 'yacht_booking_nonce')` as a hidden input, but `calendar.js` (lines 336343) builds `formData` manually from named fields and never includes it. The actual nonce travels via `X-WP-Nonce` header from `yachtBookingData.nonce` (a `wp_create_nonce('wp_rest')` value), which matches what `Rest_Controller::create_booking()` verifies.
**Risk:** Code rot — looks like it's doing something but isn't. Either remove the `wp_nonce_field` call, or wire JS to send it as a fallback for the no-JS case (currently there is no no-JS fallback anyway).
---
### L3. Logging disabled in production
All `error_log()` calls gated on `WP_DEBUG === true`. GCal sync errors, token refresh failures, and iCal import problems are silently discarded in production. Consider a persistent log table or option-based error queue surfaced in admin.
---
### L4. No incremental DB migration path
**File:** `includes/class-installer.php`
No `upgrade()` method runs when `yacht_booking_version` in `wp_options` differs from `YACHT_BOOKING_VERSION`. `dbDelta()` adds missing columns but won't remove old ones or modify existing column types. Future schema changes risk silent failures on existing installs.
---
### L5. `yacht_booking` CPT exposed to default WP REST API
**File:** `includes/class-booking.php` line 48
`show_in_rest => true` registers the CPT at `/wp/v2/yacht_booking`. The plugin's own `/yacht-booking/v1/` namespace is the intended REST surface. Set `show_in_rest => false` unless there is a reason for the default WP exposure.
---
### L6. `Booking::create()` does not validate end > start
**File:** `includes/class-booking.php` lines 229230
A booking with `end_date` before `start_date` is stored without error. `get_date_range()` while-loop exits immediately, no availability rows are created, and the booking is invisible in the calendar but exists as a CPT post.
---
## CHANGELOG
**2026-05-05 (revision):** Subagent audit produced two false positives that were initially listed as CRITICAL. Verified by tracing call sites:
- **Old C1** (`mark_as_available` deletes all rows) → demoted to **L1**. The function is never called anywhere in the plugin; all cancellation paths use the correct `clear_booking_availability($booking_id)`. Remains a trap for future devs but no production impact.
- **Old C3** (POST /bookings nonce mismatch) → demoted to **L2**. The actual auth path (JS → `X-WP-Nonce` header → `wp_verify_nonce(..., 'wp_rest')`) is fully consistent. The `wp_nonce_field('yacht_booking_submit', ...)` in shortcode/widget renders a hidden input that JS never reads — dead form field, not a security gap.
Race condition (old C2 → C1) and unencrypted OAuth tokens (old C4 → C2) remain valid CRITICAL items.

View File

@@ -0,0 +1,110 @@
# Coding Conventions
**Analysis Date:** 2026-05-05
## PHP Style
**Standard:** WordPress Coding Standards (not PSR-12).
- 1 tab indentation
- `array()` long-form always — never `[]` short syntax in plugin code
- Spaces inside control structure parens: `if ( $condition )`, `foreach ( $items as $item )`
- No spaces inside function call parens: `get_post_meta( $id, '_key', true )`
- Every PHP file starts with direct-access guard:
```php
if ( ! defined( 'ABSPATH' ) ) { exit; }
```
- File-level docblock with `@package YachtBooking`
## Naming
| Thing | Convention | Example |
|-------|-----------|---------|
| Classes | PascalCase (underscore for multi-word) | `Rest_Controller`, `Yacht_Booking` |
| Methods | snake_case | `get_yacht_id()`, `mark_as_booked()` |
| Variables | snake_case | `$booking_id`, `$start_date` |
| Constants | SCREAMING_SNAKE_CASE | `YACHT_BOOKING_VERSION` |
| Post meta keys | underscore-prefixed, namespaced | `_booking_start_date`, `_yacht_capacity` |
| WP option keys | `yacht_booking_` prefix | `yacht_booking_version` |
| Boolean getters | `is_` / `has_` prefix | `is_available()`, `has_yacht_calendar_widget()` |
| Static getters | `get_{thing}($id)` | `Booking::get_customer_name($id)` |
| Static updaters | `update_{thing}($id, $value)` | `Yacht::update_capacity($id, $v)` |
## Class Patterns
**Singleton** — for hook-registering classes (must only run once):
```php
private static $instance = null;
public static function get_instance() {
if ( null === self::$instance ) { self::$instance = new self(); }
return self::$instance;
}
private function __construct() { /* register hooks here */ }
```
Used by: `Yacht_Booking`, `Admin`, `Shortcode`, `Sync_Controller`
**Static utility** — for pure data accessors with no instance state:
```php
public static function get_customer_name( $booking_id ) {
return get_post_meta( $booking_id, '_booking_customer_name', true );
}
```
Used by: `Booking`, `Yacht`, `Availability`, `Settings`, `Email_Templates`
**All WP hook registration goes in constructor or `init_hooks()` method.**
## Security Patterns
- **Nonces in admin forms:** `wp_nonce_field()` on output, `check_admin_referer()` on submit
- **Nonces in REST endpoints:** `X-WP-Nonce` header verified with `wp_verify_nonce($header, 'wp_rest')`
- **iCal feed:** per-yacht token in `_yacht_ical_token` meta, compared with `hash_equals()`
- **Admin REST endpoints:** `current_user_can('yacht_booking_manage_bookings')`
- **Sanitization on input:** `sanitize_text_field()`, `sanitize_email()`, `absint()`, `wp_kses_post()`
- **Escaping on output:** `esc_html()`, `esc_attr()`, `esc_url()`, `wp_kses_post()`
- **All `$wpdb` queries:** use `$wpdb->prepare()` — no raw SQL
## WordPress Integration Patterns
- CPT registration via static `register()` method called by orchestrator
- Options: `add_option()` (idempotent) on activation, `get_option()` / `update_option()` for runtime
- Capability checks use custom caps (`yacht_booking_manage_bookings`), not `manage_options`
- Admin form submissions: POST → `admin_init` handler → `wp_safe_redirect()` (PRG pattern)
- Error feedback: `?error=message` query string + `admin_notices` hook display
## JavaScript Style
- **Pattern:** IIFE `(function($) { 'use strict'; ... })(jQuery)` in both JS files
- **Variable naming:** camelCase; jQuery-wrapped objects prefixed with `$` (`$form`, `$submitBtn`)
- **`calendar.js`:** Named inner functions (`initYachtCalendar`, `formatDate`, `escapeHtml`); uses `const`/`let`
- **`admin.js`:** Object-literal module pattern (`const YachtBookingAdmin = { init, bindEvents, handleManualSync }`)
- **Data bridge:** `wp_localize_script()` injects `yachtBookingData` (apiUrl, nonce, bookingEnabled, i18n) and `yachtBookingAdmin`
- **Error display:** Inline HTML into `.yacht-booking-response` / `.yacht-inquiry-response` divs; messages from `yachtBookingData.i18n`
## CSS / Markup
- BEM-like kebab-case with `yacht-` prefix: `.yacht-calendar-wrapper`, `.yacht-booking-form-container`
- Modifier suffixes: `.yacht-calendar-view-only`, `.yacht-legend-swatch-past`
- JS state classes: `.is-active`, `.yacht-day-available`, `.yacht-day-booked`
- Mobile-first approach
## Internationalisation
- All user-visible strings: `__()`, `esc_html__()`, `esc_attr_e()` with domain `'yacht-booking'`
- Strings with variables: `printf( esc_html__( 'Text %s', 'yacht-booking' ), esc_html( $var ) )` — never concatenation
- Translators hint: `/* translators: %s: yacht name */` before `printf`
## Documentation
- Public/protected methods always get docblocks; private helpers sometimes omitted
- Inline block comments label logical sections: `// Get yacht title`, `// Save booking meta`
- HTML comments label template sections in Polish: `<!-- Nazwa jachtu -->`
- JS functions get JSDoc blocks: `/** Format date to YYYY-MM-DD */`
- PHPDoc generics notation for complex types: `@return array<int, array<string, mixed>>`
## Error Handling
- PHP: return `false` or `WP_Error` on failure
- REST: return `new \WP_Error( 'code', message, array( 'status' => 4xx ) )`
- No exceptions used anywhere in the plugin
- GCal API: `is_wp_error()` check only — HTTP error codes are NOT checked (known gap)
- Logging: `error_log()` with prefixes only when `WP_DEBUG === true`; nothing in production

View File

@@ -0,0 +1,120 @@
# External Integrations
**Analysis Date:** 2026-05-05
## Google Calendar API v3
**Purpose:** Bidirectional sync — bookings push to Google Calendar as events; Google Calendar events pull back as availability blocks.
**Implementation:** Native PHP, no SDK. Uses WordPress HTTP API (`wp_remote_post`, `wp_remote_get`, `wp_remote_request`).
**Files:**
- `integrations/google-calendar/class-gcal-service.php` — API calls
- `integrations/google-calendar/class-oauth-handler.php` — OAuth 2.0 token management
- `integrations/google-calendar/class-sync-controller.php` — Cron orchestration (Singleton)
**Authentication:** OAuth 2.0 with `offline` access (refresh token).
- Auth endpoint: `https://accounts.google.com/o/oauth2/v2/auth`
- Token endpoint: `https://oauth2.googleapis.com/token`
- Scope: `https://www.googleapis.com/auth/calendar`
- Credentials stored in `wp_options` key `yacht_booking_gcal_credentials`
- Tokens stored in `wp_options` key `yacht_booking_gcal_tokens`
- Calendar ID stored in `yacht_booking_gcal_calendar_id` (defaults to `'primary'`)
- **OAuth redirect URI hardcoded:** `https://jachty.pagedev.pl/wp-admin/admin.php?page=yacht-bookings-settings&tab=google-calendar&gcal_callback=1`
**Outbound API calls:**
| Method | URL | Purpose |
|--------|-----|---------|
| POST | `.../calendars/{id}/events` | Create event |
| PATCH | `.../calendars/{id}/events/{eventId}` | Update event |
| DELETE | `.../calendars/{id}/events/{eventId}` | Delete event |
| GET | `.../calendars/{id}/events` | List events (pull sync) |
| GET | `.../users/me/calendarList` | List calendars |
| POST | `https://oauth2.googleapis.com/token` | Token exchange/refresh |
| GET | `https://www.googleapis.com/oauth2/v2/userinfo` | Get connected email |
**Sync model:** Pull-only for external → local (hourly WP Cron `yacht_booking_pull_from_gcal`). Push for local → GCal on `yacht_booking_created` / `yacht_booking_status_changed` (scheduled single cron events).
**Limitations:**
- Single shared calendar for all yachts — GCal events apply to every yacht, not per-yacht
- No incoming webhooks — polling model only
- No retry logic on API failure
## iCal Integration
### Export (Feed)
**Purpose:** Generate `.ics` file per yacht for subscription by external clients (other booking systems, personal calendars).
**File:** `integrations/ical/class-ical-feed.php`
**URL pattern:** `{home_url}/yacht-ical/{yacht_id}/{token}.ics`
- Token stored in `_yacht_ical_token` post meta (24-char alphanumeric, auto-generated)
- Verified with `hash_equals()` — timing-safe
- Returns `text/calendar` with all non-cancelled bookings for that yacht
- Regenerate token: `ICal_Feed::regenerate_token($yacht_id)`
### Import (Subscribe)
**Purpose:** Subscribe to external iCal URLs per yacht; import events as availability blocks (hourly cron).
**File:** `integrations/ical/class-ical-import.php`
**Config:** Per-yacht `_yacht_ical_import_url` post meta (set via `ICal_Import::set_import_url()`)
**Behavior:** Fetches URL via `wp_remote_get()` (30-second timeout, SSL verification), parses VEVENT components, creates `yacht_booking` posts with `_booking_source = 'ical_import'`, marks dates as blocked via `Availability::mark_as_blocked()`.
**Limitation:** Does not check VEVENT `STATUS` property — cancelled events in the feed are imported as blocks.
## jsDelivr CDN
**Purpose:** FullCalendar library loaded from CDN (no local copy).
| Asset | URL |
|-------|-----|
| FullCalendar JS | `https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js` |
| FullCalendar CSS | `https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.css` |
| Polish locale | `https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.10/locales/pl.global.min.js` |
Enqueued in `Yacht_Booking::enqueue_frontend_assets()`, conditional on page content.
## Email
**Provider:** WordPress `wp_mail()` only — no Mailgun, SendGrid, or other transactional service.
**Templates:** `includes/class-email-templates.php` — templates stored in `wp_options` as tagged strings, compiled with `get_booking_template_data()`.
**When sent:**
- On booking creation: admin notification via `Rest_Controller::send_booking_notification()` (hooked on `yacht_booking_created`)
- On status change: customer notification via `Admin::send_customer_notification()` (hooked on `yacht_booking_status_changed`)
- On inquiry create: admin + customer emails via `Inquiry::send_emails()` (built inline, not using Email_Templates)
**Headers:** `From:` pulled from `yacht_booking_email_from` option; `Reply-To:` set explicitly.
## REST API (Internal)
Namespace: `yacht-booking/v1` — base: `/wp-json/yacht-booking/v1/`
| Method | Endpoint | Auth | Handler |
|--------|----------|------|---------|
| GET | `/yachts` | Public | `get_yachts()` |
| GET | `/yachts/{id}` | Public | `get_yacht()` |
| GET | `/availability/{yacht_id}?start=Y-m-d&end=Y-m-d` | Public | `get_availability()` |
| POST | `/bookings` | Public + `X-WP-Nonce` | `create_booking()` |
| POST | `/inquiries` | Public + `X-WP-Nonce` | `create_inquiry()` |
| GET | `/bookings` | Admin cap | `get_bookings()` |
| PUT | `/bookings/{id}/status` | Admin cap | `update_booking_status()` |
All registered in `api/class-rest-controller.php`.
## Monitoring & Observability
- No error tracking service (no Sentry, Bugsnag)
- `error_log()` used with prefixes: `[Yacht Booking - GCal]`, `[Yacht Booking - GCal Sync]`, `[Yacht Booking - iCal]`
- Logging only fires when `WP_DEBUG === true` — silent in production
## Deployment
- **Method:** FTP via VS Code ftp-kr extension (`.vscode/ftp-kr.json`)
- **CI/CD:** None — fully manual
- **Production URL:** `https://jachty.pagedev.pl` (hardcoded in GCal OAuth redirect)

View File

@@ -0,0 +1,52 @@
# Codebase Overview
**Project:** jachty3.pagedev.pl — Yacht Booking System
**Analyzed:** 2026-05-05
## What This Is
A custom WordPress plugin (`yacht-booking-system`) that provides yacht rental booking management with a FullCalendar frontend, REST API backend, Google Calendar bidirectional sync, and iCal feed export/import. Built for a Polish yacht charter company at `https://jachty.pagedev.pl`.
## Primary Entry Point
`wp-content/plugins/yacht-booking-system/yacht-booking-system.php`
- Defines 5 plugin constants (`YACHT_BOOKING_VERSION`, `YACHT_BOOKING_PLUGIN_DIR`, etc.)
- Registers a custom `spl_autoload_register` for the `YachtBooking\` namespace → `includes/class-*.php`
- Fires `Yacht_Booking::get_instance()` on `plugins_loaded`
- Activation hook: runs `Installer::install()`, schedules GCal and iCal cron jobs, flushes rewrite rules
## Core Data Model
Two parallel stores:
1. **WordPress CPTs** (in `wp_posts` + `wp_postmeta`):
- `yacht` — meta: capacity, price_per_day, gcal_id, features, ical_token, ical_import_url
- `yacht_booking` — meta: yacht_id, start_date, end_date, status, customer_*, total_price, gcal_event_id, source
- `yacht_inquiry` — meta: yacht_id, customer_*, preferred_dates, message (hidden from WP admin UI)
2. **Custom availability table** `wp_yacht_availability`:
- One row per yacht-date pair; only `booked` and `blocked` rows stored; absence = available
- Unique key on `(yacht_id, date)`
## User-Facing Features
- FullCalendar month view embedded via `[yacht_calendar yacht_id="X"]` shortcode or Elementor widget
- Booking form (date selection → customer details → submission via REST)
- Inquiry form (non-binding contact, sends email to admin)
- iCal feed per yacht at `{home_url}/yacht-ical/{yacht_id}/{token}.ics`
## Admin Features
- Custom admin menu with 6 pages: Bookings list, Inquiries list, Yachts list/edit, Settings, Google Calendar
- Approve/Cancel/Delete bookings with PRG pattern
- CSV export of bookings
- Google Calendar bidirectional sync (OAuth 2.0, manual + hourly cron)
- iCal import from external URLs per yacht (hourly cron)
- Email notifications: admin on booking create, customer on status change
## Language / Locale
UI language: Polish. All strings use `__()` / `esc_html__()` with `yacht-booking` text domain.
## Team / Deployment
Single developer. FTP deploy via ftp-kr VS Code extension. No CI/CD pipeline. Production: `https://jachty.pagedev.pl`. No Docker, no local environment config detected.

65
.paul/codebase/stack.md Normal file
View File

@@ -0,0 +1,65 @@
# Technology Stack
**Analysis Date:** 2026-05-05
## Languages
- **PHP 7.4+** — all server-side plugin logic (`Requires PHP: 7.4` in plugin header)
- **JavaScript (ES5/IIFE)** — frontend calendar + booking form (`calendar.js`), admin panel JS (`admin.js`)
- **SCSS** — frontend calendar styles (source at `frontend/assets/css/calendar.scss`; must be manually compiled)
- **SQL** — custom table queries via `$wpdb->prepare()`
## Runtime
- **WordPress 6.0+** (`Requires at least: 6.0`)
- **Elementor Pro** — optional; plugin checks `class_exists('\Elementor\Plugin')` and degrades gracefully
- **MySQL** — custom table + WordPress standard tables
## Key Dependencies
| Dependency | Version | How Loaded |
|-----------|---------|-----------|
| FullCalendar | 6.1.10 | jsDelivr CDN |
| FullCalendar Polish locale | 6.1.10 | jsDelivr CDN |
| jQuery | WP bundled | WordPress core |
| Google Calendar API v3 | — | Native HTTP (no SDK) |
**CDN URLs:**
- `https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.js`
- `https://cdn.jsdelivr.net/npm/fullcalendar@6.1.10/index.global.min.css`
- `https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.10/locales/pl.global.min.js`
## Plugin Constants
Defined in `yacht-booking-system.php` (lines 2327):
```php
YACHT_BOOKING_VERSION = '1.0.0'
YACHT_BOOKING_PLUGIN_FILE = __FILE__
YACHT_BOOKING_PLUGIN_DIR = plugin_dir_path(__FILE__)
YACHT_BOOKING_PLUGIN_URL = plugin_dir_url(__FILE__)
YACHT_BOOKING_PLUGIN_BASENAME = plugin_basename(__FILE__)
```
## Autoloading
Custom `spl_autoload_register` (lines 3256, `yacht-booking-system.php`):
- Namespace prefix: `YachtBooking\`
- Maps to: `YACHT_BOOKING_PLUGIN_DIR . 'includes/'`
- Rule: `YachtBooking\Foo_Bar``includes/class-foo-bar.php`
- **Only covers `includes/`** — integration and API classes loaded via explicit `require_once`
## Configuration Storage
- No `.env` file — all runtime config in `wp_options`
- Key options: `yacht_booking_gcal_credentials`, `yacht_booking_gcal_tokens`, `yacht_booking_gcal_calendar_id`, `yacht_booking_gcal_sync_enabled`, `yacht_booking_default_status`, `yacht_booking_enable_notifications`
## Build Pipeline
None. No Composer, no npm, no webpack/vite/esbuild. Assets are static hand-authored files deployed via FTP (`ftp-kr` VS Code extension, config: `.vscode/ftp-kr.json`).
## Platform Requirements
- **Dev:** VS Code + ftp-kr, PHP 7.4+ CLI (`php -l <file>` for syntax checks)
- **Production:** PHP 7.4+, MySQL, WordPress 6.0+, shared hosting or VPS
- **Note:** OAuth redirect URI hardcoded to `https://jachty.pagedev.pl` — staging/local OAuth will fail

View File

@@ -0,0 +1,94 @@
# Codebase Structure
**Analysis Date:** 2026-05-05
## Directory Layout
```
wp-content/plugins/yacht-booking-system/
├── yacht-booking-system.php # Plugin entry: constants, autoloader, hooks
├── includes/ # Core PHP classes (autoloaded via spl_autoload_register)
│ ├── class-yacht-booking.php # Main Singleton orchestrator
│ ├── class-yacht.php # Yacht CPT + static meta accessors
│ ├── class-booking.php # Booking CPT + static meta accessors
│ ├── class-inquiry.php # Inquiry CPT + static meta accessors + email sending
│ ├── class-availability.php # wp_yacht_availability table read/write
│ ├── class-installer.php # DB table creation, default options
│ ├── class-settings.php # wp_options accessors + formatting helpers
│ └── class-email-templates.php # Email template storage, compilation, tag replacement
├── api/ # REST API (loaded via require_once, not autoloader)
│ └── class-rest-controller.php # All REST routes under yacht-booking/v1
├── admin/ # WP admin panel (is_admin() gate)
│ ├── class-admin.php # Admin Singleton: menu, form handling, CSV export
│ ├── class-booking-list-table.php # WP_List_Table for bookings
│ ├── class-yacht-list-table.php # WP_List_Table for yachts
│ ├── class-inquiry-list-table.php # WP_List_Table for inquiries
│ ├── views/
│ │ └── yacht-edit.php # Add/edit yacht form template
│ └── assets/
│ ├── css/admin.css
│ └── js/admin.js
├── frontend/
│ ├── class-calendar-widget.php # Elementor widget (yacht-calendar)
│ ├── class-shortcode.php # [yacht_calendar] shortcode, Singleton
│ └── assets/
│ ├── css/calendar.css # Compiled from calendar.scss
│ ├── css/calendar.scss # SCSS source (manual compile required)
│ └── js/calendar.js # IIFE jQuery; consumes REST API via yachtBookingData
├── integrations/
│ ├── google-calendar/ # Loaded via explicit require_once (not autoloader)
│ │ ├── class-oauth-handler.php # OAuth 2.0 token storage + refresh
│ │ ├── class-gcal-service.php # Google Calendar API HTTP calls
│ │ └── class-sync-controller.php # Cron sync orchestration, Singleton
│ └── ical/
│ ├── class-ical-feed.php # Generate iCal feed, serve via rewrite rule
│ └── class-ical-import.php # Import external iCal URLs via cron
└── languages/ # .pot / .po / .mo for yacht-booking text domain
```
**Project root (WordPress install root — not plugin directory):**
```
/
├── test-yacht-plugin.php # Manual smoke test (run in browser as admin)
├── test-api-availability.php # Manual integration test (run in browser as admin)
└── tmp-fix-polish.php # Leftover artifact — single path string, no PHP code
```
## Naming Rules
| Thing | Convention | Example |
|-------|-----------|---------|
| PHP class files | `class-{kebab-case}.php` | `class-rest-controller.php` |
| PHP class names | `PascalCase` (with underscores) | `Rest_Controller`, `Yacht_Booking` |
| Directories | lowercase kebab-case | `google-calendar/`, `yacht-booking-system/` |
| Autoloader mapping | `YachtBooking\Foo_Bar``includes/class-foo-bar.php` | — |
| Sub-namespaces (integrations) | `YachtBooking\Integrations\GoogleCalendar` | — |
## Where to Add New Code
**New core feature:**
- File: `includes/class-{feature}.php`, namespace `YachtBooking\`
- Load: relies on autoloader automatically (for `includes/` only)
**New REST endpoint:**
- Add method + route registration to `api/class-rest-controller.php`
- Public: `'permission_callback' => '__return_true'` + nonce check inside handler
- Admin: `'permission_callback' => array($this, 'can_manage_bookings')`
**New admin page:**
- Add `add_submenu_page()` in `Admin::register_admin_menu()`
- Add `render_{page}()` method in `admin/class-admin.php`
- Add form processing method hooked on `admin_init` in `Admin::__construct()`
- View HTML: extract to `admin/views/{page}.php`
**New external integration:**
- Create `integrations/{service-name}/`
- Follow pattern: `class-{service}-service.php` + `class-sync-controller.php`
- Register cron: `static setup_cron()` + `static clear_cron()`, call from activation/deactivation hooks
- Load via `require_once` in `Yacht_Booking::load_dependencies()`
**New setting:**
- Add default in `Installer::create_options()`
- Add typed accessor in `Settings` class
- Add UI in `Admin::render_general_settings()` or new settings tab
- Save in `Admin::save_settings()`

106
.paul/codebase/testing.md Normal file
View File

@@ -0,0 +1,106 @@
# Testing
**Analysis Date:** 2026-05-05
## Framework
None. No PHPUnit, no Jest, no Vitest. No automated test runner configured.
**Only testing mechanism:** Two manual PHP scripts in the project root, run in-browser while logged in as admin against the live database.
## Test Files
| File | Type | Purpose |
|------|------|---------|
| `test-yacht-plugin.php` | Smoke test | Verifies plugin environment is set up correctly |
| `test-api-availability.php` | Integration test | Exercises REST endpoint + Availability class |
**How to run:**
```
# Syntax check (run after every PHP edit):
php -l wp-content/plugins/yacht-booking-system/includes/class-booking.php
# Browser smoke test (must be logged in as admin):
http://jachty.pagedev.local/test-yacht-plugin.php
# Browser integration test:
http://jachty.pagedev.local/test-api-availability.php
```
## What Is Tested
### test-yacht-plugin.php (Smoke Test)
1. Plugin file exists on disk
2. Plugin is active (`is_plugin_active()`)
3. `wp_yacht_availability` table exists
4. CPTs registered (`yacht`, `yacht_booking`)
5. Plugin options set (`yacht_booking_version`, `yacht_booking_installed_at`)
6. Custom capabilities assigned to `administrator` role
7. REST namespace `yacht-booking/v1` registered + route list
8. Admin menu `yacht-bookings` slug present
9. Yacht CPT CRUD: `wp_insert_post()` + meta + `wp_delete_post()` (cleanup included)
### test-api-availability.php (Integration Test)
1. Fetch first yacht from DB
2. Call `/availability/{yacht_id}` via `wp_remote_get()` — check HTTP 200, count statuses
3. Direct call `Availability::get_availability_calendar()` — display in HTML table
4. Direct call `Availability::is_available()` on +7 to +10 day range
5. `mark_as_booked()` with fake booking ID 999 → re-check → `clear_booking_availability(999)` → re-check (full CRUD cycle)
## What Is NOT Tested
- `POST /bookings` — full booking creation flow via REST never tested
- Email sending (`send_booking_notification`, `send_customer_notification`, `Inquiry::send_emails`)
- Google Calendar OAuth flow
- GCal sync (push and pull)
- iCal import and export
- Admin form processing (`process_yacht_save`, `process_booking_actions`, `save_settings`)
- Nonce verification in REST endpoints
- Status transitions (`pending``confirmed``cancelled`) and side effects
- `Shortcode::render_calendar()` output
- `Calendar_Widget::render()` output
- All JavaScript behavior
- Concurrent booking race conditions
- `mark_as_available()` bug (deletes all rows for yacht — see concerns.md)
## Test Pattern
```php
// Access guard
require_once __DIR__ . '/wp-load.php';
if (!current_user_can('manage_options')) { die('Admin only'); }
// Numbered HTML sections
echo '<h2>1. Section Name</h2>';
// Visual pass/fail — no assertions that throw
if ($condition) {
echo '✅ Thing works<br>';
} else {
echo '❌ Thing broken<br>';
die(); // hard stop only on fatal blockers
}
// Data output
echo '<details><summary>Raw data</summary><pre>';
print_r($data);
echo '</pre></details>';
```
## Mocking
None. All tests hit the live WordPress database. Side effects are real:
- `test-yacht-plugin.php`: creates and deletes a test yacht post
- `test-api-availability.php`: inserts availability rows with `booking_id=999`, then deletes them
## Coverage Tooling
None. No coverage measurement.
## Style Notes in Test Files
Intentional deviations from plugin code conventions (acceptable for dev-only scripts):
- Short array syntax `[]` used (plugin uses `array()`)
- No namespaces
- Partial output escaping — `$yacht->post_title` sometimes raw-echoed
- One raw `$wpdb->get_var()` without `prepare()` (`test-yacht-plugin.php:49`) — safe here as table name is not user input