repository = $repository ?? new Repository(); $this->service = $service ?? new Service( $this->repository ); } /** Ordinary requests trust the stored schema version (no SHOW queries); the worker, settings page and setup verify the table. A failed creation is retried at most hourly. */ public function register(): void { if ( get_option( Migrator::OPTION, '' ) === '' && ! get_transient( self::RETRY ) && ! Migrator::ensure() ) { set_transient( self::RETRY, 1, HOUR_IN_SECONDS ); } $this->ready = get_option( Migrator::OPTION, '' ) === Migrator::VERSION; add_filter( 'cron_schedules', array( $this, 'schedules' ) ); add_filter( 'abientot_register_admin_modules', array( $this, 'modules' ) ); add_filter( 'abientot_render_admin_module', array( $this, 'render' ), 10, 2 ); add_action( 'admin_post_' . self::ACTION, array( $this, 'manage' ) ); add_action( self::HOOK, array( $this, 'tick' ) ); add_action( 'abientot_booking_committed', array( $this, 'booking_committed' ), 10, 4 ); } /** Explicit activation/setup recovery; ordinary views remain read-only. */ public static function restore_schedule(): bool { $module = new self(); $module->ready = get_option( Migrator::OPTION, '' ) === Migrator::VERSION && Migrator::verified(); if ( ! $module->ready ) { return false; } add_filter( 'cron_schedules', array( $module, 'schedules' ) ); try { $module->schedule(); return true; } finally { remove_filter( 'cron_schedules', array( $module, 'schedules' ) ); } } public function schedules( array $schedules ): array { $schedules[ self::SCHEDULE ] = array( 'interval' => 300, 'display' => __( 'Every five minutes', 'abientot' ), ); return $schedules; } public function schedule(): void { $enabled = $this->ready && $this->repository->settings()['enabled']; if ( ! $enabled ) { $result = wp_clear_scheduled_hook( self::HOOK, array(), true ); if ( is_wp_error( $result ) || $result === false ) { throw new \RuntimeException( 'reminder_unschedule' ); } return; } if ( ! wp_next_scheduled( self::HOOK ) ) { $result = wp_schedule_event( time() + 60, self::SCHEDULE, self::HOOK, array(), true ); if ( is_wp_error( $result ) || $result === false ) { throw new \RuntimeException( 'reminder_schedule' ); } } } /** Observable worker progress; read-only pages never update this checkpoint. */ public function tick(): void { if ( ! $this->ready || ! Migrator::verified() ) { return; } $health = array( 'at' => time(), 'state' => 'finished', 'accepted' => 0, ); try { $health['accepted'] = $this->service->run(); } catch ( \Throwable $error ) { $health['state'] = 'failed'; } try { update_option( self::LAST_RUN, $health, false ); } catch ( \Throwable $error ) { unset( $error ); // The queue remains the durable record if health storage fails. } } public function health(): array { $last = get_option( self::LAST_RUN, array() ); $last = is_array( $last ) && isset( $last['at'], $last['state'] ) && is_int( $last['at'] ) && $last['at'] > 0 && in_array( $last['state'], array( 'finished', 'failed' ), true ) ? $last : null; $next = wp_next_scheduled( self::HOOK ); return array( 'last' => $last, 'next' => $next ? $next : null, 'overdue' => $next && $next < time() - 10 * MINUTE_IN_SECONDS, ); } public function booking_committed( string $operation, int $id, ?array $before = null, array $after = array() ): void { if ( ! $this->ready ) { return; } global $wpdb; $suppressed = $wpdb->suppress_errors( true ); try { if ( ! $this->repository->settings()['enabled'] ) { return; } $this->repository->sync( $id, time() ); } catch ( \Throwable $error ) { unset( $error ); // Bounded reconciliation recovers missed lifecycle events without corrupting booking responses. } finally { $wpdb->suppress_errors( $suppressed ); } } public function modules( array $modules ): array { $modules['reminders'] = array( 'title' => __( 'Email reminders', 'abientot' ), 'menu_title' => __( 'Email reminders', 'abientot' ), 'capability' => 'abientot_manage_settings', 'description' => __( 'Enable booking reminders and review delivery attempts.', 'abientot' ), ); return $modules; } public function render( bool $rendered, string $module ): bool { if ( $module !== 'reminders' ) { return $rendered; } $this->page(); return true; } public function manage(): void { $input = Input::post( self::ACTION ); if ( ! current_user_can( 'abientot_manage_settings' ) ) { wp_die( esc_html__( 'You do not have permission to manage settings.', 'abientot' ), '', array( 'response' => 403 ) ); } if ( ! Request::is_post() ) { wp_die( esc_html__( 'Invalid request.', 'abientot' ), '', array( 'response' => 405 ) ); } $code = 'saved'; try { $this->ready = Migrator::ensure(); if ( ! $this->ready ) { throw new \RuntimeException( 'reminder_schema' ); } $operation = $input->key( 'operation' ); if ( $operation === 'save' ) { $lead = $input->has( 'lead_hours' ) ? filter_var( $input->text( 'lead_hours' ), FILTER_VALIDATE_INT ) : false; $subject = $input->text( 'subject' ); $body = $input->html( 'body' ); if ( $lead === false || $lead < 1 || $lead > 168 || trim( $subject ) === '' || trim( $body ) === '' ) { throw new \DomainException( 'reminder_input' ); } $this->repository->configure( array( 'enabled' => '1' === $input->key( 'enabled' ), 'lead_hours' => $lead, 'subject' => $subject, 'body' => $body, ), time() ); $this->schedule(); } elseif ( in_array( $operation, array( 'retry', 'dismiss' ), true ) ) { $id = $input->has( 'job_id' ) ? filter_var( $input->text( 'job_id' ), FILTER_VALIDATE_INT ) : false; if ( ! $id || $id < 1 ) { throw new \DomainException( 'reminder_input' ); } $this->repository->intervene( $id, $operation, '1' === $input->key( 'acknowledge' ), time() ); } else { throw new \DomainException( 'reminder_input' ); } } catch ( \Throwable $error ) { $code = 'failed'; } wp_safe_redirect( RedirectNotice::url( admin_url( 'admin.php?page=abientot-settings&tab=communication' ), array( 'reminder_result' => $code ) ) ); exit; } private function hidden( string $operation ): void { wp_nonce_field( self::ACTION ); echo ''; } public function page(): void { if ( ! current_user_can( 'abientot_manage_settings' ) ) { wp_die( esc_html__( 'You do not have permission to manage settings.', 'abientot' ) ); } echo '
' . esc_html__( 'Reminder storage is unavailable. No reminder will be sent.', 'abientot' ) . '
' . esc_html__( 'Reminder storage is unavailable. No reminder will be sent.', 'abientot' ) . '
'; return; } $notice = RedirectNotice::get( 'reminder_result' ); if ( in_array( $notice, array( 'saved', 'failed' ), true ) ) { echo '' . esc_html( $notice === 'saved' ? __( 'Reminder settings or queue updated.', 'abientot' ) : __( 'The reminder action could not be completed. Review the settings and delivery state before trying again.', 'abientot' ) ) . '
'abientot-settings', 'tab' => 'communication', 'reminder_page' => $number, ), admin_url( 'admin.php' ) ) ) . '">' . esc_html( $number ) . ''; } } ?>
__( 'Pending', 'abientot' ), 'leased' => __( 'Preparing delivery', 'abientot' ), 'sending' => __( 'Sending', 'abientot' ), 'retry' => __( 'Retry scheduled', 'abientot' ), 'sent' => __( 'Sent', 'abientot' ), 'cancelled' => __( 'Cancelled', 'abientot' ), 'expired' => __( 'Expired', 'abientot' ), 'uncertain' => __( 'Uncertain — review required', 'abientot' ), 'failed' => __( 'Failed — review required', 'abientot' ), ); return $labels[ $state ] ?? __( 'Unknown', 'abientot' ); } private static function result_label( string $code ): string { $labels = array( '' => __( 'No delivery attempt', 'abientot' ), 'sent' => __( 'Mail transport accepted the message', 'abientot' ), 'transport_false' => __( 'Mail transport rejected the message', 'abientot' ), 'transport_exception' => __( 'Delivery interrupted; outcome unknown', 'abientot' ), 'lease_expired' => __( 'Worker stopped before completing the attempt', 'abientot' ), 'disabled' => __( 'Reminders disabled', 'abientot' ), 'booking_changed' => __( 'Booking changed', 'abientot' ), 'booking_unavailable' => __( 'Booking is no longer eligible', 'abientot' ), 'not_deliverable' => __( 'Current booking or recipient is not eligible', 'abientot' ), 'manager_retry' => __( 'Manager requested a retry', 'abientot' ), 'manager_dismissed' => __( 'Manager dismissed this reminder', 'abientot' ), ); return $labels[ $code ] ?? __( 'Review required', 'abientot' ); } }