Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
- Workflow `delay` action — pauses a workflow run for N seconds and resumes the remaining actions via a per-minute WP-Cron sweep. Backed by a new `escalated_deferred_workflow_jobs` table with a composite `(status, run_at)` index for efficient polling. Existing installs need to reactivate the plugin to pick up the new table.
- Users management admin page (Escalated → Users) — list WordPress users with their Escalated admin/agent roles, search by name/email, paginated 20 per page. Toggle the `escalated_admin` / `escalated_agent` WP roles per user with the same self-demote and admin→agent cascade rules as the Laravel reference (escalated-laravel #94). Gated by the `escalated_user_manage` capability (held by `escalated_admin` and `administrator` roles).
- Two-factor authentication (TOTP + recovery codes), porting the Laravel reference. RFC 6238 TOTP implemented in pure PHP (`hash_hmac('sha1', ...)`, base32-decoded secret) with no external dependency. New `escalated_two_factors` table (one row per user) stores the AES-256-CBC-encrypted secret, a JSON array of SHA-256-hashed single-use recovery codes, and `confirmed_at`. Self-service REST routes under `escalated/v1/admin/two-factor` — status, setup (secret + otpauth URI + recovery codes), confirm, verify (TOTP or recovery code challenge), regenerate recovery codes, and disable — each acting on the authenticating token's user. Existing installs need to reactivate the plugin to pick up the new table.
- Functional knowledge base, porting the Laravel reference. Two new tables — `escalated_article_categories` (self-referencing category tree with slug/position) and `escalated_articles` (draft/published status with `published_at`, unique slug, optional category + author, and view/helpful counters) — replace the never-registered `escalated_article` custom post type the widget previously queried (it always returned nothing). Admin CRUD REST routes under `escalated/v1/admin/kb/articles` and `escalated/v1/admin/kb/categories`, gated by the existing `escalated_kb_view`/`_create`/`_edit`/`_delete` capabilities (no new capability added). Public widget endpoints (`/widget/articles`, `/widget/articles/{slug}`, and a new `/widget/articles/{slug}/feedback`) now read published articles from these tables, increment the view counter, return related articles, and record helpful/not-helpful feedback when enabled. `Activator::maybe_upgrade()` creates the tables on version bump — existing installs pick them up automatically on upgrade.

### Changed
- License changed from GPL-2.0-or-later to MIT.
Expand Down
4 changes: 2 additions & 2 deletions escalated.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Plugin Name: Escalated
* Plugin URI: https://github.com/escalated-dev/escalated-wordpress
* Description: A full-featured helpdesk and ticketing system with multi-role support, SLA tracking, escalation rules, inbound email, macros, and REST API.
* Version: 1.2.1
* Version: 1.3.0
* Author: Escalated
* Author URI: https://escalated.dev
* License: MIT
Expand All @@ -18,7 +18,7 @@
exit;
}

define('ESCALATED_VERSION', '1.2.1');
define('ESCALATED_VERSION', '1.3.0');
define('ESCALATED_PLUGIN_FILE', __FILE__);
define('ESCALATED_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ESCALATED_PLUGIN_URL', plugin_dir_url(__FILE__));
Expand Down
2 changes: 2 additions & 0 deletions includes/Api/class-api-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public function register_routes(): void
new Two_Factor_Controller,
new Events_Controller,
new Widget_Controller,
new Article_Controller,
new Article_Category_Controller,
new Saved_View_Controller,
new Ticket_Snooze_Controller,
new Ticket_Split_Controller,
Expand Down
247 changes: 247 additions & 0 deletions includes/Api/class-article-category-controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
<?php

/**
* Article Category Controller - admin CRUD for knowledge base categories.
*
* Ports the Laravel reference Admin\ArticleCategoryController. Reads require
* escalated_kb_view; writes require escalated_kb_edit — reusing existing
* capabilities so no new capability is introduced.
*
* Routes (namespace escalated/v1):
* GET /admin/kb/categories index
* POST /admin/kb/categories store
* PUT /admin/kb/categories/{id} update
* PATCH /admin/kb/categories/{id} update
* DELETE /admin/kb/categories/{id} destroy
*/

namespace Escalated\Api;

use Escalated\Models\ArticleCategory;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;

class Article_Category_Controller extends Base_Controller
{
public function register_routes(): void
{
$ns = $this->namespace;

register_rest_route($ns, '/admin/kb/categories', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'index'],
'permission_callback' => [$this, 'permission_view'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'store'],
'permission_callback' => [$this, 'permission_edit'],
],
]);

register_rest_route($ns, '/admin/kb/categories/(?P<id>\d+)', [
[
'methods' => ['PUT', 'PATCH'],
'callback' => [$this, 'update'],
'permission_callback' => [$this, 'permission_edit'],
'args' => ['id' => ['required' => true, 'type' => 'integer']],
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'destroy'],
'permission_callback' => [$this, 'permission_edit'],
'args' => ['id' => ['required' => true, 'type' => 'integer']],
],
]);
}

