prefix . 'chargeforms_forms'; } public static function get( $id ) { global $wpdb; $table = self::table(); $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $table, $id ), ARRAY_A ); // 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. if ( ! $row ) { return null; } return self::hydrate( $row ); } public static function all( $status = null ) { global $wpdb; $table = self::table(); if ( $status ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE status = %s ORDER BY updated_at DESC', $table, $status ), ARRAY_A ); // 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. } else { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY updated_at DESC', $table ), ARRAY_A ); // 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. } return array_map( [ __CLASS__, 'hydrate' ], $rows ?: [] ); } public static function create( array $data ) { global $wpdb; $table = self::table(); $validation = Form_Validator::validate( $data ); if ( is_wp_error( $validation ) ) { return $validation; } // created_at/updated_at set explicitly in GMT (roadmap issue #27), // not left to the table's own DEFAULT CURRENT_TIMESTAMP — that // default's actual timezone depends on the MySQL server's session // config, not the WordPress site's, and Form::query()'s date-range // filters are built with gmdate() against these same columns. $now = current_time( 'mysql', true ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom plugin table, no core API exists for it. $table, [ 'title' => sanitize_text_field( $data['title'] ?? __( 'Untitled Form', 'chargeforms' ) ), 'status' => 'published', 'fields' => wp_json_encode( Rule_Migrator::migrate_fields( $data['fields'] ?? [] ) ), 'settings' => wp_json_encode( $data['settings'] ?? new \stdClass() ), 'created_by' => get_current_user_id(), 'created_at' => $now, 'updated_at' => $now, ], [ '%s', '%s', '%s', '%s', '%d', '%s', '%s' ] ); $created = self::get( $wpdb->insert_id ); if ( $created ) { /* translators: %s: form title */ Activity_Log::log( 'form_created', 'form', $created['id'], sprintf( __( 'Created form "%s"', 'chargeforms' ), $created['title'] ) ); } return $created; } public static function update( $id, array $data ) { global $wpdb; $table = self::table(); $validation = Form_Validator::validate( $data ); if ( is_wp_error( $validation ) ) { return $validation; } $fields = []; $formats = []; if ( isset( $data['title'] ) ) { $fields['title'] = sanitize_text_field( $data['title'] ); $formats[] = '%s'; } if ( isset( $data['fields'] ) ) { $fields['fields'] = wp_json_encode( Rule_Migrator::migrate_fields( $data['fields'] ) ); $formats[] = '%s'; } if ( isset( $data['settings'] ) ) { $fields['settings'] = wp_json_encode( $data['settings'] ); $formats[] = '%s'; } if ( isset( $data['status'] ) ) { $fields['status'] = sanitize_key( $data['status'] ); $formats[] = '%s'; } // GMT (roadmap issue #27) — matches Form::create()'s explicit GMT // created_at/updated_at above and the gmdate()-built date-range // filters in Form::query(). $fields['updated_at'] = current_time( 'mysql', true ); $formats[] = '%s'; $wpdb->update( $table, $fields, [ 'id' => $id ], $formats, [ '%d' ] ); // 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. $updated = self::get( $id ); if ( $updated ) { /* translators: %s: form title */ Activity_Log::log( 'form_updated', 'form', $id, sprintf( __( 'Updated form "%s"', 'chargeforms' ), $updated['title'] ) ); } return $updated; } public static function delete( $id ) { global $wpdb; $form = self::get( $id ); Entry::delete_for_form( $id ); $wpdb->delete( self::table(), [ 'id' => $id ], [ '%d' ] ); // 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. if ( $form ) { /* translators: %s: form title */ Activity_Log::log( 'form_deleted', 'form', $id, sprintf( __( 'Deleted form "%s"', 'chargeforms' ), $form['title'] ) ); } return true; } public static function duplicate( $id ) { $source = self::get( $id ); if ( ! $source ) { return null; } return self::create( [ /* translators: %s: original form title */ 'title' => sprintf( __( '%s (copy)', 'chargeforms' ), $source['title'] ), 'fields' => $source['fields'], 'settings' => $source['settings'], ] ); } /** * Flexible listing query for the admin Forms screen: search, status * filter, sort, and real LIMIT/OFFSET pagination (not a client-side * filter over a fully-loaded table, which breaks the moment more than * one page of forms exists). * * @param array $args { * @type string $status 'all'|'published'|'draft'. Default 'all'. * @type string $search Free-text match against title. * @type string $sort 'newest'|'oldest'|'title_asc'|'title_desc'. * @type int $page 1-indexed page number. * @type int $per_page Rows per page. * } * @return array { items: array, total: int, pages: int, page: int, per_page: int } */ public static function query( array $args = [] ) { global $wpdb; $table = self::table(); $where = []; $params = []; $status = $args['status'] ?? 'all'; if ( 'all' !== $status ) { $where[] = 'status = %s'; $params[] = sanitize_key( $status ); } $search = trim( (string) ( $args['search'] ?? '' ) ); if ( '' !== $search ) { $where[] = 'title LIKE %s'; $params[] = '%' . $wpdb->esc_like( $search ) . '%'; } // 'Multi-step Form' is the one category we can honestly detect from // stored data (a page_break field present) — Payment Form and // Conversational Form aren't real ChargeForms features yet, so they're // deliberately not offered as filters here. if ( 'multistep' === ( $args['category'] ?? '' ) ) { $where[] = 'fields LIKE %s'; $params[] = '%' . $wpdb->esc_like( '"type":"page_break"' ) . '%'; } if ( ! empty( $args['date_from'] ) ) { $where[] = 'created_at >= %s'; $params[] = gmdate( 'Y-m-d 00:00:00', strtotime( $args['date_from'] ) ); } if ( ! empty( $args['date_to'] ) ) { $where[] = 'created_at <= %s'; $params[] = gmdate( 'Y-m-d 23:59:59', strtotime( $args['date_to'] ) ); } $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; $orderby_map = [ 'newest' => 'updated_at DESC', 'oldest' => 'updated_at ASC', 'title_asc' => 'title ASC', 'title_desc' => 'title DESC', 'id_asc' => 'id ASC', 'id_desc' => 'id DESC', ]; $order_sql = $orderby_map[ $args['sort'] ?? 'newest' ] ?? $orderby_map['newest']; $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i {$where_sql}", array_merge( [ $table ], $params ) ) ); // 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. $per_page = max( 1, (int) ( $args['per_page'] ?? 10 ) ); $pages = max( 1, (int) ceil( $total / $per_page ) ); $page = max( 1, min( (int) ( $args['page'] ?? 1 ), $pages ) ); $offset = ( $page - 1 ) * $per_page; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i {$where_sql} ORDER BY {$order_sql} LIMIT %d OFFSET %d", array_merge( [ $table ], $params, [ $per_page, $offset ] ) ), ARRAY_A ); // 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. // roadmap issue #26 ("Form Query REST Does Not Return Entry Counts // Reliably"): this response previously had no 'entries' key at // all, so the Vue forms-list screen's `f.entries ?? 0` fallback // silently showed 0 for every form — not a wrong count, just // always-absent data read as zero. One GROUP BY query for this // page's forms (mirroring Entry::count_by_form()'s use elsewhere // for the same reason: no N+1 per row) instead of Entry:: // count_for_form() once per form. $hydrated = array_map( [ __CLASS__, 'hydrate' ], $rows ?: [] ); $entry_counts = Entry::count_by_form( array_column( $hydrated, 'id' ) ); $unread_counts = Entry::count_unread_by_form( array_column( $hydrated, 'id' ) ); foreach ( $hydrated as &$form ) { $form['entries'] = $entry_counts[ $form['id'] ] ?? 0; $form['unread_entries'] = $unread_counts[ $form['id'] ] ?? 0; } unset( $form ); return [ 'items' => $hydrated, 'total' => $total, 'pages' => $pages, 'page' => $page, 'per_page' => $per_page, ]; } public static function count( $status = null ) { global $wpdb; $table = self::table(); if ( $status ) { return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', $table, $status ) ); // 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. } return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', $table ) ); // 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. } private static function hydrate( array $row ) { return [ 'id' => (int) $row['id'], 'title' => $row['title'], 'status' => $row['status'], 'fields' => json_decode( $row['fields'], true ) ?: [], 'settings' => json_decode( $row['settings'], true ) ?: [], 'created_by' => (int) $row['created_by'], 'created_at' => $row['created_at'], 'updated_at' => $row['updated_at'], ]; } }