| @@ -17,8 +17,9 @@ | ||
| 17 | 17 | use Forge12\DoubleOptIn\FormSettings\FormSettingsValidator; |
| 18 | 18 | use Forge12\DoubleOptIn\Integration\SubmittedContent; |
| 19 | 19 | use Forge12\DoubleOptIn\Service\ConfirmationMailResender; |
| 20 | 20 | use Forge12\DoubleOptIn\Service\ResendResult; |
| 21 | +use Forge12\DoubleOptIn\Subscription\SubscriptionGroups; | |
| 21 | 22 | use Forge12\Shared\LoggerInterface; |
| 22 | 23 | |
| 23 | 24 | if ( ! defined( 'ABSPATH' ) ) { |
| 24 | 25 | exit; |
| @@ -32,8 +33,14 @@ | ||
| 32 | 33 | class AdminRestController { |
| 33 | 34 | |
| 34 | 35 | const API_NAMESPACE = 'f12-doi/v1'; |
| 35 | 36 | |
| 37 | + /** | |
| 38 | + * SQL form of OptIn::isOptedOut(): not confirmed, withdrawal IP and time | |
| 39 | + * recorded. A re-opt-in clears the time, so the row counts as confirmed again. | |
| 40 | + */ | |
| 41 | + private const REVOKED_SQL = "(doubleoptin = 0 AND ipaddr_optout IS NOT NULL AND ipaddr_optout <> '' AND optouttime IS NOT NULL AND optouttime NOT IN ('', '0'))"; | |
| 42 | + | |
| 36 | 43 | private LoggerInterface $logger; |
| 37 | 44 | private FormSettingsService $formService; |
| 38 | 45 | private FormSettingsValidator $formValidator; |
| 39 | 46 | |
| @@ -107,8 +114,19 @@ | ||
| 107 | 114 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 108 | 115 | ) |
| 109 | 116 | ); |
| 110 | 117 | |
| 118 | + // ── Subscription groups (read-only; managed by an add-on) ── | |
| 119 | + register_rest_route( | |
| 120 | + self::API_NAMESPACE, | |
| 121 | + '/subscription-groups', | |
| 122 | + array( | |
| 123 | + 'methods' => \WP_REST_Server::READABLE, | |
| 124 | + 'callback' => array( $this, 'getSubscriptionGroups' ), | |
| 125 | + 'permission_callback' => array( $this, 'checkPermission' ), | |
| 126 | + ) | |
| 127 | + ); | |
| 128 | + | |
| 111 | 129 | // ── Opt-Ins ──────────────────────────────────────────────── |
| 112 | 130 | register_rest_route( |
| 113 | 131 | self::API_NAMESPACE, |
| 114 | 132 | '/optins', |
| @@ -617,9 +635,10 @@ | ||
| 617 | 635 | $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; |
| 618 | 636 | |
| 619 | 637 | $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); |
| 620 | 638 | $confirmed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE doubleoptin = 1" ); |
| 621 | - $pending = $total - $confirmed; | |
| 639 | + $revoked = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE " . self::REVOKED_SQL ); // phpcs:ignore WordPress.DB.PreparedSQL -- fixed SQL, no input. | |
| 640 | + $pending = max( 0, $total - $confirmed - $revoked ); | |
| 622 | 641 | $rate = $total > 0 ? round( ( $confirmed / $total ) * 100, 1 ) : 0; |
| 623 | 642 | |
| 624 | 643 | // Recent opt-ins (raw activity feed — not analytics). |
| 625 | 644 | // Time-bucketed activity, top-forms breakdown and the big |
| @@ -625,9 +644,9 @@ | ||
| 625 | 644 | // Time-bucketed activity, top-forms breakdown and the big |
| 626 | 645 | // conversion-rate card moved into addon-analytics, which |
| 627 | 646 | // renders them at the `dashboard.widget` mount point. |
| 628 | 647 | $recent = $wpdb->get_results( |
| 629 | - "SELECT id, email, cf_form_id, doubleoptin, createtime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 648 | + "SELECT id, email, cf_form_id, doubleoptin, createtime, ipaddr_optout, optouttime FROM {$table} ORDER BY id DESC LIMIT 5", | |
| 630 | 649 | ARRAY_A |
| 631 | 650 | ); |
| 632 | 651 | |
| 633 | 652 | foreach ( $recent as &$row ) { |
| @@ -633,14 +652,18 @@ | ||
| 633 | 652 | foreach ( $recent as &$row ) { |
| 634 | 653 | $post = get_post( (int) $row['cf_form_id'] ); |
| 635 | 654 | $row['formName'] = $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ); |
| 636 | 655 | $row['confirmed'] = (int) $row['doubleoptin'] === 1; |
| 656 | + $row['revoked'] = self::isRevokedRow( $row ); | |
| 657 | + unset( $row['ipaddr_optout'], $row['optouttime'] ); | |
| 637 | 658 | } |
| 659 | + unset( $row ); | |
| 638 | 660 | |
| 639 | 661 | $data = array( |
| 640 | 662 | 'totalOptins' => $total, |
| 641 | 663 | 'confirmed' => $confirmed, |
| 642 | 664 | 'pending' => $pending, |
| 665 | + 'revoked' => $revoked, | |
| 643 | 666 | 'conversionRate' => $rate, |
| 644 | 667 | 'recentOptins' => $recent ?: array(), |
| 645 | 668 | ); |
| 646 | 669 | |
| @@ -692,8 +715,35 @@ | ||
| 692 | 715 | // ═══════════════════════════════════════════════════════════════ |
| 693 | 716 | // OPT-INS |
| 694 | 717 | // ═══════════════════════════════════════════════════════════════ |
| 695 | 718 | |
| 719 | + /** | |
| 720 | + * Subscription groups for the filter in the opt-in list. `available` | |
| 721 | + * is false when no add-on provides groups, so the SPA hides the | |
| 722 | + * controls instead of showing an empty filter. | |
| 723 | + */ | |
| 724 | + public function getSubscriptionGroups( \WP_REST_Request $request ): \WP_REST_Response { | |
| 725 | + $groups = array(); | |
| 726 | + foreach ( SubscriptionGroups::resolver()->groups() as $group ) { | |
| 727 | + $groups[] = array( | |
| 728 | + 'key' => $group->getKey(), | |
| 729 | + 'label' => $group->getLabel(), | |
| 730 | + 'memberCount' => count( $group->getMembers() ), | |
| 731 | + ); | |
| 732 | + } | |
| 733 | + | |
| 734 | + return new \WP_REST_Response( | |
| 735 | + array( | |
| 736 | + 'success' => true, | |
| 737 | + 'data' => array( | |
| 738 | + 'available' => SubscriptionGroups::isAvailable(), | |
| 739 | + 'groups' => $groups, | |
| 740 | + ), | |
| 741 | + ), | |
| 742 | + 200 | |
| 743 | + ); | |
| 744 | + } | |
| 745 | + | |
| 696 | 746 | public function getOptins( \WP_REST_Request $request ): \WP_REST_Response { |
| 697 | 747 | $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); |
| 698 | 748 | $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) ); |
| 699 | 749 | $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' ); |
| @@ -721,8 +771,11 @@ | ||
| 721 | 771 | if ( $status === 'confirmed' ) { |
| 722 | 772 | $where[] = 'doubleoptin = 1'; |
| 723 | 773 | } elseif ( $status === 'pending' ) { |
| 724 | 774 | $where[] = '(doubleoptin = 0 OR doubleoptin IS NULL)'; |
| 775 | + $where[] = 'NOT ' . self::REVOKED_SQL; | |
| 776 | + } elseif ( $status === 'revoked' ) { | |
| 777 | + $where[] = self::REVOKED_SQL; | |
| 725 | 778 | } |
| 726 | 779 | |
| 727 | 780 | if ( $formId !== null && $formId !== '' ) { |
| 728 | 781 | $where[] = 'cf_form_id = %d'; |
| @@ -728,8 +781,22 @@ | ||
| 728 | 781 | $where[] = 'cf_form_id = %d'; |
| 729 | 782 | $params[] = (int) $formId; |
| 730 | 783 | } |
| 731 | 784 | |
| 785 | + // Subscription group (5.12.0). An unknown key selects nothing, so a | |
| 786 | + // stale link can never widen into the unfiltered list. | |
| 787 | + $groupKey = sanitize_text_field( (string) ( $request->get_param( 'group' ) ?? '' ) ); | |
| 788 | + if ( $groupKey !== '' ) { | |
| 789 | + $group = SubscriptionGroups::resolver()->findGroup( $groupKey ); | |
| 790 | + if ( $group === null ) { | |
| 791 | + $where[] = '1 = 0'; | |
| 792 | + } else { | |
| 793 | + list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 794 | + $where[] = $groupSql; | |
| 795 | + $params = array_merge( $params, $groupParams ); | |
| 796 | + } | |
| 797 | + } | |
| 798 | + | |
| 732 | 799 | // Opt-ins whose confirmation mail could not be sent (5.8.0). |
| 733 | 800 | if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { |
| 734 | 801 | $where[] = 'mail_status = %s'; |
| 735 | 802 | $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; |
| @@ -798,8 +865,12 @@ | ||
| 798 | 865 | } |
| 799 | 866 | |
| 800 | 867 | $data = $this->formatOptinRow( $row, true ); |
| 801 | 868 | |
| 869 | + // Other sign-ups of the same address in the same subscription | |
| 870 | + // group (5.12.0). The records stay separate; this only links them. | |
| 871 | + $data['linkedOptIns'] = $this->linkedOptIns( $row ); | |
| 872 | + | |
| 802 | 873 | // Dev-mode UI hint: surface whether the reset-confirmation |
| 803 | 874 | // endpoint is reachable for this request, so the React detail |
| 804 | 875 | // page can show/hide the "Reset to pending" button without |
| 805 | 876 | // having to probe the endpoint and handle a 403. Mirrors |
| @@ -2170,8 +2241,21 @@ | ||
| 2170 | 2241 | // HELPERS |
| 2171 | 2242 | // ═══════════════════════════════════════════════════════════════ |
| 2172 | 2243 | |
| 2173 | 2244 | /** |
| 2245 | + * PHP form of REVOKED_SQL for a raw table row. | |
| 2246 | + * | |
| 2247 | + * @param array<string, mixed> $row The database row. | |
| 2248 | + */ | |
| 2249 | + private static function isRevokedRow( array $row ): bool { | |
| 2250 | + $optOutTime = (string) ( $row['optouttime'] ?? '' ); | |
| 2251 | + | |
| 2252 | + return (int) ( $row['doubleoptin'] ?? 0 ) !== 1 | |
| 2253 | + && (string) ( $row['ipaddr_optout'] ?? '' ) !== '' | |
| 2254 | + && $optOutTime !== '' && $optOutTime !== '0'; | |
| 2255 | + } | |
| 2256 | + | |
| 2257 | + /** | |
| 2174 | 2258 | * Format an opt-in database row for the API response. |
| 2175 | 2259 | * |
| 2176 | 2260 | * @param array $row The database row. |
| 2177 | 2261 | * @param bool $detailed Whether to include full detail (content, mail data). |
| @@ -2177,8 +2261,74 @@ | ||
| 2177 | 2261 | * @param bool $detailed Whether to include full detail (content, mail data). |
| 2178 | 2262 | * |
| 2179 | 2263 | * @return array Formatted data. |
| 2180 | 2264 | */ |
| 2265 | + /** | |
| 2266 | + * @param array<string, mixed> $row A database row. | |
| 2267 | + * | |
| 2268 | + * @return array{key:string, label:string}|null | |
| 2269 | + */ | |
| 2270 | + private function subscriptionOfRow( array $row ): ?array { | |
| 2271 | + $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2272 | + (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2273 | + (string) ( $row['form_ref'] ?? '' ) | |
| 2274 | + ); | |
| 2275 | + | |
| 2276 | + return $group === null ? null : array( | |
| 2277 | + 'key' => $group->getKey(), | |
| 2278 | + 'label' => $group->getLabel(), | |
| 2279 | + ); | |
| 2280 | + } | |
| 2281 | + | |
| 2282 | + /** | |
| 2283 | + * The other records of the same address that belong to the same | |
| 2284 | + * subscription group as the given record. | |
| 2285 | + * | |
| 2286 | + * @param array<string, mixed> $row A database row. | |
| 2287 | + * | |
| 2288 | + * @return array<int, array<string, mixed>> | |
| 2289 | + */ | |
| 2290 | + private function linkedOptIns( array $row ): array { | |
| 2291 | + $group = SubscriptionGroups::resolver()->groupForForm( | |
| 2292 | + (int) ( $row['cf_form_id'] ?? 0 ), | |
| 2293 | + (string) ( $row['form_ref'] ?? '' ) | |
| 2294 | + ); | |
| 2295 | + $email = (string) ( $row['email'] ?? '' ); | |
| 2296 | + if ( $group === null || $email === '' ) { | |
| 2297 | + return array(); | |
| 2298 | + } | |
| 2299 | + | |
| 2300 | + global $wpdb; | |
| 2301 | + $table = $wpdb->prefix . 'f12_cf7_doubleoptin'; | |
| 2302 | + list( $groupSql, $groupParams ) = $group->toSqlCondition(); | |
| 2303 | + | |
| 2304 | + // The group condition is built from fixed column names and `%d`/`%s` | |
| 2305 | + // placeholders only; every value travels in the parameter list. | |
| 2306 | + $found = $wpdb->get_results( | |
| 2307 | + $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders come from SubscriptionGroup::toSqlCondition(). | |
| 2308 | + "SELECT * FROM {$table} WHERE email = %s AND id <> %d AND {$groupSql} ORDER BY id DESC LIMIT 50", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared | |
| 2309 | + array_merge( array( $email, (int) $row['id'] ), $groupParams ) | |
| 2310 | + ), | |
| 2311 | + ARRAY_A | |
| 2312 | + ); | |
| 2313 | + | |
| 2314 | + $linked = array(); | |
| 2315 | + foreach ( (array) $found as $other ) { | |
| 2316 | + $data = $this->formatOptinRow( $other ); | |
| 2317 | + $linked[] = array( | |
| 2318 | + 'id' => $data['id'], | |
| 2319 | + 'formId' => $data['formId'], | |
| 2320 | + 'formName' => $data['formName'], | |
| 2321 | + 'formRef' => $data['formRef'], | |
| 2322 | + 'confirmed' => $data['confirmed'], | |
| 2323 | + 'revoked' => $data['revoked'], | |
| 2324 | + 'createtime' => $data['createtime'], | |
| 2325 | + ); | |
| 2326 | + } | |
| 2327 | + | |
| 2328 | + return $linked; | |
| 2329 | + } | |
| 2330 | + | |
| 2181 | 2331 | private function formatOptinRow( array $row, bool $detailed = false ): array { |
| 2182 | 2332 | $post = get_post( (int) $row['cf_form_id'] ); |
| 2183 | 2333 | |
| 2184 | 2334 | $data = array( |
| @@ -2188,8 +2338,10 @@ | ||
| 2188 | 2338 | 'formId' => (int) $row['cf_form_id'], |
| 2189 | 2339 | 'formName' => $post ? $post->post_title : sprintf( '#%d', $row['cf_form_id'] ), |
| 2190 | 2340 | 'category' => (int) $row['category'], |
| 2191 | 2341 | 'confirmed' => (int) $row['doubleoptin'] === 1, |
| 2342 | + // Consent withdrawn via the opt-out (5.9.0). Not "pending". | |
| 2343 | + 'revoked' => self::isRevokedRow( $row ), | |
| 2192 | 2344 | // Confirmation mail: 'sent' (handed to the mail server), 'failed', |
| 2193 | 2345 | // or '' (recorded before 5.8.0). Since 5.8.0. |
| 2194 | 2346 | 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), |
| 2195 | 2347 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| @@ -2194,8 +2346,13 @@ | ||
| 2194 | 2346 | 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), |
| 2195 | 2347 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2196 | 2348 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2197 | 2349 | ); |
| 2350 | + | |
| 2351 | + // Instance inside the form id, e.g. the Elementor widget (5.12.0). | |
| 2352 | + $data['formRef'] = (string) ( $row['form_ref'] ?? '' ); | |
| 2353 | + // Subscription group of the record, or null (5.12.0). | |
| 2354 | + $data['subscription'] = $this->subscriptionOfRow( $row ); | |
| 2198 | 2355 | |
| 2199 | 2356 | if ( $detailed ) { |
| 2200 | 2357 | $data['ipRegister'] = $row['ipaddr_register']; |
| 2201 | 2358 | $data['ipConfirmation'] = $row['ipaddr_confirmation']; |