suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $wpdb->options ), ARRAY_A ), 'reminder_read' ); if ( ! $engine || strtoupper( (string) $engine[0]['ENGINE'] ) !== 'INNODB' ) { throw new \RuntimeException( 'reminder_options_engine' ); } Db::done( $wpdb->suppress_errors( true ), $wpdb->query( 'START TRANSACTION' ), 'reminder_write' ); $started = true; $result = $callback(); Db::done( $wpdb->suppress_errors( true ), $wpdb->query( 'COMMIT' ), 'reminder_write' ); $started = false; return $result; } catch ( \Throwable $error ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } throw $error; } finally { if ( $owned ) { BookingLock::release(); } } } /** Settings read from the database, bypassing the options cache. */ public function settings(): array { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", Settings::OPTION ), ARRAY_A ), 'reminder_read' ); return Settings::normalize( $rows ? maybe_unserialize( $rows[0]['option_value'] ) : null ); } /** * Writes an option inside the current transaction. * * @param mixed $value Option value. */ private function option( string $name, $value ): void { global $wpdb; Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->options}(option_name,option_value,autoload) VALUES(%s,%s,'off') ON DUPLICATE KEY UPDATE option_value=VALUES(option_value)", $name, maybe_serialize( $value ) ) ), 'reminder_write' ); } private function clear_options(): void { foreach ( array( Settings::OPTION, Settings::CURSOR, 'alloptions', 'notoptions' ) as $key ) { wp_cache_delete( $key, 'options' ); } } public function configure( array $settings, int $now ): array { $result = $this->transaction( function () use ( $settings, $now ) { $old = $this->settings(); $new = Settings::normalize( $settings ); $new['revision'] = $old['revision'] + 1; $this->option( Settings::OPTION, $new ); $this->option( Settings::CURSOR, 0 ); if ( ! $new['enabled'] ) { $this->invalidate( null, 'disabled', $now ); } return $new; } ); $this->clear_options(); return $result; } public function booking( int $id ): ?array { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT a.*,c.email AS reminder_email,s.slug AS status_slug,s.blocks_availability FROM {$wpdb->prefix}abientot_appointments a" . " INNER JOIN {$wpdb->prefix}abientot_customers c ON c.id=a.customer_id" . " INNER JOIN {$wpdb->prefix}abientot_booking_statuses s ON s.id=a.status_id" . " INNER JOIN {$wpdb->prefix}abientot_services v ON v.id=a.service_id" . " INNER JOIN {$wpdb->prefix}abientot_staff f ON f.id=a.staff_id" . " INNER JOIN {$wpdb->prefix}abientot_locations l ON l.id=a.location_id WHERE a.id=%d", $id ), ARRAY_A ), 'reminder_read' ); return $rows[0] ?? null; } public static function eligible( ?array $booking, int $now ): bool { return $booking && ( $booking['source'] ?? '' ) !== 'import' && (int) $booking['blocks_availability'] === 1 && ! in_array( $booking['status_slug'], array( 'cancelled', 'completed', 'no-show', 'no_show' ), true ) && is_email( $booking['reminder_email'] ) && strtotime( $booking['start_utc'] . ' UTC' ) > $now; } public static function start_digest( array $booking ): string { return hash( 'sha256', $booking['id'] . '|' . $booking['start_utc'] ); } public static function booking_digest( array $booking ): string { $data = array(); foreach ( array( 'id', 'customer_id', 'service_id', 'staff_id', 'location_id', 'start_utc', 'end_utc', 'timezone', 'duration_minutes' ) as $key ) { $data[ $key ] = (string) $booking[ $key ]; } $data['email'] = strtolower( trim( $booking['reminder_email'] ) ); return hash_hmac( 'sha256', wp_json_encode( $data ), wp_salt( 'nonce' ) ); } /** * Cancels open jobs (a job already sending becomes uncertain). * * @param int|null $appointment One booking, or null for every booking. * @param string|null $keep Start digest of the occurrence to leave untouched. */ private function invalidate( ?int $appointment, string $code, int $now, ?string $keep = null ): void { global $wpdb; Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET lease_hash=IF(state='sending',lease_hash,NULL),state=IF(state='sending','uncertain','cancelled'),last_code=%s,lease_until=NULL,updated_at=%s" . " WHERE ( %d = 1 OR appointment_id=%d ) AND ( %d = 0 OR start_digest<>%s ) AND state IN('pending','retry','leased','sending')", $code, gmdate( 'Y-m-d H:i:s', $now ), null === $appointment ? 1 : 0, (int) $appointment, null === $keep ? 0 : 1, (string) $keep ) ), 'reminder_write' ); } private function sync_locked( int $id, int $now ): void { global $wpdb; $config = $this->settings(); $booking = $this->booking( $id ); if ( ! $config['enabled'] || ! self::eligible( $booking, $now ) ) { $this->invalidate( $id, $config['enabled'] ? 'booking_unavailable' : 'disabled', $now ); return; } $start = self::start_digest( $booking ); $digest = self::booking_digest( $booking ); $this->invalidate( $id, 'booking_changed', $now, $start ); $due = gmdate( 'Y-m-d H:i:s', strtotime( $booking['start_utc'] . ' UTC' ) - $config['lead_hours'] * HOUR_IN_SECONDS ); $stamp = gmdate( 'Y-m-d H:i:s', $now ); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_reminder_queue WHERE appointment_id=%d AND start_digest=%s FOR UPDATE", $id, $start ), ARRAY_A ), 'reminder_read' ); if ( ! $rows ) { Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}abientot_reminder_queue(appointment_id,start_digest,booking_digest,due_at,next_attempt_at,state,attempts,created_at,updated_at) VALUES(%d,%s,%s,%s,%s,'pending',0,%s,%s)", $id, $start, $digest, $due, $due, $stamp, $stamp ) ), 'reminder_write' ); return; } $row = $rows[0]; if ( in_array( $row['state'], array( 'sent', 'uncertain', 'failed', 'expired' ), true ) || ( $row['state'] === 'cancelled' && ! in_array( $row['last_code'], array( 'disabled', 'booking_changed', 'booking_unavailable' ), true ) ) ) { return; } if ( $row['state'] === 'sending' || $row['state'] === 'leased' ) { if ( ! hash_equals( $row['booking_digest'], $digest ) ) { Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET lease_hash=IF(state='sending',lease_hash,NULL),state=%s,last_code='booking_changed',lease_until=NULL,updated_at=%s WHERE id=%d", $row['state'] === 'sending' ? 'uncertain' : 'cancelled', $stamp, $row['id'] ) ), 'reminder_write' ); } else { Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET updated_at=%s WHERE id=%d", $stamp, $row['id'] ) ), 'reminder_write' ); } return; } $next = $row['state'] === 'retry' ? max( $due, $row['next_attempt_at'] ) : $due; Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET booking_digest=%s,due_at=%s,next_attempt_at=%s,state=%s,updated_at=%s WHERE id=%d", $digest, $due, $next, $row['state'] === 'retry' ? 'retry' : 'pending', $stamp, $row['id'] ) ), 'reminder_write' ); } public function sync( int $id, int $now ): void { $this->transaction( function () use ( $id, $now ) { $this->sync_locked( $id, $now ); } ); } public function reconcile( int $now, int $limit = 100 ): void { global $wpdb; $limit = max( 1, min( 100, $limit ) ); $this->transaction( function () use ( $now, $limit, $wpdb ) { $stamp = gmdate( 'Y-m-d H:i:s', $now ); Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state=IF(state='sending','uncertain','retry'),last_code='lease_expired',next_attempt_at=%s,lease_hash=NULL,lease_until=NULL,updated_at=%s WHERE state IN('leased','sending') AND lease_until<=%s", $stamp, $stamp, $stamp ) ), 'reminder_write' ); if ( ! $this->settings()['enabled'] ) { $this->invalidate( null, 'disabled', $now ); return; } $cursorrows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name=%s", Settings::CURSOR ), ARRAY_A ), 'reminder_read' ); $cursor = max( 0, (int) ( $cursorrows[0]['option_value'] ?? 0 ) ); $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}abientot_appointments WHERE id>%d AND start_utc>%s AND source<>'import' ORDER BY id LIMIT %d", $cursor, $stamp, $limit ), ARRAY_A ), 'reminder_read' ); foreach ( $rows as $row ) { $this->sync_locked( (int) $row['id'], $now ); } $this->option( Settings::CURSOR, count( $rows ) === $limit ? (int) end( $rows )['id'] : 0 ); $audit = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT appointment_id FROM {$wpdb->prefix}abientot_reminder_queue WHERE state IN('pending','retry','leased','sending') GROUP BY appointment_id ORDER BY MIN(updated_at),appointment_id LIMIT %d", $limit ), ARRAY_A ), 'reminder_read' ); foreach ( $audit as $row ) { $this->sync_locked( (int) $row['appointment_id'], $now ); } } ); $this->clear_options(); } public function claim( int $now ): ?array { global $wpdb; return $this->transaction( function () use ( $now, $wpdb ) { if ( ! $this->settings()['enabled'] ) { return null; } $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_reminder_queue WHERE state IN('pending','retry') AND next_attempt_at<=%s ORDER BY next_attempt_at,id LIMIT 1 FOR UPDATE", gmdate( 'Y-m-d H:i:s', $now ) ), ARRAY_A ), 'reminder_read' ); if ( ! $rows ) { return null; } $row = $rows[0]; $this->sync_locked( (int) $row['appointment_id'], $now ); $fresh = $this->job( (int) $row['id'] ); if ( ! in_array( $fresh['state'], array( 'pending', 'retry' ), true ) || $fresh['next_attempt_at'] > gmdate( 'Y-m-d H:i:s', $now ) ) { return null; } $raw = bin2hex( random_bytes( 32 ) ); $hash = hash( 'sha256', $raw ); Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state='leased',lease_hash=%s,lease_until=%s,updated_at=%s WHERE id=%d", $hash, gmdate( 'Y-m-d H:i:s', $now + 300 ), gmdate( 'Y-m-d H:i:s', $now ), $row['id'] ) ), 'reminder_write' ); $fresh['lease'] = $raw; return $fresh; } ); } public function job( int $id ): ?array { global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_reminder_queue WHERE id=%d", $id ), ARRAY_A ), 'reminder_read' ); return $rows[0] ?? null; } /** The lease is not permission to send under an older configuration. */ public function begin_send( array $job, int $now ): ?array { global $wpdb; return $this->transaction( function () use ( $job, $now, $wpdb ) { $row = $this->job( (int) $job['id'] ); if ( ! $row || $row['state'] !== 'leased' || ! hash_equals( (string) $row['lease_hash'], hash( 'sha256', $job['lease'] ) ) || strtotime( $row['lease_until'] . ' UTC' ) <= $now ) { return null; } $this->sync_locked( (int) $row['appointment_id'], $now ); $row = $this->job( (int) $row['id'] ); if ( $row['state'] !== 'leased' ) { return null; } $config = $this->settings(); if ( ! $config['enabled'] ) { return null; } $booking = $this->booking( (int) $row['appointment_id'] ); $due = gmdate( 'Y-m-d H:i:s', strtotime( $booking['start_utc'] . ' UTC' ) - $config['lead_hours'] * HOUR_IN_SECONDS ); $stamp = gmdate( 'Y-m-d H:i:s', $now ); if ( $due > $stamp ) { // No transport has started: release only this lease, preserving previous attempts. $state = ( $job['state'] ?? '' ) === 'retry' ? 'retry' : 'pending'; Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state=%s,due_at=%s,next_attempt_at=%s,lease_hash=NULL,lease_until=NULL,updated_at=%s WHERE id=%d", $state, $due, max( $due, $row['next_attempt_at'] ), $stamp, $row['id'] ) ), 'reminder_write' ); return null; } Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state='sending',due_at=%s,attempts=attempts+1,updated_at=%s WHERE id=%d", $due, $stamp, $row['id'] ) ), 'reminder_write' ); return $config; } ); } /** Fresh read immediately before transport; a snapshot is never a recipient fallback. */ public function deliverable( array $job, int $now ): bool { $row = $this->job( (int) $job['id'] ); $booking = $this->booking( (int) $job['appointment_id'] ); $config = $this->settings(); return ! BookingLock::is_held() && $config['enabled'] && $row && $row['state'] === 'sending' && hash_equals( (string) $row['lease_hash'], hash( 'sha256', $job['lease'] ) ) && self::eligible( $booking, $now ) && strtotime( $booking['start_utc'] . ' UTC' ) - $config['lead_hours'] * HOUR_IN_SECONDS <= $now && hash_equals( $row['booking_digest'], self::booking_digest( $booking ) ); } /** Only the worker which has positively skipped transport may recover its own marker. */ public function finish_unsent( array $job, int $now ): void { global $wpdb; $this->transaction( function () use ( $job, $now, $wpdb ) { $row = $this->job( (int) $job['id'] ); if ( ! $row || ! in_array( $row['state'], array( 'sending', 'uncertain' ), true ) || ! hash_equals( (string) $row['lease_hash'], hash( 'sha256', $job['lease'] ) ) ) { return; } $config = $this->settings(); $booking = $this->booking( (int) $row['appointment_id'] ); $stamp = gmdate( 'Y-m-d H:i:s', $now ); $attempts = max( 0, (int) $row['attempts'] - 1 ); $state = 'cancelled'; $code = 'booking_unavailable'; $due = $row['due_at']; $next = $row['next_attempt_at']; $digest = $row['booking_digest']; if ( ! $config['enabled'] ) { $code = 'disabled'; } elseif ( self::eligible( $booking, $now ) && hash_equals( $row['start_digest'], self::start_digest( $booking ) ) ) { // Timing/contact changes can be retried safely: this owner never called transport. $due = gmdate( 'Y-m-d H:i:s', strtotime( $booking['start_utc'] . ' UTC' ) - $config['lead_hours'] * HOUR_IN_SECONDS ); $next = max( $due, $row['next_attempt_at'] ); $digest = self::booking_digest( $booking ); $state = $attempts > 0 ? 'retry' : 'pending'; $code = ''; } elseif ( $booking && ! hash_equals( $row['start_digest'], self::start_digest( $booking ) ) ) { $code = 'booking_changed'; } Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state=%s,last_code=%s,attempts=%d,booking_digest=%s,due_at=%s,next_attempt_at=%s,lease_hash=NULL,lease_until=NULL,updated_at=%s WHERE id=%d", $state, $code, $attempts, $digest, $due, $next, $stamp, $row['id'] ) ), 'reminder_write' ); } ); } public function finish( array $job, string $outcome, int $now ): void { if ( $outcome === 'not_deliverable' ) { $this->finish_unsent( $job, $now ); return; } global $wpdb; $this->transaction( function () use ( $job, $outcome, $now, $wpdb ) { $row = $this->job( (int) $job['id'] ); if ( ! $row || $row['state'] !== 'sending' || ! hash_equals( (string) $row['lease_hash'], hash( 'sha256', $job['lease'] ) ) ) { return; } if ( $outcome === 'sent' ) { $state = 'sent'; } elseif ( $outcome === 'transport_false' ) { $state = (int) $row['attempts'] >= 3 ? 'failed' : 'retry'; } else { $state = 'uncertain'; } $next = gmdate( 'Y-m-d H:i:s', $now + ( (int) $row['attempts'] === 1 ? 300 : 900 ) ); $stamp = gmdate( 'Y-m-d H:i:s', $now ); Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state=%s,last_code=%s,next_attempt_at=%s,sent_at=IF(%d=1,%s,NULL),lease_hash=NULL,lease_until=NULL,updated_at=%s WHERE id=%d", $state, $outcome, $next, $state === 'sent' ? 1 : 0, $stamp, $stamp, $row['id'] ) ), 'reminder_write' ); } ); } public function intervene( int $id, string $operation, bool $acknowledged, int $now ): void { global $wpdb; $this->transaction( function () use ( $id, $operation, $acknowledged, $now, $wpdb ) { $row = $this->job( $id ); if ( ! $row || ! in_array( $row['state'], array( 'failed', 'uncertain' ), true ) ) { throw new \DomainException( 'reminder_state' ); } $stamp = gmdate( 'Y-m-d H:i:s', $now ); if ( $operation === 'retry' ) { if ( ! $acknowledged ) { throw new \DomainException( 'reminder_acknowledgement' ); } $booking = $this->booking( (int) $row['appointment_id'] ); if ( ! $this->settings()['enabled'] || ! self::eligible( $booking, $now ) || ! hash_equals( $row['start_digest'], self::start_digest( $booking ) ) ) { throw new \DomainException( 'reminder_ineligible' ); } Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state='retry',attempts=0,booking_digest=%s,next_attempt_at=%s,last_code='manager_retry',lease_hash=NULL,lease_until=NULL,updated_at=%s WHERE id=%d", self::booking_digest( $booking ), $stamp, $stamp, $id ) ), 'reminder_write' ); } elseif ( $operation === 'dismiss' ) { Db::done( $wpdb->suppress_errors( true ), $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}abientot_reminder_queue SET state='cancelled',last_code='manager_dismissed',updated_at=%s WHERE id=%d", $stamp, $id ) ), 'reminder_write' ); } else { throw new \DomainException( 'reminder_operation' ); } } ); } public function page( int $page ): array { global $wpdb; $count = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( "SELECT COUNT(*) AS total FROM {$wpdb->prefix}abientot_reminder_queue", ARRAY_A ), 'reminder_read' ); $total = (int) $count[0]['total']; $pages = max( 1, (int) ceil( $total / 20 ) ); $page = max( 1, min( $pages, $page ) ); return array( 'rows' => Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}abientot_reminder_queue ORDER BY id DESC LIMIT 20 OFFSET %d", ( $page - 1 ) * 20 ), ARRAY_A ), 'reminder_read' ), 'total' => $total, 'page' => $page, 'pages' => $pages, ); } }