insert( $this->table(), array( 'object_type' => substr( sanitize_key( $object_type ), 0, 40 ), 'object_id' => $object_id, 'event' => substr( sanitize_key( $event ), 0, 100 ), 'actor_type' => $actor_type ?? ( $user_id ? 'user' : 'system' ), 'actor_id' => $actor_type === 'system' || $actor_type === 'customer' ? null : ( $actor_id ?? ( $user_id ?: null ) ), 'message' => $message, 'context_json' => $context ? wp_json_encode( $context ) : null, 'created_at' => gmdate( 'Y-m-d H:i:s' ), ) ); } /** Append immutable due-start snapshots under the same serialization boundary as edits. */ public function capture_due_starts( int $since ): bool { global $wpdb; if ( ! current_user_can( 'abientot_manage_bookings' ) || BookingLock::is_held() ) { return false; } if ( ! BookingLock::acquire() ) { return false; } $started = false; try { \Abientot\Support\TransactionGuard::prepare_write(); if ( false === $wpdb->query( 'START TRANSACTION' ) ) { return false; } $started = true; $appointments = $wpdb->prefix . 'abientot_appointments'; $statuses = $wpdb->prefix . 'abientot_booking_statuses'; if ( false === $wpdb->query( $wpdb->prepare( "INSERT INTO %i (object_type,object_id,event,actor_type,actor_id,message,context_json,created_at) SELECT 'appointment',a.id,'booking_started','system',NULL,CONCAT('Appointment #',a.id,' is starting.'),JSON_OBJECT('start_utc',DATE_FORMAT(a.start_utc,'%%Y-%%m-%%d %%H:%%i:%%s'),'location_id',a.location_id),a.start_utc FROM %i a INNER JOIN %i st ON st.id=a.status_id WHERE a.start_utc >= %s AND a.start_utc <= %s AND st.slug NOT IN ('cancelled','no_show') AND NOT EXISTS (SELECT 1 FROM %i h WHERE h.object_type='appointment' AND h.object_id=a.id AND h.event='booking_started' AND (h.created_at=a.start_utc OR JSON_UNQUOTE(JSON_EXTRACT(CASE WHEN JSON_VALID(h.context_json) THEN h.context_json ELSE '{}' END,'$.start_utc'))=DATE_FORMAT(a.start_utc,'%%Y-%%m-%%d %%H:%%i:%%s'))) ORDER BY a.start_utc DESC,a.id DESC LIMIT 2000", $this->table(), $appointments, $statuses, gmdate( 'Y-m-d H:i:s', $since ), gmdate( 'Y-m-d H:i:s' ), $this->table() ) ) ) { return false; } if ( false === $wpdb->query( 'COMMIT' ) ) { return false; } $started = false; return true; } catch ( \Throwable $error ) { return false; } finally { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } BookingLock::release(); } } /** Protected paginated immutable history; never projects arbitrary context. Non-managers of settings never see settings events. */ public function can_read_event( int $id ): bool { global $wpdb; return $id > 0 && current_user_can( 'abientot_manage_bookings' ) && '1' === (string) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i h WHERE h.id=%d AND ( %d = 1 OR ( h.object_type <> 'settings' AND h.event NOT LIKE %s AND h.event NOT LIKE %s ) ) LIMIT 1", $this->table(), $id, current_user_can( 'abientot_manage_settings' ) ? 1 : 0, 'settings_%', 'configuration_%' ) ); } /** Find the containing page for a notification deep link, respecting the same scope. */ public function page_for_event( int $id, int $per_page = 25 ): int { global $wpdb; if ( $id <= 0 || ! current_user_can( 'abientot_manage_bookings' ) ) { return 1; } $all = current_user_can( 'abientot_manage_settings' ) ? 1 : 0; $row = $wpdb->get_row( $wpdb->prepare( "SELECT h.created_at,h.id FROM %i h WHERE h.id=%d AND ( %d = 1 OR ( h.object_type <> 'settings' AND h.event NOT LIKE %s AND h.event NOT LIKE %s ) )", $this->table(), $id, $all, 'settings_%', 'configuration_%' ), ARRAY_A ); if ( ! is_array( $row ) ) { return 1; } $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i h WHERE ( %d = 1 OR ( h.object_type <> 'settings' AND h.event NOT LIKE %s AND h.event NOT LIKE %s ) ) AND (h.created_at>%s OR (h.created_at=%s AND h.id>%d))", $this->table(), $all, 'settings_%', 'configuration_%', $row['created_at'], $row['created_at'], $id ) ); return 1 + (int) floor( (int) $count / max( 1, min( 100, $per_page ) ) ); } public function activity_page( int $page = 1, int $per_page = 25, ?string $since_utc = null ): array { global $wpdb; if ( ! current_user_can( 'abientot_manage_bookings' ) ) { return array( 'rows' => array(), 'has_more' => false ); } $per_page = max( 1, min( 100, $per_page ) ); $page = max( 1, min( 1000000, $page ) ); $appointments = $wpdb->prefix . 'abientot_appointments'; $since = $since_utc ?? '1000-01-01 00:00:00'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT CONCAT('log:',h.id) AS activity_key,h.id,h.object_type,h.object_id,h.event,h.actor_type,h.actor_id,h.message,h.created_at,h.context_json AS location_context,COALESCE(a.location_id,0) AS location_id FROM %i h LEFT JOIN %i a ON h.object_type='appointment' AND a.id=h.object_id WHERE ( %d = 1 OR ( h.object_type <> 'settings' AND h.event NOT LIKE %s AND h.event NOT LIKE %s ) ) AND h.created_at >= %s ORDER BY h.created_at DESC,h.id DESC LIMIT %d OFFSET %d", $this->table(), $appointments, current_user_can( 'abientot_manage_settings' ) ? 1 : 0, 'settings_%', 'configuration_%', $since, $per_page + 1, ( $page - 1 ) * $per_page ), ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); foreach ( $rows as &$row ) { $context = json_decode( (string) ( $row['location_context'] ?? '' ), true ); if ( is_array( $context ) && isset( $context['location_id'] ) && is_int( $context['location_id'] ) && $context['location_id'] >= 0 ) { $row['location_id'] = $context['location_id']; } if ( ( $row['object_type'] ?? '' ) === 'settings' && is_array( $context ) && in_array( $context['field'] ?? null, array( 'hours', 'extend_closures', 'categories' ), true ) ) { $row['notification_field'] = $context['field']; } unset( $row['location_context'] ); } unset( $row ); return array( 'rows' => array_slice( $rows, 0, $per_page ), 'has_more' => count( $rows ) > $per_page ); } /** * Records a booking activity event. * * @param array $context Event context. */ public function record_booking_event( int $appointment_id, string $event, string $message, array $context = array() ): bool { $user_id = get_current_user_id(); return $this->insert( array( 'object_type' => 'appointment', 'object_id' => $appointment_id, 'event' => $event, 'actor_type' => $user_id > 0 ? 'user' : 'system', 'actor_id' => $user_id > 0 ? $user_id : null, 'message' => $message, 'context_json' => empty( $context ) ? null : wp_json_encode( $context ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), ) ) > 0; } /** * Records a public customer self-service booking event. * * @param array $context Event context. */ public function record_customer_booking_event( int $appointment_id, string $event, string $message, array $context = array() ): bool { return $this->insert( array( 'object_type' => 'appointment', 'object_id' => $appointment_id, 'event' => $event, 'actor_type' => 'customer', 'actor_id' => null, 'message' => $message, 'context_json' => empty( $context ) ? null : wp_json_encode( $context ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), ) ) > 0; } /** * Returns recent activity for a booking. * * @return array> */ public function for_booking( int $appointment_id, int $limit = 25 ): array { global $wpdb; if ( $appointment_id <= 0 ) { return array(); } $limit = max( 1, min( 100, $limit ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_activity_log WHERE object_type = %s AND object_id = %d ORDER BY created_at DESC, id DESC LIMIT %d", 'appointment', $appointment_id, $limit ), ARRAY_A ); $rows = is_array( $rows ) ? $rows : array(); foreach ( $rows as &$row ) { if ( 'booking_started' === $row['event'] ) { $row['message'] = sprintf( /* translators: %d: booking ID. */ __( 'Appointment #%d is starting.', 'abientot' ), $appointment_id ); } } unset( $row ); return $rows; } }