/**
* @return bool|WP_Error
*/
public function permission_view()
{
return $this->require_cap('escalated_kb_view');
}

/**
* @return bool|WP_Error
*/
public function permission_edit()
{
return $this->require_cap('escalated_kb_edit');
}

/**
* @return bool|WP_Error
*/
private function require_cap(string $cap)
{
if (! is_user_logged_in()) {
return new WP_Error(
'escalated_unauthorized',
__('You must be logged in.', 'escalated'),
['status' => 401]
);
}

if (! current_user_can($cap)) {
return new WP_Error(
'escalated_forbidden',
__('You do not have permission to manage knowledge base categories.', 'escalated'),
['status' => 403]
);
}

return true;
}

/**
* GET /admin/kb/categories
*/
public function index(WP_REST_Request $request)
{
unset($request);

$categories = [];
foreach (ArticleCategory::all_with_article_counts() as $row) {
$categories[] = $this->format_category($row);
}

return $this->success(['categories' => $categories]);
}

/**
* POST /admin/kb/categories
*/
public function store(WP_REST_Request $request)
{
$data = $this->parse_json_body($request);

$validated = $this->validate($data);
if (is_wp_error($validated)) {
return $validated;
}

$slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['name']);
$validated['slug'] = ArticleCategory::unique_slug($slug_base);

$id = ArticleCategory::create($validated);
if ($id === false) {
return $this->error('escalated_create_failed', __('Failed to create category.', 'escalated'), 500);
}

return $this->success(['id' => (int) $id, 'category' => $this->format_category(ArticleCategory::find($id))], 201);
}

/**
* PUT/PATCH /admin/kb/categories/{id}
*/
public function update(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
if (! ArticleCategory::find($id)) {
return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404);
}

$data = $this->parse_json_body($request);

$validated = $this->validate($data, $id);
if (is_wp_error($validated)) {
return $validated;
}

$slug_base = sanitize_title($validated['slug'] !== '' ? $validated['slug'] : $validated['name']);
$validated['slug'] = ArticleCategory::unique_slug($slug_base, $id);

$ok = ArticleCategory::update($id, $validated);
if ($ok === false) {
return $this->error('escalated_update_failed', __('Failed to update category.', 'escalated'), 500);
}

return $this->success(['category' => $this->format_category(ArticleCategory::find($id))]);
}

/**
* DELETE /admin/kb/categories/{id}
*/
public function destroy(WP_REST_Request $request)
{
$id = (int) $request->get_param('id');
if (! ArticleCategory::find($id)) {
return $this->error('escalated_not_found', __('Category not found.', 'escalated'), 404);
}

ArticleCategory::delete($id);

return $this->success(null, 204);
}

/**
* Validate + normalise a category payload.
*
* @param array<string, mixed> $data
* @param int|null $self_id The row being updated (excluded from parent checks).
* @return array<string, mixed>|WP_Error
*/
private function validate(array $data, $self_id = null)
{
$name = isset($data['name']) ? trim(sanitize_text_field((string) $data['name'])) : '';
if ($name === '') {
return $this->error('escalated_invalid', __('A name is required.', 'escalated'), 422);
}

$parent_id = null;
if (isset($data['parent_id']) && $data['parent_id'] !== '' && $data['parent_id'] !== null) {
$parent_id = (int) $data['parent_id'];
if ($self_id !== null && $parent_id === (int) $self_id) {
return $this->error('escalated_invalid', __('A category cannot be its own parent.', 'escalated'), 422);
}
if (! ArticleCategory::find($parent_id)) {
return $this->error('escalated_invalid', __('The selected parent category does not exist.', 'escalated'), 422);
}
}

return [
'name' => $name,
'slug' => isset($data['slug']) ? (string) $data['slug'] : '',
'parent_id' => $parent_id,
'position' => isset($data['position']) ? max(0, (int) $data['position']) : 0,
'description' => isset($data['description']) ? sanitize_textarea_field((string) $data['description']) : null,
];
}

/**
* @return array<string, mixed>
*/
private function format_category($row): array
{
return [
'id' => (int) $row->id,
'name' => $row->name,
'slug' => $row->slug,
'parent_id' => $row->parent_id !== null ? (int) $row->parent_id : null,
'position' => (int) $row->position,
'description' => $row->description,
'articles_count' => isset($row->articles_count) ? (int) $row->articles_count : 0,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
];
}

/**
* @return array<string, mixed>
*/
private function parse_json_body(WP_REST_Request $request): array
{
$json = $request->get_json_params();
if (is_array($json)) {
return $json;
}

$body = $request->get_body_params();

return is_array($body) ? $body : [];
}
}
Loading