| @@ -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; |
| @@ -113,8 +114,19 @@ | ||
| 113 | 114 | 'permission_callback' => array( $this, 'checkPermission' ), |
| 114 | 115 | ) |
| 115 | 116 | ); |
| 116 | 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 | + | |
| 117 | 129 | // ── Opt-Ins ──────────────────────────────────────────────── |
| 118 | 130 | register_rest_route( |
| 119 | 131 | self::API_NAMESPACE, |
| 120 | 132 | '/optins', |
| @@ -703,8 +715,35 @@ | ||
| 703 | 715 | // ═══════════════════════════════════════════════════════════════ |
| 704 | 716 | // OPT-INS |
| 705 | 717 | // ═══════════════════════════════════════════════════════════════ |
| 706 | 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 | + | |
| 707 | 746 | public function getOptins( \WP_REST_Request $request ): \WP_REST_Response { |
| 708 | 747 | $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); |
| 709 | 748 | $perPage = max( 1, min( 100, (int) $request->get_param( 'per_page' ) ?: 20 ) ); |
| 710 | 749 | $search = sanitize_text_field( $request->get_param( 'search' ) ?? '' ); |
| @@ -742,8 +781,22 @@ | ||
| 742 | 781 | $where[] = 'cf_form_id = %d'; |
| 743 | 782 | $params[] = (int) $formId; |
| 744 | 783 | } |
| 745 | 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 | + | |
| 746 | 799 | // Opt-ins whose confirmation mail could not be sent (5.8.0). |
| 747 | 800 | if ( sanitize_text_field( (string) ( $request->get_param( 'mail' ) ?? '' ) ) === 'failed' ) { |
| 748 | 801 | $where[] = 'mail_status = %s'; |
| 749 | 802 | $params[] = \Forge12\DoubleOptIn\Repository\OptInMailStatusRepository::FAILED; |
| @@ -812,8 +865,12 @@ | ||
| 812 | 865 | } |
| 813 | 866 | |
| 814 | 867 | $data = $this->formatOptinRow( $row, true ); |
| 815 | 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 | + | |
| 816 | 873 | // Dev-mode UI hint: surface whether the reset-confirmation |
| 817 | 874 | // endpoint is reachable for this request, so the React detail |
| 818 | 875 | // page can show/hide the "Reset to pending" button without |
| 819 | 876 | // having to probe the endpoint and handle a 403. Mirrors |
| @@ -2204,8 +2261,74 @@ | ||
| 2204 | 2261 | * @param bool $detailed Whether to include full detail (content, mail data). |
| 2205 | 2262 | * |
| 2206 | 2263 | * @return array Formatted data. |
| 2207 | 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 | + | |
| 2208 | 2331 | private function formatOptinRow( array $row, bool $detailed = false ): array { |
| 2209 | 2332 | $post = get_post( (int) $row['cf_form_id'] ); |
| 2210 | 2333 | |
| 2211 | 2334 | $data = array( |
| @@ -2223,8 +2346,13 @@ | ||
| 2223 | 2346 | 'mailStatus' => (string) ( $row['mail_status'] ?? '' ), |
| 2224 | 2347 | 'createtime' => $this->toSiteLocalTime( $row['createtime'] ), |
| 2225 | 2348 | 'updatetime' => $this->toSiteLocalTime( $row['updatetime'] ), |
| 2226 | 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 ); | |
| 2227 | 2355 | |
| 2228 | 2356 | if ( $detailed ) { |
| 2229 | 2357 | $data['ipRegister'] = $row['ipaddr_register']; |
| 2230 | 2358 | $data['ipConfirmation'] = $row['ipaddr_confirmation']; |