$context Ids for the suppression event, e.g. `cart_id`. * @param bool $holdout Whether this flow keeps a control group. * @param bool $recent Whether a recent sale order keeps the unit out (DATA-MODEL.md §4-15). * Off for flows the customer's own cart or purchase starts. * @param int|null $windowDays Effect window this flow fixes for this entry, reported to the hub. * * @return array{group: string|null, reason: string|null} A group on entry, otherwise the reason. */ public static function enter( string $flow, string $module, string $unit, ?string $unitId, string $rawPhone, string $notification, array $context = [], bool $holdout = true, bool $recent = false, ?int $windowDays = null ): array { $phone = PhoneNumber::parse( $rawPhone ); if ( null === $phone ) { return self::refused( self::REASON_NO_CONTACT ); } $canonical = $phone->canonical(); $reason = null; // A shop's own test numbers always get the messages and never enter the // numbers: no control group, no weekly cap, no flow.entered for the // dashboard to count. An opt-out is still honoured. if ( SendPolicy::isTestPhone( $canonical ) && ! Optouts::has( $canonical ) ) { return [ 'group' => self::TREATMENT, 'reason' => null, ]; } if ( Optouts::has( $canonical ) ) { $reason = SendPolicy::REASON_OPTED_OUT; } elseif ( $recent && Stats::boughtWithin( $canonical, SendPolicy::recentDays() ) ) { $reason = SendPolicy::REASON_RECENT; } elseif ( self::atCap( $canonical ) ) { $reason = SendPolicy::REASON_CAP; } if ( null !== $reason ) { Notifier::reportSuppressed( new Message( $notification, $canonical, '', [], '', false, '', '', $context, $module ), $reason ); return self::refused( $reason ); } $percent = $holdout ? SendPolicy::holdoutPercent() : 0; $group = self::groupFor( SiteKey::get(), $flow, $canonical, $percent ); self::record( $flow, $unit, $unitId, $canonical, $group ); if ( Events::listening() ) { $data = [ 'flow' => $flow, 'module' => $module, 'unit' => $unit, 'unit_id' => $unitId, 'group' => $group, 'holdout_percent' => $percent, 'phone' => $canonical, ]; // Contract 1.2: both optional, sent only when there is something to say. if ( null !== $windowDays ) { $data['window_days'] = max( 1, min( 365, $windowDays ) ); } if ( [] !== $context ) { $data['context'] = $context; } Events::emit( 'flow.entered', $data ); } return [ 'group' => $group, 'reason' => null, ]; } /** * The group a number falls in, for a given control-group share. * * Pure, so it can be tested without WordPress. * * @param string $key Site key. * @param string $flow Flow slug. * @param string $canonical Number in canonical form. * @param int $percent Control-group share, 0 to 100. */ public static function groupFor( string $key, string $flow, string $canonical, int $percent ): string { return self::bucket( $key, $flow, $canonical ) < $percent ? self::HOLDOUT : self::TREATMENT; } /** * A stable number from 0 to 99 for one number in one flow. * * @param string $key Site key. * @param string $flow Flow slug. * @param string $canonical Number in canonical form. */ public static function bucket( string $key, string $flow, string $canonical ): int { // Eight hex digits are 32 bits; the modulo bias over 100 buckets is below // one in forty million, far under anything the dashboard could notice. return (int) ( hexdec( substr( hash_hmac( 'sha256', 'flow|' . $flow . '|' . $canonical, $key ), 0, 8 ) ) % 100 ); } /** * Has this flow entered as many units today, in the site's day, as it allows? * * Asked before enter(): a unit over the limit waits for tomorrow rather than * being refused, and both groups wait alike. * * @param string $flow Flow slug. * @param int $cap Entries a day; zero or less is no limit. */ public static function atDailyCap( string $flow, int $cap ): bool { if ( $cap < 1 ) { return false; } global $wpdb; $midnight = ( new \DateTimeImmutable( 'today', wp_timezone() ) )->getTimestamp(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One indexed count per due unit, in the queue. $entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE flow = %s AND entered_at >= %s', Schema::table( 'flow_entries' ), $flow, gmdate( 'Y-m-d H:i:s', $midnight ) ) ); return $entries >= $cap; } /** * Forget a number, for a privacy erasure. * * @param string $canonical Number in canonical form. * * @return int Rows removed. */ public static function forget( string $canonical ): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE phone_hash = %s', Schema::table( 'flow_entries' ), SiteKey::phoneHash( $canonical ) ) ); } /** * Has this number already entered as many flows as the cap allows? * * @param string $canonical Number in canonical form. */ private static function atCap( string $canonical ): bool { $cap = SendPolicy::weeklyCap(); if ( $cap < 1 ) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One indexed count per flow entry, in the queue. $entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE phone_hash = %s AND entered_at >= %s', Schema::table( 'flow_entries' ), SiteKey::phoneHash( $canonical ), gmdate( 'Y-m-d H:i:s', time() - ( self::CAP_DAYS * DAY_IN_SECONDS ) ) ) ); return $entries >= $cap; } /** * Log an entry, and now and then trim the log. * * @param string $flow Flow slug. * @param string $unit Unit kind. * @param string|null $unitId Unit id. * @param string $canonical Number in canonical form. * @param string $group Group assigned. */ private static function record( string $flow, string $unit, ?string $unitId, string $canonical, string $group ): void { global $wpdb; $table = Schema::table( 'flow_entries' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table. $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (flow, unit, unit_id, phone_hash, grp, entered_at) VALUES (%s, %s, %s, %s, %s, %s)', $table, $flow, $unit, (string) $unitId, SiteKey::phoneHash( $canonical ), self::HOLDOUT === $group ? 2 : 1, gmdate( 'Y-m-d H:i:s' ) ) ); // Trimmed on roughly one entry in fifty rather than by a job of its own: a // store that never enters a flow never grows the table, and one that does // trims it as it goes. if ( 1 === wp_rand( 1, 50 ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table, bounded delete. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE entered_at < %s LIMIT 1000', $table, gmdate( 'Y-m-d H:i:s', time() - ( self::KEEP_DAYS * DAY_IN_SECONDS ) ) ) ); } } /** * The shape enter() returns when a unit does not enter. * * @param string $reason Why. * * @return array{group: null, reason: string} */ private static function refused( string $reason ): array { return [ 'group' => null, 'reason' => $reason, ]; } }