get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $forms_table = $wpdb->prefix . 'chargeforms_forms'; $entries_table = $wpdb->prefix . 'chargeforms_entries'; $entry_meta_table = $wpdb->prefix . 'chargeforms_entry_meta'; $form_starts_table = $wpdb->prefix . 'chargeforms_form_starts'; $activity_log_table = $wpdb->prefix . 'chargeforms_activity_log'; $api_log_table = $wpdb->prefix . 'chargeforms_api_log'; $notification_log_table = $wpdb->prefix . 'chargeforms_notification_log'; $subscriptions_table = $wpdb->prefix . 'chargeforms_subscriptions'; $drafts_table = $wpdb->prefix . 'chargeforms_drafts'; $draft_files_table = $wpdb->prefix . 'chargeforms_draft_files'; $email_log_table = $wpdb->prefix . 'chargeforms_email_log'; $sql = "CREATE TABLE {$forms_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'published', fields LONGTEXT NULL, settings LONGTEXT NULL, created_by BIGINT UNSIGNED NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY status (status) ) {$charset_collate}; CREATE TABLE {$entries_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT UNSIGNED NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'unread', response LONGTEXT NULL, ip_address VARCHAR(100) NULL, user_agent VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY form_id_status (form_id, status), KEY form_id_created (form_id, created_at) ) {$charset_collate}; CREATE TABLE {$entry_meta_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entry_id BIGINT UNSIGNED NOT NULL, meta_key VARCHAR(191) NOT NULL, meta_value LONGTEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY entry_id (entry_id), KEY meta_key (meta_key) ) {$charset_collate}; CREATE TABLE {$form_starts_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT UNSIGNED NOT NULL, event_type VARCHAR(10) NOT NULL DEFAULT 'start', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY form_id_type_created (form_id, event_type, created_at) ) {$charset_collate}; CREATE TABLE {$activity_log_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NULL, action VARCHAR(50) NOT NULL, object_type VARCHAR(30) NOT NULL, object_id BIGINT UNSIGNED NULL, description VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY created_at (created_at), KEY object (object_type, object_id) ) {$charset_collate}; CREATE TABLE {$api_log_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, integration VARCHAR(50) NOT NULL, form_id BIGINT UNSIGNED NULL, request_url VARCHAR(500) NULL, response_code SMALLINT NULL, response_excerpt VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY created_at (created_at), KEY form_id (form_id) ) {$charset_collate}; CREATE TABLE {$notification_log_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT UNSIGNED NOT NULL, entry_id BIGINT UNSIGNED NULL, to_email VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'pending', attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, last_error VARCHAR(500) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, sent_at DATETIME NULL, PRIMARY KEY (id), KEY form_id_created (form_id, created_at), KEY status (status) ) {$charset_collate}; CREATE TABLE {$subscriptions_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT UNSIGNED NOT NULL, entry_id BIGINT UNSIGNED NOT NULL, gateway VARCHAR(20) NOT NULL DEFAULT 'stripe', gateway_subscription_id VARCHAR(191) NOT NULL, gateway_customer_id VARCHAR(191) NULL, plan_interval VARCHAR(10) NOT NULL DEFAULT 'month', amount DECIMAL(10,2) NOT NULL DEFAULT 0, currency VARCHAR(10) NOT NULL DEFAULT 'usd', status VARCHAR(20) NOT NULL DEFAULT 'incomplete', trial_end DATETIME NULL, current_period_end DATETIME NULL, cancel_at_period_end TINYINT(1) UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY gateway_subscription (gateway, gateway_subscription_id), KEY form_id (form_id), KEY entry_id (entry_id), KEY status (status) ) {$charset_collate}; CREATE TABLE {$drafts_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, form_id BIGINT UNSIGNED NOT NULL, token_hash VARCHAR(64) NOT NULL, owner_type VARCHAR(10) NOT NULL DEFAULT 'anon', user_id BIGINT UNSIGNED NULL, current_step SMALLINT UNSIGNED NOT NULL DEFAULT 0, response LONGTEXT NULL, values_hash VARCHAR(64) NULL, ip_address VARCHAR(100) NULL, source_url VARCHAR(500) NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', expires_at DATETIME NOT NULL, last_email_sent_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash), KEY form_id (form_id), KEY user_id_form_id (user_id, form_id), KEY status_expires (status, expires_at) ) {$charset_collate}; CREATE TABLE {$draft_files_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, draft_id BIGINT UNSIGNED NOT NULL, field_id VARCHAR(191) NOT NULL, file_path VARCHAR(500) NOT NULL, file_url VARCHAR(500) NOT NULL, original_name VARCHAR(255) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY draft_id (draft_id) ) {$charset_collate}; CREATE TABLE {$email_log_table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, connection_id VARCHAR(20) NULL, connection_name VARCHAR(191) NULL, email_type VARCHAR(30) NOT NULL DEFAULT 'other', form_id BIGINT UNSIGNED NULL, entry_id BIGINT UNSIGNED NULL, recipient VARCHAR(255) NOT NULL, subject VARCHAR(255) NULL, status VARCHAR(10) NOT NULL, error_category VARCHAR(40) NULL, error_message VARCHAR(500) NULL, duration_ms INT UNSIGNED NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY created_at (created_at), KEY connection_id (connection_id), KEY form_id (form_id) ) {$charset_collate};"; dbDelta( $sql ); self::register_capability(); update_option( 'chargeforms_db_version', CHARGEFORMS_VERSION ); if ( $is_first_install ) { self::seed_default_forms(); } } /** * A brand-new install with an empty Forms list is a real first- * impression problem — a new user landing on "0 forms" with nothing to * click but "Create your first form" has nothing to explore, unlike * the reference product (and most competitors), which start with a couple of * ready-to-use forms already sitting there. Reuses the exact same * "Simple Contact Form" and "Newsletter Signup Form" template field * definitions New_Form_Page::templates() already offers in "Choose a * Template" — one definition, not a second copy that could drift. * Both start unpublished as a DRAFT (see Form_Validator/Form::create - * status is always forced to 'published' there, so this sets it back * down immediately after) so a fresh site never accidentally goes live * with two forms nobody consciously chose to publish; the admin sees * them, edits the recipient email, and publishes when ready. */ private static function seed_default_forms() { if ( ! empty( Form::all() ) ) { return; // Never overwrite/duplicate on a site that already has forms. } $templates = \ChargeForms\Admin\New_Form_Page::templates(); foreach ( [ 'contact', 'newsletter' ] as $key ) { if ( empty( $templates[ $key ] ) ) { continue; } $form = Form::create( [ 'title' => $templates[ $key ]['label'], 'fields' => $templates[ $key ]['fields'], ] ); if ( $form && ! is_wp_error( $form ) ) { Form::update( $form['id'], [ 'status' => 'draft' ] ); } } } /** * A dedicated capability, not a hardcoded manage_options check, so a * future "grant this Editor access to just ChargeForms" feature only * needs to add this one capability to their role — no code changes. * Granted to Administrator by default, same as manage_options already was. */ public static function register_capability() { $role = get_role( 'administrator' ); if ( $role && ! $role->has_cap( 'chargeforms_manage_forms' ) ) { $role->add_cap( 'chargeforms_manage_forms' ); } } public static function deactivate() { // Deliberately leave tables and data intact on deactivation. // Destructive cleanup only happens on uninstall.php, and only if the // user has opted in via a settings checkbox (added post-v1). Email_Summaries::unschedule(); Subscription_Reconciler::unschedule(); Draft_Cleanup::unschedule(); License_Cron::unschedule(); } /** * Re-runs dbDelta on every admin load when the stored schema version is * behind CHARGEFORMS_VERSION, so schema changes (new columns/indexes) reach * sites that installed an older version without needing reactivation. * dbDelta is idempotent — safe to call repeatedly, it only applies diffs. */ public static function maybe_upgrade() { if ( get_option( 'chargeforms_db_version' ) === CHARGEFORMS_VERSION ) { return; } self::activate(); self::migrate_legacy_entry_values(); // Runs the first batch synchronously (same guarantee as before for // any site with Rule_Migrator::BATCH_SIZE or fewer forms — the // common case), deferring only the REMAINDER to background WP-Cron // for larger sites (roadmap issue #17) rather than scanning every // form inline on this one request regardless of how many there // are. See schedule_background_migration()'s doc comment for why // a fully-deferred job isn't safe here. \ChargeForms\Includes\Logic\Rule_Migrator::schedule_background_migration(); } /** * One-time backfill for sites that collected entries under the old * schema (per-field rows in entry_meta: field_key/field_value) before * entries gained their own `response` JSON column. dbDelta only adds * columns — it never renames or drops them — so those old columns are * still sitting there untouched after the upgrade; without this, every * entry collected before the upgrade would silently show no submitted * values (response is NULL for rows that predate the column existing), * even though the raw data was never actually lost. * * Safe to run repeatedly: only ever touches entries whose response is * still empty, and does nothing at all on installs that never had the * old entry_meta columns in the first place (i.e. every fresh install). */ public static function migrate_legacy_entry_values() { global $wpdb; $meta_table = $wpdb->prefix . 'chargeforms_entry_meta'; $entries_table = $wpdb->prefix . 'chargeforms_entries'; $has_legacy_columns = $wpdb->get_var( $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. 'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = %s AND table_name = %s AND column_name = %s', DB_NAME, $meta_table, 'field_key' ) ); if ( ! $has_legacy_columns ) { return; } $entry_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. $wpdb->prepare( 'SELECT DISTINCT m.entry_id FROM %i m INNER JOIN %i e ON e.id = m.entry_id WHERE m.field_key IS NOT NULL AND ( e.response IS NULL OR e.response = \'\' )', $meta_table, $entries_table ) ); foreach ( $entry_ids as $entry_id ) { $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. $wpdb->prepare( 'SELECT field_key, field_value FROM %i WHERE entry_id = %d AND field_key IS NOT NULL', $meta_table, $entry_id ), ARRAY_A ); if ( ! $rows ) { continue; } $values = []; foreach ( $rows as $row ) { $values[ $row['field_key'] ] = $row['field_value']; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table, no core API exists for it; per-request freshness needed; not worth an object-cache layer for these tables. $entries_table, [ 'response' => wp_json_encode( $values ) ], [ 'id' => $entry_id ], [ '%s' ], [ '%d' ] ); } } }