| @@ -164,8 +164,9 @@ | ||
| 164 | 164 | ); |
| 165 | 165 | $actions = array( new FollowUpAction( 'plan', FollowUpAction::KIND_MARKER, 'Plan', false ) ); |
| 166 | 166 | $this->repository->plan( $optIn->get_id(), $adapter->getIntegration(), $actions, array(), FollowUpAction::fingerprint( $actions ), $this->now() ); |
| 167 | 167 | $this->completeImmediately( $optIn, 'plan', FollowUpResult::failedPermanent( 'plan_failed' ) ); |
| 168 | + $this->planGlobals( $optIn ); | |
| 168 | 169 | return true; |
| 169 | 170 | } |
| 170 | 171 | |
| 171 | 172 | $skipReasons = array(); |
| @@ -201,12 +202,70 @@ | ||
| 201 | 202 | 'skipped' => count( $skipReasons ), |
| 202 | 203 | ) |
| 203 | 204 | ); |
| 204 | 205 | |
| 206 | + $this->planGlobals( $optIn ); | |
| 207 | + | |
| 205 | 208 | return true; |
| 206 | 209 | } |
| 207 | 210 | |
| 208 | 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 | + /** | |
| 209 | 268 | * Execute the eligible actions of an opt-in. |
| 210 | 269 | * |
| 211 | 270 | * @param string $trigger One of FollowUpAttempt::TRIGGER_*. |
| 212 | 271 | * @param array{include_unknown?: bool, action_ids?: string[]} $options |
| @@ -270,13 +329,8 @@ | ||
| 270 | 329 | // Another request won every claim — it owns this attempt. |
| 271 | 330 | return $this->repository->findByOptIn( $optInId ); |
| 272 | 331 | } |
| 273 | 332 | |
| 274 | - $actions = array(); | |
| 275 | - foreach ( $claimed as $record ) { | |
| 276 | - $actions[] = $record->toAction(); | |
| 277 | - } | |
| 278 | - | |
| 279 | 333 | $this->logger->info( |
| 280 | 334 | 'Follow-up attempt started', |
| 281 | 335 | array( |
| 282 | 336 | 'plugin' => 'double-opt-in', |
| @@ -289,22 +343,53 @@ | ||
| 289 | 343 | ); |
| 290 | 344 | |
| 291 | 345 | $startedAt = microtime( true ); |
| 292 | 346 | $results = array(); |
| 293 | - $fallback = 'action_outcome_unknown'; | |
| 294 | - try { | |
| 295 | - $results = $adapter->execute( $optIn, $actions, $attempt ); | |
| 296 | - } catch ( \Throwable $e ) { | |
| 297 | - $fallback = 'adapter_exception'; | |
| 298 | - $this->logger->error( | |
| 299 | - 'Follow-up adapter threw', | |
| 300 | - array( | |
| 301 | - 'plugin' => 'double-opt-in', | |
| 302 | - 'optin_id' => $optInId, | |
| 303 | - 'attempt_id' => $attempt->getId(), | |
| 304 | - 'exception' => get_class( $e ), | |
| 305 | - ) | |
| 306 | - ); | |
| 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 | + } | |
| 307 | 392 | } |
| 308 | 393 | $durationMs = (int) round( ( microtime( true ) - $startedAt ) * 1000 ); |
| 309 | 394 | |
| 310 | 395 | $finishedAt = $this->now(); |
| @@ -312,9 +397,9 @@ | ||
| 312 | 397 | $nextRetry = 0; |
| 313 | 398 | foreach ( $claimed as $actionId => $record ) { |
| 314 | 399 | $result = $results[ $actionId ] ?? null; |
| 315 | 400 | if ( ! $result instanceof FollowUpResult ) { |
| 316 | - $result = FollowUpResult::unknown( $fallback ); | |
| 401 | + $result = FollowUpResult::unknown( $fallbacks[ $actionId ] ?? 'action_outcome_unknown' ); | |
| 317 | 402 | } |
| 318 | 403 | |
| 319 | 404 | $attemptNumber = $record->attempts + 1; |
| 320 | 405 | // Position in the automatic-retry budget. A manual retry starts |
| @@ -350,23 +435,34 @@ | ||
| 350 | 435 | } |
| 351 | 436 | |
| 352 | 437 | $records = $this->repository->findByOptIn( $optInId ); |
| 353 | 438 | $statuses = array(); |
| 439 | + $byOwner = array( $adapter->getIntegration() => array() ); | |
| 354 | 440 | foreach ( $records as $record ) { |
| 355 | - $statuses[ $record->actionId ] = $record->status; | |
| 441 | + $statuses[ $record->actionId ] = $record->status; | |
| 442 | + $byOwner[ $record->integration ][ $record->actionId ] = $record->status; | |
| 356 | 443 | } |
| 357 | 444 | |
| 358 | - try { | |
| 359 | - $adapter->onSettled( $optIn, $statuses ); | |
| 360 | - } catch ( \Throwable $e ) { | |
| 361 | - $this->logger->error( | |
| 362 | - 'Follow-up onSettled threw', | |
| 363 | - array( | |
| 364 | - 'plugin' => 'double-opt-in', | |
| 365 | - 'optin_id' => $optInId, | |
| 366 | - 'exception' => get_class( $e ), | |
| 367 | - ) | |
| 368 | - ); | |
| 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 | + } | |
| 369 | 465 | } |
| 370 | 466 | |
| 371 | 467 | $this->auditAttempt( $optIn, $adapter, $attempt, $statuses, $report, $durationMs ); |
| 372 | 468 | |
| @@ -478,8 +574,38 @@ | ||
| 478 | 574 | } |
| 479 | 575 | } |
| 480 | 576 | |
| 481 | 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 | + /** | |
| 482 | 608 | * Backoff in seconds before automatic retry N (1-based position of |
| 483 | 609 | * the attempt that just failed within the current budget — reset by |
| 484 | 610 | * every manual retry), or null when automatic retries are exhausted. |
| 485 | 611 | */ |
| @@ -588,25 +714,25 @@ | ||
| 588 | 714 | |
| 589 | 715 | switch ( $aggregate ) { |
| 590 | 716 | case FollowUpStatus::AGGREGATE_COMPLETED: |
| 591 | 717 | $severity = AuditLogger::SEVERITY_INFO; |
| 592 | - $message = 'Follow-up actions completed'; | |
| 718 | + $message = __( 'Follow-up actions completed', 'double-opt-in' ); | |
| 593 | 719 | break; |
| 594 | 720 | case FollowUpStatus::AGGREGATE_PENDING: |
| 595 | 721 | $severity = AuditLogger::SEVERITY_WARNING; |
| 596 | - $message = 'Follow-up actions failed, retry scheduled'; | |
| 722 | + $message = __( 'Follow-up actions failed, retry scheduled', 'double-opt-in' ); | |
| 597 | 723 | break; |
| 598 | 724 | case FollowUpStatus::AGGREGATE_UNKNOWN: |
| 599 | 725 | $severity = AuditLogger::SEVERITY_WARNING; |
| 600 | - $message = 'Follow-up actions with unknown outcome'; | |
| 726 | + $message = __( 'Follow-up actions with unknown outcome', 'double-opt-in' ); | |
| 601 | 727 | break; |
| 602 | 728 | case FollowUpStatus::AGGREGATE_PARTIAL: |
| 603 | 729 | $severity = AuditLogger::SEVERITY_ERROR; |
| 604 | - $message = 'Follow-up actions partially failed'; | |
| 730 | + $message = __( 'Follow-up actions partially failed', 'double-opt-in' ); | |
| 605 | 731 | break; |
| 606 | 732 | default: |
| 607 | 733 | $severity = AuditLogger::SEVERITY_ERROR; |
| 608 | - $message = 'Follow-up actions failed'; | |
| 734 | + $message = __( 'Follow-up actions failed', 'double-opt-in' ); | |
| 609 | 735 | } |
| 610 | 736 | |
| 611 | 737 | $details = array( |
| 612 | 738 | 'event' => 'follow_up.attempt', |