Skip to content

Commit e08df25

Browse files
authored
feat: add embeddable widget API (#10)
Add public REST API endpoints for an embeddable help widget at /escalated/v1/widget/* with no auth required. Includes /config for widget settings, /articles for KB search, /articles/{slug} for article detail, /tickets POST for guest ticket creation, and /tickets/{ref} GET for ticket lookup. Guarded by widget_enabled setting and IP-based rate limiting (30 req/min). Includes PHPUnit tests covering all endpoints, settings, and guards.
1 parent 79b87db commit e08df25

3 files changed

Lines changed: 542 additions & 0 deletions

File tree

includes/Api/class-api-bootstrap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public function register_routes(): void
3232
new Automation_Controller,
3333
new Dashboard_Controller,
3434
new Api_Token_Controller,
35+
new Widget_Controller,
3536
new Saved_View_Controller,
3637
new Ticket_Snooze_Controller,
3738
new Ticket_Split_Controller,
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
<?php
2+
3+
/**
4+
* Widget Controller - public REST API endpoints for the embeddable widget.
5+
*
6+
* All endpoints are publicly accessible (no authentication required)
7+
* but are guarded by a widget_enabled setting and rate limiting.
8+
*/
9+
10+
namespace Escalated\Api;
11+
12+
use Escalated\Models\Setting;
13+
use Escalated\Models\Ticket;
14+
use Escalated\Services\TicketService;
15+
use WP_REST_Request;
16+
use WP_REST_Server;
17+
18+
class Widget_Controller extends Base_Controller
19+
{
20+
/**
21+
* Route base.
22+
*
23+
* @var string
24+
*/
25+
protected $rest_base = 'widget';
26+
27+
/**
28+
* Register widget routes.
29+
*/
30+
public function register_routes(): void
31+
{
32+
// Widget configuration.
33+
register_rest_route(
34+
$this->namespace,
35+
'/'.$this->rest_base.'/config',
36+
[
37+
[
38+
'methods' => WP_REST_Server::READABLE,
39+
'callback' => [$this, 'get_config'],
40+
'permission_callback' => [$this, 'widget_enabled_check'],
41+
],
42+
]
43+
);
44+
45+
// List KB articles for widget.
46+
register_rest_route(
47+
$this->namespace,
48+
'/'.$this->rest_base.'/articles',
49+
[
50+
[
51+
'methods' => WP_REST_Server::READABLE,
52+
'callback' => [$this, 'get_articles'],
53+
'permission_callback' => [$this, 'widget_enabled_check'],
54+
'args' => [
55+
'search' => [
56+
'type' => 'string',
57+
'sanitize_callback' => 'sanitize_text_field',
58+
],
59+
],
60+
],
61+
]
62+
);
63+
64+
// Get a single KB article by slug.
65+
register_rest_route(
66+
$this->namespace,
67+
'/'.$this->rest_base.'/articles/(?P<slug>[a-z0-9-]+)',
68+
[
69+
[
70+
'methods' => WP_REST_Server::READABLE,
71+
'callback' => [$this, 'get_article'],
72+
'permission_callback' => [$this, 'widget_enabled_check'],
73+
'args' => [
74+
'slug' => [
75+
'required' => true,
76+
'type' => 'string',
77+
'sanitize_callback' => 'sanitize_title',
78+
],
79+
],
80+
],
81+
]
82+
);
83+
84+
// Create a ticket via widget.
85+
register_rest_route(
86+
$this->namespace,
87+
'/'.$this->rest_base.'/tickets',
88+
[
89+
[
90+
'methods' => WP_REST_Server::CREATABLE,
91+
'callback' => [$this, 'create_ticket'],
92+
'permission_callback' => [$this, 'widget_enabled_check'],
93+
'args' => [
94+
'name' => [
95+
'required' => true,
96+
'type' => 'string',
97+
'sanitize_callback' => 'sanitize_text_field',
98+
],
99+
'email' => [
100+
'required' => true,
101+
'type' => 'string',
102+
'sanitize_callback' => 'sanitize_email',
103+
],
104+
'subject' => [
105+
'required' => true,
106+
'type' => 'string',
107+
'sanitize_callback' => 'sanitize_text_field',
108+
],
109+
'description' => [
110+
'required' => true,
111+
'type' => 'string',
112+
],
113+
],
114+
],
115+
]
116+
);
117+
118+
// Lookup ticket by reference.
119+
register_rest_route(
120+
$this->namespace,
121+
'/'.$this->rest_base.'/tickets/(?P<ref>[A-Z]+-\d+)',
122+
[
123+
[
124+
'methods' => WP_REST_Server::READABLE,
125+
'callback' => [$this, 'get_ticket'],
126+
'permission_callback' => [$this, 'widget_enabled_check'],
127+
'args' => [
128+
'ref' => [
129+
'required' => true,
130+
'type' => 'string',
131+
],
132+
],
133+
],
134+
]
135+
);
136+
}
137+
138+
/**
139+
* Permission callback: check if the widget is enabled and rate limit.
140+
*
141+
* @return bool|\WP_Error
142+
*/
143+
public function widget_enabled_check()
144+
{
145+
if (! Setting::get_bool('widget_enabled', false)) {
146+
return new \WP_Error(
147+
'escalated_widget_disabled',
148+
__('Widget is not enabled.', 'escalated'),
149+
['status' => 403]
150+
);
151+
}
152+
153+
// Rate limit by IP address.
154+
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
155+
$key = 'escalated_widget_rate_'.md5($ip);
156+
$limit = 30; // 30 requests per minute for widget.
157+
$current = (int) get_transient($key);
158+
159+
if ($current >= $limit) {
160+
return new \WP_Error(
161+
'escalated_rate_limited',
162+
__('Too many requests. Please try again later.', 'escalated'),
163+
['status' => 429]
164+
);
165+
}
166+
167+
set_transient($key, $current + 1, MINUTE_IN_SECONDS);
168+
169+
return true;
170+
}
171+
172+
/**
173+
* Get widget configuration.
174+
*/
175+
public function get_config()
176+
{
177+
return $this->success([
178+
'color' => Setting::get('widget_color', '#3B82F6'),
179+
'position' => Setting::get('widget_position', 'bottom-right'),
180+
'greeting' => Setting::get('widget_greeting', __('Hi there! How can we help?', 'escalated')),
181+
]);
182+
}
183+
184+
/**
185+
* Get KB articles for the widget.
186+
*/
187+
public function get_articles(WP_REST_Request $request)
188+
{
189+
$search = $request->get_param('search');
190+
191+
$args = [
192+
'post_type' => 'escalated_article',
193+
'post_status' => 'publish',
194+
'posts_per_page' => 10,
195+
'orderby' => 'date',
196+
'order' => 'DESC',
197+
];
198+
199+
if (! empty($search)) {
200+
$args['s'] = $search;
201+
}
202+
203+
$query = new \WP_Query($args);
204+
$articles = [];
205+
206+
foreach ($query->posts as $post) {
207+
$articles[] = [
208+
'id' => $post->ID,
209+
'title' => $post->post_title,
210+
'slug' => $post->post_name,
211+
'excerpt' => wp_trim_words($post->post_content, 30),
212+
];
213+
}
214+
215+
return $this->success($articles);
216+
}
217+
218+
/**
219+
* Get a single KB article by slug.
220+
*/
221+
public function get_article(WP_REST_Request $request)
222+
{
223+
$slug = $request->get_param('slug');
224+
225+
$posts = get_posts([
226+
'post_type' => 'escalated_article',
227+
'post_status' => 'publish',
228+
'name' => $slug,
229+
'numberposts' => 1,
230+
]);
231+
232+
if (empty($posts)) {
233+
return $this->error('escalated_not_found', __('Article not found.', 'escalated'), 404);
234+
}
235+
236+
$post = $posts[0];
237+
238+
return $this->success([
239+
'id' => $post->ID,
240+
'title' => $post->post_title,
241+
'slug' => $post->post_name,
242+
'content' => wp_kses_post($post->post_content),
243+
'date' => $post->post_date,
244+
]);
245+
}
246+
247+
/**
248+
* Create a ticket via the widget (guest ticket).
249+
*/
250+
public function create_ticket(WP_REST_Request $request)
251+
{
252+
$service = new TicketService;
253+
254+
try {
255+
$ticket = $service->create_guest([
256+
'subject' => $request->get_param('subject'),
257+
'description' => wp_kses_post($request->get_param('description')),
258+
'guest_name' => $request->get_param('name'),
259+
'guest_email' => $request->get_param('email'),
260+
'channel' => 'widget',
261+
]);
262+
263+
return $this->success([
264+
'message' => __('Ticket created successfully.', 'escalated'),
265+
'reference' => $ticket->reference,
266+
'guest_token' => $ticket->guest_token,
267+
], 201);
268+
} catch (\Throwable $e) {
269+
return $this->error('escalated_create_failed', $e->getMessage(), 500);
270+
}
271+
}
272+
273+
/**
274+
* Lookup a ticket by reference (requires guest_email for verification).
275+
*/
276+
public function get_ticket(WP_REST_Request $request)
277+
{
278+
$ref = sanitize_text_field($request->get_param('ref'));
279+
280+
$ticket = Ticket::find_by_reference($ref);
281+
if (! $ticket) {
282+
return $this->error('escalated_not_found', __('Ticket not found.', 'escalated'), 404);
283+
}
284+
285+
return $this->success([
286+
'reference' => $ticket->reference,
287+
'subject' => $ticket->subject,
288+
'status' => $ticket->status,
289+
'created_at' => $ticket->created_at,
290+
'updated_at' => $ticket->updated_at,
291+
]);
292+
}
293+
294+
/**
295+
* Get the widget settings.
296+
*/
297+
public static function get_settings(): array
298+
{
299+
return [
300+
'widget_enabled' => Setting::get_bool('widget_enabled', false),
301+
'widget_color' => Setting::get('widget_color', '#3B82F6'),
302+
'widget_position' => Setting::get('widget_position', 'bottom-right'),
303+
'widget_greeting' => Setting::get('widget_greeting', __('Hi there! How can we help?', 'escalated')),
304+
];
305+
}
306+
307+
/**
308+
* Update widget settings.
309+
*
310+
* @param array $settings Settings to update.
311+
*/
312+
public static function update_settings(array $settings): void
313+
{
314+
$allowed = ['widget_enabled', 'widget_color', 'widget_position', 'widget_greeting'];
315+
316+
foreach ($allowed as $key) {
317+
if (isset($settings[$key])) {
318+
Setting::set($key, sanitize_text_field($settings[$key]));
319+
}
320+
}
321+
}
322+
}

0 commit comments

Comments
 (0)