Files
WooDoo/includes/class-woodoo-calendar.php
Malin 68c1ff4455 feat: initial WooDoo plugin – WooCommerce & Odoo 19 integration
- Odoo JSON-RPC client (no Composer, uses wp_remote_post)
- Admin settings page under WooCommerce with connection test
- Customer linking: search Odoo partners from WP user profile
- My Account: Odoo Invoices tab with PDF proxy download
- My Account: Book a Meeting tab (slot calculator + calendar.event)
- WC order → Odoo sale.order auto-sync on processing status
- Products matched by SKU; partner auto-created from billing info
- Uninstall cleanup (options, user meta, order meta, DB table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:58:27 +02:00

319 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* My Account Book a Meeting tab.
* Reads booked slots from Odoo calendar.event and creates new bookings.
*/
defined( 'ABSPATH' ) || exit;
class WooDoo_Calendar {
const ENDPOINT = 'book-meeting';
public static function init(): void {
add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'add_menu_item' ], 30 );
add_action( 'woocommerce_account_' . self::ENDPOINT . '_endpoint', [ __CLASS__, 'render' ] );
add_filter( 'woocommerce_get_query_vars', [ __CLASS__, 'add_query_var' ] );
add_action( 'wp_ajax_woodoo_get_slots', [ __CLASS__, 'ajax_get_slots' ] );
add_action( 'wp_ajax_woodoo_book_slot', [ __CLASS__, 'ajax_book_slot' ] );
add_action( 'wp_ajax_woodoo_get_meetings',[ __CLASS__, 'ajax_get_meetings' ] );
add_action( 'wp_ajax_woodoo_cancel_meeting', [ __CLASS__, 'ajax_cancel_meeting' ] );
}
public static function add_query_var( array $vars ): array {
$vars[ self::ENDPOINT ] = self::ENDPOINT;
return $vars;
}
public static function add_menu_item( array $items ): array {
$items[ self::ENDPOINT ] = __( 'Book a Meeting', 'woodoo' );
return $items;
}
// ── Render ────────────────────────────────────────────────────────────
public static function render(): void {
$user_id = get_current_user_id();
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
$user = get_userdata( $user_id );
$api = woodoo_api();
if ( ! $api ) {
echo '<p class="woodoo-notice woodoo-error">' .
esc_html__( 'Booking is not available right now. Please contact support.', 'woodoo' ) .
'</p>';
return;
}
// Upcoming bookings for this user
$upcoming = [];
if ( $partner_id ) {
$now = gmdate( 'Y-m-d H:i:s' );
$upcoming = $api->search_read(
'calendar.event',
[
[ 'partner_ids', 'in', [ $partner_id ] ],
[ 'start', '>=', $now ],
[ 'active', '=', true ],
],
[ 'id', 'name', 'start', 'stop', 'location', 'description', 'videocall_location' ],
20,
0,
'start asc'
);
}
include WOODOO_DIR . 'templates/myaccount-calendar.php';
}
// ── Slot Calculation ──────────────────────────────────────────────────
/**
* Returns available booking slots for a given date (Y-m-d).
* Filters out already-booked times from Odoo calendar.
*
* @return array List of ['start' => 'Y-m-d H:i', 'end' => 'Y-m-d H:i']
*/
public static function get_available_slots( string $date ): array {
$available_days = (array) get_option( 'woodoo_available_days', [ 1, 2, 3, 4, 5 ] );
$from = get_option( 'woodoo_available_from', '09:00' );
$to = get_option( 'woodoo_available_to', '17:00' );
$duration = (int) get_option( 'woodoo_meeting_duration', 30 );
$ts = strtotime( $date );
$day_of_w = (int) gmdate( 'N', $ts ); // 1=Mon … 7=Sun
if ( ! in_array( $day_of_w, array_map( 'intval', $available_days ), true ) ) {
return [];
}
// Build all slots for the day
$slots = [];
$from_ts = strtotime( $date . ' ' . $from . ':00' );
$to_ts = strtotime( $date . ' ' . $to . ':00' );
$step = $duration * 60;
for ( $start = $from_ts; $start + $step <= $to_ts; $start += $step ) {
$slots[] = [
'start' => gmdate( 'Y-m-d H:i', $start ),
'end' => gmdate( 'Y-m-d H:i', $start + $step ),
'start_ts' => $start,
'end_ts' => $start + $step,
];
}
// Fetch existing events from Odoo for this day
$api = woodoo_api();
if ( ! $api ) return $slots;
$day_start = $date . ' 00:00:00';
$day_end = $date . ' 23:59:59';
$cache_key = 'woodoo_booked_' . $date;
$booked = get_transient( $cache_key );
if ( false === $booked ) {
$booked = $api->search_read(
'calendar.event',
[
[ 'start', '>=', $day_start ],
[ 'start', '<=', $day_end ],
[ 'active', '=', true ],
],
[ 'id', 'start', 'stop' ],
100
);
set_transient( $cache_key, $booked, 120 );
}
// Remove slots that overlap with existing events
$available = array_filter( $slots, function ( $slot ) use ( $booked ) {
foreach ( $booked as $event ) {
$e_start = strtotime( $event['start'] );
$e_end = strtotime( $event['stop'] );
// Overlap: slot_start < e_end AND slot_end > e_start
if ( $slot['start_ts'] < $e_end && $slot['end_ts'] > $e_start ) {
return false;
}
}
return true;
} );
// Strip internal timestamps before returning
return array_values( array_map( fn($s) => [
'start' => $s['start'],
'end' => $s['end'],
], $available ) );
}
// ── AJAX: Get Slots ───────────────────────────────────────────────────
public static function ajax_get_slots(): void {
check_ajax_referer( 'woodoo_calendar', 'nonce' );
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
$date = sanitize_text_field( wp_unslash( $_POST['date'] ?? '' ) );
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ) {
wp_send_json_error( 'Invalid date format' );
}
// Don't allow past dates
if ( strtotime( $date ) < strtotime( 'today' ) ) {
wp_send_json_success( [] );
}
wp_send_json_success( self::get_available_slots( $date ) );
}
// ── AJAX: Book Slot ───────────────────────────────────────────────────
public static function ajax_book_slot(): void {
check_ajax_referer( 'woodoo_calendar', 'nonce' );
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
$start = sanitize_text_field( wp_unslash( $_POST['start'] ?? '' ) );
$end = sanitize_text_field( wp_unslash( $_POST['end'] ?? '' ) );
$notes = sanitize_textarea_field( wp_unslash( $_POST['notes'] ?? '' ) );
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $start )
|| ! preg_match( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $end ) ) {
wp_send_json_error( 'Invalid date/time format' );
}
if ( strtotime( $start ) < time() ) {
wp_send_json_error( __( 'Cannot book a slot in the past.', 'woodoo' ) );
}
$user_id = get_current_user_id();
$user = get_userdata( $user_id );
$api = woodoo_api();
if ( ! $api ) wp_send_json_error( 'API not configured' );
// Find or create Odoo partner for the customer
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
if ( ! $partner_id ) {
$partner_id = $api->find_or_create_partner(
$user->user_email,
$user->display_name
);
if ( $partner_id ) {
update_user_meta( $user_id, 'woodoo_odoo_partner_id', $partner_id );
}
}
if ( ! $partner_id ) {
wp_send_json_error( __( 'Could not find your Odoo record.', 'woodoo' ) );
}
// Get the Odoo user (organizer) the API user
$me_uid = $api->authenticate();
// Find res.users for this uid to get partner_id of organizer
$organizer_users = $api->search_read(
'res.users',
[ [ 'id', '=', $me_uid ] ],
[ 'partner_id' ],
1
);
$organizer_partner_id = $organizer_users[0]['partner_id'][0] ?? null;
$title = get_option( 'woodoo_meeting_title_prefix', 'Meeting via WooDoo' )
. ' ' . $user->display_name;
$partner_ids = array_filter( [ $partner_id, $organizer_partner_id ] );
$event_vals = [
'name' => $title,
'start' => $start . ':00',
'stop' => $end . ':00',
'description' => $notes ?: false,
'partner_ids' => array_map( fn($id) => [ 4, $id, 0 ], $partner_ids ),
'privacy' => 'confidential',
];
$event_id = $api->create( 'calendar.event', $event_vals );
if ( ! $event_id ) {
wp_send_json_error( __( 'Could not create meeting in Odoo. Please try again.', 'woodoo' ) );
}
// Invalidate booked slots cache for this date
$date = substr( $start, 0, 10 );
delete_transient( 'woodoo_booked_' . $date );
wp_send_json_success( [
'event_id' => $event_id,
'start' => $start,
'end' => $end,
'message' => __( 'Meeting booked! You will receive a confirmation from Odoo.', 'woodoo' ),
] );
}
// ── AJAX: Get User's Meetings ─────────────────────────────────────────
public static function ajax_get_meetings(): void {
check_ajax_referer( 'woodoo_calendar', 'nonce' );
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
$user_id = get_current_user_id();
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
if ( ! $partner_id ) wp_send_json_success( [] );
$api = woodoo_api();
if ( ! $api ) wp_send_json_error( 'API not configured' );
$now = gmdate( 'Y-m-d H:i:s' );
$meetings = $api->search_read(
'calendar.event',
[
[ 'partner_ids', 'in', [ $partner_id ] ],
[ 'start', '>=', $now ],
[ 'active', '=', true ],
],
[ 'id', 'name', 'start', 'stop', 'location', 'videocall_location' ],
20,
0,
'start asc'
);
wp_send_json_success( $meetings );
}
// ── AJAX: Cancel Meeting ──────────────────────────────────────────────
public static function ajax_cancel_meeting(): void {
check_ajax_referer( 'woodoo_calendar', 'nonce' );
if ( ! is_user_logged_in() ) wp_send_json_error( 'Not logged in', 401 );
$event_id = absint( $_POST['event_id'] ?? 0 );
$user_id = get_current_user_id();
$partner_id = (int) get_user_meta( $user_id, 'woodoo_odoo_partner_id', true );
if ( ! $event_id || ! $partner_id ) wp_send_json_error( 'Invalid request' );
$api = woodoo_api();
if ( ! $api ) wp_send_json_error( 'API not configured' );
// Verify the event belongs to this user
$events = $api->search(
'calendar.event',
[ [ 'id', '=', $event_id ], [ 'partner_ids', 'in', [ $partner_id ] ] ],
1
);
if ( empty( $events ) ) {
wp_send_json_error( __( 'Meeting not found.', 'woodoo' ) );
}
// Unlink the customer partner from the event (don't delete the event)
$api->write(
'calendar.event',
[ $event_id ],
[ 'partner_ids' => [ [ 3, $partner_id, 0 ] ] ]
);
wp_send_json_success( [ 'message' => __( 'Meeting cancelled.', 'woodoo' ) ] );
}
}