diff --git a/README.md b/README.md index a4f6c256..4d015d3d 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,163 @@ -# WordPress Back-end Challenge +# WP Favorites Plugin -Desafio para os futuros programadores back-end em WordPress da Apiki. +Plugin WordPress para favoritar posts via REST API, com persistência em tabela customizada. -## Introdução +## Funcionalidades -Desenvolva um Plugin em WordPress que implemente a funcionalidade de favoritar posts para usuários logados usando a [WP REST API](https://developer.wordpress.org/rest-api/). +- Favoritar e desfavoritar posts (toggle) +- Listar favoritos do usuário logado +- Persistência em tabela própria (`wp_favorites`) +- Criação automática da tabela ao ativar o plugin +- Sistema de versionamento da tabela para futuras atualizações +- Script front-end com feedback visual e tratamento de erros -**Especifícações**: +## Requisitos -* Possibilidade de favoritar e desfavoritar um post; -* Persistir os dados em uma [tabela a parte](https://codex.wordpress.org/Creating_Tables_with_Plugins); +- PHP >= 7.4 +- WordPress >= 5.0 -## Instruções +## Instalação -1. Efetue o fork deste repositório e crie um branch com o seu nome e sobrenome. (exemplo: fulano-dasilva) -2. Após finalizar o desafio, crie um Pull Request. -3. Aguarde algum contribuidor realizar o code review. +1. Copie a pasta `wp-favorites-plugin` para `wp-content/plugins/` +2. Ative o plugin no painel do WordPress +3. A tabela `wp_favorites` será criada automaticamente -## Pré-requisitos +## Estrutura do projeto -* PHP >= 5.6 -* Orientado a objetos +``` +wp-favorites-plugin/ +├── wp-favorites-plugin.php # Bootstrap: constantes, hooks de ativação +├── uninstall.php # Remove tabela e opções ao desinstalar +├── includes/ +│ ├── class-database.php # Criação e atualização da tabela +│ ├── class-plugin.php # Registro de hooks e enqueue de scripts +│ ├── class-api.php # Endpoints REST (registro e callbacks) +│ └── class-favorites.php # Regras de negócio (toggle, list) +└── assets/ + └── js/ + └── favorites.js # Front-end: requisições e feedback visual +``` -## Dúvidas +## Endpoints REST -Em caso de dúvidas, crie uma issue. +Todos os endpoints exigem autenticação (usuário logado + nonce). + +### Toggle favorito + +``` +POST /wp-json/wpfav/v1/toggle +``` + +**Body (JSON):** +```json +{ "post_id": 123 } +``` + +**Resposta — adicionado (201):** +```json +{ + "status": "added", + "post_id": 123, + "message": "Post adicionado aos favoritos." +} +``` + +**Resposta — removido (200):** +```json +{ + "status": "removed", + "post_id": 123, + "message": "Post removido dos favoritos." +} +``` + +**Resposta — post não encontrado (404):** +```json +{ + "code": "invalid_post", + "message": "O post informado não existe ou não é válido.", + "data": { "status": 404 } +} +``` + +**Resposta — não autenticado (401):** +```json +{ + "code": "rest_forbidden", + "message": "Você precisa estar logado para usar esta funcionalidade.", + "data": { "status": 401 } +} +``` + +### Listar favoritos + +``` +GET /wp-json/wpfav/v1/list +``` + +**Resposta (200):** +```json +{ + "total": 2, + "favorites": [ + { "post_id": "123", "created_at": "2026-03-21 10:00:00" }, + { "post_id": "45", "created_at": "2026-03-20 08:30:00" } + ] +} +``` + +## Uso no tema (botão de favoritar) + +O plugin registra automaticamente o script `favorites.js` para usuários logados. +Adicione o atributo `data-wpfav-post-id` a qualquer botão no seu tema: + +```php + + + +``` + +O script escuta cliques via delegação de evento no `document`, portanto funciona +com conteúdo carregado dinamicamente (infinite scroll, AJAX, etc.). + +### Exemplo de estilo CSS básico + +```css +.wpfav-btn { cursor: pointer; } +.wpfav-btn.wpfav--active { color: red; } +.wpfav-btn[disabled] { opacity: 0.5; } +``` + +### Ouvindo o evento customizado + +```js +document.addEventListener( 'wpfav:toggled', function ( event ) { + console.log( 'Post', event.detail.postId, '→', event.detail.status ); +} ); +``` + +## Estrutura do banco de dados + +Tabela: `wp_favorites` + +| Coluna | Tipo | Descrição | +|--------------|---------------------|-------------------------------| +| `id` | BIGINT UNSIGNED PK | Identificador único | +| `user_id` | BIGINT UNSIGNED | ID do usuário (FK wp_users) | +| `post_id` | BIGINT UNSIGNED | ID do post (FK wp_posts) | +| `created_at` | DATETIME | Data/hora de criação | + +Índices: `PRIMARY KEY (id)`, `UNIQUE KEY user_post (user_id, post_id)`, `KEY idx_user_id (user_id)` + +## Versionamento da tabela + +A versão da tabela é armazenada na opção `wpfav_db_version`. +Ao atualizar o plugin, incremente `WPFAV_VERSION` em `wp-favorites-plugin.php`. +O método `WPFAV_Database::maybe_update()` (hook `plugins_loaded`) detecta a diferença +de versão e chama `create_table()` via `dbDelta()` automaticamente. diff --git a/wp-favorites-plugin/assets/js/favorites.js b/wp-favorites-plugin/assets/js/favorites.js new file mode 100644 index 00000000..a641456e --- /dev/null +++ b/wp-favorites-plugin/assets/js/favorites.js @@ -0,0 +1,109 @@ +/** + * WP Favorites Plugin — front-end + * + * Usa wpFavSettings (definido via wp_localize_script) para obter: + * wpFavSettings.apiUrl — URL base da REST API do plugin + * wpFavSettings.nonce — nonce gerado pelo WordPress para autenticação REST + */ + +( function () { + 'use strict'; + + /** + * Alterna o estado de favorito de um post. + * + * @param {number} postId - ID do post. + * @param {HTMLElement} [btn] - Botão que disparou a ação (opcional, para feedback visual). + * @returns {Promise} + */ + async function toggleFavorite( postId, btn ) { + if ( ! postId || postId <= 0 ) { + console.error( 'wpfav: post_id inválido.', postId ); + return; + } + + // Feedback visual imediato: desabilita o botão durante a requisição. + if ( btn ) { + btn.disabled = true; + btn.setAttribute( 'aria-busy', 'true' ); + } + + try { + const response = await fetch( wpFavSettings.apiUrl + '/toggle', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpFavSettings.nonce, + }, + body: JSON.stringify( { post_id: parseInt( postId, 10 ) } ), + } ); + + // Trata erros HTTP (401, 404, 500, etc.) + if ( ! response.ok ) { + const errorData = await response.json().catch( () => ( {} ) ); + const message = errorData.message || `Erro HTTP ${ response.status }`; + console.error( 'wpfav toggle error:', message ); + + if ( btn ) { + btn.setAttribute( 'data-wpfav-error', message ); + } + + return; + } + + const data = await response.json(); + + // Atualiza o estado visual do botão conforme a resposta da API. + if ( btn ) { + const isFavorited = data.status === 'added'; + btn.classList.toggle( 'wpfav--active', isFavorited ); + btn.setAttribute( 'aria-pressed', String( isFavorited ) ); + btn.setAttribute( 'title', isFavorited ? 'Remover dos favoritos' : 'Adicionar aos favoritos' ); + btn.removeAttribute( 'data-wpfav-error' ); + } + + // Dispara um evento customizado para que o tema/outros scripts possam reagir. + document.dispatchEvent( + new CustomEvent( 'wpfav:toggled', { + detail: { postId: data.post_id, status: data.status }, + } ) + ); + } catch ( error ) { + // Trata falhas de rede (sem conexão, timeout, etc.) + console.error( 'wpfav: falha na requisição.', error ); + + if ( btn ) { + btn.setAttribute( 'data-wpfav-error', 'Falha na conexão. Tente novamente.' ); + } + } finally { + // Reabilita o botão independentemente do resultado. + if ( btn ) { + btn.disabled = false; + btn.removeAttribute( 'aria-busy' ); + } + } + } + + /** + * Delega o clique em botões com data-wpfav-post-id dentro do document. + * Compatível com conteúdo carregado dinamicamente (infinite scroll, etc.). + * + * Uso no tema: + * + */ + document.addEventListener( 'click', function ( event ) { + const btn = event.target.closest( '[data-wpfav-post-id]' ); + + if ( ! btn ) { + return; + } + + const postId = parseInt( btn.dataset.wpfavPostId, 10 ); + toggleFavorite( postId, btn ); + } ); + + // Expõe a função globalmente para uso programático se necessário. + window.wpFavToggle = toggleFavorite; +} )(); diff --git a/wp-favorites-plugin/includes/class-api.php b/wp-favorites-plugin/includes/class-api.php new file mode 100644 index 00000000..e716bdc7 --- /dev/null +++ b/wp-favorites-plugin/includes/class-api.php @@ -0,0 +1,94 @@ + WP_REST_Server::CREATABLE, + 'callback' => [ self::class, 'toggle' ], + 'permission_callback' => [ self::class, 'user_is_logged_in' ], + 'args' => [ + 'post_id' => [ + 'required' => true, + 'type' => 'integer', + 'minimum' => 1, + 'sanitize_callback' => 'absint', + 'validate_callback' => static function ( $value ) { + return is_numeric( $value ) && (int) $value > 0; + }, + 'description' => __( 'ID do post a favoritar/desfavoritar.', 'wp-favorites-plugin' ), + ], + ], + ] + ); + + register_rest_route( + 'wpfav/v1', + '/list', + [ + 'methods' => WP_REST_Server::READABLE, // 'GET' + 'callback' => [ self::class, 'get_favorites' ], + 'permission_callback' => [ self::class, 'user_is_logged_in' ], + ] + ); + } + + /** + * Callback do endpoint POST /toggle. + * + * @param WP_REST_Request $request Objeto da requisição. + * + * @return WP_REST_Response|WP_Error + */ + public static function toggle( WP_REST_Request $request ) { + $user_id = get_current_user_id(); + $post_id = (int) $request->get_param( 'post_id' ); + + return WPFAV_Favorites::toggle_favorite( $user_id, $post_id ); + } + + /** + * Callback do endpoint GET /list. + * + * @return WP_REST_Response + */ + public static function get_favorites(): WP_REST_Response { + $user_id = get_current_user_id(); + + return WPFAV_Favorites::get_user_favorites( $user_id ); + } + + /** + * Permission callback reutilizável: verifica se o usuário está logado. + * + * @return bool|WP_Error + */ + public static function user_is_logged_in() { + if ( is_user_logged_in() ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'Você precisa estar logado para usar esta funcionalidade.', 'wp-favorites-plugin' ), + [ 'status' => 401 ] + ); + } +} diff --git a/wp-favorites-plugin/includes/class-database.php b/wp-favorites-plugin/includes/class-database.php new file mode 100644 index 00000000..88f10e27 --- /dev/null +++ b/wp-favorites-plugin/includes/class-database.php @@ -0,0 +1,62 @@ +prefix . 'favorites'; + $charset_collate = $wpdb->get_charset_collate(); + + /* + * IMPORTANTE: dbDelta() é sensível ao formato do SQL. + * Referência: https://developer.wordpress.org/plugins/creating-tables-with-plugins/ + */ + $sql = "CREATE TABLE {$table_name} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT(20) UNSIGNED NOT NULL, + post_id BIGINT(20) UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY user_post (user_id, post_id), + KEY idx_user_id (user_id) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + update_option( self::DB_VERSION_OPTION, WPFAV_VERSION ); + } + + /** + * Verifica se a versão instalada da tabela está desatualizada + * e executa create_table() caso necessário. + * Chamada no hook plugins_loaded para suportar atualizações. + * + * @return void + */ + public static function maybe_update(): void { + $installed_version = get_option( self::DB_VERSION_OPTION, '0.0.0' ); + + if ( version_compare( $installed_version, WPFAV_VERSION, '<' ) ) { + self::create_table(); + } + } +} diff --git a/wp-favorites-plugin/includes/class-favorites.php b/wp-favorites-plugin/includes/class-favorites.php new file mode 100644 index 00000000..f50fba52 --- /dev/null +++ b/wp-favorites-plugin/includes/class-favorites.php @@ -0,0 +1,148 @@ + 404 ] + ); + } + + global $wpdb; + $table = $wpdb->prefix . 'favorites'; + + // Verifica se o favorito já existe. + $exists = $wpdb->get_var( + $wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d AND post_id = %d LIMIT 1", + $user_id, + $post_id + ) + ); + + if ( $exists ) { + $deleted = $wpdb->delete( + $table, + [ + 'user_id' => $user_id, + 'post_id' => $post_id, + ], + [ '%d', '%d' ] + ); + + if ( false === $deleted ) { + return new WP_Error( + 'db_error', + __( 'Erro ao remover o favorito.', 'wp-favorites-plugin' ), + [ 'status' => 500 ] + ); + } + + return new WP_REST_Response( + [ + 'status' => 'removed', + 'post_id' => $post_id, + 'message' => __( 'Post removido dos favoritos.', 'wp-favorites-plugin' ), + ], + 200 + ); + } + + // Insere o novo favorito declarando os formatos de cada coluna. + $inserted = $wpdb->insert( + $table, + [ + 'user_id' => $user_id, + 'post_id' => $post_id, + ], + [ '%d', '%d' ] + ); + + if ( false === $inserted ) { + return new WP_Error( + 'db_error', + __( 'Erro ao salvar o favorito.', 'wp-favorites-plugin' ), + [ 'status' => 500 ] + ); + } + + return new WP_REST_Response( + [ + 'status' => 'added', + 'post_id' => $post_id, + 'message' => __( 'Post adicionado aos favoritos.', 'wp-favorites-plugin' ), + ], + 201 + ); + } + + /** + * Retorna todos os posts favoritados por um usuário. + * + * @param int $user_id ID do usuário. + * + * @return WP_REST_Response + */ + public static function get_user_favorites( int $user_id ): WP_REST_Response { + $user_id = absint( $user_id ); + + global $wpdb; + $table = $wpdb->prefix . 'favorites'; + + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT post_id, created_at FROM {$table} WHERE user_id = %d ORDER BY created_at DESC", + $user_id + ) + ); + + return new WP_REST_Response( + [ + 'total' => count( $results ), + 'favorites' => $results, + ], + 200 + ); + } + + /** + * Verifica se um post_id é válido: existe, é do tipo 'post' e está publicado. + * + * @param int $post_id ID do post. + * + * @return bool + */ + private static function post_is_valid( int $post_id ): bool { + if ( $post_id <= 0 ) { + return false; + } + + $post = get_post( $post_id ); + + return ( $post instanceof WP_Post && 'post' === $post->post_type && 'publish' === $post->post_status ); + } +} diff --git a/wp-favorites-plugin/includes/class-plugin.php b/wp-favorites-plugin/includes/class-plugin.php new file mode 100644 index 00000000..7812fedb --- /dev/null +++ b/wp-favorites-plugin/includes/class-plugin.php @@ -0,0 +1,68 @@ +load_dependencies(); + + + add_action( 'plugins_loaded', [ 'WPFAV_Database', 'maybe_update' ] ); + add_action( 'rest_api_init', [ 'WPFAV_API', 'register_routes' ] ); + add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + } + + /** + * Carrega os arquivos de classes necessários. + * + * @return void + */ + private function load_dependencies(): void { + require_once WPFAV_PLUGIN_PATH . 'includes/class-api.php'; + require_once WPFAV_PLUGIN_PATH . 'includes/class-favorites.php'; + } + + /** + * Registra e localiza o script JS do plugin. + * + * @return void + */ + public function enqueue_scripts(): void { + if ( ! is_user_logged_in() ) { + return; + } + + wp_enqueue_script( + 'wpfav-favorites', + WPFAV_PLUGIN_URL . 'assets/js/favorites.js', + [], + WPFAV_VERSION, + true + ); + + /* + * wp_localize_script: passa dados do PHP para o JS de forma segura. + */ + wp_localize_script( + 'wpfav-favorites', + 'wpFavSettings', + [ + 'apiUrl' => esc_url_raw( rest_url( 'wpfav/v1' ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ] + ); + } +} diff --git a/wp-favorites-plugin/uninstall.php b/wp-favorites-plugin/uninstall.php new file mode 100644 index 00000000..4e60ab8c --- /dev/null +++ b/wp-favorites-plugin/uninstall.php @@ -0,0 +1,17 @@ +prefix . 'favorites'; + +$wpdb->query( 'DROP TABLE IF EXISTS `' . esc_sql( $table ) . '`' ); + +delete_option( 'wpfav_db_version' ); diff --git a/wp-favorites-plugin/wp-favorites-plugin.php b/wp-favorites-plugin/wp-favorites-plugin.php new file mode 100644 index 00000000..f68a33c2 --- /dev/null +++ b/wp-favorites-plugin/wp-favorites-plugin.php @@ -0,0 +1,31 @@ +run(); +} + +wpfav_init();