{ note } (201) * PUT /notes/{id} { content? (author only), status? open|resolved (thread roots) } -> { note } * DELETE /notes/{id} author (or manage_options) only; a root takes its replies -> { deleted, ids } * GET /notes/users?postId=&search= { users: [{id, name, slug}] } users who can edit the document (@mentions) */ final class NotesController { public const NS = 'zadex/v1'; public const USERS_LIMIT = 10; private Notes $notes; public function __construct(?Notes $notes = null) { $this->notes = $notes ?? new Notes(); } public function routes(): void { register_rest_route(self::NS, '/notes', [ ['methods' => 'GET', 'callback' => [$this, 'list'], 'permission_callback' => [$this, 'can_post']], ['methods' => 'POST', 'callback' => [$this, 'create'], 'permission_callback' => [$this, 'can_post']], ]); register_rest_route(self::NS, '/notes/users', [ 'methods' => 'GET', 'callback' => [$this, 'users'], 'permission_callback' => [$this, 'can_post'], ]); register_rest_route(self::NS, '/notes/(?P\d+)', [ ['methods' => 'PUT', 'callback' => [$this, 'update'], 'permission_callback' => [$this, 'can_note']], ['methods' => 'DELETE', 'callback' => [$this, 'delete'], 'permission_callback' => [$this, 'can_note']], ]); } /** postId from the query string or the JSON body. */ public static function post_id(WP_REST_Request $request): int { $id = $request['postId']; if ($id === null) { $json = $request->get_json_params(); $id = is_array($json) ? ($json['postId'] ?? 0) : 0; } return is_numeric($id) ? (int) $id : 0; } public static function allowed(int $post_id): bool { return $post_id > 0 && current_user_can('edit_post', $post_id) && Access::mode() !== Access::NONE; } public function can_post(WP_REST_Request $request): bool { return self::allowed(self::post_id($request)); } public function can_note(WP_REST_Request $request): bool { $note = $this->notes->get((int) $request['id']); return $note !== null && self::allowed($note['post_id']); } public function list(WP_REST_Request $request): WP_REST_Response { $notes = $this->notes->for_post(self::post_id($request)); return new WP_REST_Response(['notes' => array_map([$this, 'prepare'], $notes)]); } public function create(WP_REST_Request $request) { $body = $request->get_json_params(); $body = is_array($body) ? $body : []; $post_id = self::post_id($request); $content = Notes::clean_content($body['content'] ?? ''); if ($content === '') { return new WP_Error('zadex_note_empty', __('A note needs some text.', 'zadex'), ['status' => 400]); } $parent_id = max(0, (int) ($body['parentId'] ?? 0)); $element_id = Notes::clean_element_id($body['elementId'] ?? ''); $position = Notes::clean_position($body['position'] ?? null); if ($parent_id > 0) { $parent = $this->notes->get($parent_id); if (!$parent || $parent['post_id'] !== $post_id || $parent['parent_id'] !== 0) { return new WP_Error('zadex_note_parent', __('The note you reply to does not exist.', 'zadex'), ['status' => 400]); } // Replies live in their thread: the anchor is the root's. $element_id = $parent['element_id']; $position = null; } $mentions = Mentions::resolve($content, $post_id); $id = $this->notes->insert([ 'post_id' => $post_id, 'element_id' => $element_id, 'parent_id' => $parent_id, 'author_id' => get_current_user_id(), 'content' => $content, 'mentions' => $mentions, 'position' => $position, ]); $note = $id ? $this->notes->get($id) : null; if (!$note) { return new WP_Error('zadex_note_failed', __('The note could not be saved.', 'zadex'), ['status' => 500]); } Mentions::notify($note, $mentions, self::link($note)); do_action('zadex/notes/created', $note); return new WP_REST_Response(['note' => $this->prepare($note)], 201); } public function update(WP_REST_Request $request) { $note = $this->notes->get((int) $request['id']); if (!$note) { return new WP_Error('zadex_not_found', __('Note not found.', 'zadex'), ['status' => 404]); } $body = $request->get_json_params(); $body = is_array($body) ? $body : []; $fields = []; $new_mentions = []; if (array_key_exists('content', $body)) { if ($note['author_id'] !== get_current_user_id()) { return new WP_Error('zadex_note_forbidden', __('You can only edit your own notes.', 'zadex'), ['status' => 403]); } $content = Notes::clean_content($body['content']); if ($content === '') { return new WP_Error('zadex_note_empty', __('A note needs some text.', 'zadex'), ['status' => 400]); } $mentions = Mentions::resolve($content, $note['post_id']); $new_mentions = array_values(array_diff($mentions, $note['mentions'])); $fields['content'] = $content; $fields['mentions'] = $mentions; } if (array_key_exists('status', $body)) { if ($note['parent_id'] !== 0 || !in_array($body['status'], Notes::STATUSES, true)) { return new WP_Error('zadex_note_status', __('Only a thread can be resolved or reopened.', 'zadex'), ['status' => 400]); } $fields['status'] = $body['status']; } if (!$fields) { return new WP_Error('zadex_bad_request', __('Nothing to update.', 'zadex'), ['status' => 400]); } $this->notes->update($note['id'], $fields); $note = $this->notes->get($note['id']) ?? $note; if ($new_mentions) { Mentions::notify($note, $new_mentions, self::link($note)); } return new WP_REST_Response(['note' => $this->prepare($note)]); } public function delete(WP_REST_Request $request) { $note = $this->notes->get((int) $request['id']); if (!$note) { return new WP_Error('zadex_not_found', __('Note not found.', 'zadex'), ['status' => 404]); } if (!self::can_delete($note)) { return new WP_Error('zadex_note_forbidden', __('You can only delete your own notes.', 'zadex'), ['status' => 403]); } return new WP_REST_Response(['deleted' => true, 'ids' => $this->notes->delete($note['id'])]); } public function users(WP_REST_Request $request): WP_REST_Response { $post_id = self::post_id($request); $search = trim(sanitize_text_field((string) ($request['search'] ?? ''))); $args = [ 'number' => 50, 'orderby' => 'display_name', 'fields' => 'all', 'capability' => 'edit_posts', ]; if ($search !== '') { $args['search'] = '*' . $search . '*'; $args['search_columns'] = ['user_login', 'user_nicename', 'display_name']; } $out = []; foreach ((array) get_users($args) as $user) { if (!is_object($user) || !Mentions::can_be_mentioned($user, $post_id)) { continue; } $out[] = ['id' => (int) $user->ID, 'name' => (string) $user->display_name, 'slug' => (string) $user->user_nicename]; if (count($out) >= self::USERS_LIMIT) { break; } } return new WP_REST_Response(['users' => $out]); } public static function can_delete(array $note): bool { return $note['author_id'] === get_current_user_id() || current_user_can('manage_options'); } /** Editor deep link that opens and focuses the note's thread. */ public static function link(array $note): string { $root = $note['parent_id'] ?: $note['id']; return (string) get_preview_post_link($note['post_id'], ['zadex' => 'edit', 'note' => $root]); } /** REST shape (camelCase, ISO dates, author name, what the current user may do). */ public function prepare(array $note): array { $author = get_userdata($note['author_id']); $me = get_current_user_id(); return [ 'id' => $note['id'], 'postId' => $note['post_id'], 'elementId' => $note['element_id'], 'parentId' => $note['parent_id'], 'author' => [ 'id' => $note['author_id'], 'name' => $author ? (string) $author->display_name : __('Deleted user', 'zadex'), ], 'content' => $note['content'], 'mentions' => $note['mentions'], 'status' => $note['status'], 'position' => $note['position'], 'createdAt' => self::iso($note['created_at']), 'updatedAt' => self::iso($note['updated_at']), 'canEdit' => $note['author_id'] === $me, 'canDelete' => self::can_delete($note), ]; } private static function iso(string $mysql): string { return $mysql !== '' ? str_replace(' ', 'T', $mysql) . 'Z' : ''; } }