diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d635786 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Build Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get plugin version + id: version + run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Build zip + run: | + plugin_name="fluent-crm-custom-features" + mkdir "$plugin_name" + cp -r classes "$plugin_name/" + cp *.php "$plugin_name/" + zip -r "${plugin_name}.zip" "$plugin_name" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: fluent-crm-custom-features.zip + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 21bca1b..8607472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies /vendor/ -/vendor-prefixed/ +/vendor-prefixed/* +!/vendor-prefixed/.gitkeep /node_modules/ # Composer diff --git a/classes/Actions/EnrichContactAction.php b/classes/Actions/EnrichContactAction.php new file mode 100644 index 0000000..26f8bf1 --- /dev/null +++ b/classes/Actions/EnrichContactAction.php @@ -0,0 +1,812 @@ + + */ + private array $providers = []; + + /** + * EnrichContactAction constructor. + */ + public function __construct() { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->actionName = 'enrich_contact'; + $this->priority = 30; + parent::__construct(); + } + + /** + * Get block definition shown in the automation editor sidebar. + * + * @return array + */ + public function getBlock() { + return [ + 'category' => __( 'CRM', 'fluent-crm-custom-features' ), + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'description' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'icon' => 'fc-icon-wp_user_meta', + 'settings' => [ + 'provider' => EnrichmentSettings::getActiveProvider(), + 'enrichment_scope' => 'both', + 'min_likelihood' => 6, + 'data_behavior' => 'fill_empty', + 'reprocess' => 'no', + 'company_handling' => 'create_or_update', + 'tag_on_success' => [], + 'tag_on_no_match' => [], + ], + ]; + } + + /** + * Get block field definitions for the settings editor. + * + * @return array + */ + public function getBlockFields() { + return [ + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'sub_title' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'fields' => [ + 'provider' => [ + 'type' => 'select', + 'label' => __( 'Enrichment Provider', 'fluent-crm-custom-features' ), + 'options' => $this->getProviderOptions(), + 'inline_help' => __( 'Configure providers in FluentCRM Custom Features settings.', 'fluent-crm-custom-features' ), + ], + 'enrichment_scope' => [ + 'type' => 'radio', + 'label' => __( 'Enrichment Scope', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'both', + 'title' => __( 'Person + Company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'person', + 'title' => __( 'Person Only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'company', + 'title' => __( 'Company Only', 'fluent-crm-custom-features' ), + ], + ], + ], + 'min_likelihood' => [ + 'type' => 'input-number', + 'label' => __( 'Minimum Likelihood Score (1-10)', 'fluent-crm-custom-features' ), + 'wrapper_class' => 'fc_2col_inline pad-r-20', + 'inline_help' => __( 'Higher = more accurate but fewer matches. Recommended: 6.', 'fluent-crm-custom-features' ), + ], + 'data_behavior' => [ + 'type' => 'radio', + 'label' => __( 'Data Behavior', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'fill_empty', + 'title' => __( 'Fill empty fields only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'overwrite', + 'title' => __( 'Overwrite all fields', 'fluent-crm-custom-features' ), + ], + ], + ], + 'reprocess' => [ + 'type' => 'yes_no_check', + 'check_label' => __( 'Re-enrich contacts that have already been enriched', 'fluent-crm-custom-features' ), + ], + 'company_handling' => [ + 'type' => 'radio', + 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'create_or_update', + 'title' => __( 'Create or update company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'update_existing', + 'title' => __( 'Update existing company only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'none', + 'title' => __( "Don't touch companies", 'fluent-crm-custom-features' ), + ], + ], + 'dependency' => [ + 'depends_on' => 'enrichment_scope', + 'value' => 'person', + 'operator' => '!=', + ], + ], + 'tag_on_success' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on Successful Enrichment', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + 'tag_on_no_match' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on No Match', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + ], + ]; + } + + /** + * Execute the enrichment action. + * + * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. + * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $funnel_metric Funnel metric. + */ + public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_metric ) { + $settings = $sequence->settings; + $scope = Arr::get( $settings, 'enrichment_scope', 'both' ); + + // --- 1. Detect context --- + $is_company_trigger = $this->isCompanyTriggered( $subscriber, $sequence ); + + if ( $is_company_trigger && 'person' === $scope ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + if ( $is_company_trigger ) { + $scope = 'company'; + } + + // --- 2. Check skip conditions --- + $reprocess = 'yes' === Arr::get( $settings, 'reprocess', 'no' ); + if ( ! $reprocess && ! $is_company_trigger ) { + $enriched_at = $subscriber->getMeta( 'enriched_at', 'custom_field' ); + if ( $enriched_at ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + } + + // --- 3. Resolve provider --- + $provider = $this->resolveProvider( Arr::get( $settings, 'provider', '' ) ); + if ( ! $provider ) { + $this->log( 'error', 'No enrichment provider configured or found.' ); + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + $min_likelihood = (int) Arr::get( $settings, 'min_likelihood', 6 ); + $data_behavior = Arr::get( $settings, 'data_behavior', 'fill_empty' ); + $company_handling = Arr::get( $settings, 'company_handling', 'create_or_update' ); + $person_result = null; + $company_result = null; + $person_success = false; + $company_success = false; + + // --- 4. Person enrichment --- + if ( in_array( $scope, [ 'both', 'person' ], true ) && $subscriber->email ) { + $person_result = $this->enrichPerson( + $provider, + $subscriber, + $min_likelihood, + $data_behavior, + $funnel_subscriber_id, + $sequence + ); + + $person_success = $person_result instanceof PersonResult; + } + + // --- 5. Company enrichment --- + if ( in_array( $scope, [ 'both', 'company' ], true ) ) { + $company_result = $this->enrichCompany( + $provider, + $subscriber, + $person_result, + $is_company_trigger, + $min_likelihood, + $data_behavior, + $company_handling, + $funnel_subscriber_id, + $sequence + ); + + $company_success = $company_result instanceof CompanyResult; + } + + // --- 6. Apply tags --- + $refreshed = Subscriber::where( 'id', $subscriber->id )->first(); + + if ( $person_success || $company_success ) { + $success_tags = Arr::get( $settings, 'tag_on_success', [] ); + if ( $success_tags && $refreshed ) { + $refreshed->attachTags( $success_tags ); + } + } elseif ( ! $person_success && ! $company_success ) { + $no_match_tags = Arr::get( $settings, 'tag_on_no_match', [] ); + if ( $no_match_tags && $refreshed ) { + $refreshed->attachTags( $no_match_tags ); + } + } + + // --- 7. Fire hooks --- + if ( $refreshed ) { + do_action( 'custom_crm/contact_enriched', $refreshed, $person_result, $company_result ); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Run person enrichment and apply results to the subscriber. + * + * @param EnrichmentProvider $provider The provider. + * @param Subscriber $subscriber The subscriber. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * + * @return PersonResult|EnrichmentError|null + */ + private function enrichPerson( + EnrichmentProvider $provider, + Subscriber $subscriber, + int $min_likelihood, + string $data_behavior, + int $funnel_subscriber_id, + $sequence + ) { + EnrichmentFields::ensureFieldsExist(); + + $params = [ + 'email' => $subscriber->email, + 'first_name' => $subscriber->first_name ?? '', + 'last_name' => $subscriber->last_name ?? '', + 'min_likelihood' => $min_likelihood, + ]; + + $result = $provider->enrichPerson( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'person' ); + return $result; + } + + if ( $result->likelihood && $result->likelihood < $min_likelihood ) { + $this->log( 'info', "Person enrichment below threshold ({$result->likelihood} < {$min_likelihood}) for {$subscriber->email}" ); + return null; + } + + // Map native subscriber fields. + $subscriber_fields = $result->toSubscriberFields(); + + if ( 'fill_empty' === $data_behavior ) { + $subscriber_fields = $this->filterEmptyOnly( $subscriber, $subscriber_fields ); + } + + if ( $subscriber_fields ) { + $subscriber->fill( $subscriber_fields ); + $dirty = $subscriber->getDirty(); + if ( $dirty ) { + $subscriber->save(); + do_action( 'fluent_crm/contact_updated', $subscriber, $dirty ); + } + } + + // Map custom fields. + $custom_fields = $result->toCustomFields(); + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + + if ( 'fill_empty' === $data_behavior ) { + $custom_fields = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom_fields ); + // Always update these metadata fields regardless of behavior. + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + } + + if ( $custom_fields ) { + $subscriber->syncCustomFieldValues( $custom_fields, false ); + } + + $this->log( 'info', "Person enrichment successful for {$subscriber->email} (likelihood: {$result->likelihood})" ); + + return $result; + } + + /** + * Run company enrichment and apply results. + * + * @param EnrichmentProvider $provider The provider. + * @param Subscriber $subscriber The subscriber. + * @param mixed $person_result PersonResult or null. + * @param bool $is_company_trigger Whether this is a company-triggered funnel. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param string $company_handling Company handling mode. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * + * @return CompanyResult|EnrichmentError|null + */ + private function enrichCompany( + EnrichmentProvider $provider, + Subscriber $subscriber, + $person_result, + bool $is_company_trigger, + int $min_likelihood, + string $data_behavior, + string $company_handling, + int $funnel_subscriber_id, + $sequence + ) { + // Determine company identifier. + $company_website = null; + $company_name = null; + + if ( $is_company_trigger && $subscriber->company_id ) { + $existing_company = Company::find( $subscriber->company_id ); + if ( $existing_company ) { + $company_website = $existing_company->website; + $company_name = $existing_company->name; + } + } + + if ( ! $company_website && $subscriber->email ) { + $domain = FreeEmailDetector::extractDomain( $subscriber->email ); + if ( $domain && ! FreeEmailDetector::isFreeEmail( $subscriber->email ) ) { + $company_website = $domain; + } + } + + // Fallback to person enrichment result. + if ( ! $company_website && $person_result instanceof PersonResult ) { + $company_website = $person_result->job_company_website; + $company_name = $person_result->job_company_name; + } + + if ( ! $company_website && ! $company_name ) { + $this->log( 'info', "No company identifier found for {$subscriber->email}, skipping company enrichment." ); + return null; + } + + $params = array_filter( [ + 'website' => $company_website, + 'name' => $company_name, + 'min_likelihood' => $min_likelihood, + ] ); + + $result = $provider->enrichCompany( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'company' ); + return $result; + } + + // Determine match confidence. + $email_domain = $subscriber->email ? FreeEmailDetector::extractDomain( $subscriber->email ) : ''; + $result_domain = $this->normalizeDomain( $result->website ?? '' ); + $match_type = ( $email_domain && $result_domain && $email_domain === $result_domain ) ? 'confirmed' : 'inferred'; + + // Handle company based on setting. + $company = null; + + if ( 'none' === $company_handling ) { + $this->storeCompanyInCustomFields( $subscriber, $result, $match_type, $provider->getSlug(), $data_behavior ); + } elseif ( 'update_existing' === $company_handling ) { + if ( $subscriber->company_id ) { + $company = Company::find( $subscriber->company_id ); + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider->getSlug(), $data_behavior ); + } + } + } else { + // create_or_update. + $company = $this->findOrCreateCompany( $result, $match_type, $provider->getSlug(), $data_behavior ); + if ( $company && ! $is_company_trigger && $subscriber->email ) { + $subscriber->company_id = $company->id; + $subscriber->save(); + $subscriber->attachCompanies( [ $company->id ] ); + } + } + + $this->log( 'info', "Company enrichment successful: {$result->name} ({$match_type})" ); + + if ( $company ) { + do_action( 'custom_crm/company_enriched', $company, $result ); + } + + return $result; + } + + /** + * Find an existing company by website domain or name, or create a new one. + * + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * + * @return Company|null + */ + private function findOrCreateCompany( + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): ?Company { + $company = null; + + // Try to find by website domain. + if ( $result->website ) { + $normalized = $this->normalizeDomain( $result->website ); + $escaped = addcslashes( $normalized, '%_' ); + $company = Company::where( 'website', 'LIKE', "%{$escaped}%" )->first(); + } + + // Fallback: find by exact name. + if ( ! $company && $result->name ) { + $company = Company::where( 'name', $result->name )->first(); + } + + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider_slug, $data_behavior ); + return $company; + } + + // Create new company. + $fields = $result->toCompanyFields(); + $fields['meta'] = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + + if ( empty( $fields['name'] ) ) { + return null; + } + + return Company::create( $fields ); + } + + /** + * Update an existing company from enrichment results. + * + * @param Company $company The company model. + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + */ + private function updateCompanyFromResult( + Company $company, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + $fields = $result->toCompanyFields(); + + if ( 'fill_empty' === $data_behavior ) { + $fields = $this->filterEmptyCompanyFields( $company, $fields ); + } + + if ( $fields ) { + $company->fill( $fields ); + $company->save(); + } + + // Always update meta. + $meta = $company->meta; + $enrichment_meta = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + $company->meta = array_merge( is_array( $meta ) ? $meta : [], $enrichment_meta ); + $company->save(); + } + + /** + * Build the enrichment portion of company meta. + * + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * + * @return array + */ + private function buildCompanyMeta( CompanyResult $result, string $match_type, string $provider_slug ): array { + return array_merge( + $result->toCompanyMeta(), + [ + 'enrichment_company_match' => $match_type, + 'enrichment_provider' => $provider_slug, + 'enriched_at' => current_time( 'mysql' ), + ] + ); + } + + /** + * Store company data in contact custom fields (when company_handling = 'none'). + * + * @param Subscriber $subscriber The subscriber. + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $_provider_slug Provider slug (reserved for future use). + * @param string $data_behavior 'fill_empty' or 'overwrite'. + */ + private function storeCompanyInCustomFields( + Subscriber $subscriber, + CompanyResult $result, + string $match_type, + string $_provider_slug, + string $data_behavior + ): void { + EnrichmentFields::ensureFieldsExist(); + + $custom = [ + 'enrichment_company_name' => $result->name, + 'enrichment_industry' => $result->industry, + 'enrichment_company_match' => $match_type, + ]; + + $custom = array_filter( $custom, static fn( $v ) => null !== $v ); + + if ( 'fill_empty' === $data_behavior ) { + $custom = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom ); + } + + if ( $custom ) { + $subscriber->syncCustomFieldValues( $custom, false ); + } + } + + /** + * Handle an enrichment error: log it and optionally mark the sequence. + * + * @param EnrichmentError $error The error. + * @param Subscriber $subscriber The subscriber. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * @param string $context 'person' or 'company'. + */ + private function handleError( + EnrichmentError $error, + Subscriber $subscriber, + int $funnel_subscriber_id, + $sequence, + string $context + ): void { + $email = $subscriber->email ?? '(unknown)'; + + $log_level = in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::QUOTA_EXCEEDED, EnrichmentError::PROVIDER_ERROR ], true ) + ? 'error' + : 'info'; + + $this->log( $log_level, "{$context} enrichment error for {$email}: [{$error->code}] {$error->message}" ); + + // For fatal errors, skip the sequence. + if ( in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::INVALID_INPUT, EnrichmentError::QUOTA_EXCEEDED ], true ) ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + } + } + + /** + * Filter subscriber fields to only those that are currently empty. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static fn( $_value, $key ) => empty( $subscriber->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter custom fields to only those that are currently empty on the subscriber. + * + * Uses Subscriber::getMeta() to check each field individually since FluentCRM + * stores custom field values as individual meta rows. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Custom field slug => value. + * + * @return array + */ + private function filterEmptyCustomFieldsOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static function ( $_value, $key ) use ( $subscriber ) { + $existing = $subscriber->getMeta( $key, 'custom_field' ); + + // getMeta() returns false when the meta row doesn't exist. + // Also treat empty strings and null as "empty". + return false === $existing || '' === $existing || null === $existing; + }, + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter company fields to only those that are currently empty. + * + * @param Company $company Company model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyCompanyFields( Company $company, array $fields ): array { + return array_filter( + $fields, + static fn( $_value, $key ) => empty( $company->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Detect if this is a company-triggered funnel. + * + * @param Subscriber $_subscriber The subscriber (unused, context detection uses sequence). + * @param mixed $sequence The funnel sequence. + * + * @return bool + */ + private function isCompanyTriggered( Subscriber $_subscriber, $sequence ): bool { + // Check funnel trigger name if available. + if ( isset( $sequence->funnel ) && isset( $sequence->funnel->trigger_name ) ) { + $trigger = $sequence->funnel->trigger_name; + if ( false !== strpos( $trigger, 'company' ) ) { + return true; + } + } + + return false; + } + + /** + * Normalize a domain for comparison (strip protocol, www, trailing slash). + * + * @param string $url The URL or domain to normalize. + * + * @return string + */ + private function normalizeDomain( string $url ): string { + $domain = strtolower( $url ); + $domain = preg_replace( '#^https?://#', '', $domain ) ?? $domain; + $domain = preg_replace( '#^www\.#', '', $domain ) ?? $domain; + $domain = rtrim( $domain, '/' ); + + return $domain; + } + + /** + * Get provider options for the dropdown. + * + * @return array + */ + private function getProviderOptions(): array { + $providers = $this->getRegisteredProviders(); + $options = []; + + foreach ( $providers as $provider ) { + $settings = EnrichmentSettings::getProviderSettings( $provider->getSlug() ); + if ( ! empty( $settings['api_key'] ) ) { + $options[] = [ + 'id' => $provider->getSlug(), + 'title' => $provider->getName(), + ]; + } + } + + if ( empty( $options ) ) { + $options[] = [ + 'id' => '', + 'title' => __( '-- No providers configured --', 'fluent-crm-custom-features' ), + ]; + } + + return $options; + } + + /** + * Resolve a provider instance by slug. + * + * @param string $slug Provider slug. + * + * @return EnrichmentProvider|null + */ + private function resolveProvider( string $slug ): ?EnrichmentProvider { + if ( '' === $slug ) { + $slug = EnrichmentSettings::getActiveProvider(); + } + + $providers = $this->getRegisteredProviders(); + + foreach ( $providers as $provider ) { + if ( $provider->getSlug() === $slug ) { + $settings = EnrichmentSettings::getProviderSettings( $slug ); + if ( ! empty( $settings['api_key'] ) ) { + return $provider; + } + } + } + + return null; + } + + /** + * Get all registered providers. + * + * @return EnrichmentProvider[] + */ + private function getRegisteredProviders(): array { + if ( $this->providers ) { + return $this->providers; + } + + $providers = [ + new PDLProvider(), + ]; + + /** + * Register additional enrichment providers. + * + * @param EnrichmentProvider[] $providers Array of provider instances. + */ + $this->providers = apply_filters( 'custom_crm/enrichment_providers', $providers ); + + return $this->providers; + } + + /** + * Log a message to FluentCRM's internal logger. + * + * @param string $level 'info', 'error', 'warning'. + * @param string $message Log message. + */ + private function log( string $level, string $message ): void { + if ( function_exists( '\fluentCrmLog' ) ) { + \fluentCrmLog( $message ); // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid + } + + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "[CustomCRM Enrichment][{$level}] {$message}" ); + } + } +} diff --git a/classes/Actions/RandomWaitTimeAction.php b/classes/Actions/RandomWaitTimeAction.php index 5016178..7fda92e 100644 --- a/classes/Actions/RandomWaitTimeAction.php +++ b/classes/Actions/RandomWaitTimeAction.php @@ -47,7 +47,7 @@ public function getBlock() { ], ]; - return array_merge_recursive( $block, $customize_block ); + return array_replace_recursive( $block, $customize_block ); } /** @@ -63,6 +63,10 @@ public function savingAction( $sequence, $funnel ) { $max = Arr::get( $sequence, 'settings.wait_time_amount_max' ); $unit = Arr::get( $sequence, 'settings.wait_time_unit' ); + // Ensure numeric types before arithmetic. + $min = is_numeric( $min ) ? (float) $min : 0; + $max = is_numeric( $max ) ? (float) $max : 0; + if ( $min >= 0 && $max > 0 ) { $sequence['settings']['wait_time_amount'] = $max; @@ -82,20 +86,6 @@ public function savingAction( $sequence, $funnel ) { return $sequence; } - /** - * Get the action settings. - * - * @param array $sequence The sequence settings. - * @param array $funnel The funnel settings. - * - * @return array - */ - public function gettingAction( $sequence, $funnel ) { - $sequence = parent::gettingAction( $sequence, $funnel ); - - return $sequence; - } - /** * Get the block fields for the action. * @@ -170,11 +160,21 @@ public function getBlockFields() { * @return int */ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $funnel_subscriber_id ) { + // Only process sequences that have random wait settings. + if ( ! Arr::get( $settings, 'wait_time_amount_min' ) && ! Arr::get( $settings, 'wait_time_amount_max' ) ) { + return $delay_in_seconds; + } + $delay = Arr::get( $settings, 'wait_time_amount', null ); $min = Arr::get( $settings, 'wait_time_amount_min', null ); $max = Arr::get( $settings, 'wait_time_amount_max', 0 ); $unit = Arr::get( $settings, 'wait_time_unit' ); + // Ensure numeric types — array_merge_recursive can turn scalars into arrays. + $delay = is_numeric( $delay ) ? (float) $delay : 0; + $min = is_numeric( $min ) ? (float) $min : null; + $max = is_numeric( $max ) ? (float) $max : 0; + $wait_times = $delay; if ( $min >= 0 && $max > 0 ) { @@ -199,20 +199,6 @@ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $fun $wait_times = $wait_times * 60 * 60 * 24 * ( 365 / 12 ); } - if ( $wait_times !== $delay_in_seconds ) { - // Track the random time as an event for debugging. - \FluentCrmApi( 'event_tracker' )->track( [ - 'event_key' => 'random_wait_time', // Required - 'title' => 'Randomized Wait Time', // Required - 'value' => wp_json_encode([ - 'next_sequence' => gmdate( 'Y-m-d H:i:s', time() + $wait_times ), - 'delay' => $wait_times, - ]), - 'email' => 'daniel@code-atlantic.com', - 'provider' => 'debug', // If left empty, 'custom' will be added. - ], false ); - } - return $wait_times; } } diff --git a/classes/Actions/UpdateContactPropertyAction.php b/classes/Actions/UpdateContactPropertyAction.php index 7ae16b5..00f11bd 100644 --- a/classes/Actions/UpdateContactPropertyAction.php +++ b/classes/Actions/UpdateContactPropertyAction.php @@ -309,13 +309,10 @@ public function formatCustomFieldValues( $values, $fields = [] ) { $is_array_type = Arr::get( $fields, $value_key . '.type' ) === 'checkbox' || Arr::get( $fields, $value_key . '.type' ) === 'select-multi'; if ( ! is_array( $value ) && $is_array_type ) { - $item_values = explode( ',', $value ); - $trimmedvalues = []; - foreach ( $item_values as $item_value ) { - $trimmedvalues[] = trim( $item_value ); - } - if ( $item_value ) { - $values[ $value_key ] = $trimmedvalues; + $item_values = explode( ',', $value ); + $trimmed_values = array_filter( array_map( 'trim', $item_values ), 'strlen' ); + if ( $trimmed_values ) { + $values[ $value_key ] = $trimmed_values; } } } diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php new file mode 100644 index 0000000..6be804c --- /dev/null +++ b/classes/Conditions/AutomationConditions.php @@ -0,0 +1,336 @@ + $groups Existing condition groups. + * @param mixed $funnel The current funnel. + * + * @return array + */ + public function addConditionGroups( array $groups, $funnel ): array { + // Add Event Tracking group (evaluation handled by FunnelConditionHelper::assessEventTrackingConditions). + if ( Helper::isExperimentalEnabled( 'event_tracking' ) ) { + $groups['event_tracking'] = [ + 'label' => __( 'Event Tracking', 'fluent-crm-custom-features' ), + 'value' => 'event_tracking', + 'children' => $this->getEventTrackingChildren(), + ]; + } + + // Add Automation Completion group. + $groups['automations'] = [ + 'label' => __( 'Automations', 'fluent-crm-custom-features' ), + 'value' => 'automations', + 'children' => $this->getAutomationChildren(), + ]; + + // Add EDD License group. + if ( function_exists( 'edd_software_licensing' ) ) { + $groups['edd_licenses'] = [ + 'label' => __( 'EDD Licenses', 'fluent-crm-custom-features' ), + 'value' => 'edd_licenses', + 'children' => $this->getEddLicenseChildren(), + ]; + } + + return $groups; + } + + /** + * Get event tracking condition options. + * + * Provides a list of tracked event keys that can be used as conditions. + * + * @return array> + */ + private function getEventTrackingChildren(): array { + $events = fluentCrmDb()->table( 'fc_event_tracking' ) + ->select( 'event_key', 'title' ) + ->groupBy( 'event_key' ) + ->get(); + + $children = []; + foreach ( $events as $event ) { + $children[] = [ + 'label' => $event->title ?: $event->event_key, + 'value' => $event->event_key, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has performed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not performed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Get automation completion condition options. + * + * Provides a list of funnels that can be checked for completion. + * + * @return array> + */ + private function getAutomationChildren(): array { + $funnels = fluentCrmDb()->table( 'fc_funnels' ) + ->select( 'id', 'title' ) + ->orderBy( 'title', 'ASC' ) + ->get(); + + $children = []; + foreach ( $funnels as $funnel_item ) { + $children[] = [ + 'label' => $funnel_item->title, + 'value' => 'funnel_completed_' . $funnel_item->id, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has completed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not completed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Get EDD license condition options. + * + * Lists all EDD downloads that have at least one license, so the condition + * UI shows "Has active license for [Product Name]" yes/no. + * + * @return array> + */ + private function getEddLicenseChildren(): array { + $products = fluentCrmDb()->table( 'edd_licenses' ) + ->select( 'download_id' ) + ->groupBy( 'download_id' ) + ->get(); + + $children = []; + foreach ( $products as $product ) { + $download = get_post( $product->download_id ); + if ( ! $download ) { + continue; + } + + $children[] = [ + 'label' => $download->post_title, + 'value' => 'edd_license_' . $product->download_id, + 'type' => 'selections', + 'options' => [ + 'valid' => __( 'Valid license (active or inactive)', 'fluent-crm-custom-features' ), + 'active' => __( 'Active (activated on a site)', 'fluent-crm-custom-features' ), + 'inactive' => __( 'Inactive (not activated)', 'fluent-crm-custom-features' ), + 'expired' => __( 'Expired', 'fluent-crm-custom-features' ), + 'disabled' => __( 'Disabled', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => true, + 'is_singular_value' => false, + ]; + } + + return $children; + } + + /** + * Assess EDD license conditions. + * + * Checks if the subscriber has an active (or inactive) license for a specific + * EDD product by querying the edd_licenses table via customer_id. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessEddLicenseConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + if ( ! function_exists( 'edd_software_licensing' ) ) { + return false; + } + + // Resolve the EDD customer from the subscriber's email or user_id. + $customer = null; + if ( $subscriber->user_id ) { + $customer = new \EDD_Customer( $subscriber->user_id, true ); + } + if ( ( ! $customer || ! $customer->id ) && $subscriber->email ) { + $customer = new \EDD_Customer( $subscriber->email ); + } + if ( ! $customer || ! $customer->id ) { + // No EDD customer — no license can match. + return false; + } + + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? []; + + if ( strpos( $prop, 'edd_license_' ) !== 0 ) { + continue; + } + + $download_id = (int) str_replace( 'edd_license_', '', $prop ); + if ( ! $download_id ) { + continue; + } + + // Backward compatibility: convert old yes/no values. + if ( $value === 'yes' ) { + $value = [ 'valid' ]; + } elseif ( $value === 'no' ) { + $value = [ 'valid' ]; + $operator = ( $operator === '=' || $operator === 'in' ) ? '!=' : '='; + } + + // Normalize value to an array of selected options. + if ( ! is_array( $value ) ) { + $value = [ $value ]; + } + + // Map selected options to EDD license statuses. + $statuses = $this->mapLicenseOptionToStatuses( $value ); + + if ( empty( $statuses ) ) { + return false; + } + + $has_matching_license = fluentCrmDb()->table( 'edd_licenses' ) + ->where( 'customer_id', $customer->id ) + ->where( 'download_id', $download_id ) + ->whereIn( 'status', $statuses ) + ->exists(); + + if ( $operator === '=' || $operator === 'in' ) { + if ( ! $has_matching_license ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_matching_license ) { + return false; + } + } else { + return false; + } + } + + return $result; + } + + /** + * Map UI option values to EDD license status strings. + * + * @param array $options Selected option values (e.g. ['valid', 'expired']). + * + * @return array EDD license statuses to query. + */ + private function mapLicenseOptionToStatuses( array $options ): array { + $status_map = [ + 'valid' => [ 'active', 'inactive' ], + 'active' => [ 'active' ], + 'inactive' => [ 'inactive' ], + 'expired' => [ 'expired' ], + 'disabled' => [ 'disabled' ], + ]; + + $statuses = []; + foreach ( $options as $option ) { + if ( isset( $status_map[ $option ] ) ) { + $statuses = array_merge( $statuses, $status_map[ $option ] ); + } + } + + return array_unique( $statuses ); + } + + /** + * Assess automation completion conditions. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessAutomationConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? 'yes'; + + // Extract funnel ID from the property name (funnel_completed_{id}). + if ( strpos( $prop, 'funnel_completed_' ) !== 0 ) { + continue; + } + + $funnel_id = (int) str_replace( 'funnel_completed_', '', $prop ); + if ( ! $funnel_id ) { + continue; + } + + $has_completed = FunnelSubscriber::where( 'subscriber_id', $subscriber->id ) + ->where( 'funnel_id', $funnel_id ) + ->where( 'status', 'completed' ) + ->exists(); + + $expects_completed = ( $value === 'yes' ); + + if ( $operator === '=' || $operator === 'in' ) { + if ( $has_completed !== $expects_completed ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_completed === $expects_completed ) { + return false; + } + } else { + // Unknown operator — fail safe. + return false; + } + } + + return $result; + } +} diff --git a/classes/EddLicenseActivationTracker.php b/classes/EddLicenseActivationTracker.php new file mode 100644 index 0000000..7dc64a5 --- /dev/null +++ b/classes/EddLicenseActivationTracker.php @@ -0,0 +1,54 @@ +get_license( $license_id ); + + if ( ! $license || ! $license->customer_id ) { + return; + } + + $customer = new \EDD_Customer( $license->customer_id ); + + if ( ! $customer->id || ! $customer->email ) { + return; + } + + $download = get_post( $download_id ); + $product_name = $download ? $download->post_title : ''; + + FluentCrmApi( 'tracker' )->track( [ + 'email' => $customer->email, + 'provider' => 'edd', + 'event_key' => 'license_activated', + 'title' => 'Activated license key', + 'value' => $product_name, + ] ); + } +} diff --git a/classes/Enrichment/CompanyResult.php b/classes/Enrichment/CompanyResult.php new file mode 100644 index 0000000..ce0f33e --- /dev/null +++ b/classes/Enrichment/CompanyResult.php @@ -0,0 +1,245 @@ +|null + */ + public ?array $naics_codes = null; + + // --- Enrichment metadata --- + + /** + * Match likelihood score (0-10). + * + * @var int|null + */ + public ?int $likelihood = null; + + /** + * Return fields that map to native Company columns. + * + * @return array Only non-null values. + */ + public function toCompanyFields(): array { + $map = [ + 'name' => $this->name, + 'industry' => $this->industry, + 'type' => $this->type, + 'website' => $this->website, + 'email' => $this->email, + 'phone' => $this->phone, + 'address_line_1' => $this->address_line_1, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'employees_number' => $this->employees_number, + 'description' => $this->description, + 'logo' => $this->logo, + 'linkedin_url' => $this->linkedin_url, + 'facebook_url' => $this->facebook_url, + 'twitter_url' => $this->twitter_url, + 'date_of_start' => $this->date_of_start, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return extra fields for the Company meta JSON column. + * + * @return array Only non-null values. + */ + public function toCompanyMeta(): array { + $map = [ + 'funding_raised' => $this->funding_raised, + 'funding_stage' => $this->funding_stage, + 'inferred_revenue' => $this->inferred_revenue, + 'employee_growth_rate_12mo' => $this->employee_growth_rate_12mo, + 'ticker' => $this->ticker, + 'naics_codes' => $this->naics_codes, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} diff --git a/classes/Enrichment/EnrichmentError.php b/classes/Enrichment/EnrichmentError.php new file mode 100644 index 0000000..a0556d9 --- /dev/null +++ b/classes/Enrichment/EnrichmentError.php @@ -0,0 +1,78 @@ +code = $code; + $this->message = $message; + $this->http_status = $http_status; + $this->retryable = $retryable; + } +} diff --git a/classes/Enrichment/EnrichmentFields.php b/classes/Enrichment/EnrichmentFields.php new file mode 100644 index 0000000..c451cc0 --- /dev/null +++ b/classes/Enrichment/EnrichmentFields.php @@ -0,0 +1,151 @@ +> + */ + public static function getFieldDefinitions(): array { + return [ + [ + 'slug' => 'enrichment_job_title', + 'label' => 'Job Title', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_role', + 'label' => 'Job Role', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_level', + 'label' => 'Job Level', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_linkedin_url', + 'label' => 'LinkedIn URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_twitter_url', + 'label' => 'Twitter URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_facebook_url', + 'label' => 'Facebook URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_github_url', + 'label' => 'GitHub URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_sex', + 'label' => 'Sex', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'male', 'female' ], + ], + [ + 'slug' => 'enrichment_pronouns', + 'label' => 'Pronouns', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_inferred_salary', + 'label' => 'Inferred Salary', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_industry', + 'label' => 'Industry', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enriched_at', + 'label' => 'Enriched At', + 'type' => 'date_time', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_provider', + 'label' => 'Enrichment Provider', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_likelihood', + 'label' => 'Match Likelihood', + 'type' => 'number', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_match', + 'label' => 'Company Match Type', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'confirmed', 'inferred' ], + ], + ]; + } +} diff --git a/classes/Enrichment/EnrichmentProvider.php b/classes/Enrichment/EnrichmentProvider.php new file mode 100644 index 0000000..0af5533 --- /dev/null +++ b/classes/Enrichment/EnrichmentProvider.php @@ -0,0 +1,118 @@ + $params Keys like 'email', 'first_name', 'last_name', 'company', etc. + * + * @return PersonResult|EnrichmentError + */ + abstract public function enrichPerson( array $params ); + + /** + * Enrich a company. + * + * @param array $params Keys like 'website', 'name', 'linkedin_url', etc. + * + * @return CompanyResult|EnrichmentError + */ + abstract public function enrichCompany( array $params ); + + /** + * Declare settings fields this provider needs (shown on settings page). + * + * @return array> Keyed by field name. + */ + abstract public function getSettingsFields(): array; + + /** + * Validate saved settings (e.g. test an API key). + * + * @param array $settings The provider's saved settings. + * + * @return true|string True on success, error message string on failure. + */ + abstract public function validateSettings( array $settings ); + + /** + * Map a provider-specific HTTP error to a normalized EnrichmentError. + * + * @param int $http_status HTTP status code. + * @param array $response_body Decoded response body. + * + * @return EnrichmentError + */ + abstract protected function mapError( int $http_status, array $response_body ): EnrichmentError; + + /** + * Make an HTTP GET request via WordPress HTTP API. + * + * @param string $url Full URL with query params. + * @param array $headers Request headers. + * + * @return array{status:int,body:array}|EnrichmentError + */ + protected function httpGet( string $url, array $headers = [] ) { + $response = wp_remote_get( + $url, + [ + 'headers' => $headers, + 'timeout' => 15, + ] + ); + + if ( is_wp_error( $response ) ) { + return new EnrichmentError( + EnrichmentError::NETWORK_ERROR, + $response->get_error_message(), + null, + true + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) ) { + $body = []; + } + + if ( $status < 200 || $status >= 300 ) { + return $this->mapError( $status, $body ); + } + + return [ + 'status' => $status, + 'body' => $body, + ]; + } +} diff --git a/classes/Enrichment/EnrichmentSettings.php b/classes/Enrichment/EnrichmentSettings.php new file mode 100644 index 0000000..b70c914 --- /dev/null +++ b/classes/Enrichment/EnrichmentSettings.php @@ -0,0 +1,197 @@ +>} + */ + public static function getAll(): array { + $defaults = [ + 'active_provider' => '', + 'providers' => [], + ]; + + $settings = get_option( self::OPTION_KEY, $defaults ); + + return wp_parse_args( $settings, $defaults ); + } + + /** + * Save all settings. + * + * @param array $settings Full settings array. + */ + public static function saveAll( array $settings ): void { + update_option( self::OPTION_KEY, $settings, false ); + } + + /** + * Get settings for a specific provider. + * + * @param string $slug Provider slug. + * + * @return array + */ + public static function getProviderSettings( string $slug ): array { + $all = self::getAll(); + + return $all['providers'][ $slug ] ?? []; + } + + /** + * Save settings for a specific provider. + * + * @param string $slug Provider slug. + * @param array $settings Provider-specific settings. + */ + public static function saveProviderSettings( string $slug, array $settings ): void { + $all = self::getAll(); + $all['providers'][ $slug ] = $settings; + + // Auto-set active provider if none set. + if ( empty( $all['active_provider'] ) ) { + $all['active_provider'] = $slug; + } + + self::saveAll( $all ); + } + + /** + * Get the active provider slug. + * + * @return string + */ + public static function getActiveProvider(): string { + $all = self::getAll(); + + return $all['active_provider'] ?? ''; + } + + /** + * Get the API key for a provider, decrypted. + * + * @param string $slug Provider slug. + * + * @return string API key or empty string. + */ + public static function getApiKey( string $slug ): string { + $settings = self::getProviderSettings( $slug ); + + $encrypted = $settings['api_key'] ?? ''; + + if ( '' === $encrypted ) { + return ''; + } + + return self::decrypt( $encrypted ); + } + + /** + * Encrypt a value for storage using OpenSSL with WordPress AUTH_KEY as the key. + * + * Requires the OpenSSL extension. Returns empty string on failure. + * + * @param string $value Plain text value. + * + * @return string Encrypted string (base64-encoded with 'enc:' prefix), or empty on failure. + */ + public static function encrypt( string $value ): string { + if ( ! function_exists( 'openssl_encrypt' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] OpenSSL extension required for API key encryption.' ); + return ''; + } + + $key = self::getEncryptionKey(); + $iv = openssl_random_pseudo_bytes( 16 ); + + if ( false === $iv ) { + return ''; + } + + $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + + if ( false === $encrypted ) { + return ''; + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return 'enc:' . base64_encode( $iv . $encrypted ); + } + + /** + * Decrypt a stored value. + * + * Returns empty string if decryption fails (corrupted data, missing OpenSSL, etc.). + * + * @param string $encrypted Encrypted string. + * + * @return string Decrypted value, or empty string on failure. + */ + public static function decrypt( string $encrypted ): string { + if ( 0 === strpos( $encrypted, 'enc:' ) ) { + if ( ! function_exists( 'openssl_decrypt' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] OpenSSL extension required to decrypt API key.' ); + return ''; + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $raw = base64_decode( substr( $encrypted, 4 ), true ); + + if ( false === $raw || strlen( $raw ) < 17 ) { + return ''; + } + + $iv = substr( $raw, 0, 16 ); + $data = substr( $raw, 16 ); + $key = self::getEncryptionKey(); + + $decrypted = openssl_decrypt( $data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + + return ( false !== $decrypted ) ? $decrypted : ''; + } + + if ( 0 === strpos( $encrypted, 'b64:' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + $decoded = base64_decode( substr( $encrypted, 4 ), true ); + return ( false !== $decoded ) ? $decoded : ''; + } + + // Plain text from old storage. + return $encrypted; + } + + /** + * Derive an encryption key from WordPress salts. + * + * Logs a warning if AUTH_KEY is not defined (shared fallback key is insecure). + * + * @return string 32-byte key. + */ + private static function getEncryptionKey(): string { + if ( ! defined( 'AUTH_KEY' ) || '' === AUTH_KEY ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] AUTH_KEY not defined; using weak fallback for encryption.' ); + $salt = 'custom-crm-fallback-key'; + } else { + $salt = AUTH_KEY; + } + + return hash( 'sha256', $salt, true ); + } +} diff --git a/classes/Enrichment/FreeEmailDetector.php b/classes/Enrichment/FreeEmailDetector.php new file mode 100644 index 0000000..ceab60b --- /dev/null +++ b/classes/Enrichment/FreeEmailDetector.php @@ -0,0 +1,95 @@ + Only non-null values. + */ + public function toSubscriberFields(): array { + $map = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'phone' => $this->phone, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'address_line_1' => $this->address_line_1, + 'date_of_birth' => $this->date_of_birth, + 'timezone' => $this->timezone, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'avatar' => $this->avatar, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return fields that map to FluentCRM custom fields. + * + * @return array Only non-null values, keyed by custom field slug. + */ + public function toCustomFields(): array { + $map = [ + 'enrichment_job_title' => $this->job_title, + 'enrichment_job_role' => $this->job_role, + 'enrichment_job_level' => $this->job_level, + 'enrichment_company_name' => $this->job_company_name, + 'enrichment_linkedin_url' => $this->linkedin_url, + 'enrichment_twitter_url' => $this->twitter_url, + 'enrichment_facebook_url' => $this->facebook_url, + 'enrichment_github_url' => $this->github_url, + 'enrichment_sex' => $this->sex, + 'enrichment_pronouns' => $this->pronouns, + 'enrichment_inferred_salary' => $this->inferred_salary, + 'enrichment_industry' => $this->industry, + 'enrichment_likelihood' => $this->likelihood, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} diff --git a/classes/Enrichment/Providers/PDLProvider.php b/classes/Enrichment/Providers/PDLProvider.php new file mode 100644 index 0000000..8387241 --- /dev/null +++ b/classes/Enrichment/Providers/PDLProvider.php @@ -0,0 +1,334 @@ + [ + 'label' => __( 'API Key', 'fluent-crm-custom-features' ), + 'type' => 'password', + 'placeholder' => __( 'Your People Data Labs API key', 'fluent-crm-custom-features' ), + 'help' => __( 'Get your key at dashboard.peopledatalabs.com', 'fluent-crm-custom-features' ), + ], + 'api_tier' => [ + 'label' => __( 'Plan Tier', 'fluent-crm-custom-features' ), + 'type' => 'select', + 'options' => [ + 'free' => __( 'Free (100 req/min)', 'fluent-crm-custom-features' ), + 'paid' => __( 'Paid (1,000 req/min)', 'fluent-crm-custom-features' ), + ], + 'default' => 'free', + ], + ]; + } + + /** + * {@inheritDoc} + * + * @param array $settings Provider settings to validate. + */ + public function validateSettings( array $settings ) { + $api_key = $settings['api_key'] ?? ''; + + if ( '' === $api_key ) { + return __( 'API key is required.', 'fluent-crm-custom-features' ); + } + + // Make a lightweight test call — search for a known test entity. + $url = add_query_arg( + [ + 'email' => 'sean@peopledatalabs.com', + 'min_likelihood' => 1, + 'data_include' => 'full_name', + 'pretty' => 'false', + ], + self::PERSON_ENDPOINT + ); + + $result = $this->httpGet( + $url, + [ 'X-Api-Key' => $api_key ] + ); + + if ( $result instanceof EnrichmentError ) { + if ( EnrichmentError::AUTH_FAILED === $result->code ) { + return __( 'Invalid API key.', 'fluent-crm-custom-features' ); + } + + return $result->message; + } + + return true; + } + + /** + * {@inheritDoc} + * + * @param array $params Enrichment parameters (email, first_name, etc.). + */ + public function enrichPerson( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'email' => $params['email'] ?? '', + 'first_name' => $params['first_name'] ?? '', + 'last_name' => $params['last_name'] ?? '', + 'company' => $params['company'] ?? '', + 'location' => $params['location'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 6, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::PERSON_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapPersonResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + * + * @param array $params Enrichment parameters (website, name, linkedin_url, etc.). + */ + public function enrichCompany( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'website' => $params['website'] ?? '', + 'name' => $params['name'] ?? '', + 'profile' => $params['linkedin_url'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 2, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapCompanyResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + * + * @param int $http_status HTTP status code from the provider. + * @param array $response_body Decoded response body. + */ + protected function mapError( int $http_status, array $response_body ): EnrichmentError { + $message = $response_body['error']['message'] ?? "HTTP {$http_status}"; + + return match ( $http_status ) { + 400 => new EnrichmentError( EnrichmentError::INVALID_INPUT, $message, 400, false ), + 401 => new EnrichmentError( EnrichmentError::AUTH_FAILED, $message, 401, false ), + 402, 403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), + 404 => new EnrichmentError( EnrichmentError::NO_MATCH, 'No matching record found.', 404, false ), + 429 => new EnrichmentError( EnrichmentError::RATE_LIMITED, $message, 429, true ), + default => new EnrichmentError( + $http_status >= 500 ? EnrichmentError::PROVIDER_ERROR : EnrichmentError::PROVIDER_ERROR, + $message, + $http_status, + $http_status >= 500 + ), + }; + } + + /** + * Map PDL person response to PersonResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return PersonResult + */ + private function mapPersonResponse( array $body ): PersonResult { + $data = $body['data'] ?? $body; + $result = new PersonResult(); + + $result->first_name = $data['first_name'] ?? null; + $result->last_name = $data['last_name'] ?? null; + $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); + $result->city = $data['location_locality'] ?? null; + $result->state = $data['location_region'] ?? null; + $result->country = $data['location_country'] ?? null; + $result->postal_code = $data['location_postal_code'] ?? null; + $result->address_line_1 = $data['location_street_address'] ?? null; + $result->date_of_birth = $data['birth_date'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->github_url = $data['github_url'] ?? null; + $result->job_title = $data['job_title'] ?? null; + $result->job_role = $data['job_title_role'] ?? null; + $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); + $result->inferred_salary = $data['inferred_salary'] ?? null; + $result->sex = $data['sex'] ?? null; + + // Derive pronouns from sex if available. + if ( $result->sex && null === $result->pronouns ) { + $result->pronouns = match ( strtolower( $result->sex ) ) { + 'male' => 'he/him', + 'female' => 'she/her', + default => null, + }; + } + + // Job level: PDL returns an array, take the first. + $levels = $data['job_title_levels'] ?? []; + $result->job_level = is_array( $levels ) && $levels ? $levels[0] : null; + + // Company data from person response. + $result->job_company_name = $data['job_company_name'] ?? null; + $result->job_company_website = $data['job_company_website'] ?? null; + + // Geo coordinates. + $geo = $data['location_geo'] ?? null; + if ( $geo && is_string( $geo ) && str_contains( $geo, ',' ) ) { + $parts = explode( ',', $geo ); + $result->latitude = (float) trim( $parts[0] ); + $result->longitude = (float) trim( $parts[1] ); + } + + $result->likelihood = $body['likelihood'] ?? null; + + return apply_filters( 'custom_crm/enrichment_person_result', $result, $body ); + } + + /** + * Map PDL company response to CompanyResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return CompanyResult + */ + private function mapCompanyResponse( array $body ): CompanyResult { + // PDL company responses put fields at root level (not nested under 'data'). + $data = $body; + $result = new CompanyResult(); + + $result->name = $data['display_name'] ?? ( $data['name'] ?? null ); + $result->industry = $data['industry'] ?? null; + $result->type = $data['type'] ?? null; + $result->website = $data['website'] ?? null; + $result->phone = $data['phone'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->employees_number = $data['employee_count'] ?? null; + $result->description = $data['summary'] ?? null; + $result->logo = $data['logo'] ?? null; + $result->ticker = $data['ticker'] ?? null; + $result->inferred_revenue = $data['inferred_revenue'] ?? null; + $result->funding_raised = isset( $data['total_funding_raised'] ) ? (int) $data['total_funding_raised'] : null; + $result->funding_stage = $data['latest_funding_stage'] ?? null; + + // Founded year -> date_of_start. + if ( ! empty( $data['founded'] ) ) { + $result->date_of_start = (string) $data['founded']; + } + + // Location fields. + $location = $data['location'] ?? []; + if ( is_array( $location ) ) { + $result->address_line_1 = $location['street_address'] ?? null; + $result->city = $location['locality'] ?? null; + $result->state = $location['region'] ?? null; + $result->country = $location['country'] ?? null; + $result->postal_code = $location['postal_code'] ?? null; + } + + // Employee growth rate. + $growth = $data['employee_growth_rate'] ?? []; + if ( is_array( $growth ) && isset( $growth['12_month'] ) ) { + $result->employee_growth_rate_12mo = (float) $growth['12_month']; + } + + // NAICS codes. + $result->naics_codes = $data['naics'] ?? null; + + $result->likelihood = $body['likelihood'] ?? null; + + return apply_filters( 'custom_crm/enrichment_company_result', $result, $body ); + } + + /** + * Get the decrypted API key for this provider. + * + * @return string + */ + private function getApiKey(): string { + return EnrichmentSettings::getApiKey( $this->getSlug() ); + } +} diff --git a/classes/Integrations/CustomEmailCSS.php b/classes/Integrations/CustomEmailCSS.php new file mode 100644 index 0000000..aa50c63 --- /dev/null +++ b/classes/Integrations/CustomEmailCSS.php @@ -0,0 +1,271 @@ +. + * + * @param string $html The rendered email HTML. + * @param string $email_body The email body content. + * @param array $template_config Template configuration. + * + * @return string + */ + public function inject_css( string $html, $email_body = '', $template_config = [] ): string { + $css = $this->get_css(); + + if ( empty( $css ) ) { + return $html; + } + + $css = $this->add_important( $css ); + + return str_replace( + '', + '' . "\n" . '', + $html + ); + } + + /** + * Add submenu page under FluentCRM. + */ + public function add_menu_page(): void { + add_submenu_page( + 'fluentcrm-admin', + esc_html__( 'Custom Email CSS', 'fluent-crm-custom-features' ), + esc_html__( 'Custom CSS', 'fluent-crm-custom-features' ), + 'manage_options', + self::PAGE_SLUG, + [ $this, 'render_page' ] + ); + } + + /** + * Enqueue CodeMirror assets on our admin page only. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_scripts( string $hook_suffix ): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { + return; + } + + $settings = wp_enqueue_code_editor( [ 'type' => 'text/css' ] ); + + if ( false === $settings ) { + return; + } + + wp_add_inline_script( + 'code-editor', + sprintf( + 'jQuery( function() { wp.codeEditor.initialize( "fluentcrm-custom-css", %s ); } );', + wp_json_encode( $settings ) + ) + ); + } + + /** + * Handle the form save. + */ + public function handle_save(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] + ); + } + + check_admin_referer( 'fluentcrm_custom_css_save' ); + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_css() below. + $css = isset( $_POST['custom_css'] ) ? wp_unslash( $_POST['custom_css'] ) : ''; + $css = $this->sanitize_css( $css ); + + fluentcrm_update_option( self::OPTION_KEY, $css ); + + wp_safe_redirect( add_query_arg( + [ + 'page' => self::PAGE_SLUG, + 'updated' => '1', + ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + /** + * Render the admin page. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] + ); + } + + $css = $this->get_css(); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $updated = isset( $_GET['updated'] ) && '1' === $_GET['updated']; + + ?> +
+

+ + + + + +

+ !important markup */ + esc_html__( 'Add custom CSS that will be injected into all FluentCRM email templates. %s is automatically added to every declaration so your styles override inline styles and template defaults.', 'fluent-crm-custom-features' ), + '!important' + ); + ?> +

+ +
+ + + + + + + +
+
+ where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); } elseif ( 'not_contains' === $operator ) { @@ -285,10 +286,9 @@ public static function apply_event_tracking_filter( $query, $filters ) { $q ->where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); - break; } } } diff --git a/classes/Migrations/FixDripMergeTags.php b/classes/Migrations/FixDripMergeTags.php new file mode 100644 index 0000000..f1c5b16 --- /dev/null +++ b/classes/Migrations/FixDripMergeTags.php @@ -0,0 +1,210 @@ + + */ + private static array $replacements = [ + // Footer links (full HTML versions). + '{{ manage_subscriptions_link }}' => '{{crm.manage_subscription_html|Manage Preferences}}', + '{{ unsubscribe_link }}' => '{{crm.unsubscribe_html|Unsubscribe}}', + + // Footer links (URL-only versions). + '{{ manage_subscriptions_url }}' => '##crm.manage_subscription_url##', + '{{ unsubscribe_url }}' => '##crm.unsubscribe_url##', + + // Postal address and account. + '{{ inline_postal_address }}' => '{{crm.business_address}}', + '{{ account.name }}' => '{{crm.business_name}}', + + // Subscriber fields. + '{{ subscriber.first_name }}' => '{{contact.first_name}}', + '{{ subscriber.last_name }}' => '{{contact.last_name}}', + '{{ subscriber.email }}' => '{{contact.email}}', + ]; + + /** + * Regex-based replacements for patterns with variable content. + * + * @var array + */ + private static array $regex_replacements = [ + '/\{\{\s*manage_subscriptions_link\s*\}\}/i' => '{{crm.manage_subscription_html|Manage Preferences}}', + '/\{\{\s*unsubscribe_link\s*\}\}/i' => '{{crm.unsubscribe_html|Unsubscribe}}', + '/\{\{\s*manage_subscriptions_url\s*\}\}/i' => '##crm.manage_subscription_url##', + '/\{\{\s*unsubscribe_url\s*\}\}/i' => '##crm.unsubscribe_url##', + '/\{\{\s*inline_postal_address\s*\}\}/i' => '{{crm.business_address}}', + '/\{\{\s*account\.name\s*\}\}/i' => '{{crm.business_name}}', + '/\{\{\s*subscriber\.first_name\s*\}\}/i' => '{{contact.first_name}}', + '/\{\{\s*subscriber\.last_name\s*\}\}/i' => '{{contact.last_name}}', + '/\{\{\s*subscriber\.email\s*\}\}/i' => '{{contact.email}}', + '/\{\{\s*subscriber\.custom_fields\.(\w+)\s*\}\}/i' => '{{contact.custom.$1}}', + '/\{\{\s*subscriber\.(\w+)\s*\}\}/i' => '{{contact.$1}}', + ]; + + /** + * Run the migration. + * + * @param bool $dry_run If true, only report what would change without modifying data. + * + * @return array{updated: int, skipped: int, errors: int} + */ + public static function run( bool $dry_run = false ): array { + $db = fluentCrmDb(); + + // Find all campaigns with Drip merge tags in email_body. + $campaigns = $db->table( 'fc_campaigns' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); + } ) + ->get(); + + $stats = [ + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + ]; + + foreach ( $campaigns as $campaign ) { + $original = $campaign->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( $dry_run ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( + '[FixDripMergeTags] DRY RUN — Would update campaign #%d (%s)', + $campaign->id, + $campaign->title ?? 'untitled' + ) ); + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaigns' ) + ->where( 'id', $campaign->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } + + // Also fix campaign_emails (individual sent emails) if they exist. + $emails = $db->table( 'fc_campaign_emails' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); + } ) + ->get(); + + foreach ( $emails as $email ) { + $original = $email->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( $dry_run ) { + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaign_emails' ) + ->where( 'id', $email->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } + + // Also fix funnel sequence settings (automation email bodies). + $sequences = $db->table( 'fc_funnel_sequences' ) + ->where( 'action_name', 'send_custom_email' ) + ->where( 'settings', 'LIKE', '%{{ %' ) + ->get(); + + foreach ( $sequences as $seq ) { + $settings = maybe_unserialize( $seq->settings ); + if ( ! is_array( $settings ) || empty( $settings['email_body'] ) ) { + ++$stats['skipped']; + continue; + } + + $original = $settings['email_body']; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( ! $dry_run ) { + $settings['email_body'] = $updated; + $result = $db->table( 'fc_funnel_sequences' ) + ->where( 'id', $seq->id ) + ->update( [ 'settings' => maybe_serialize( $settings ) ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } else { + ++$stats['updated']; + } + } + + return $stats; + } + + /** + * Convert Drip merge tags to FluentCRM merge tags. + * + * @param string $text Email content with Drip merge tags. + * + * @return string Content with FluentCRM merge tags. + */ + public static function convertMergeTags( string $text ): string { + foreach ( self::$regex_replacements as $pattern => $replacement ) { + $result = preg_replace( $pattern, $replacement, $text ); + if ( $result !== null ) { + $text = $result; + } + } + + return $text; + } +} diff --git a/classes/SmartLinkHandler.php b/classes/SmartLinkHandler.php index 4d9fa9f..4aab644 100644 --- a/classes/SmartLinkHandler.php +++ b/classes/SmartLinkHandler.php @@ -91,10 +91,13 @@ public function handleClick( $slug, $contact = null ) { * @return string The target URL with query parameters preserved. */ public function getTargetUrl( $smart_link, $contact ) { - $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // Define the parameters to ignore. + $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query params for smart link redirect, nonce not applicable. - $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); // Filter out ignored parameters. - $query_string = http_build_query( $query_params ); // Build the query string from remaining parameters. + $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); + + // Sanitize all forwarded query parameters (handles nested arrays). + $query_params = map_deep( $query_params, 'sanitize_text_field' ); + $query_string = http_build_query( $query_params ); $target_url = $smart_link->target_url; @@ -104,13 +107,13 @@ public function getTargetUrl( $smart_link, $contact ) { $target_url = esc_url_raw( $target_url ); } - if ( false === strpos( $target_url, '?' ) ) { - $target_url .= '?'; - } else { - $target_url .= '&'; + // Only append separator and query string if there are params to forward. + if ( $query_string ) { + $separator = ( false === strpos( $target_url, '?' ) ) ? '?' : '&'; + $target_url = $target_url . $separator . $query_string; } - return $target_url . $query_string; + return $target_url; } /** diff --git a/docs/superpowers/plans/2026-03-26-enrichment-action.md b/docs/superpowers/plans/2026-03-26-enrichment-action.md new file mode 100644 index 0000000..563ac20 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-enrichment-action.md @@ -0,0 +1,2127 @@ +# Enrichment Action Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a provider-agnostic FluentCRM automation action block that enriches contacts and companies using People Data Labs (PDL) as the first provider. + +**Architecture:** Single action block with abstract provider interface. DTOs (PersonResult, CompanyResult) normalize API responses. PDLProvider maps PDL's API to the DTOs. EnrichContactAction orchestrates the flow: detect context, call provider, map results to FluentCRM Subscriber/Company fields. + +**Tech Stack:** PHP 7.4+, WordPress HTTP API (`wp_remote_get`), FluentCRM BaseAction, FluentCRM Subscriber/Company models + +**Spec:** `docs/superpowers/specs/2026-03-26-enrichment-action-design.md` + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `classes/Enrichment/EnrichmentError.php` | Normalized error DTO | +| `classes/Enrichment/PersonResult.php` | Person data DTO | +| `classes/Enrichment/CompanyResult.php` | Company data DTO | +| `classes/Enrichment/EnrichmentProvider.php` | Abstract provider contract | +| `classes/Enrichment/FreeEmailDetector.php` | Free email domain checker | +| `classes/Enrichment/EnrichmentSettings.php` | Provider settings CRUD | +| `classes/Enrichment/EnrichmentFields.php` | Custom field auto-creation | +| `classes/Enrichment/Providers/PDLProvider.php` | PDL API implementation | +| `classes/Actions/EnrichContactAction.php` | FluentCRM action block | +| `fluent-crm-custom-features.php` | Register the action (modify) | + +--- + +### Task 1: EnrichmentError DTO + +**Files:** +- Create: `classes/Enrichment/EnrichmentError.php` + +- [ ] **Step 1: Create the error DTO class** + +```php +code = $code; + $this->message = $message; + $this->http_status = $http_status; + $this->retryable = $retryable; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentError.php +git commit -m "feat(enrichment): add EnrichmentError DTO" +``` + +--- + +### Task 2: PersonResult DTO + +**Files:** +- Create: `classes/Enrichment/PersonResult.php` + +- [ ] **Step 1: Create the person result DTO** + +```php + Only non-null values. + */ + public function toSubscriberFields(): array { + $map = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'phone' => $this->phone, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'address_line_1' => $this->address_line_1, + 'date_of_birth' => $this->date_of_birth, + 'timezone' => $this->timezone, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'avatar' => $this->avatar, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return fields that map to FluentCRM custom fields. + * + * @return array Only non-null values, keyed by custom field slug. + */ + public function toCustomFields(): array { + $map = [ + 'enrichment_job_title' => $this->job_title, + 'enrichment_job_role' => $this->job_role, + 'enrichment_job_level' => $this->job_level, + 'enrichment_company_name' => $this->job_company_name, + 'enrichment_linkedin_url' => $this->linkedin_url, + 'enrichment_twitter_url' => $this->twitter_url, + 'enrichment_facebook_url' => $this->facebook_url, + 'enrichment_github_url' => $this->github_url, + 'enrichment_sex' => $this->sex, + 'enrichment_pronouns' => $this->pronouns, + 'enrichment_inferred_salary' => $this->inferred_salary, + 'enrichment_industry' => $this->industry, + 'enrichment_likelihood' => $this->likelihood, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/PersonResult.php +git commit -m "feat(enrichment): add PersonResult DTO" +``` + +--- + +### Task 3: CompanyResult DTO + +**Files:** +- Create: `classes/Enrichment/CompanyResult.php` + +- [ ] **Step 1: Create the company result DTO** + +```php + Only non-null values. + */ + public function toCompanyFields(): array { + $map = [ + 'name' => $this->name, + 'industry' => $this->industry, + 'type' => $this->type, + 'website' => $this->website, + 'email' => $this->email, + 'phone' => $this->phone, + 'address_line_1' => $this->address_line_1, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'employees_number' => $this->employees_number, + 'description' => $this->description, + 'logo' => $this->logo, + 'linkedin_url' => $this->linkedin_url, + 'facebook_url' => $this->facebook_url, + 'twitter_url' => $this->twitter_url, + 'date_of_start' => $this->date_of_start, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return extra fields for the Company meta JSON column. + * + * @return array Only non-null values. + */ + public function toCompanyMeta(): array { + $map = [ + 'funding_raised' => $this->funding_raised, + 'funding_stage' => $this->funding_stage, + 'inferred_revenue' => $this->inferred_revenue, + 'employee_growth_rate_12mo' => $this->employee_growth_rate_12mo, + 'ticker' => $this->ticker, + 'naics_codes' => $this->naics_codes, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/CompanyResult.php +git commit -m "feat(enrichment): add CompanyResult DTO" +``` + +--- + +### Task 4: EnrichmentProvider Abstract Class + +**Files:** +- Create: `classes/Enrichment/EnrichmentProvider.php` + +- [ ] **Step 1: Create the abstract provider** + +```php + $params Keys like 'email', 'first_name', 'last_name', 'company', etc. + * + * @return PersonResult|EnrichmentError + */ + abstract public function enrichPerson( array $params ); + + /** + * Enrich a company. + * + * @param array $params Keys like 'website', 'name', 'linkedin_url', etc. + * + * @return CompanyResult|EnrichmentError + */ + abstract public function enrichCompany( array $params ); + + /** + * Declare settings fields this provider needs (shown on settings page). + * + * @return array> Keyed by field name. + */ + abstract public function getSettingsFields(): array; + + /** + * Validate saved settings (e.g. test an API key). + * + * @param array $settings The provider's saved settings. + * + * @return true|string True on success, error message string on failure. + */ + abstract public function validateSettings( array $settings ); + + /** + * Map a provider-specific HTTP error to a normalized EnrichmentError. + * + * @param int $http_status HTTP status code. + * @param array $response_body Decoded response body. + * + * @return EnrichmentError + */ + abstract protected function mapError( int $http_status, array $response_body ): EnrichmentError; + + /** + * Make an HTTP GET request via WordPress HTTP API. + * + * @param string $url Full URL with query params. + * @param array $headers Request headers. + * + * @return array{status:int,body:array}|EnrichmentError + */ + protected function httpGet( string $url, array $headers = [] ) { + $response = wp_remote_get( + $url, + [ + 'headers' => $headers, + 'timeout' => 15, + ] + ); + + if ( is_wp_error( $response ) ) { + return new EnrichmentError( + EnrichmentError::NETWORK_ERROR, + $response->get_error_message(), + null, + true + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) ) { + $body = []; + } + + if ( $status < 200 || $status >= 300 ) { + return $this->mapError( $status, $body ); + } + + return [ + 'status' => $status, + 'body' => $body, + ]; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentProvider.php +git commit -m "feat(enrichment): add abstract EnrichmentProvider with httpGet helper" +``` + +--- + +### Task 5: FreeEmailDetector + +**Files:** +- Create: `classes/Enrichment/FreeEmailDetector.php` + +- [ ] **Step 1: Create the free email detector** + +```php +>} + */ + public static function getAll(): array { + $defaults = [ + 'active_provider' => '', + 'providers' => [], + ]; + + $settings = get_option( self::OPTION_KEY, $defaults ); + + return wp_parse_args( $settings, $defaults ); + } + + /** + * Save all settings. + * + * @param array $settings Full settings array. + */ + public static function saveAll( array $settings ): void { + update_option( self::OPTION_KEY, $settings, false ); + } + + /** + * Get settings for a specific provider. + * + * @param string $slug Provider slug. + * + * @return array + */ + public static function getProviderSettings( string $slug ): array { + $all = self::getAll(); + + return $all['providers'][ $slug ] ?? []; + } + + /** + * Save settings for a specific provider. + * + * @param string $slug Provider slug. + * @param array $settings Provider-specific settings. + */ + public static function saveProviderSettings( string $slug, array $settings ): void { + $all = self::getAll(); + $all['providers'][ $slug ] = $settings; + + // Auto-set active provider if none set. + if ( empty( $all['active_provider'] ) ) { + $all['active_provider'] = $slug; + } + + self::saveAll( $all ); + } + + /** + * Get the active provider slug. + * + * @return string + */ + public static function getActiveProvider(): string { + $all = self::getAll(); + + return $all['active_provider'] ?? ''; + } + + /** + * Get the API key for a provider, decrypted. + * + * @param string $slug Provider slug. + * + * @return string API key or empty string. + */ + public static function getApiKey( string $slug ): string { + $settings = self::getProviderSettings( $slug ); + + $encrypted = $settings['api_key'] ?? ''; + + if ( '' === $encrypted ) { + return ''; + } + + return self::decrypt( $encrypted ); + } + + /** + * Encrypt a value for storage. + * + * Uses wp_encrypt() on WP 6.5+, falls back to base64 with site salt. + * + * @param string $value Plain text value. + * + * @return string Encrypted string. + */ + public static function encrypt( string $value ): string { + if ( function_exists( 'wp_encrypt' ) ) { + $result = wp_encrypt( $value ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // Fallback: base64 with salt prefix for identification. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return 'b64:' . base64_encode( $value ); + } + + /** + * Decrypt a stored value. + * + * @param string $encrypted Encrypted string. + * + * @return string Decrypted value. + */ + public static function decrypt( string $encrypted ): string { + if ( str_starts_with( $encrypted, 'b64:' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return base64_decode( substr( $encrypted, 4 ) ); + } + + if ( function_exists( 'wp_decrypt' ) ) { + $result = wp_decrypt( $encrypted ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // If we can't decrypt, return as-is (may be plain text from old storage). + return $encrypted; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentSettings.php +git commit -m "feat(enrichment): add EnrichmentSettings with encrypted API key storage" +``` + +--- + +### Task 7: EnrichmentFields (Custom Field Auto-Creation) + +**Files:** +- Create: `classes/Enrichment/EnrichmentFields.php` + +- [ ] **Step 1: Create the custom field manager** + +```php +> + */ + public static function getFieldDefinitions(): array { + return [ + [ + 'slug' => 'enrichment_job_title', + 'label' => 'Job Title', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_role', + 'label' => 'Job Role', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_level', + 'label' => 'Job Level', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_linkedin_url', + 'label' => 'LinkedIn URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_twitter_url', + 'label' => 'Twitter URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_facebook_url', + 'label' => 'Facebook URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_github_url', + 'label' => 'GitHub URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_sex', + 'label' => 'Sex', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'male', 'female' ], + ], + [ + 'slug' => 'enrichment_pronouns', + 'label' => 'Pronouns', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_inferred_salary', + 'label' => 'Inferred Salary', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_industry', + 'label' => 'Industry', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enriched_at', + 'label' => 'Enriched At', + 'type' => 'date_time', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_provider', + 'label' => 'Enrichment Provider', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_likelihood', + 'label' => 'Match Likelihood', + 'type' => 'number', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_match', + 'label' => 'Company Match Type', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'confirmed', 'inferred' ], + ], + ]; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentFields.php +git commit -m "feat(enrichment): add EnrichmentFields for custom field auto-creation" +``` + +--- + +### Task 8: PDLProvider + +**Files:** +- Create: `classes/Enrichment/Providers/PDLProvider.php` + +- [ ] **Step 1: Create the PDL provider class** + +```php + [ + 'label' => __( 'API Key', 'fluent-crm-custom-features' ), + 'type' => 'password', + 'placeholder' => __( 'Your People Data Labs API key', 'fluent-crm-custom-features' ), + 'help' => __( 'Get your key at dashboard.peopledatalabs.com', 'fluent-crm-custom-features' ), + ], + 'api_tier' => [ + 'label' => __( 'Plan Tier', 'fluent-crm-custom-features' ), + 'type' => 'select', + 'options' => [ + 'free' => __( 'Free (100 req/min)', 'fluent-crm-custom-features' ), + 'paid' => __( 'Paid (1,000 req/min)', 'fluent-crm-custom-features' ), + ], + 'default' => 'free', + ], + ]; + } + + /** + * {@inheritDoc} + */ + public function validateSettings( array $settings ) { + $api_key = $settings['api_key'] ?? ''; + + if ( '' === $api_key ) { + return __( 'API key is required.', 'fluent-crm-custom-features' ); + } + + // Make a lightweight test call — search for a known test entity. + $url = add_query_arg( + [ + 'email' => 'sean@peopledatalabs.com', + 'min_likelihood' => 1, + 'data_include' => 'full_name', + 'pretty' => 'false', + ], + self::PERSON_ENDPOINT + ); + + $result = $this->httpGet( + $url, + [ 'X-Api-Key' => $api_key ] + ); + + if ( $result instanceof EnrichmentError ) { + if ( EnrichmentError::AUTH_FAILED === $result->code ) { + return __( 'Invalid API key.', 'fluent-crm-custom-features' ); + } + + return $result->message; + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function enrichPerson( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'email' => $params['email'] ?? '', + 'first_name' => $params['first_name'] ?? '', + 'last_name' => $params['last_name'] ?? '', + 'company' => $params['company'] ?? '', + 'location' => $params['location'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 6, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::PERSON_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapPersonResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + public function enrichCompany( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'website' => $params['website'] ?? '', + 'name' => $params['name'] ?? '', + 'profile' => $params['linkedin_url'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 2, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapCompanyResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + protected function mapError( int $http_status, array $response_body ): EnrichmentError { + $message = $response_body['error']['message'] ?? "HTTP {$http_status}"; + + return match ( $http_status ) { + 400 => new EnrichmentError( EnrichmentError::INVALID_INPUT, $message, 400, false ), + 401 => new EnrichmentError( EnrichmentError::AUTH_FAILED, $message, 401, false ), + 402,403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), + 404 => new EnrichmentError( EnrichmentError::NO_MATCH, 'No matching record found.', 404, false ), + 429 => new EnrichmentError( EnrichmentError::RATE_LIMITED, $message, 429, true ), + default => new EnrichmentError( + $http_status >= 500 ? EnrichmentError::PROVIDER_ERROR : EnrichmentError::PROVIDER_ERROR, + $message, + $http_status, + $http_status >= 500 + ), + }; + } + + /** + * Map PDL person response to PersonResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return PersonResult + */ + private function mapPersonResponse( array $body ): PersonResult { + $data = $body['data'] ?? $body; + $result = new PersonResult(); + + $result->first_name = $data['first_name'] ?? null; + $result->last_name = $data['last_name'] ?? null; + $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); + $result->city = $data['location_locality'] ?? null; + $result->state = $data['location_region'] ?? null; + $result->country = $data['location_country'] ?? null; + $result->postal_code = $data['location_postal_code'] ?? null; + $result->address_line_1 = $data['location_street_address'] ?? null; + $result->date_of_birth = $data['birth_date'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->github_url = $data['github_url'] ?? null; + $result->job_title = $data['job_title'] ?? null; + $result->job_role = $data['job_title_role'] ?? null; + $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); + $result->inferred_salary = $data['inferred_salary'] ?? null; + $result->sex = $data['sex'] ?? null; + + // Derive pronouns from sex if available. + if ( $result->sex && null === $result->pronouns ) { + $result->pronouns = match ( strtolower( $result->sex ) ) { + 'male' => 'he/him', + 'female' => 'she/her', + default => null, + }; + } + + // Job level: PDL returns an array, take the first. + $levels = $data['job_title_levels'] ?? []; + $result->job_level = is_array( $levels ) && $levels ? $levels[0] : null; + + // Company data from person response. + $result->job_company_name = $data['job_company_name'] ?? null; + $result->job_company_website = $data['job_company_website'] ?? null; + + // Geo coordinates. + $geo = $data['location_geo'] ?? null; + if ( $geo && is_string( $geo ) && str_contains( $geo, ',' ) ) { + $parts = explode( ',', $geo ); + $result->latitude = (float) trim( $parts[0] ); + $result->longitude = (float) trim( $parts[1] ); + } + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var PersonResult */ + return apply_filters( 'custom_crm/enrichment_person_result', $result, $body ); + } + + /** + * Map PDL company response to CompanyResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return CompanyResult + */ + private function mapCompanyResponse( array $body ): CompanyResult { + // PDL company responses put fields at root level (not nested under 'data'). + $data = $body; + $result = new CompanyResult(); + + $result->name = $data['display_name'] ?? ( $data['name'] ?? null ); + $result->industry = $data['industry'] ?? null; + $result->type = $data['type'] ?? null; + $result->website = $data['website'] ?? null; + $result->phone = $data['phone'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->employees_number = $data['employee_count'] ?? null; + $result->description = $data['summary'] ?? null; + $result->logo = $data['logo'] ?? null; + $result->ticker = $data['ticker'] ?? null; + $result->inferred_revenue = $data['inferred_revenue'] ?? null; + $result->funding_raised = isset( $data['total_funding_raised'] ) ? (int) $data['total_funding_raised'] : null; + $result->funding_stage = $data['latest_funding_stage'] ?? null; + + // Founded year -> date_of_start. + if ( ! empty( $data['founded'] ) ) { + $result->date_of_start = (string) $data['founded']; + } + + // Location fields. + $location = $data['location'] ?? []; + if ( is_array( $location ) ) { + $result->address_line_1 = $location['street_address'] ?? null; + $result->city = $location['locality'] ?? null; + $result->state = $location['region'] ?? null; + $result->country = $location['country'] ?? null; + $result->postal_code = $location['postal_code'] ?? null; + } + + // Employee growth rate. + $growth = $data['employee_growth_rate'] ?? []; + if ( is_array( $growth ) && isset( $growth['12_month'] ) ) { + $result->employee_growth_rate_12mo = (float) $growth['12_month']; + } + + // NAICS codes. + $result->naics_codes = $data['naics'] ?? null; + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var CompanyResult */ + return apply_filters( 'custom_crm/enrichment_company_result', $result, $body ); + } + + /** + * Get the decrypted API key for this provider. + * + * @return string + */ + private function getApiKey(): string { + return EnrichmentSettings::getApiKey( $this->getSlug() ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/Providers/PDLProvider.php +git commit -m "feat(enrichment): add PDLProvider with person and company enrichment" +``` + +--- + +### Task 9: EnrichContactAction — FluentCRM Action Block + +**Files:** +- Create: `classes/Actions/EnrichContactAction.php` + +- [ ] **Step 1: Create the action block class** + +This is the largest file. It implements getBlock(), getBlockFields(), and handle() per FluentCRM's BaseAction pattern. + +```php + + */ + private array $providers = []; + + public function __construct() { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->actionName = 'enrich_contact'; + $this->priority = 30; + parent::__construct(); + } + + /** + * Get block definition shown in the automation editor sidebar. + * + * @return array + */ + public function getBlock() { + return [ + 'category' => __( 'CRM', 'fluent-crm-custom-features' ), + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'description' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'icon' => 'fc-icon-wp_user_meta', + 'settings' => [ + 'provider' => EnrichmentSettings::getActiveProvider(), + 'enrichment_scope' => 'both', + 'min_likelihood' => 6, + 'data_behavior' => 'fill_empty', + 'reprocess' => 'no', + 'company_handling' => 'create_or_update', + 'tag_on_success' => [], + 'tag_on_no_match' => [], + ], + ]; + } + + /** + * Get block field definitions for the settings editor. + * + * @return array + */ + public function getBlockFields() { + return [ + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'sub_title' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'fields' => [ + 'provider' => [ + 'type' => 'select', + 'label' => __( 'Enrichment Provider', 'fluent-crm-custom-features' ), + 'options' => $this->getProviderOptions(), + 'inline_help' => __( 'Configure providers in FluentCRM Custom Features settings.', 'fluent-crm-custom-features' ), + ], + 'enrichment_scope' => [ + 'type' => 'radio', + 'label' => __( 'Enrichment Scope', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'both', + 'title' => __( 'Person + Company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'person', + 'title' => __( 'Person Only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'company', + 'title' => __( 'Company Only', 'fluent-crm-custom-features' ), + ], + ], + ], + 'min_likelihood' => [ + 'type' => 'input-number', + 'label' => __( 'Minimum Likelihood Score (1-10)', 'fluent-crm-custom-features' ), + 'wrapper_class' => 'fc_2col_inline pad-r-20', + 'inline_help' => __( 'Higher = more accurate but fewer matches. Recommended: 6.', 'fluent-crm-custom-features' ), + ], + 'data_behavior' => [ + 'type' => 'radio', + 'label' => __( 'Data Behavior', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'fill_empty', + 'title' => __( 'Fill empty fields only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'overwrite', + 'title' => __( 'Overwrite all fields', 'fluent-crm-custom-features' ), + ], + ], + ], + 'reprocess' => [ + 'type' => 'yes_no_check', + 'check_label' => __( 'Re-enrich contacts that have already been enriched', 'fluent-crm-custom-features' ), + ], + 'company_handling' => [ + 'type' => 'radio', + 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'create_or_update', + 'title' => __( 'Create or update company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'update_existing', + 'title' => __( 'Update existing company only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'none', + 'title' => __( "Don't touch companies", 'fluent-crm-custom-features' ), + ], + ], + 'dependency' => [ + 'depends_on' => 'enrichment_scope', + 'value' => 'person', + 'operator' => '!=', + ], + ], + 'tag_on_success' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on Successful Enrichment', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + 'tag_on_no_match' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on No Match', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + ], + ]; + } + + /** + * Execute the enrichment action. + * + * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. + * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $funnel_metric Funnel metric. + */ + public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_metric ) { + $settings = $sequence->settings; + $scope = Arr::get( $settings, 'enrichment_scope', 'both' ); + + // --- 1. Detect context --- + $is_company_trigger = $this->isCompanyTriggered( $subscriber, $sequence ); + + if ( $is_company_trigger && 'person' === $scope ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + if ( $is_company_trigger ) { + $scope = 'company'; + } + + // --- 2. Check skip conditions --- + $reprocess = 'yes' === Arr::get( $settings, 'reprocess', 'no' ); + if ( ! $reprocess && ! $is_company_trigger ) { + $enriched_at = $subscriber->getMeta( 'enriched_at', 'custom_field' ); + if ( $enriched_at ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + } + + // --- 3. Resolve provider --- + $provider = $this->resolveProvider( Arr::get( $settings, 'provider', '' ) ); + if ( ! $provider ) { + $this->log( 'error', 'No enrichment provider configured or found.' ); + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + $min_likelihood = (int) Arr::get( $settings, 'min_likelihood', 6 ); + $data_behavior = Arr::get( $settings, 'data_behavior', 'fill_empty' ); + $company_handling = Arr::get( $settings, 'company_handling', 'create_or_update' ); + $person_result = null; + $company_result = null; + $person_success = false; + $company_success = false; + + // --- 4. Person enrichment --- + if ( in_array( $scope, [ 'both', 'person' ], true ) && $subscriber->email ) { + $person_result = $this->enrichPerson( + $provider, + $subscriber, + $min_likelihood, + $data_behavior, + $funnel_subscriber_id, + $sequence + ); + + $person_success = $person_result instanceof PersonResult; + } + + // --- 5. Company enrichment --- + if ( in_array( $scope, [ 'both', 'company' ], true ) ) { + $company_result = $this->enrichCompany( + $provider, + $subscriber, + $person_result, + $is_company_trigger, + $min_likelihood, + $data_behavior, + $company_handling, + $funnel_subscriber_id, + $sequence + ); + + $company_success = $company_result instanceof CompanyResult; + } + + // --- 6. Apply tags --- + $refreshed = Subscriber::where( 'id', $subscriber->id )->first(); + + if ( $person_success || $company_success ) { + $success_tags = Arr::get( $settings, 'tag_on_success', [] ); + if ( $success_tags && $refreshed ) { + $refreshed->attachTags( $success_tags ); + } + } elseif ( ! $person_success && ! $company_success ) { + $no_match_tags = Arr::get( $settings, 'tag_on_no_match', [] ); + if ( $no_match_tags && $refreshed ) { + $refreshed->attachTags( $no_match_tags ); + } + } + + // --- 7. Fire hooks --- + if ( $refreshed ) { + do_action( 'custom_crm/contact_enriched', $refreshed, $person_result, $company_result ); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Run person enrichment and apply results to the subscriber. + * + * @return PersonResult|EnrichmentError|null + */ + private function enrichPerson( + EnrichmentProvider $provider, + Subscriber $subscriber, + int $min_likelihood, + string $data_behavior, + int $funnel_subscriber_id, + $sequence + ) { + EnrichmentFields::ensureFieldsExist(); + + $params = [ + 'email' => $subscriber->email, + 'first_name' => $subscriber->first_name ?? '', + 'last_name' => $subscriber->last_name ?? '', + 'min_likelihood' => $min_likelihood, + ]; + + $result = $provider->enrichPerson( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'person' ); + return $result; + } + + if ( $result->likelihood && $result->likelihood < $min_likelihood ) { + $this->log( 'info', "Person enrichment below threshold ({$result->likelihood} < {$min_likelihood}) for {$subscriber->email}" ); + return null; + } + + // Map native subscriber fields. + $subscriber_fields = $result->toSubscriberFields(); + + if ( 'fill_empty' === $data_behavior ) { + $subscriber_fields = $this->filterEmptyOnly( $subscriber, $subscriber_fields ); + } + + if ( $subscriber_fields ) { + $subscriber->fill( $subscriber_fields ); + $dirty = $subscriber->getDirty(); + if ( $dirty ) { + $subscriber->save(); + do_action( 'fluent_crm/contact_updated', $subscriber, $dirty ); + } + } + + // Map custom fields. + $custom_fields = $result->toCustomFields(); + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + + if ( 'fill_empty' === $data_behavior ) { + $custom_fields = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom_fields ); + // Always update these metadata fields regardless of behavior. + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + } + + if ( $custom_fields ) { + $subscriber->syncCustomFieldValues( $custom_fields, false ); + } + + $this->log( 'info', "Person enrichment successful for {$subscriber->email} (likelihood: {$result->likelihood})" ); + + return $result; + } + + /** + * Run company enrichment and apply results. + * + * @return CompanyResult|EnrichmentError|null + */ + private function enrichCompany( + EnrichmentProvider $provider, + Subscriber $subscriber, + $person_result, + bool $is_company_trigger, + int $min_likelihood, + string $data_behavior, + string $company_handling, + int $funnel_subscriber_id, + $sequence + ) { + // Determine company identifier. + $company_website = null; + $company_name = null; + + if ( $is_company_trigger && $subscriber->company_id ) { + $existing_company = Company::find( $subscriber->company_id ); + if ( $existing_company ) { + $company_website = $existing_company->website; + $company_name = $existing_company->name; + } + } + + if ( ! $company_website && $subscriber->email ) { + $domain = FreeEmailDetector::extractDomain( $subscriber->email ); + if ( $domain && ! FreeEmailDetector::isFreeEmail( $subscriber->email ) ) { + $company_website = $domain; + } + } + + // Fallback to person enrichment result. + if ( ! $company_website && $person_result instanceof PersonResult ) { + $company_website = $person_result->job_company_website; + $company_name = $person_result->job_company_name; + } + + if ( ! $company_website && ! $company_name ) { + $this->log( 'info', "No company identifier found for {$subscriber->email}, skipping company enrichment." ); + return null; + } + + $params = array_filter( [ + 'website' => $company_website, + 'name' => $company_name, + 'min_likelihood' => $min_likelihood, + ] ); + + $result = $provider->enrichCompany( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'company' ); + return $result; + } + + // Determine match confidence. + $email_domain = $subscriber->email ? FreeEmailDetector::extractDomain( $subscriber->email ) : ''; + $result_domain = $this->normalizeDomain( $result->website ?? '' ); + $match_type = ( $email_domain && $result_domain && $email_domain === $result_domain ) ? 'confirmed' : 'inferred'; + + // Handle company based on setting. + if ( 'none' === $company_handling ) { + $this->storeCompanyInCustomFields( $subscriber, $result, $match_type, $provider->getSlug(), $data_behavior ); + } elseif ( 'update_existing' === $company_handling ) { + if ( $subscriber->company_id ) { + $company = Company::find( $subscriber->company_id ); + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider->getSlug(), $data_behavior ); + } + } + } else { + // create_or_update. + $company = $this->findOrCreateCompany( $result, $match_type, $provider->getSlug(), $data_behavior ); + if ( $company && ! $is_company_trigger && $subscriber->email ) { + $subscriber->company_id = $company->id; + $subscriber->save(); + $subscriber->attachCompanies( [ $company->id ] ); + } + } + + $this->log( 'info', "Company enrichment successful: {$result->name} ({$match_type})" ); + + do_action( 'custom_crm/company_enriched', $company ?? null, $result ); + + return $result; + } + + /** + * Find an existing company by website domain or name, or create a new one. + * + * @return Company|null + */ + private function findOrCreateCompany( + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): ?Company { + $company = null; + + // Try to find by website domain. + if ( $result->website ) { + $normalized = $this->normalizeDomain( $result->website ); + $company = Company::where( 'website', 'LIKE', "%{$normalized}%" )->first(); + } + + // Fallback: find by exact name. + if ( ! $company && $result->name ) { + $company = Company::where( 'name', $result->name )->first(); + } + + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider_slug, $data_behavior ); + return $company; + } + + // Create new company. + $fields = $result->toCompanyFields(); + $fields['meta'] = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + + if ( empty( $fields['name'] ) ) { + return null; + } + + return Company::create( $fields ); + } + + /** + * Update an existing company from enrichment results. + */ + private function updateCompanyFromResult( + Company $company, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + $fields = $result->toCompanyFields(); + + if ( 'fill_empty' === $data_behavior ) { + $fields = $this->filterEmptyCompanyFields( $company, $fields ); + } + + if ( $fields ) { + $company->fill( $fields ); + $company->save(); + } + + // Always update meta. + $meta = $company->meta; + $enrichment_meta = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + $company->meta = array_merge( $meta, $enrichment_meta ); + $company->save(); + } + + /** + * Build the enrichment portion of company meta. + * + * @return array + */ + private function buildCompanyMeta( CompanyResult $result, string $match_type, string $provider_slug ): array { + return array_merge( + $result->toCompanyMeta(), + [ + 'enrichment_company_match' => $match_type, + 'enrichment_provider' => $provider_slug, + 'enriched_at' => current_time( 'mysql' ), + ] + ); + } + + /** + * Store company data in contact custom fields (when company_handling = 'none'). + */ + private function storeCompanyInCustomFields( + Subscriber $subscriber, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + EnrichmentFields::ensureFieldsExist(); + + $custom = [ + 'enrichment_company_name' => $result->name, + 'enrichment_industry' => $result->industry, + 'enrichment_company_match' => $match_type, + ]; + + $custom = array_filter( $custom, static fn( $v ) => null !== $v ); + + if ( 'fill_empty' === $data_behavior ) { + $custom = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom ); + } + + if ( $custom ) { + $subscriber->syncCustomFieldValues( $custom, false ); + } + } + + /** + * Handle an enrichment error: log it and optionally mark the sequence. + */ + private function handleError( + EnrichmentError $error, + Subscriber $subscriber, + int $funnel_subscriber_id, + $sequence, + string $context + ): void { + $email = $subscriber->email ?? '(unknown)'; + + $log_level = in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::QUOTA_EXCEEDED, EnrichmentError::PROVIDER_ERROR ], true ) + ? 'error' + : 'info'; + + $this->log( $log_level, "{$context} enrichment error for {$email}: [{$error->code}] {$error->message}" ); + + // For fatal errors, skip the sequence. + if ( in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::INVALID_INPUT, EnrichmentError::QUOTA_EXCEEDED ], true ) ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + } + } + + /** + * Filter subscriber fields to only those that are currently empty. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $subscriber->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter custom fields to only those that are currently empty on the subscriber. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Custom field slug => value. + * + * @return array + */ + private function filterEmptyCustomFieldsOnly( Subscriber $subscriber, array $fields ): array { + $slugs = array_keys( $fields ); + $existing_values = $subscriber->getCustomFieldValues( $slugs ); + + return array_filter( + $fields, + static function ( $value, $key ) use ( $existing_values ) { + $existing = $existing_values[ $key ] ?? null; + return empty( $existing ); + }, + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter company fields to only those that are currently empty. + * + * @param Company $company Company model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyCompanyFields( Company $company, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $company->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Detect if this is a company-triggered funnel. + */ + private function isCompanyTriggered( Subscriber $subscriber, $sequence ): bool { + // Check funnel trigger name if available. + if ( isset( $sequence->funnel ) && isset( $sequence->funnel->trigger_name ) ) { + $trigger = $sequence->funnel->trigger_name; + if ( str_contains( $trigger, 'company' ) ) { + return true; + } + } + + return false; + } + + /** + * Normalize a domain for comparison (strip protocol, www, trailing slash). + */ + private function normalizeDomain( string $url ): string { + $domain = strtolower( $url ); + $domain = preg_replace( '#^https?://#', '', $domain ); + $domain = preg_replace( '#^www\.#', '', $domain ); + $domain = rtrim( $domain, '/' ); + + return $domain; + } + + /** + * Get provider options for the dropdown. + * + * @return array + */ + private function getProviderOptions(): array { + $providers = $this->getRegisteredProviders(); + $options = []; + + foreach ( $providers as $provider ) { + $settings = EnrichmentSettings::getProviderSettings( $provider->getSlug() ); + if ( ! empty( $settings['api_key'] ) ) { + $options[] = [ + 'id' => $provider->getSlug(), + 'title' => $provider->getName(), + ]; + } + } + + if ( empty( $options ) ) { + $options[] = [ + 'id' => '', + 'title' => __( '-- No providers configured --', 'fluent-crm-custom-features' ), + ]; + } + + return $options; + } + + /** + * Resolve a provider instance by slug. + */ + private function resolveProvider( string $slug ): ?EnrichmentProvider { + if ( '' === $slug ) { + $slug = EnrichmentSettings::getActiveProvider(); + } + + $providers = $this->getRegisteredProviders(); + + foreach ( $providers as $provider ) { + if ( $provider->getSlug() === $slug ) { + $settings = EnrichmentSettings::getProviderSettings( $slug ); + if ( ! empty( $settings['api_key'] ) ) { + return $provider; + } + } + } + + return null; + } + + /** + * Get all registered providers. + * + * @return EnrichmentProvider[] + */ + private function getRegisteredProviders(): array { + if ( $this->providers ) { + return $this->providers; + } + + $providers = [ + new PDLProvider(), + ]; + + /** + * Register additional enrichment providers. + * + * @param EnrichmentProvider[] $providers Array of provider instances. + */ + $this->providers = apply_filters( 'custom_crm/enrichment_providers', $providers ); + + return $this->providers; + } + + /** + * Log a message to FluentCRM's internal logger. + * + * @param string $level 'info', 'error', 'warning'. + * @param string $message Log message. + */ + private function log( string $level, string $message ): void { + if ( function_exists( 'fluentCrmLog' ) ) { + fluentCrmLog( $message ); + } + + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "[CustomCRM Enrichment][{$level}] {$message}" ); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Actions/EnrichContactAction.php +git commit -m "feat(enrichment): add EnrichContactAction automation block" +``` + +--- + +### Task 10: Register the Action in the Plugin Bootstrap + +**Files:** +- Modify: `fluent-crm-custom-features.php` (add registration line in the `init` callback) + +- [ ] **Step 1: Read the current bootstrap to confirm the exact insertion point** + +The file already has an `add_action('init', function() { ... })` block that registers other actions. Add the new action registration inside this block. + +- [ ] **Step 2: Add the registration line** + +Add the following line inside the existing `init` callback, after the other action registrations: + +```php +( new \CustomCRM\Actions\EnrichContactAction() ); +``` + +This should go after the existing `( new \CustomCRM\Webhooks() );` line (or wherever the last action registration is). + +- [ ] **Step 3: Commit** + +```bash +git add fluent-crm-custom-features.php +git commit -m "feat(enrichment): register EnrichContactAction in plugin bootstrap" +``` + +--- + +### Task 11: Verify Autoloading and Lint + +- [ ] **Step 1: Verify all new classes are autoloadable** + +The plugin uses a custom PSR-4 autoloader that maps `CustomCRM\` to `classes/`. Verify the namespace paths are correct: + +```bash +cd /Users/zackkatz/Local/dev/app/public/wp-content/plugins/fluent-crm-custom-features +php -r " +spl_autoload_register(function(\$class) { + \$prefix = 'CustomCRM\\\\'; + if (strncmp(\$prefix, \$class, strlen(\$prefix)) !== 0) return; + \$relative = substr(\$class, strlen(\$prefix)); + \$file = __DIR__ . '/classes/' . str_replace('\\\\', '/', \$relative) . '.php'; + echo \$class . ' => ' . \$file . ' => ' . (file_exists(\$file) ? 'OK' : 'MISSING') . PHP_EOL; +}); +\$classes = [ + 'CustomCRM\Enrichment\EnrichmentError', + 'CustomCRM\Enrichment\PersonResult', + 'CustomCRM\Enrichment\CompanyResult', + 'CustomCRM\Enrichment\EnrichmentProvider', + 'CustomCRM\Enrichment\FreeEmailDetector', + 'CustomCRM\Enrichment\EnrichmentSettings', + 'CustomCRM\Enrichment\EnrichmentFields', + 'CustomCRM\Enrichment\Providers\PDLProvider', + 'CustomCRM\Actions\EnrichContactAction', +]; +foreach (\$classes as \$c) { new \$c; } +" +``` + +Expected: All classes resolve to existing files and show "OK". + +- [ ] **Step 2: Run PHPCS lint** + +```bash +composer lint 2>&1 | head -50 +``` + +Fix any lint errors in the new files. + +- [ ] **Step 3: Run PHPStan** + +```bash +composer phpstan 2>&1 | head -80 +``` + +Fix any static analysis errors in the new files. + +- [ ] **Step 4: Commit any lint/phpstan fixes** + +```bash +git add -A +git commit -m "fix(enrichment): address lint and static analysis findings" +``` + +--- + +### Task 12: Final Commit and Summary + +- [ ] **Step 1: Verify all files are committed** + +```bash +git status +git log --oneline -12 +``` + +Expected: Clean working tree, all enrichment commits visible in the log. + +- [ ] **Step 2: Tag completion** + +No tag needed — this is on a feature branch. The commits are ready for PR review. diff --git a/docs/superpowers/specs/2026-03-26-enrichment-action-design.md b/docs/superpowers/specs/2026-03-26-enrichment-action-design.md new file mode 100644 index 0000000..769b6a7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-enrichment-action-design.md @@ -0,0 +1,384 @@ +# Enrich Contact Action Block — Design Spec + +## Overview + +A provider-agnostic FluentCRM automation action block that enriches contacts and companies using external data providers. The first provider implementation is People Data Labs (PDL). The action maps enrichment data to FluentCRM Subscriber fields, custom fields, and Company records. + +## Goals + +- Automatically enrich contacts with job title, company, social profiles, location, and demographic data +- Auto-create and link FluentCRM Companies from enrichment data or email domain +- Support both contact-triggered and company-triggered automations +- Design for swappable providers — PDL is the first, but the architecture supports Clearbit, Apollo, ZoomInfo, etc. +- Store enrichment metadata generically (no provider-specific prefixes in field names) + +## Architecture: Approach A — Single Action Block + +One action block handles both person and company enrichment. When scope includes both, it calls Person Enrichment first, then Company Enrichment using data from the person response (or email domain). Two sequential API calls in a single action step. + +**Why this approach:** +- The common case (enrich both) is the default and easiest to configure +- Sequential API calls add <1s total — invisible in FluentCRM's async cron execution +- Company enrichment benefits from person enrichment data (job_company_* fields) +- Simpler automation flows — one block instead of two + +## Action Block Settings + +**Action Name:** `enrich_contact` +**Category:** CRM +**Title:** Enrich Contact + +| Setting | Type | Default | Description | +|---|---|---|---| +| Provider | Dropdown | First configured | Which enrichment provider to use | +| Enrichment Scope | Radio | `both` | "Person + Company" / "Person Only" / "Company Only" | +| Min Likelihood | Number (1-10) | `6` | Minimum provider confidence score to accept a match | +| Data Behavior | Radio | `fill_empty` | "Fill empty fields only" / "Overwrite all fields" | +| Reprocess | Checkbox | unchecked | "Re-enrich contacts that have already been enriched" | +| Company Handling | Radio | `create_or_update` | "Create or update company" / "Update existing only" / "Don't touch companies" | +| Tag on Success | Tag selector | empty | Optional tag to apply after successful enrichment | +| Tag on No Match | Tag selector | empty | Optional tag to apply when provider returns no match | + +### Trigger Context Behavior + +The action adapts based on the automation trigger: + +| Trigger Context | Scope = "Person + Company" | Scope = "Person Only" | Scope = "Company Only" | +|---|---|---|---| +| Contact-triggered | Enrich person, then company | Enrich person only | Enrich company from email domain | +| Company-triggered | Degrades to Company Only | Marks sequence as skipped | Enrich company from name/website | + +**Context detection:** FluentCRM's `handle()` always receives a `$subscriber`. For company-triggered funnels, the subscriber may be a placeholder or the company owner. The action detects company context by checking: (1) whether `$subscriber->company_id` is set and `$subscriber->email` is empty or a placeholder, or (2) by inspecting the funnel trigger type via `$sequence->funnel->trigger_name`. If the trigger is company-based (e.g., `company_created`), the action loads the Company model from the subscriber's `company_id` and uses it as the primary enrichment target. This detection logic should be verified against FluentCRM's actual company-trigger implementation during development. + +### Company Match Confidence + +When a company is identified and linked to a contact: + +- **Confirmed**: Email domain matches the company's website domain (`jane@acme.com` -> company website is `acme.com`) +- **Inferred**: Free email provider (`jane@gmail.com`) but enrichment says she works at Acme Corp. Company is created/linked but marked as inferred. + +Stored in the `enrichment_company_match` custom field on the contact and in the company's `meta` JSON column. + +## Provider Abstraction + +### Interface: EnrichmentProvider + +```php +abstract class EnrichmentProvider { + abstract public function getSlug(): string; + abstract public function getName(): string; + abstract public function enrichPerson(array $params): PersonResult|EnrichmentError; + abstract public function enrichCompany(array $params): CompanyResult|EnrichmentError; + abstract public function getSettingsFields(): array; + abstract public function validateSettings(array $settings): bool|string; + abstract protected function mapError(int $httpStatus, array $responseBody): EnrichmentError; +} +``` + +### Error Handling + +All providers map their HTTP responses to a normalized `EnrichmentError`: + +```php +class EnrichmentError { + public string $code; // Normalized error code + public string $message; // Human-readable message + public ?int $httpStatus; // Raw HTTP status from provider + public bool $retryable; // Whether the action should retry +} +``` + +**Normalized error codes:** + +| Code | Meaning | PDL HTTP Status | Retryable | +|---|---|---|---| +| `no_match` | Entity not found | 404 | No | +| `invalid_input` | Bad/insufficient input params | 400 | No | +| `auth_failed` | Bad API key | 401 | No | +| `rate_limited` | Too many requests | 429 | Yes | +| `quota_exceeded` | Billing/credit limit hit | 402, 403 | No | +| `provider_error` | Upstream API failure | 5xx | Yes | +| `network_error` | Connection timeout, DNS failure | N/A | Yes | + +**Action behavior per error:** + +| Error code | Action behavior | +|---|---| +| `no_match` | Apply no-match tag, continue to next step | +| `invalid_input` | Log warning, mark sequence as skipped | +| `auth_failed` | Log error, mark skipped permanently | +| `rate_limited` | Log warning, mark skipped (FluentCRM retries on next cron) | +| `quota_exceeded` | Log error, mark skipped | +| `provider_error` | Log error, mark skipped (retryable on next run) | +| `network_error` | Log warning, mark skipped (retryable) | + +The action never retries inline. For retryable errors, FluentCRM's cron-based retry handles re-execution. For non-retryable errors, the contact is marked skipped with a logged note. + +All API calls and errors are logged via `fluentcrm_log()`. + +### PDL Provider Implementation + +**Person Enrichment:** +- Endpoint: `GET https://api.peopledatalabs.com/v5/person/enrich` +- Input: subscriber email (primary), plus first_name, last_name, company, location if available +- Min input: email alone is sufficient +- Returns 100+ fields including `job_company_*` data + +**Company Enrichment:** +- Endpoint: `GET https://api.peopledatalabs.com/v5/company/enrich` +- Input: website (primary), name (fallback), linkedin_url (fallback) +- Min input: one of name, website, ticker, or profile + +**Billing:** PDL charges per match (200 response only). 404s are free. + +**Rate Limits:** +- Free tier: 100/min (person), 10/min (company) +- Paid tier: 1,000/min both + +## Field Mapping + +### PersonResult DTO -> Subscriber (native fields) + +| PersonResult field | Subscriber field | PDL source field | +|---|---|---| +| `first_name` | `first_name` | `first_name` | +| `last_name` | `last_name` | `last_name` | +| `phone` | `phone` | `mobile_phone` or `phone_numbers[0]` | +| `city` | `city` | `location_locality` | +| `state` | `state` | `location_region` | +| `country` | `country` | `location_country` | +| `postal_code` | `postal_code` | `location_postal_code` | +| `address_line_1` | `address_line_1` | `location_street_address` | +| `date_of_birth` | `date_of_birth` | `birth_date` | +| `timezone` | `timezone` | Inferred from location | +| `latitude` | `latitude` | From `location_geo` | +| `longitude` | `longitude` | From `location_geo` | +| `avatar` | `avatar` | Profile photo URL if available | + +### PersonResult DTO -> Custom Fields + +| PersonResult field | Custom field slug | Type | +|---|---|---| +| `job_title` | `enrichment_job_title` | text | +| `job_role` | `enrichment_job_role` | text | +| `job_level` | `enrichment_job_level` | text | +| `job_company_name` | `enrichment_company_name` | text | +| `linkedin_url` | `enrichment_linkedin_url` | text | +| `twitter_url` | `enrichment_twitter_url` | text | +| `facebook_url` | `enrichment_facebook_url` | text | +| `github_url` | `enrichment_github_url` | text | +| `sex` | `enrichment_sex` | select-one | +| `pronouns` | `enrichment_pronouns` | text | +| `inferred_salary` | `enrichment_inferred_salary` | text | +| `industry` | `enrichment_industry` | text | +| `enriched_at` | `enriched_at` | date_time | +| `enrichment_provider` | `enrichment_provider` | text | +| `enrichment_likelihood` | `enrichment_likelihood` | number | +| `enrichment_company_match` | `enrichment_company_match` | select-one | + +The `enrichment_pronouns` field can be auto-populated by the provider from `sex` data (he/him, she/her) or directly if the provider returns pronoun data. This mapping is a provider-level decision, customizable via the `custom_crm/enrichment_person_result` filter. + +### CompanyResult DTO -> Company (native fields) + +| CompanyResult field | Company field | PDL source field | +|---|---|---| +| `name` | `name` | `display_name` | +| `industry` | `industry` | `industry` | +| `type` | `type` | `type` (public/private/nonprofit) | +| `website` | `website` | `website` | +| `email` | `email` | If available | +| `phone` | `phone` | If available | +| `address_line_1` | `address_line_1` | `location.street_address` | +| `city` | `city` | `location.locality` | +| `state` | `state` | `location.region` | +| `country` | `country` | `location.country` | +| `postal_code` | `postal_code` | `location.postal_code` | +| `employees_number` | `employees_number` | `employee_count` | +| `description` | `description` | `summary` | +| `logo` | `logo` | If available | +| `linkedin_url` | `linkedin_url` | `linkedin_url` | +| `facebook_url` | `facebook_url` | `facebook_url` | +| `twitter_url` | `twitter_url` | `twitter_url` | +| `date_of_start` | `date_of_start` | `founded` (year) | + +### CompanyResult DTO -> Company meta (JSON column) + +| Field | PDL source | +|---|---| +| `enrichment_company_match` | `confirmed` or `inferred` | +| `enrichment_provider` | Provider slug (e.g., "pdl") | +| `enriched_at` | Timestamp | +| `funding_raised` | `total_funding_raised` | +| `funding_stage` | `latest_funding_stage` | +| `inferred_revenue` | `inferred_revenue` | +| `employee_growth_rate_12mo` | `employee_growth_rate.12_month` | +| `ticker` | Stock ticker if public | +| `naics_codes` | Industry classification codes | + +## Execution Flow + +``` +1. DETECT CONTEXT + +-- Contact-triggered? -> have subscriber email + +-- Company-triggered? -> have company name/website + +2. CHECK SKIP CONDITIONS + +-- Reprocess disabled + contact has enriched_at? -> skip + +-- No API key configured for selected provider? -> skip + log warning + +3. RESOLVE PROVIDER + +-- Instantiate provider from setting (default: PDL) + +4. PERSON ENRICHMENT (if scope includes person + have subscriber) + +-- Call provider.enrichPerson(email, first_name, last_name, ...) + +-- Error or below min_likelihood? + | +-- Apply "no match" tag if configured + | +-- Continue to company step (may still work from email domain) + +-- Success + above threshold? + +-- Map PersonResult -> Subscriber fields (respecting fill_empty/overwrite) + +-- Set enriched_at and enrichment_provider custom fields + +-- Stash job_company_name, job_company_website for company step + +5. COMPANY ENRICHMENT (if scope includes company) + +-- Determine company identifier: + | +-- Company-triggered? -> use company.website or company.name + | +-- Contact has business email? -> extract domain -> use as website + | +-- Free email? -> use job_company_website from step 4 (if available) + +-- No identifier found? -> skip company enrichment + +-- Call provider.enrichCompany(name, website, ...) + +-- Error? -> skip + +-- Success? + +-- Determine match confidence: + | +-- Email domain == company website domain? -> "confirmed" + | +-- Otherwise -> "inferred" + +-- Based on Company Handling setting: + +-- "Create or update" -> + | +-- Find existing Company by website domain + | +-- Create if not found, update if found + | +-- Store match confidence in company meta + | +-- Link subscriber <-> company (pivot + company_id) + +-- "Update existing only" -> + | +-- Update company if subscriber already linked to one + +-- "Don't touch companies" -> + +-- Store company data in contact custom fields only + +6. APPLY TAGS + +-- Enrichment succeeded? -> apply success tag if configured + +-- Both person + company failed? -> apply no-match tag if configured + +7. FIRE HOOKS + +-- do_action('custom_crm/contact_enriched', $subscriber, $personResult, $companyResult) + +-- do_action('custom_crm/company_enriched', $company, $companyResult) +``` + +### Free Email Detection + +A helper checks the email domain against a list of known free providers (gmail.com, yahoo.com, hotmail.com, outlook.com, icloud.com, aol.com, protonmail.com, etc.). The list is filterable: + +```php +apply_filters('custom_crm/free_email_domains', $domains) +``` + +### Company Matching + +When looking for an existing FluentCRM Company, match by normalized website domain (strip protocol, `www.`, trailing slash). If no website match, fall back to exact name match. + +## Settings Page & API Key Management + +A settings section for provider configuration. Each registered provider contributes its own fields. + +### Database Structure + +```php +// Option key: custom_crm_enrichment_settings +[ + 'active_provider' => 'pdl', + 'providers' => [ + 'pdl' => [ + 'api_key' => '(encrypted)', + 'api_tier' => 'free', // free | paid + ], + ], +] +``` + +### Provider Settings Registration + +Each provider declares its settings fields and validation: + +```php +// In EnrichmentProvider (abstract) +abstract public function getSettingsFields(): array; +abstract public function validateSettings(array $settings): bool|string; +``` + +PDL declares: `api_key` (password field) and `api_tier` (select: free/paid). + +### Settings Page Features + +- **Test Connection** button per provider — calls `validateSettings()`, shows inline success/failure +- API key stored encrypted via `wp_encrypt()` (WP 6.5+) with fallback to site-salted encoding for older installs +- Provider dropdown in action block only shows providers with valid configured API keys +- If no providers configured, the action block shows a notice linking to settings + +## Custom Field Auto-Creation + +On plugin activation and lazily on first enrichment run, ensure all required FluentCRM custom fields exist: + +| Slug | Label | Type | Group | +|---|---|---|---| +| `enrichment_job_title` | Job Title | text | Enrichment | +| `enrichment_job_role` | Job Role | text | Enrichment | +| `enrichment_job_level` | Job Level | text | Enrichment | +| `enrichment_company_name` | Company Name | text | Enrichment | +| `enrichment_linkedin_url` | LinkedIn URL | text | Enrichment | +| `enrichment_twitter_url` | Twitter URL | text | Enrichment | +| `enrichment_facebook_url` | Facebook URL | text | Enrichment | +| `enrichment_github_url` | GitHub URL | text | Enrichment | +| `enrichment_sex` | Sex | select-one | Enrichment | +| `enrichment_pronouns` | Pronouns | text | Enrichment | +| `enrichment_inferred_salary` | Inferred Salary | text | Enrichment | +| `enrichment_industry` | Industry | text | Enrichment | +| `enriched_at` | Enriched At | date_time | Enrichment | +| `enrichment_provider` | Enrichment Provider | text | Enrichment | +| `enrichment_likelihood` | Match Likelihood | number | Enrichment | +| `enrichment_company_match` | Company Match Type | select-one | Enrichment | + +Uses `fluentcrm_get_option('contact_custom_fields')` / `fluentcrm_update_option()`. A transient prevents repeated DB reads after initial check. + +## File Structure + +``` +classes/ + Enrichment/ + EnrichmentProvider.php # Abstract provider contract + EnrichmentError.php # Normalized error DTO + PersonResult.php # Person data DTO + CompanyResult.php # Company data DTO + EnrichmentFields.php # Auto-creates FluentCRM custom fields + EnrichmentSettings.php # Read/write provider settings, encrypt API keys + FreeEmailDetector.php # Free email domain checker (filterable) + Providers/ + PDLProvider.php # People Data Labs implementation + Actions/ + EnrichContactAction.php # FluentCRM action block +``` + +## Hooks & Filters + +| Hook | Type | Purpose | +|---|---|---| +| `custom_crm/enrichment_providers` | Filter | Register additional providers | +| `custom_crm/free_email_domains` | Filter | Add/remove free email domains | +| `custom_crm/enrichment_person_result` | Filter | Modify person DTO before saving (e.g., pronouns mapping) | +| `custom_crm/enrichment_company_result` | Filter | Modify company DTO before saving | +| `custom_crm/contact_enriched` | Action | Fires after contact enrichment completes | +| `custom_crm/company_enriched` | Action | Fires after company enrichment completes | + +## Stretch Goals (not in initial implementation) + +- Daily/monthly budget cap for API credits +- Cooldown period before re-enrichment +- Bulk enrichment UI (process existing contacts outside of automation flows) +- Additional providers (Clearbit, Apollo, ZoomInfo) +- Enrichment history log per contact (track changes over time) diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 74d52bb..21109e9 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -1,24 +1,35 @@ register(); + + // Track EDD license activations as FluentCRM events. + ( new \CustomCRM\EddLicenseActivationTracker() )->register(); + // Remove the default smart link handler. remove_all_actions( 'fluentcrm_smartlink_clicked' ); remove_all_actions( 'fluentcrm_smartlink_clicked_direct' ); @@ -46,120 +63,97 @@ function () { add_action( 'fluentcrm_smartlink_clicked', [ $fix_smart_link_redirects, 'handleClick' ], 9, 1 ); add_action( 'fluentcrm_smartlink_clicked_direct', [ $fix_smart_link_redirects, 'handleClick' ], 9, 2 ); + + // Custom CSS editor for FluentCRM email templates. + ( new \CustomCRM\Integrations\CustomEmailCSS() )->register(); + + // Contact and company enrichment via external providers (PDL, etc.). + ( new \CustomCRM\Actions\EnrichContactAction() ); }, 99 ); -// Hook to add custom dashboard metrics -// add_action( 'fluent_crm/dashboard_stats', 'add_custom_dashboard_metrics' ); - -/** - * Add custom dashboard metrics. - * - * @param array $data The dashboard data. - * - * @return array - */ -function add_custom_dashboard_metrics( $data ) { - // Example: Adding a new metric for total subscribers. - $total_subscribers = \FluentCrm\App\Models\Subscriber::count(); - - $data['total_subscribers_metric'] = [ - 'title' => __( 'Total Subscribers', 'fluent-crm-custom-features' ), - 'count' => $total_subscribers, - 'route' => [ - 'name' => 'subscribers', +// Hook to register custom REST API endpoints. +add_action( 'rest_api_init', function () { + register_rest_route( 'fluent-crm/v1', '/list-growth', [ + 'methods' => 'GET', + 'callback' => 'customcrm_get_list_growth', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'from' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], + 'to' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], ], - ]; - - // Add more metrics as needed - return $data; -} - -// Hook to register a custom report -add_action( 'fluent_crm/reporting/reports', 'register_custom_report' ); + ] ); +} ); /** - * Register a custom report. + * Validate a date parameter for the REST API. * - * @param array $reports The reports array. + * @param string $value The parameter value. * - * @return array + * @return bool */ -function register_custom_report( $reports ) { - $reports['custom_report'] = [ - 'title' => __( 'Custom Report', 'fluent-crm-custom-features' ), - 'callback' => 'render_custom_report', - ]; - - return $reports; -} +function customcrm_validate_date_param( $value ) { + // Allow empty values (defaults will be used). + if ( empty( $value ) ) { + return true; + } -/** - * Render the custom report. - * - * @return void - */ -function render_custom_report() { - // Logic to render your custom report - $subscribers = \FluentCrm\App\Models\Subscriber::all(); - // Output your report data here - echo '

' . esc_html__( 'Custom Report', 'fluent-crm-custom-features' ) . '

'; - echo '
    '; - foreach ( $subscribers as $subscriber ) { - echo '
  • ' . esc_html( $subscriber->email ) . '
  • '; + // Must match YYYY-MM-DD format and be a valid date. + if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) ) { + return false; } - echo '
'; -} -// Hook to register a custom REST API endpoint -add_action('rest_api_init', function () { - register_rest_route('fluent-crm/v1', '/list-growth', [ - 'methods' => 'GET', - 'callback' => 'get_list_growth', - 'permission_callback' => '__return_true', - ]); -}); + return checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ); +} /** - * Get List Growth metrics + * Get List Growth metrics. * * @param WP_REST_Request $request The REST request object. + * * @return WP_REST_Response */ -function get_list_growth( WP_REST_Request $request ) { +function customcrm_get_list_growth( WP_REST_Request $request ) { $from = $request->get_param( 'from' ); $to = $request->get_param( 'to' ); - // Convert dates to Carbon instances - $from_date = \Carbon\Carbon::parse( $from ); - $to_date = \Carbon\Carbon::parse( $to ); + // Default to current month if not provided. + $from = ! empty( $from ) ? sanitize_text_field( $from ) : gmdate( 'Y-m-01' ); + $to = ! empty( $to ) ? sanitize_text_field( $to ) : gmdate( 'Y-m-t' ); - // Count new subscribers + // Count new subscribers. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); - // Count unsubscribed + // Count unsubscribed. $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); - // Calculate net growth + // Calculate net growth. $net_growth = $new_subscribers - $unsubscribed; - return new WP_REST_Response([ + return new WP_REST_Response( [ 'new_subscribers' => $new_subscribers, 'unsubscribed' => $unsubscribed, 'net_growth' => $net_growth, - ], 200); + ], 200 ); } - // Hook to add custom metrics to the dashboard. -add_filter( 'fluent_crm/dashboard_data', 'add_custom_dashboard_metrics_for_list_growth' ); +add_filter( 'fluent_crm/dashboard_data', 'customcrm_add_dashboard_list_growth_metrics' ); /** * Add custom dashboard metrics for list growth. @@ -168,7 +162,7 @@ function get_list_growth( WP_REST_Request $request ) { * * @return array */ -function add_custom_dashboard_metrics_for_list_growth( $data ) { +function customcrm_add_dashboard_list_growth_metrics( $data ) { // Get the date range from the request or set default values. // phpcs:disable WordPress.Security.NonceVerification.Recommended $from = isset( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : gmdate( 'Y-m-01' ); @@ -177,12 +171,12 @@ function add_custom_dashboard_metrics_for_list_growth( $data ) { // Calculate new subscribers and unsubscribes. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); diff --git a/package.json b/package.json index 964d7de..de6006e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ }, "files": [ "classes/**/*", - "vendor-prefixed/**/*", - "*.php", - ".phpcs.xml.dist" + "*.php" ], "scripts": { "release": "node bin/build-release.js" diff --git a/vendor-prefixed/.gitkeep b/vendor-prefixed/.gitkeep new file mode 100644 index 0000000..e69de29