| 1 |
<?php |
| 2 |
|
| 3 |
namespace Templately\Modules\PostImportFeedback\REST; |
| 4 |
|
| 5 |
use Templately\API\API; |
| 6 |
use Templately\Modules\PostImportFeedback\Widget; |
| 7 |
use Templately\Utils\Helper; |
| 8 |
use Templately\Utils\Response\ErrorCode; |
| 9 |
use Templately\Utils\Response\ResponseNormalizer; |
| 10 |
use WP_Error; |
| 11 |
use WP_REST_Request; |
| 12 |
|
| 13 |
/** |
| 14 |
* REST replacement for the two post-import-feedback admin-ajax actions |
| 15 |
* (constitution III REST migration; PRD §9 ajax→REST backlog): |
| 16 |
* |
| 17 |
* - `wp_ajax_templately_pack_feedback_form` → POST /templately/v1/feedback |
| 18 |
* - `wp_ajax_templately_pack_import_close_feedback_modal` → POST /templately/v1/feedback/skip |
| 19 |
* |
| 20 |
* Behavior is preserved byte-for-byte from `Ajax\FeedbackController` — same cloud |
| 21 |
* forwarding (`v2/feedback/store` / `v2/feedback/close` via the shared `Helper` API |
| 22 |
* client, never raw wp_remote_*), same `Widget::LAST_SHOWN_META` cooldown writes, same |
| 23 |
* `templately_fsi_complete = 'done'` write on skip/close. Only the transport moved. |
| 24 |
* |
| 25 |
* The old ajax handlers (`Ajax\FeedbackController::feedback_form()` / |
| 26 |
* `::import_close_feedback_modal()`, still wired through |
| 27 |
* `modules/full-site-import`'s shared nonce/capability AJAX registrar) are kept |
| 28 |
* running as deprecated shims: a browser tab holding an already-cached feedback JS |
| 29 |
* bundle would still POST/GET the old action names, and dropping the handlers would |
| 30 |
* 400 those requests. Same reasoning documented for the B8 GlobalSettings migration. |
| 31 |
*/ |
| 32 |
class Feedback extends API { |
| 33 |
|
| 34 |
/** |
| 35 |
* Auth comparison — REST is a strict SUPERSET of the ajax path, never weaker: |
| 36 |
* |
| 37 |
* ajax path (FullSiteImport::add_ajax_action wrapper): a valid `templately_nonce` |
| 38 |
* nonce + `install_plugins` AND `install_themes`. |
| 39 |
* |
| 40 |
* REST path: WP core verifies the `X-WP-Nonce` (wp_rest) BEFORE permission_callback |
| 41 |
* runs, then `_permission_check()` (base API) requires `delete_posts`, this override |
| 42 |
* requires the same `install_plugins` + `install_themes` pair, and finally |
| 43 |
* `parent::permission_check()` requires a connected/verified account (`api_key`). |
| 44 |
* |
| 45 |
* The api_key requirement is the one addition over ajax — always satisfied here since |
| 46 |
* feedback only fires after an FSI import, which itself requires a connected account. |
| 47 |
*/ |
| 48 |
public function permission_check( WP_REST_Request $request ) { |
| 49 |
$this->request = $request; |
| 50 |
|
| 51 |
if ( ! current_user_can( 'install_plugins' ) || ! current_user_can( 'install_themes' ) ) { |
| 52 |
return new WP_Error( |
| 53 |
'rest_forbidden', |
| 54 |
__( 'Sorry, you are not allowed to submit feedback.', 'templately' ), |
| 55 |
[ 'status' => rest_authorization_required_code() ] |
| 56 |
); |
| 57 |
} |
| 58 |
|
| 59 |
return parent::permission_check( $request ); |
| 60 |
} |
| 61 |
|
| 62 |
public function register_routes() { |
| 63 |
$this->post( 'feedback', [ $this, 'submit_feedback' ], [ |
| 64 |
'rating' => [ |
| 65 |
'required' => true, |
| 66 |
], |
| 67 |
] ); |
| 68 |
|
| 69 |
$this->post( 'feedback/skip', [ $this, 'skip_feedback' ] ); |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* POST /templately/v1/feedback — submit the star rating + optional review. |
| 74 |
* |
| 75 |
* Mirrors `Ajax\FeedbackController::feedback_form()`: forwards |
| 76 |
* `{description, email, rating, pack_id}` to the cloud at `v2/feedback/store`, |
| 77 |
* and on success writes `Widget::LAST_SHOWN_META` to start the FR-005 30-day |
| 78 |
* cooldown (FR-013). Failure paths do NOT write the cooldown — same as the ajax |
| 79 |
* handler — because the widget's subsequent close call (skip_feedback) records it. |
| 80 |
*/ |
| 81 |
public function submit_feedback() { |
| 82 |
$review_description = $this->get_param( 'review-description', '', 'sanitize_textarea_field' ); |
| 83 |
$review_email = $this->get_param( 'review-email', '', 'sanitize_email' ); |
| 84 |
$rating = $this->get_param( 'rating', 0, 'absint' ); |
| 85 |
$pack_id = get_user_meta( get_current_user_id(), 'templately_fsi_pack_id', true ); |
| 86 |
|
| 87 |
$response = Helper::make_api_post_request( 'v2/feedback/store', [ |
| 88 |
'description' => $review_description, |
| 89 |
'email' => $review_email, |
| 90 |
'rating' => (int) $rating, |
| 91 |
'pack_id' => (int) $pack_id, |
| 92 |
], [], 30 ); |
| 93 |
|
| 94 |
// 043 FR-003 — the central normalizer classifies transport failures and |
| 95 |
// error bodies alike, and it is what guarantees no upstream stack trace |
| 96 |
// reaches the client (the old `extract_error_from_response()` could return |
| 97 |
// a whole decoded debug-500 body as the "message"). |
| 98 |
$normalized = ResponseNormalizer::normalize( $response ); |
| 99 |
|
| 100 |
if ( $normalized->is_error() ) { |
| 101 |
$error = $normalized->error(); |
| 102 |
|
| 103 |
// A repeat submission is NOT a failure. The cloud answers it with |
| 104 |
// HTTP 400 `{hasFeedback:true}` — captured live as |
| 105 |
// `fixtures/rest-feedback-already-submitted.json`, and the gate is per |
| 106 |
// USER, not per pack. Treating it as an error meant the cooldown was |
| 107 |
// never written, so the widget came back and asked again for feedback |
| 108 |
// the user had already given. Their feedback IS recorded; say so. |
| 109 |
if ( ErrorCode::ALREADY_SUBMITTED === $error->code() ) { |
| 110 |
update_user_meta( get_current_user_id(), Widget::LAST_SHOWN_META, time() ); |
| 111 |
|
| 112 |
return $this->success( __( 'Your feedback has already been submitted. Thank you!', 'templately' ) ); |
| 113 |
} |
| 114 |
|
| 115 |
return $this->error( $error->code(), $error->message(), 'feedback', $error->status() ); |
| 116 |
} |
| 117 |
|
| 118 |
$payload = $normalized->payload(); |
| 119 |
$message = is_array( $payload ) && isset( $payload['message'] ) ? $payload['message'] : ''; |
| 120 |
|
| 121 |
if ( '' === $message ) { |
| 122 |
// The cloud answers a successful store with a message; its absence |
| 123 |
// means we did not get the response we think we did. |
| 124 |
return $this->error( |
| 125 |
ErrorCode::SERVER_ERROR, |
| 126 |
__( 'The feedback service returned an unexpected response.', 'templately' ), |
| 127 |
'feedback', |
| 128 |
500 |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
// FR-005 / FR-013: a submission starts the 30-day cooldown, same as a skip/close. |
| 133 |
update_user_meta( get_current_user_id(), Widget::LAST_SHOWN_META, time() ); |
| 134 |
|
| 135 |
return $this->success( $message ); |
| 136 |
} |
| 137 |
|
| 138 |
/** |
| 139 |
* POST /templately/v1/feedback/skip — record a skip/dismiss and start the cooldown. |
| 140 |
* |
| 141 |
* Mirrors `Ajax\FeedbackController::import_close_feedback_modal()`: when a |
| 142 |
* `closeAction` dismiss reason is present, forwards `{action, email, pack_id}` to |
| 143 |
* the cloud at `v2/feedback/close`; in ALL cases writes |
| 144 |
* `templately_fsi_complete = 'done'` and `Widget::LAST_SHOWN_META` (FR-014). |
| 145 |
*/ |
| 146 |
public function skip_feedback() { |
| 147 |
$return = null; |
| 148 |
|
| 149 |
$close_action = $this->get_param( 'closeAction', '', 'sanitize_text_field' ); |
| 150 |
if ( ! empty( $close_action ) ) { |
| 151 |
$review_email = $this->get_param( 'review-email', '', 'sanitize_email' ); |
| 152 |
$pack_id = get_user_meta( get_current_user_id(), 'templately_fsi_pack_id', true ); |
| 153 |
|
| 154 |
$response = Helper::make_api_post_request( 'v2/feedback/close', [ |
| 155 |
'action' => $close_action, |
| 156 |
'email' => $review_email, |
| 157 |
'pack_id' => (int) $pack_id, |
| 158 |
], [], 30 ); |
| 159 |
|
| 160 |
$return = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 161 |
} |
| 162 |
|
| 163 |
update_user_meta( get_current_user_id(), 'templately_fsi_complete', 'done' ); |
| 164 |
// FR-005 / FR-014: record when the widget was dismissed so the 30-day cooldown |
| 165 |
// (evaluated by Widget::is_eligible()) can expire on a rolling basis instead of |
| 166 |
// suppressing forever. |
| 167 |
update_user_meta( get_current_user_id(), Widget::LAST_SHOWN_META, time() ); |
| 168 |
|
| 169 |
return $this->success( $return ); |
| 170 |
} |
| 171 |
} |
| 172 |
|