sprintf( __( '%s created successfully', 'ad-fuz' ), $name ), /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ 'updated' => sprintf( __( '%s updated successfully', 'ad-fuz' ), $name ), /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ 'deleted' => sprintf( __( '%s deleted successfully', 'ad-fuz' ), $name ), /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ 'create_failed' => sprintf( __( 'Failed to create %s', 'ad-fuz' ), $name ), /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ 'update_failed' => sprintf( __( 'Failed to update %s', 'ad-fuz' ), $name ), /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ 'delete_failed' => sprintf( __( 'Failed to delete %s', 'ad-fuz' ), $name ), ); } /** * Get table name from Model */ protected static function getTable(): string { return static::getModel()::table(); } /** * Override in child controllers to handle related table saves (1:M, etc.) */ protected static function afterSave( int $recordId, array $cleanData ): void { // no-op by default } /** * Override in child controllers to handle related table cleanup after delete. */ protected static function afterDelete( int $recordId ): void { // no-op by default } /** * Get schema from Model for validation */ protected static function getSchema(): array { return static::getModel()::schema(); } /** * Optional: extra data in success response */ protected static function extraResponseData( int $id, array $data ): array { return array(); } protected static function getControllerSlug(): string { return lcfirst( substr( basename( static::class ), 0, - 10 ) ); } /** * Schema-driven request sanitization + validation */ protected static function sanitiseRequest(): array|WP_Error { $raw = static::request()->all(); $schema = static::getSchema(); $validated = Validator::validate( $schema, $raw ); if ( is_wp_error( $validated ) ) { return $validated; } if ( isset( $schema['slug'] ) && empty( $validated['slug'] ) && empty( $validated['id'] ) ) { $validated['slug'] = wp_generate_uuid4(); } if ( isset( $schema['user_id'] ) && empty( $validated['user_id'] ) ) { $validated['user_id'] = get_current_user_id() ?: 0; } return $validated; } /** * Shared save logic for both AJAX and form-based submissions. * Returns the saved record ID, or throws on failure. * * @return int The created or updated record ID. */ protected static function persistForm( string $redirect_on_error = '' ): int { $cleanData = static::sanitiseRequest(); if ( is_wp_error( $cleanData ) ) { // Validation errors — no DB action happened, no log needed static::redirectWithErrors( $cleanData->get_error_messages(), $redirect_on_error ); } // Determine create vs update AFTER cleanData is available $isUpdate = ! empty( $cleanData['id'] ); $entityId = $isUpdate ? (int) $cleanData['id'] : 0; // Capture BEFORE state for update diff $before = $isUpdate ? DB::get( static::getModel()::table() )->find( $entityId ) : array(); $recordId = static::persistRecord( $cleanData ); if ( ! $recordId ) { // Log the failure before redirecting. DBLog::for( static::getEntityType() )->logFailure( $isUpdate ? 'update' : 'create', $entityId, $cleanData ); static::redirectWithErrors( array( __( 'Failed to save record. Please try again.', 'ad-fuz' ) ), $redirect_on_error ); } // Log success. if ( $isUpdate ) { $after = DB::get( static::getModel()::table() )->find( $recordId ); DBLog::for( static::getEntityType() )->logUpdate( $recordId, $before ?? array(), $after ?? array() ); } else { DBLog::for( static::getEntityType() )->logCreate( $recordId, $cleanData ); } static::handleMeta( $recordId ); static::afterSave( $recordId, $cleanData ); return $recordId; } /** * Store errors in a transient and redirect back to the form. */ protected static function redirectWithErrors( array $errors, string $fallback_url = '' ): never { $transient_key = 'adfuz_form_errors_' . get_current_user_id(); set_transient( $transient_key, $errors, 60 ); // expires in 60 seconds $redirect = $fallback_url ?: ( wp_get_referer() ?: admin_url( 'admin.php' ) ); $redirect = add_query_arg( 'adfuz_error', '1', $redirect ); wp_safe_redirect( $redirect ); exit; } public static function getSuccessMessage(): string { $transient_key = 'adfuz_form_success_' . get_current_user_id(); $message = get_transient( $transient_key ); if ( $message ) { delete_transient( $transient_key ); Common::log_file( array( '$message' => $message ) ); return $message; } return ''; } protected static function redirectWithSuccess( string $message, string $fallback_url = '' ): never { $transient_key = 'adfuz_form_success_' . get_current_user_id(); set_transient( $transient_key, $message, 60 ); // expires in 60 seconds $redirect = $fallback_url ?: ( wp_get_referer() ?: admin_url( 'admin.php' ) ); wp_safe_redirect( $redirect ); exit; } /** * Read and immediately delete form errors for the current user. * Call this in your form renderPage. * * @return array Empty array if no errors. */ public static function getFormErrors(): array { $transient_key = 'adfuz_form_errors_' . get_current_user_id(); $errors = get_transient( $transient_key ); if ( $errors ) { delete_transient( $transient_key ); return (array) $errors; } return array(); } /** * Main save handler (create/update) */ public static function save(): void { // Security checks Common::checkCapability( static::getCapabilities() ); Common::verifyNonce(); Common::log_file( array( '$_POST' => $_POST ) ); // Validate and sanitise request FIRST $cleanData = static::sanitiseRequest(); Common::log_file( array( '$cleanData' => $cleanData ) ); if ( is_wp_error( $cleanData ) ) { wp_send_json_error( array( 'errors' => $cleanData->get_error_messages() ) ); return; // always return after wp_send_json_* } // Determine action AFTER cleanData is available $isUpdate = ! empty( $cleanData['id'] ); $action = $isUpdate ? 'updated' : 'created'; $entityId = $isUpdate ? (int) $cleanData['id'] : 0; // Capture the BEFORE state for update diff (do this before persistRecord) $before = $isUpdate ? DB::get( static::getTable() )->find( $entityId ) : array(); // Save main record $recordId = static::persistRecord( $cleanData ); if ( ! $recordId ) { // Log failure with the attempted data + DB error DBLog::for( static::getEntityType() )->logFailure( $action, $entityId, $cleanData ); wp_send_json_error( array( 'message' => __( 'Failed to save record', 'ad-fuz' ) ) ); return; } // Log success — create vs update if ( $isUpdate ) { $after = DB::get( static::getTable() )->find( $recordId ); DBLog::for( static::getEntityType() )->logUpdate( $recordId, $before ?? array(), $after ?? array() ); } else { DBLog::for( static::getEntityType() )->logCreate( $recordId, $cleanData ); } // Handle meta if present static::handleMeta( $recordId ); // Hook for child-class post-save logic static::afterSave( $recordId, $cleanData ); // Success response $messages = static::getMessages(); wp_send_json_success( array( 'id' => $recordId, 'message' => $messages[ $action ] ?? __( 'Saved successfully', 'ad-fuz' ), 'action' => $action, ) + static::extraResponseData( $recordId, $cleanData ) ); } protected static function persistRecord( array $data ): ?int { $modelClass = static::getModel(); $table = $modelClass::table(); $qb = DB::get( $table ); $id = ! empty( $data['id'] ) ? absint( $data['id'] ) : null; // 1. Process and sanitize script_html safely if ( isset( $data['script_html'], $data['ad_type'] ) && 'script' === $data['ad_type'] ) { $data['script_html'] = Validator::sanitizeAdScript( $data['script_html'] ); } elseif ( isset( $_POST['script_html'], $_POST['ad_type'] ) && 'script' === $_POST['ad_type'] ) { $data['script_html'] = Validator::sanitizeAdScript( $_POST['script_html'] ); } $data['updated_at'] = current_time( 'mysql' ); if ( $id ) { unset( $data['id'], $data['created_at'] ); $success = $qb->update( $id, $data ); return $success ? $id : null; } $data['created_at'] = $data['created_at'] ?? current_time( 'mysql' ); unset( $data['id'] ); $newId = $qb->insert( $data ); return (bool) $newId ? $newId : null; } protected static function handleMeta( int $recordId ): void { $metaTable = static::getMetaTable(); if ( ! $metaTable ) { return; } $request = static::request(); $rawMeta = $request->input( 'meta', 'post', array() ); if ( ! is_array( $rawMeta ) ) { $rawMeta = array(); } $metaModel = static::getMetaModel(); if ( ! $metaModel || ! method_exists( $metaModel, 'metaSchema' ) ) { return; } $metaData = Validator::validateMeta( $metaModel::metaSchema(), $rawMeta ); if ( is_wp_error( $metaData ) ) { Common::log_file( array( 'meta_validation_error' => $metaData->get_error_messages(), ) ); return; } if ( empty( $metaData ) ) { return; } $fkColumn = static::getMetaForeignKey(); DB::get( $metaTable ) ->where( $fkColumn, $recordId ) ->delete(); foreach ( $metaData as $key => $value ) { DB::get( $metaTable )->insert( array( $fkColumn => $recordId, 'meta_key' => sanitize_key( $key ), 'meta_value' => maybe_serialize( $value ), ) ); } } /** * Override to specify meta FK column */ protected static function getMetaForeignKey(): string { $table = static::getModel()::table(); $base = str_replace( 'adfuz_', '', $table ); $singular = rtrim( $base, 's' ); return "{$singular}_id"; } public static function delete(): void { Common::checkCapability( static::getCapabilities() ); Common::verifyNonce(); $request = static::request(); $id = (int) $request->input( 'id', 'post', 0 ); if ( ! $id ) { wp_send_json_error( array( 'message' => __( 'Invalid ID.', 'ad-fuz' ) ) ); return; } $modelClass = static::getModel(); $table = $modelClass::table(); $qb = DB::get( $table ); $deleteType = $request->input( 'delete_type', 'post', 'soft' ); // 'soft' | 'hard' $deleteMeta = (bool) $request->input( 'delete_meta', 'post', true ); $schema = $modelClass::schema(); $supportsSoftDelete = isset( $schema['status'] ) && is_array( $schema['status']['enum'] ) && in_array( 'deleted', $schema['status']['enum'] ); $isSoft = ( $deleteType === 'soft' && $supportsSoftDelete ); $success = false; if ( $isSoft ) { $success = $qb->softDelete( $id ); } else { $success = $qb->hardDelete( $id ); } $entityType = static::getEntityType(); $entityLabel = static::getEntityLabel(); $messages = static::getMessages(); if ( $success ) { // Log the delete — soft or hard if ( $isSoft ) { DBLog::for( $entityType )->logSoftDelete( $id, array( 'delete_meta' => $deleteMeta, ) ); } else { DBLog::for( $entityType )->logHardDelete( $id, array( 'delete_meta' => $deleteMeta, 'meta_deleted' => $deleteMeta, // true when meta was also wiped ) ); } static::afterDelete( $id ); wp_send_json_success( array( 'id' => $id, 'message' => $messages['deleted'] ?? __( 'Deleted successfully.', 'ad-fuz' ), 'action' => 'deleted', 'delete_type' => $deleteType, 'entity' => $entityLabel, ) ); } else { // Log the failure with the attempted context DBLog::for( $entityType )->logFailure( $isSoft ? 'soft_delete' : 'hard_delete', $id, array( 'delete_type' => $deleteType, 'delete_meta' => $deleteMeta, ) ); wp_send_json_error( array( 'id' => $id, 'message' => $messages['deletefailed'] ?? __( 'Failed to delete.', 'ad-fuz' ), ) ); } } /** * Handle meta deletion during hard delete * Override in child controllers for custom meta tables/logic */ protected static function handleHardDeleteMeta( int $recordId ): void { $metaTable = self::getMetaTable(); if ( $metaTable ) { $metaQB = DB::get( $metaTable ); $fkColumn = self::getMetaForeignKey(); $metaQB->where( $fkColumn, $recordId )->delete(); } } /** * Bulk delete support for multiple records */ public static function bulkDelete(): void { Common::checkCapability( static::getCapabilities()['delete'] ); Common::verifyNonce(); $request = static::request(); $ids = $request->input( 'ids', 'post', array() ); $deleteType = $request->input( 'delete_type', 'post', 'soft' ); $deleteMeta = (bool) $request->input( 'delete_meta', 'post', true ); if ( empty( $ids ) || ! is_array( $ids ) ) { wp_send_json_error( array( 'message' => __( 'No IDs provided.', 'ad-fuz' ) ) ); return; // BUG FIX: missing return — code would continue without this } $succeeded = array(); $failed = array(); $modelClass = static::getModel(); $schema = $modelClass::schema(); $table = $modelClass::table(); $supportsSoftDelete = isset( $schema['status'] ) && in_array( 'deleted', $schema['status']['enum'] ?? array() ); foreach ( $ids as $id ) { $id = (int) $id; if ( ! $id ) { continue; } $qb = DB::get( $table ); if ( $deleteType === 'soft' && $supportsSoftDelete ) { $result = $qb->where( 'id', $id ) ->update( $id, array( 'status' => 'deleted', 'updated_at' => current_time( 'mysql' ), ) ); } else { $result = $qb->where( 'id', $id )->delete(); if ( $deleteMeta && $result ) { static::handleHardDeleteMeta( $id ); // BUG FIX: self:: → static:: for late static binding } } if ( $result !== false ) { $succeeded[] = $id; } else { $failed[] = $id; } } // Single bulk log row — one summary instead of one row per ID DBLog::for( static::getEntityType() )->logBulk( $deleteType === 'soft' ? 'soft_delete' : 'hard_delete', array( 'succeeded' => $succeeded, 'failed' => $failed, ) ); wp_send_json_success( array( 'deleted' => count( $succeeded ), 'failed' => count( $failed ), 'total' => count( $ids ), 'type' => $deleteType, 'entity' => static::getEntityLabel(), ) ); } /** * Get soft-deleted (trashed) records * Override table/query in child if needed */ public static function getTrashed( int $limit = 20, int $offset = 0 ): void { Common::checkCapability( static::getCapabilities()['read'] ); $modelClass = static::getModel(); $trashed = $modelClass::where( 'status', 'deleted' ) ->orderBy( 'updated_at', 'DESC' ) ->limit( $limit ) ->offset( $offset ) ->get(); $total = $modelClass::where( 'status', 'deleted' )->count(); wp_send_json_success( array( 'trashed' => $trashed, 'total' => $total, 'limit' => $limit, 'offset' => $offset, ) ); } /** * Restore soft-deleted record */ public static function restore(): void { Common::checkCapability( static::getCapabilities()['update'] ); Common::verifyNonce(); $request = static::request(); $id = (int) $request->input( 'id', 'post', 0 ); if ( ! $id ) { wp_send_json_error( array( 'message' => __( 'Invalid ID.', 'ad-fuz' ) ) ); return; // BUG FIX: missing return } $modelClass = static::getModel(); $success = $modelClass::where( 'id', $id ) ->update( $id, array( 'status' => 'draft', 'updated_at' => current_time( 'mysql' ), ) ); if ( $success !== false ) { DBLog::for( static::getEntityType() )->logRestore( $id ); wp_send_json_success( array( 'id' => $id, 'message' => sprintf( /* translators: %s: Entity name (e.g. "Ad Space", "Campaign") */ __( 'Restored %s successfully.', 'ad-fuz' ), static::getEntityLabel() ), ) ); } else { DBLog::for( static::getEntityType() )->logFailure( 'restore', $id ); wp_send_json_error( array( 'message' => __( 'Failed to restore.', 'ad-fuz' ) ) ); } } }