PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / src / FollowUp / FollowUpCoordinator.php

FollowUpCoordinator.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.8.1, at src/FollowUp/FollowUpCoordinator.php

777 lines 23.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plans, claims, executes and records follow-up actions.
4 *
5 * @package Forge12\DoubleOptIn\FollowUp
6 * @since 5.6.0
7 */
8
9 declare( strict_types=1 );
10
11 namespace Forge12\DoubleOptIn\FollowUp;
12
13 use Forge12\DoubleOptIn\Audit\AuditLogger;
14 use Forge12\DoubleOptIn\Repository\FollowUpRepositoryInterface;
15 use Forge12\Shared\LoggerInterface;
16 use forge12\contactform7\CF7DoubleOptIn\OptIn;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * The single path every follow-up execution goes through — the first
24 * run after confirmation, the cron retry and the admin's manual retry.
25 *
26 * Guarantees:
27 * - An action is planned once per opt-in (unique key) and claimed by
28 * exactly one request per attempt (conditional UPDATE).
29 * - A successful action is never executed again; `unknown` is never
30 * re-executed without an explicit administrator decision.
31 * - Nothing runs for an unconfirmed or opted-out opt-in.
32 * - Results carry codes, never form data.
33 */
34 final class FollowUpCoordinator {
35
36 public const CRON_RETRY_HOOK = 'f12_doi_follow_up_retry';
37 public const CRON_SWEEP_HOOK = 'f12_doi_follow_up_sweep';
38
39 /**
40 * How long a claimed action may stay `running` before the sweep
41 * declares its outcome unknown. Must exceed the longest adapter
42 * timeout (Elementor replay: 30 s).
43 */
44 public const LEASE_SECONDS = 300;
45
46 /**
47 * Replay tickets are consumed within the same PHP request that
48 * issued them; two minutes is generous.
49 */
50 public const TICKET_TTL_SECONDS = 120;
51
52 /**
53 * A `pending` row this old on a confirmed opt-in means the confirming
54 * request died before executing it.
55 */
56 public const STALE_PENDING_SECONDS = 600;
57
58 /** @var self|null */
59 private static $instance;
60
61 /** @var FollowUpRepositoryInterface */
62 private $repository;
63
64 /** @var FollowUpAdapterRegistry */
65 private $registry;
66
67 /** @var LoggerInterface */
68 private $logger;
69
70 /** @var callable():int */
71 private $clock;
72
73 /** @var callable(string, string, string, array<string, mixed>):mixed */
74 private $audit;
75
76 /** @var callable(int, string, array<int, mixed>):mixed */
77 private $scheduler;
78
79 /**
80 * @param callable|null $clock Returns the current Unix time.
81 * @param callable|null $audit `(type, severity, message, details)`.
82 * @param callable|null $scheduler `(timestamp, hook, args)`.
83 */
84 public function __construct(
85 FollowUpRepositoryInterface $repository,
86 FollowUpAdapterRegistry $registry,
87 LoggerInterface $logger,
88 ?callable $clock = null,
89 ?callable $audit = null,
90 ?callable $scheduler = null
91 ) {
92 $this->repository = $repository;
93 $this->registry = $registry;
94 $this->logger = $logger;
95 $this->clock = $clock ?? static function (): int {
96 return time();
97 };
98 $this->audit = $audit ?? static function ( string $type, string $severity, string $message, array $details ) {
99 return AuditLogger::log( $type, $severity, $message, $details );
100 };
101 $this->scheduler = $scheduler ?? static function ( int $timestamp, string $hook, array $args ) {
102 if ( function_exists( 'wp_schedule_single_event' ) ) {
103 return wp_schedule_single_event( $timestamp, $hook, $args );
104 }
105 return false;
106 };
107 }
108
109 /**
110 * Accessor for the legacy layer (compatibility/), which cannot take
111 * constructor injection. Set by FollowUpServiceProvider.
112 */
113 public static function instance(): ?self {
114 return self::$instance;
115 }
116
117 public static function setInstance( ?self $instance ): void {
118 self::$instance = $instance;
119 }
120
121 public function getRegistry(): FollowUpAdapterRegistry {
122 return $this->registry;
123 }
124
125 public function adapterFor( OptIn $optIn ): ?FollowUpAdapterInterface {
126 return $this->registry->forOptIn( $optIn );
127 }
128
129 /**
130 * Bind the follow-up plan of an opt-in. Called during confirmation,
131 * BEFORE the confirmation is saved: if the request dies in between,
132 * the rows exist and the sweep finishes the work; if the confirmation
133 * save fails, nothing runs because execution requires a confirmed
134 * opt-in. Idempotent.
135 *
136 * @param bool $defaultMailEnabled Result of `f12_cf7_doubleoptin_send_default_mail`.
137 *
138 * @return bool False when no adapter handles this opt-in (the caller
139 * then keeps its previous behaviour).
140 */
141 public function plan( OptIn $optIn, bool $defaultMailEnabled ): bool {
142 $adapter = $this->adapterFor( $optIn );
143 if ( $adapter === null ) {
144 return false;
145 }
146
147 if ( ! empty( $this->repository->findByOptIn( $optIn->get_id() ) ) ) {
148 return true;
149 }
150
151 try {
152 $actions = $adapter->planActions( $optIn );
153 } catch ( \Throwable $e ) {
154 // A form that cannot be read (deleted, plugin half-updated) is
155 // itself a result worth recording.
156 $this->logger->error(
157 'Follow-up planning failed',
158 array(
159 'plugin' => 'double-opt-in',
160 'optin_id' => $optIn->get_id(),
161 'integration' => $adapter->getIntegration(),
162 'exception' => get_class( $e ),
163 )
164 );
165 $actions = array( new FollowUpAction( 'plan', FollowUpAction::KIND_MARKER, 'Plan', false ) );
166 $this->repository->plan( $optIn->get_id(), $adapter->getIntegration(), $actions, array(), FollowUpAction::fingerprint( $actions ), $this->now() );
167 $this->completeImmediately( $optIn, 'plan', FollowUpResult::failedPermanent( 'plan_failed' ) );
168 $this->planGlobals( $optIn );
169 return true;
170 }
171
172 $skipReasons = array();
173 foreach ( $actions as $action ) {
174 if ( $action->getSkipReason() !== '' ) {
175 $skipReasons[ $action->getId() ] = $action->getSkipReason();
176 } elseif ( ! $defaultMailEnabled && $action->isGatedByDefaultMail() ) {
177 $skipReasons[ $action->getId() ] = 'send_default_mail_disabled';
178 }
179 }
180
181 if ( empty( $actions ) ) {
182 $actions = array( new FollowUpAction( FollowUpAction::ID_NONE, FollowUpAction::KIND_MARKER, 'No follow-up actions', false ) );
183 $skipReasons = array( FollowUpAction::ID_NONE => 'no_actions_configured' );
184 }
185
186 $inserted = $this->repository->plan(
187 $optIn->get_id(),
188 $adapter->getIntegration(),
189 $actions,
190 $skipReasons,
191 FollowUpAction::fingerprint( $actions ),
192 $this->now()
193 );
194
195 $this->logger->info(
196 'Follow-up actions planned',
197 array(
198 'plugin' => 'double-opt-in',
199 'optin_id' => $optIn->get_id(),
200 'integration' => $adapter->getIntegration(),
201 'planned' => $inserted,
202 'skipped' => count( $skipReasons ),
203 )
204 );
205
206 $this->planGlobals( $optIn );
207
208 return true;
209 }
210
211 /**
212 * Add the actions of every global adapter (webhooks …) to the plan.
213 *
214 * A global adapter that fails or plans nothing leaves no trace and
215 * never disturbs the form's own plan. Actions outside the adapter's
216 * id prefix are dropped.
217 */
218 private function planGlobals( OptIn $optIn ): void {
219 foreach ( $this->registry->globals() as $integration => $adapter ) {
220 try {
221 $actions = $adapter->planActions( $optIn );
222 } catch ( \Throwable $e ) {
223 $this->logger->error(
224 'Follow-up planning of a global adapter failed',
225 array(
226 'plugin' => 'double-opt-in',
227 'optin_id' => $optIn->get_id(),
228 'integration' => $integration,
229 'exception' => get_class( $e ),
230 )
231 );
232 continue;
233 }
234
235 $prefix = $integration . ':';
236 $own = array();
237 $skip = array();
238 foreach ( $actions as $action ) {
239 if ( ! $action instanceof FollowUpAction || strpos( $action->getId(), $prefix ) !== 0 ) {
240 continue;
241 }
242 $own[] = $action;
243 if ( $action->getSkipReason() !== '' ) {
244 $skip[ $action->getId() ] = $action->getSkipReason();
245 }
246 }
247
248 if ( count( $own ) !== count( $actions ) ) {
249 $this->logger->warning(
250 'Follow-up actions outside the adapter prefix were dropped',
251 array(
252 'plugin' => 'double-opt-in',
253 'optin_id' => $optIn->get_id(),
254 'integration' => $integration,
255 'dropped' => count( $actions ) - count( $own ),
256 )
257 );
258 }
259 if ( empty( $own ) ) {
260 continue;
261 }
262
263 $this->repository->plan( $optIn->get_id(), $integration, $own, $skip, FollowUpAction::fingerprint( $own ), $this->now() );
264 }
265 }
266
267 /**
268 * Execute the eligible actions of an opt-in.
269 *
270 * @param string $trigger One of FollowUpAttempt::TRIGGER_*.
271 * @param array{include_unknown?: bool, action_ids?: string[]} $options
272 *
273 * @return FollowUpRecord[] The opt-in's rows after the run.
274 */
275 public function run( OptIn $optIn, string $trigger, array $options = array() ): array {
276 $adapter = $this->adapterFor( $optIn );
277 $optInId = $optIn->get_id();
278
279 if ( $adapter === null || $optInId <= 0 || ! $optIn->is_confirmed() ) {
280 return $this->repository->findByOptIn( $optInId );
281 }
282
283 // Consent withdrawn: nothing further may happen for this address.
284 if ( $optIn->is_optout() ) {
285 $this->repository->skipOpen( $optInId, 'opted_out', $this->now() );
286 return $this->repository->findByOptIn( $optInId );
287 }
288
289 $records = $this->repository->findByOptIn( $optInId );
290 $eligible = $this->selectEligible( $records, $trigger, $options );
291
292 if ( $trigger === FollowUpAttempt::TRIGGER_MANUAL ) {
293 // Who retried what is part of the record — above all when an
294 // administrator accepted the risk of a duplicate.
295 $includeUnknown = ! empty( $options['include_unknown'] );
296 ( $this->audit )(
297 AuditLogger::TYPE_FOLLOW_UP,
298 $includeUnknown ? AuditLogger::SEVERITY_WARNING : AuditLogger::SEVERITY_INFO,
299 $includeUnknown
300 ? 'Manual follow-up retry including actions with unknown outcome'
301 : 'Manual follow-up retry',
302 array(
303 'event' => 'follow_up.manual_retry',
304 'optin_id' => $optInId,
305 'include_unknown' => $includeUnknown,
306 'action_ids' => array_values( array_map( 'strval', (array) ( $options['action_ids'] ?? array() ) ) ),
307 'eligible' => count( $eligible ),
308 )
309 );
310 }
311
312 if ( empty( $eligible ) ) {
313 return $records;
314 }
315
316 $attempt = new FollowUpAttempt( self::generateId(), $trigger, $optInId );
317 $now = $this->now();
318 $lease = $this->format( ( $this->clock )() + self::LEASE_SECONDS );
319
320 /** @var FollowUpRecord[] $claimed */
321 $claimed = array();
322 foreach ( $eligible as $record ) {
323 if ( $this->repository->claim( $record->id, array( $record->status ), $attempt->getId(), $trigger, $now, $lease ) ) {
324 $claimed[ $record->actionId ] = $record;
325 }
326 }
327
328 if ( empty( $claimed ) ) {
329 // Another request won every claim — it owns this attempt.
330 return $this->repository->findByOptIn( $optInId );
331 }
332
333 $this->logger->info(
334 'Follow-up attempt started',
335 array(
336 'plugin' => 'double-opt-in',
337 'optin_id' => $optInId,
338 'integration' => $adapter->getIntegration(),
339 'attempt_id' => $attempt->getId(),
340 'trigger' => $trigger,
341 'actions' => array_keys( $claimed ),
342 )
343 );
344
345 $startedAt = microtime( true );
346 $results = array();
347 $fallbacks = array();
348
349 // Each adapter executes its own rows: the form adapter first (its
350 // replay may need the request to itself), then the global ones.
351 foreach ( $this->groupByAdapter( $claimed, $adapter ) as $integration => $group ) {
352 $handler = $this->handlerFor( $integration, $adapter );
353 if ( $handler === null ) {
354 // The add-on that planned these is gone. Recorded honestly;
355 // a manual retry runs them once it is back.
356 foreach ( array_keys( $group ) as $actionId ) {
357 $results[ $actionId ] = FollowUpResult::failedPermanent( 'adapter_missing' );
358 }
359 continue;
360 }
361
362 $actions = array();
363 foreach ( $group as $record ) {
364 $actions[] = $record->toAction();
365 }
366
367 $own = array();
368 try {
369 $own = $handler->execute( $optIn, $actions, $attempt );
370 } catch ( \Throwable $e ) {
371 foreach ( array_keys( $group ) as $actionId ) {
372 $fallbacks[ $actionId ] = 'adapter_exception';
373 }
374 $this->logger->error(
375 'Follow-up adapter threw',
376 array(
377 'plugin' => 'double-opt-in',
378 'optin_id' => $optInId,
379 'attempt_id' => $attempt->getId(),
380 'integration' => $integration,
381 'exception' => get_class( $e ),
382 )
383 );
384 }
385
386 // An adapter reports for its own actions only.
387 foreach ( array_keys( $group ) as $actionId ) {
388 if ( is_array( $own ) && isset( $own[ $actionId ] ) ) {
389 $results[ $actionId ] = $own[ $actionId ];
390 }
391 }
392 }
393 $durationMs = (int) round( ( microtime( true ) - $startedAt ) * 1000 );
394
395 $finishedAt = $this->now();
396 $report = array();
397 $nextRetry = 0;
398 foreach ( $claimed as $actionId => $record ) {
399 $result = $results[ $actionId ] ?? null;
400 if ( ! $result instanceof FollowUpResult ) {
401 $result = FollowUpResult::unknown( $fallbacks[ $actionId ] ?? 'action_outcome_unknown' );
402 }
403
404 $attemptNumber = $record->attempts + 1;
405 // Position in the automatic-retry budget. A manual retry starts
406 // a fresh budget (the admin fixed the cause); mirrors the
407 // repository's claim().
408 $budgetNumber = $trigger === FollowUpAttempt::TRIGGER_MANUAL ? 1 : $record->budgetAttempts + 1;
409 $next = '';
410 if ( $result->getStatus() === FollowUpStatus::FAILED_RETRYABLE ) {
411 $delay = $this->backoffDelay( $budgetNumber );
412 if ( $delay === null ) {
413 $result = $result->withStatus( FollowUpStatus::FAILED_PERMANENT );
414 } else {
415 $at = ( $this->clock )() + $delay;
416 $next = $this->format( $at );
417 $nextRetry = $nextRetry === 0 ? $at : min( $nextRetry, $at );
418 }
419 }
420
421 $this->repository->complete( $record->id, $attempt->getId(), $result, $finishedAt, $next );
422
423 $report[] = array_merge(
424 array(
425 'action_id' => $actionId,
426 'attempt_number' => $attemptNumber,
427 'next_attempt' => $next,
428 ),
429 $result->toArray()
430 );
431 }
432
433 if ( $nextRetry > 0 ) {
434 ( $this->scheduler )( $nextRetry, self::CRON_RETRY_HOOK, array( $optInId ) );
435 }
436
437 $records = $this->repository->findByOptIn( $optInId );
438 $statuses = array();
439 $byOwner = array( $adapter->getIntegration() => array() );
440 foreach ( $records as $record ) {
441 $statuses[ $record->actionId ] = $record->status;
442 $byOwner[ $record->integration ][ $record->actionId ] = $record->status;
443 }
444
445 // Each adapter settles its own rows — the form adapter's upload
446 // cleanup must not wait for a webhook.
447 foreach ( $byOwner as $integration => $own ) {
448 $handler = $this->handlerFor( (string) $integration, $adapter );
449 if ( $handler === null ) {
450 continue;
451 }
452 try {
453 $handler->onSettled( $optIn, $own );
454 } catch ( \Throwable $e ) {
455 $this->logger->error(
456 'Follow-up onSettled threw',
457 array(
458 'plugin' => 'double-opt-in',
459 'optin_id' => $optInId,
460 'integration' => $integration,
461 'exception' => get_class( $e ),
462 )
463 );
464 }
465 }
466
467 $this->auditAttempt( $optIn, $adapter, $attempt, $statuses, $report, $durationMs );
468
469 return $records;
470 }
471
472 /**
473 * Cron: expire dead leases, then run everything that is due.
474 *
475 * @param callable(int):?OptIn $loader Loads an opt-in by id.
476 *
477 * @return int Number of opt-ins processed.
478 */
479 public function sweep( callable $loader, int $limit = 20 ): int {
480 $this->repository->deleteOrphans( 500 );
481
482 $expired = $this->repository->expireLeases( $this->now() );
483 if ( $expired > 0 ) {
484 ( $this->audit )(
485 AuditLogger::TYPE_FOLLOW_UP,
486 AuditLogger::SEVERITY_WARNING,
487 'Follow-up actions with unknown outcome after an interrupted attempt',
488 array(
489 'event' => 'follow_up.lease_expired',
490 'affected' => $expired,
491 )
492 );
493 }
494
495 $ids = $this->repository->findDueOptInIds(
496 $this->now(),
497 $this->format( ( $this->clock )() - self::STALE_PENDING_SECONDS ),
498 $limit
499 );
500
501 $processed = 0;
502 foreach ( $ids as $id ) {
503 $optIn = $loader( $id );
504 if ( $optIn instanceof OptIn ) {
505 $this->run( $optIn, FollowUpAttempt::TRIGGER_CRON );
506 $processed++;
507 }
508 }
509
510 return $processed;
511 }
512
513 /**
514 * Status overview for the admin / REST.
515 *
516 * @return array{aggregate: string, actions: array<int, array<string, mixed>>}
517 */
518 public function statusFor( int $optInId, bool $confirmed ): array {
519 $records = $this->repository->findByOptIn( $optInId );
520 $statuses = array();
521 $actions = array();
522 foreach ( $records as $record ) {
523 $statuses[] = $record->status;
524 $actions[] = $record->toArray();
525 }
526
527 $aggregate = FollowUpStatus::aggregate( $statuses );
528 if ( $aggregate === FollowUpStatus::AGGREGATE_NONE && $confirmed ) {
529 // Confirmed before follow-up tracking existed (or by an
530 // integration without an adapter). Deliberately not replayed.
531 $aggregate = FollowUpStatus::AGGREGATE_LEGACY_UNKNOWN;
532 }
533
534 return array(
535 'aggregate' => $aggregate,
536 'actions' => $actions,
537 );
538 }
539
540 /**
541 * Issue a single-use replay ticket bound to one opt-in and the rows
542 * of one attempt. Only the SHA-256 is stored.
543 */
544 public function issueTicket( int $optInId, string $attemptId ): string {
545 $ticket = self::generateId() . self::generateId();
546 $this->repository->setTicket(
547 $optInId,
548 $attemptId,
549 hash( 'sha256', $ticket ),
550 $this->format( ( $this->clock )() + self::TICKET_TTL_SECONDS )
551 );
552 return $ticket;
553 }
554
555 /**
556 * Consume a replay ticket. Returns the action ids it authorises, or
557 * null for an unknown, expired or already used ticket.
558 *
559 * @return string[]|null
560 */
561 public function consumeTicket( int $optInId, string $ticket ): ?array {
562 if ( $optInId <= 0 || strlen( $ticket ) < 32 || strlen( $ticket ) > 128 ) {
563 return null;
564 }
565 return $this->repository->consumeTicket( $optInId, hash( 'sha256', $ticket ), $this->now() );
566 }
567
568 /**
569 * Cascade on opt-in deletion (retention, manual delete, eraser).
570 */
571 public function forget( int $optInId ): void {
572 if ( $optInId > 0 ) {
573 $this->repository->deleteByOptIn( $optInId );
574 }
575 }
576
577 /**
578 * Claimed rows by the integration that planned them, the form
579 * adapter's group first.
580 *
581 * @param array<string, FollowUpRecord> $claimed Keyed by action id.
582 *
583 * @return array<string, array<string, FollowUpRecord>>
584 */
585 private function groupByAdapter( array $claimed, FollowUpAdapterInterface $formAdapter ): array {
586 $groups = array( $formAdapter->getIntegration() => array() );
587 foreach ( $claimed as $actionId => $record ) {
588 $groups[ $record->integration ][ $actionId ] = $record;
589 }
590
591 return array_filter( $groups );
592 }
593
594 /**
595 * The adapter that owns rows of an integration: the opt-in's form
596 * adapter or a global one. Null when the add-on that planned them is
597 * no longer there.
598 */
599 private function handlerFor( string $integration, FollowUpAdapterInterface $formAdapter ): ?FollowUpAdapterInterface {
600 if ( $integration === $formAdapter->getIntegration() ) {
601 return $formAdapter;
602 }
603
604 return $this->registry->globals()[ $integration ] ?? null;
605 }
606
607 /**
608 * Backoff in seconds before automatic retry N (1-based position of
609 * the attempt that just failed within the current budget — reset by
610 * every manual retry), or null when automatic retries are exhausted.
611 */
612 private function backoffDelay( int $attemptNumber ): ?int {
613 $schedule = array( 60, 300, 1800 );
614 if ( function_exists( 'apply_filters' ) ) {
615 /**
616 * Delays (seconds) between automatic retries of follow-up
617 * actions that demonstrably did not run. The number of
618 * entries is the maximum number of automatic retries.
619 *
620 * @param int[] $schedule
621 *
622 * @since 5.6.0
623 */
624 $filtered = apply_filters( 'f12_doi_follow_up_backoff', $schedule );
625 if ( is_array( $filtered ) ) {
626 $schedule = array_values( array_map( 'intval', $filtered ) );
627 }
628 }
629
630 $index = $attemptNumber - 1;
631 if ( ! isset( $schedule[ $index ] ) || $schedule[ $index ] < 0 ) {
632 return null;
633 }
634 return $schedule[ $index ];
635 }
636
637 /**
638 * @param FollowUpRecord[] $records
639 * @param array{include_unknown?: bool, action_ids?: string[]} $options
640 *
641 * @return FollowUpRecord[]
642 */
643 private function selectEligible( array $records, string $trigger, array $options ): array {
644 $nowTs = ( $this->clock )();
645
646 switch ( $trigger ) {
647 case FollowUpAttempt::TRIGGER_MANUAL:
648 $allowed = array( FollowUpStatus::PENDING, FollowUpStatus::FAILED_RETRYABLE, FollowUpStatus::FAILED_PERMANENT );
649 if ( ! empty( $options['include_unknown'] ) ) {
650 $allowed[] = FollowUpStatus::UNKNOWN;
651 }
652 break;
653 case FollowUpAttempt::TRIGGER_CRON:
654 $allowed = array( FollowUpStatus::PENDING, FollowUpStatus::FAILED_RETRYABLE );
655 break;
656 default:
657 $allowed = array( FollowUpStatus::PENDING );
658 }
659
660 $only = isset( $options['action_ids'] ) && is_array( $options['action_ids'] )
661 ? array_map( 'strval', $options['action_ids'] )
662 : null;
663
664 $eligible = array();
665 foreach ( $records as $record ) {
666 if ( $record->actionKind === FollowUpAction::KIND_MARKER ) {
667 continue;
668 }
669 if ( ! in_array( $record->status, $allowed, true ) ) {
670 continue;
671 }
672 if ( $only !== null && ! in_array( $record->actionId, $only, true ) ) {
673 continue;
674 }
675 if ( $trigger === FollowUpAttempt::TRIGGER_CRON
676 && $record->status === FollowUpStatus::FAILED_RETRYABLE
677 && ( $record->nextAttemptAt === '' || strtotime( $record->nextAttemptAt . ' UTC' ) > $nowTs )
678 ) {
679 continue;
680 }
681 $eligible[] = $record;
682 }
683
684 return $eligible;
685 }
686
687 /**
688 * Record a result for a row that never needs execution (planning
689 * failure). Claims first so the unique claim path stays the only
690 * writer of results.
691 */
692 private function completeImmediately( OptIn $optIn, string $actionId, FollowUpResult $result ): void {
693 $attemptId = self::generateId();
694 $now = $this->now();
695 foreach ( $this->repository->findByOptIn( $optIn->get_id() ) as $record ) {
696 if ( $record->actionId === $actionId
697 && $this->repository->claim( $record->id, array( FollowUpStatus::PENDING ), $attemptId, FollowUpAttempt::TRIGGER_CONFIRM, $now, $now )
698 ) {
699 $this->repository->complete( $record->id, $attemptId, $result, $now, '' );
700 }
701 }
702 }
703
704 /**
705 * One audit event per attempt, with the per-action outcome in the
706 * details. Severity follows the aggregate so failures are findable
707 * in the audit log without debug logging enabled.
708 *
709 * @param array<string, string> $statuses
710 * @param array<int, array<string, mixed>> $report
711 */
712 private function auditAttempt( OptIn $optIn, FollowUpAdapterInterface $adapter, FollowUpAttempt $attempt, array $statuses, array $report, int $durationMs ): void {
713 $aggregate = FollowUpStatus::aggregate( array_values( $statuses ) );
714
715 switch ( $aggregate ) {
716 case FollowUpStatus::AGGREGATE_COMPLETED:
717 $severity = AuditLogger::SEVERITY_INFO;
718 $message = 'Follow-up actions completed';
719 break;
720 case FollowUpStatus::AGGREGATE_PENDING:
721 $severity = AuditLogger::SEVERITY_WARNING;
722 $message = 'Follow-up actions failed, retry scheduled';
723 break;
724 case FollowUpStatus::AGGREGATE_UNKNOWN:
725 $severity = AuditLogger::SEVERITY_WARNING;
726 $message = 'Follow-up actions with unknown outcome';
727 break;
728 case FollowUpStatus::AGGREGATE_PARTIAL:
729 $severity = AuditLogger::SEVERITY_ERROR;
730 $message = 'Follow-up actions partially failed';
731 break;
732 default:
733 $severity = AuditLogger::SEVERITY_ERROR;
734 $message = 'Follow-up actions failed';
735 }
736
737 $details = array(
738 'event' => 'follow_up.attempt',
739 'optin_id' => $optIn->get_id(),
740 'integration' => $adapter->getIntegration(),
741 'form_id' => $optIn->get_cf_form_id(),
742 'attempt_id' => $attempt->getId(),
743 'trigger' => $attempt->getTrigger(),
744 'aggregate' => $aggregate,
745 'duration_ms' => $durationMs,
746 'actions' => $report,
747 );
748
749 ( $this->audit )( AuditLogger::TYPE_FOLLOW_UP, $severity, $message, $details );
750
751 $this->logger->info(
752 $message,
753 array(
754 'plugin' => 'double-opt-in',
755 'optin_id' => $optIn->get_id(),
756 'attempt_id' => $attempt->getId(),
757 'aggregate' => $aggregate,
758 )
759 );
760 }
761
762 private function now(): string {
763 return $this->format( ( $this->clock )() );
764 }
765
766 private function format( int $timestamp ): string {
767 return gmdate( 'Y-m-d H:i:s', $timestamp );
768 }
769
770 /**
771 * 32 hex characters from a CSPRNG.
772 */
773 public static function generateId(): string {
774 return bin2hex( random_bytes( 16 ) );
775 }
776 }
777