Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4885848375 | ||
|
|
cc160fe8ce | ||
|
|
c490d4f387 | ||
|
|
a9bc72fdcc | ||
|
|
5b43e7d18f | ||
|
|
72feafb359 | ||
|
|
da916bc5a6 | ||
|
|
ad201691e4 | ||
|
|
dad066cb32 | ||
|
|
9c633aa09b | ||
|
|
9bbe4ef9a0 | ||
|
|
17be55bf8f | ||
|
|
f206bb5889 | ||
|
|
9e462f7f00 | ||
|
|
d5832fb58a | ||
|
|
6ce3380d6a | ||
|
|
65deedc295 | ||
|
|
13660b4398 | ||
|
|
5631731a28 |
+3
-1
@@ -6,4 +6,6 @@ settings.local.json
|
||||
# Upload files (keep folder structure, ignore actual uploads)
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
!uploads/.htaccess
|
||||
!uploads/.htaccess
|
||||
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
|
||||
vendor/
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<exclude-pattern>*/uploads/*</exclude-pattern>
|
||||
<exclude-pattern>*/migrations/*</exclude-pattern>
|
||||
<exclude-pattern>*/.gitea/*</exclude-pattern>
|
||||
<exclude-pattern>*/vendor/*</exclude-pattern>
|
||||
|
||||
<arg name="extensions" value="php"/>
|
||||
<arg name="colors"/>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Jared Vititoe
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -113,6 +113,36 @@ All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exemp
|
||||
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
|
||||
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
|
||||
|
||||
### MCP server (Claude Code and other MCP clients)
|
||||
`/mcp` is a remote [MCP](https://modelcontextprotocol.io) server (Streamable HTTP) protected by **OAuth via Authelia** — no API keys. You sign in once in the browser and every action runs **as you**: the same visibility, permissions, Workflow Designer rules, audit log and notifications as the web UI.
|
||||
|
||||
**Connect Claude Code** (one-time):
|
||||
```bash
|
||||
claude mcp add --transport http --scope user \
|
||||
--client-id tinker-tickets-mcp --callback-port 47823 \
|
||||
tinker https://t.lotusguild.org/mcp
|
||||
claude mcp login tinker # add --no-browser on a headless machine, then paste the redirect URL back
|
||||
```
|
||||
`--client-id` / `--callback-port 47823` are required: Authelia doesn't support dynamic client registration, so Claude Code uses the pre-registered public client `tinker-tickets-mcp`, whose only allowed redirect is `http://localhost:47823/callback`. Access requires the same `admin`/`employee` group as the web UI. Tokens refresh automatically (refresh token lasts 30 days); `claude mcp logout tinker` signs out.
|
||||
|
||||
| Tool | Scope | What it does |
|
||||
|------|-------|--------------|
|
||||
| `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated |
|
||||
| `get_ticket` | `tickets:read` | One ticket's details + comments, its links (Dependencies tab, phrased from this ticket's side incl. `duplicated_by`) and a `blocked` flag (any open `blocked_by`) |
|
||||
| `find_similar_tickets` | `tickets:read` | Open tickets with similar titles, by title text or an existing ticket (same finder as the ticket page's possible-duplicates list) |
|
||||
| `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) |
|
||||
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
|
||||
| `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one |
|
||||
| `assign_ticket` | `tickets:write` | Assign / unassign (admin, creator, or current assignee only) |
|
||||
| `link_tickets` | `tickets:write` | Add a `blocks` / `blocked_by` / `relates_to` / `duplicates` link ("A duplicates B" = A is the duplicate; links only, never closes anything) |
|
||||
| `unlink_tickets` | `tickets:write` | Remove a link, whichever ticket's side it was recorded from |
|
||||
|
||||
How it fits together (see issue #111 for the full design):
|
||||
- **Authelia** is the authorization server: client `tinker-tickets-mcp`, custom scopes `tickets:read`/`tickets:write`, RS256 JWT access tokens carrying `preferred_username`/`groups`. Needs Authelia **≠ 4.39.21/4.39.22** (RFC 8707 `resource` bug); 4.39.20 or ≥ 4.39.23 are fine.
|
||||
- **`mcp/server.php`** is the resource server (official `mcp/sdk`, pinned). It validates each token's signature (JWKS), issuer, **audience = this server's URL** (`MCP_RESOURCE_URL`, derived from `APP_DOMAIN`, so a beta token can't be used on prod) and expiry, then maps the user with the same rules as the web login. It serves RFC 9728 metadata at `/.well-known/oauth-protected-resource/mcp`.
|
||||
- **Composer is used only by the MCP endpoint** (`vendor/` is gitignored and installed by the deploy script with `composer install --no-dev`); nothing else in the app loads it.
|
||||
- **Reverse proxy:** `/mcp` and `/.well-known/oauth-protected-resource` are exempt from Authelia forward-auth (the token is the credential) and have `Remote-*` headers blanked; `vendor/`, `mcp/` and `composer.*` are never served.
|
||||
|
||||
### User Management & Authentication
|
||||
- **SSO Integration**: Authelia authentication with LLDAP backend
|
||||
- **Role-Based Access**: Admin and standard user roles
|
||||
@@ -355,7 +385,9 @@ tinker_tickets/
|
||||
│ ├── config.php # Config + .env loading
|
||||
│ └── requirements.php # PHP version/extension requirements (single source of
|
||||
│ # truth for scripts/check_requirements.php + api/health.php)
|
||||
├── composer.json / composer.lock # MCP endpoint dependencies only (mcp/sdk pinned); vendor/ installed at deploy
|
||||
├── controllers/
|
||||
│ ├── ApiTicketController.php # Partial ticket updates (status/fields/visibility), shared by update_ticket.php + MCP
|
||||
│ ├── CommentController.php # Comment create/edit/delete + notifications
|
||||
│ ├── DashboardController.php # Dashboard with stats + filters
|
||||
│ └── TicketController.php # Ticket CRUD + timeline + visibility
|
||||
@@ -399,11 +431,26 @@ tinker_tickets/
|
||||
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
|
||||
│ ├── 005_attachment_thumbnails.sql # ticket_attachments.thumbnail_filename
|
||||
│ ├── 006_api_key_visibility_scope.sql # api_keys.see_all_visibility (public-only by default)
|
||||
│ ├── 007_notification_retry_queue.sql # Queue for failed Matrix webhook retries
|
||||
│ └── migrate.php # CLI migration runner (tracks applied migrations)
|
||||
├── mcp/
|
||||
│ ├── server.php # MCP endpoint (/mcp): OAuth resource server; only file that loads vendor/
|
||||
│ └── src/
|
||||
│ ├── Auth/ # IdentityMiddleware (token -> user), ToolScopeMiddleware, McpIdentity
|
||||
│ ├── Tools/ # TicketReadTools, TicketWriteTools
|
||||
│ └── ToolCatalog.php # Tool registry + which tools need tickets:write
|
||||
├── scripts/
|
||||
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
|
||||
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
|
||||
├── uploads/ # File attachment storage
|
||||
├── services/
|
||||
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
|
||||
│ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.php + MCP)
|
||||
│ ├── DependencyService.php # List/add/remove ticket links (shared by ticket_dependencies.php + MCP)
|
||||
│ ├── SimilarTicketService.php # Similar-title finder (shared by check_duplicates.php + MCP)
|
||||
│ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP)
|
||||
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
|
||||
│ └── avatars/ # lldap avatar disk cache
|
||||
├── views/
|
||||
│ ├── admin/
|
||||
|
||||
+11
-144
@@ -80,155 +80,22 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
|
||||
http_response_code(400);
|
||||
// Validation, access check, mentions, audit log and notifications live in
|
||||
// CommentService so the MCP add_comment tool runs the same code path.
|
||||
require_once dirname(__DIR__) . '/services/CommentService.php';
|
||||
$result = CommentService::addComment($conn, $currentUser, $data);
|
||||
|
||||
if (!empty($result['http_status'])) {
|
||||
http_response_code($result['http_status']);
|
||||
unset($result['http_status']);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid ticket ID']);
|
||||
echo json_encode($result);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Reject empty/whitespace-only comments
|
||||
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
|
||||
if ($commentTextRaw === '') {
|
||||
http_response_code(400);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Comment text cannot be empty']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||
// markdown-enabled comment's first line out of column 0 on reload.
|
||||
$data['comment_text'] = $commentTextRaw;
|
||||
|
||||
// Never trust a client-supplied display name — always attribute the comment to
|
||||
// the authenticated session user.
|
||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||
|
||||
// Verify user can access the ticket before allowing a comment
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
http_response_code(404);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(403);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Access denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// If replying, the parent comment must belong to this same (accessible) ticket.
|
||||
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
|
||||
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
|
||||
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
|
||||
http_response_code(400);
|
||||
ob_end_clean();
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid parent comment']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract @mentions from comment text
|
||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||
$mentionedUsers = [];
|
||||
if (!empty($mentions)) {
|
||||
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||
}
|
||||
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
|
||||
// Log mentions to audit log
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'mention',
|
||||
'user',
|
||||
(string)$mentionedUser['user_id'],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'comment_id' => $result['comment_id'],
|
||||
'mentioned_username' => $mentionedUser['username']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Matrix notifications
|
||||
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||
$commentText = $data['comment_text'] ?? '';
|
||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||
|
||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
|
||||
// Only notify mentioned users who actually have access to this ticket;
|
||||
// otherwise a mention would DM them the ticket's title and comment text
|
||||
// even though canUserAccessTicket() would deny them the ticket itself.
|
||||
$accessibleMentionedUsers = array_filter(
|
||||
$mentionedUsers,
|
||||
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
|
||||
);
|
||||
if (!empty($accessibleMentionedUsers)) {
|
||||
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
|
||||
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||
if (!empty($mentionedMatrixIds)) {
|
||||
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||
}
|
||||
}
|
||||
|
||||
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
||||
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
||||
NotificationHelper::sendCommentNotification(
|
||||
$ticketId,
|
||||
$ticketTitle,
|
||||
$commentText,
|
||||
$authorDisplay,
|
||||
$ticketVisibility !== 'public',
|
||||
$ticketVisibility
|
||||
);
|
||||
}
|
||||
|
||||
// Notify watchers of the new comment
|
||||
NotificationHelper::notifyWatchers(
|
||||
$conn,
|
||||
$ticketId,
|
||||
$ticketTitle,
|
||||
'comment_added',
|
||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||
(int)$userId,
|
||||
$ticketVisibility
|
||||
);
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function ($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
// Add user info to result for frontend avatar rendering
|
||||
if ($result['success']) {
|
||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||
$result['user_id'] = $userId;
|
||||
if (isset($newCsrfToken)) {
|
||||
$result['csrf_token'] = $newCsrfToken;
|
||||
}
|
||||
if ($result['success'] && isset($newCsrfToken)) {
|
||||
$result['csrf_token'] = $newCsrfToken;
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
|
||||
+9
-79
@@ -1,11 +1,7 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||
require_once dirname(__DIR__) . '/services/AssignmentService.php';
|
||||
|
||||
// Get request data
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -15,79 +11,13 @@ if (!is_array($data)) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
$assignedTo = $data['assigned_to'] ?? null;
|
||||
// Validation, permission check, audit log, notification and stats-cache
|
||||
// invalidation live in AssignmentService so the MCP assign_ticket tool runs
|
||||
// the same code path.
|
||||
$result = AssignmentService::assign($conn, $currentUser, $data);
|
||||
|
||||
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket ID required']);
|
||||
exit;
|
||||
}
|
||||
$ticketId = $ticketIdRaw;
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
// Verify ticket exists and user can access it
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
http_response_code(404);
|
||||
echo json_encode(['success' => false, 'error' => 'Ticket not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
|
||||
http_response_code(403);
|
||||
echo json_encode(['success' => false, 'error' => 'Permission denied']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||
}
|
||||
} else {
|
||||
// Validate assigned_to is a valid user ID
|
||||
$assignedTo = (int)$assignedTo;
|
||||
$targetUser = $userModel->getUserById($assignedTo);
|
||||
if (!$targetUser) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid user ID']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Assign ticket
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||
|
||||
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
|
||||
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
|
||||
$assigneeMatrix = isset($targetUser['username'])
|
||||
? SynapseHelper::resolveUsername($targetUser['username'])
|
||||
: null;
|
||||
NotificationHelper::sendAssignmentNotification(
|
||||
$ticketId,
|
||||
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||
$assigneeName,
|
||||
$assigneeMatrix,
|
||||
$changedByDisplay,
|
||||
$ticket['visibility'] ?? 'public'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
http_response_code(500);
|
||||
apiRespond(['success' => false, 'error' => 'Failed to update ticket assignment']);
|
||||
} else {
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
apiRespond(['success' => true]);
|
||||
if (!empty($result['http_status'])) {
|
||||
http_response_code($result['http_status']);
|
||||
unset($result['http_status']);
|
||||
}
|
||||
apiRespond($result);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/services/SimilarTicketService.php';
|
||||
|
||||
// Only accept GET requests
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
@@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
// Get title parameter
|
||||
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
|
||||
|
||||
if (strlen($title) < 5) {
|
||||
ResponseHelper::success(['duplicates' => []]);
|
||||
}
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $title . '%';
|
||||
|
||||
// Get SOUNDEX of title
|
||||
$soundexTitle = soundex($title);
|
||||
|
||||
// Build visibility filter so users only see titles they have access to
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||
|
||||
// First, search for exact substring matches (case-insensitive)
|
||||
$sql = "SELECT ticket_id, title, status, priority, created_at
|
||||
FROM tickets
|
||||
WHERE (
|
||||
title LIKE ?
|
||||
OR SOUNDEX(title) = ?
|
||||
)
|
||||
AND status != 'Closed'
|
||||
AND ({$visFilter['sql']})
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10";
|
||||
|
||||
$types = "ss" . $visFilter['types'];
|
||||
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
|
||||
|
||||
// Duplicate detection is advisory (it must not block ticket creation), so on any
|
||||
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
|
||||
// mysqli may throw (default exception mode) or return false depending on config.
|
||||
try {
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare failed: ' . $conn->error);
|
||||
}
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($types, ...$params);
|
||||
}
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
if ($result === false) {
|
||||
// Non-exception mysqli mode: execute/get_result return false instead of
|
||||
// throwing. Treat as a query failure so we don't fatal on $result below.
|
||||
throw new RuntimeException('query failed: ' . $conn->error);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('check_duplicates: ' . $e->getMessage());
|
||||
ResponseHelper::success(['duplicates' => []]);
|
||||
}
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Calculate similarity score
|
||||
$similarity = 0;
|
||||
|
||||
// Check for exact substring match
|
||||
if (stripos($row['title'], $title) !== false) {
|
||||
$similarity = 90;
|
||||
// Check SOUNDEX match
|
||||
} elseif (soundex($row['title']) === $soundexTitle) {
|
||||
$similarity = 70;
|
||||
// Check word overlap
|
||||
} else {
|
||||
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
|
||||
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
|
||||
$matchingWords = array_intersect($titleWords, $rowWords);
|
||||
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
|
||||
}
|
||||
|
||||
if ($similarity >= 30) {
|
||||
$duplicates[] = [
|
||||
'ticket_id' => $row['ticket_id'],
|
||||
'title' => $row['title'],
|
||||
'status' => $row['status'],
|
||||
'priority' => $row['priority'],
|
||||
'created_at' => $row['created_at'],
|
||||
'similarity' => round($similarity)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Sort by similarity descending
|
||||
usort($duplicates, function ($a, $b) {
|
||||
return $b['similarity'] - $a['similarity'];
|
||||
});
|
||||
|
||||
// Limit to top 5
|
||||
$duplicates = array_slice($duplicates, 0, 5);
|
||||
|
||||
ResponseHelper::success(['duplicates' => $duplicates]);
|
||||
ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]);
|
||||
|
||||
@@ -192,7 +192,7 @@ try {
|
||||
|
||||
// Load flat comment list (no threading nesting in export)
|
||||
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
|
||||
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
|
||||
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id'], 5000);
|
||||
|
||||
$comments = array_map(function ($c) {
|
||||
return [
|
||||
|
||||
+15
-131
@@ -75,9 +75,7 @@ if (session_status() === PHP_SESSION_NONE) {
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/services/DependencyService.php';
|
||||
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
@@ -87,11 +85,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
|
||||
ResponseHelper::unauthorized();
|
||||
}
|
||||
|
||||
$userId = $_SESSION['user']['user_id'];
|
||||
$currentUser = $_SESSION['user'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
// users.groups is a comma-separated string; the dependency model expects an array.
|
||||
$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? ''))));
|
||||
|
||||
// CSRF Protection for POST/DELETE
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
|
||||
@@ -111,158 +105,48 @@ if ($tableCheck->num_rows === 0) {
|
||||
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
} catch (Exception $e) {
|
||||
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to initialize required components');
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
try {
|
||||
switch ($method) {
|
||||
case 'GET':
|
||||
// Get dependencies for a ticket
|
||||
$ticketId = $_GET['ticket_id'] ?? null;
|
||||
|
||||
if (!$ticketId) {
|
||||
ResponseHelper::error('Ticket ID required');
|
||||
$result = DependencyService::list($conn, $currentUser, $_GET['ticket_id'] ?? null);
|
||||
if (!$result['success']) {
|
||||
ResponseHelper::error($result['error'], $result['http_status']);
|
||||
}
|
||||
|
||||
// Verify user can access this ticket
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
|
||||
} catch (Exception $e) {
|
||||
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
|
||||
ResponseHelper::serverError('Failed to retrieve dependencies');
|
||||
}
|
||||
|
||||
ResponseHelper::success([
|
||||
'dependencies' => $dependencies,
|
||||
'dependents' => $dependents
|
||||
'dependencies' => $result['dependencies'],
|
||||
'dependents' => $result['dependents']
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
// Add a new dependency
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
ResponseHelper::error('Invalid JSON');
|
||||
}
|
||||
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
ResponseHelper::error('Both ticket_id and depends_on_id are required');
|
||||
}
|
||||
|
||||
// Verify user can access both tickets before creating dependency
|
||||
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
|
||||
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||
ResponseHelper::notFound('Target ticket not found');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log to audit
|
||||
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
ResponseHelper::created($result);
|
||||
} else {
|
||||
ResponseHelper::error($result['error']);
|
||||
$result = DependencyService::add($conn, $currentUser, $data);
|
||||
if (!$result['success']) {
|
||||
ResponseHelper::error($result['error'], $result['http_status']);
|
||||
}
|
||||
ResponseHelper::created($result);
|
||||
break;
|
||||
|
||||
case 'DELETE':
|
||||
// Remove a dependency
|
||||
// Remove a dependency, by dependency_id or by ticket IDs + type
|
||||
$data = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (!is_array($data)) {
|
||||
ResponseHelper::error('Invalid JSON');
|
||||
}
|
||||
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
// Validate dependency type
|
||||
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
if (!in_array($type, $validTypes, true)) {
|
||||
ResponseHelper::error('Invalid dependency type');
|
||||
}
|
||||
|
||||
// Verify user can access the source ticket
|
||||
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||
ResponseHelper::notFound('Ticket not found');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} elseif ($dependencyId) {
|
||||
// Look up dependency to verify ticket access before deletion
|
||||
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$depLookupStmt = $conn->prepare($depLookupSql);
|
||||
$depLookupStmt->bind_param("i", $dependencyId);
|
||||
$depLookupStmt->execute();
|
||||
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
||||
$depLookupStmt->close();
|
||||
|
||||
if (!$depRow) {
|
||||
ResponseHelper::notFound('Dependency not found');
|
||||
}
|
||||
|
||||
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
|
||||
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||
ResponseHelper::forbidden('Access denied');
|
||||
}
|
||||
|
||||
$result = $dependencyModel->removeDependency($dependencyId);
|
||||
|
||||
if ($result) {
|
||||
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
} else {
|
||||
ResponseHelper::error('Failed to remove dependency');
|
||||
}
|
||||
} else {
|
||||
ResponseHelper::error('Dependency ID or ticket IDs required');
|
||||
$result = DependencyService::remove($conn, $currentUser, $data);
|
||||
if (!$result['success']) {
|
||||
ResponseHelper::error($result['error'], $result['http_status']);
|
||||
}
|
||||
ResponseHelper::success([], 'Dependency removed');
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
+1
-256
@@ -62,262 +62,7 @@ try {
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
|
||||
// Updated controller class that handles partial updates
|
||||
class ApiTicketController
|
||||
{
|
||||
private $conn;
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLog;
|
||||
private $workflowModel;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
private $currentUser;
|
||||
|
||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->userId = $userId;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->currentUser = $currentUser;
|
||||
}
|
||||
|
||||
public function update($id, $data)
|
||||
{
|
||||
// First, get the current ticket data to fill in missing fields
|
||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||
if (!$currentTicket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Ticket not found'
|
||||
];
|
||||
}
|
||||
|
||||
// Visibility check: return 404 for tickets the user cannot access
|
||||
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Ticket not found',
|
||||
'http_status' => 404
|
||||
];
|
||||
}
|
||||
|
||||
// Any authenticated team member can update tickets.
|
||||
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
'ticket_id' => $id,
|
||||
'title' => $data['title'] ?? $currentTicket['title'],
|
||||
'description' => $data['description'] ?? $currentTicket['description'],
|
||||
'category' => $data['category'] ?? $currentTicket['category'],
|
||||
'type' => $data['type'] ?? $currentTicket['type'],
|
||||
'status' => $data['status'] ?? $currentTicket['status'],
|
||||
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
||||
];
|
||||
|
||||
// Validate required fields
|
||||
if (empty($updateData['title'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Title cannot be empty',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
|
||||
// Validate priority range
|
||||
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Priority must be between 1 and 5',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
|
||||
// Validate visibility BEFORE any DB write so a bad payload can't leave the
|
||||
// ticket half-updated (core fields committed but request reported as failed).
|
||||
$visibilityGroups = null;
|
||||
if (isset($data['visibility'])) {
|
||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||
// Convert array to comma-separated string if needed
|
||||
if (is_array($visibilityGroups)) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||
}
|
||||
|
||||
// Authorization: only an admin or the ticket's creator may change
|
||||
// visibility. Enforce only when the requested visibility actually
|
||||
// differs so ordinary edits that re-send the same value aren't blocked.
|
||||
$currentVisibility = $currentTicket['visibility'] ?? 'public';
|
||||
$currentGroups = $currentTicket['visibility_groups'] ?? null;
|
||||
$groupsProvided = array_key_exists('visibility_groups', $data);
|
||||
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|
||||
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
|
||||
if ($visibilityChanged) {
|
||||
$isCreator = $this->userId !== null
|
||||
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
|
||||
if (!$this->isAdmin && !$isCreator) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'You do not have permission to change ticket visibility',
|
||||
'http_status' => 403
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Internal visibility requires at least one group
|
||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$this->isAdmin
|
||||
);
|
||||
|
||||
if (!$allowed) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||
];
|
||||
}
|
||||
|
||||
// Enforce requires_comment transitions server-side.
|
||||
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($statusChangeComment === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'A comment is required for this status change',
|
||||
'requires_comment' => true,
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A comment accompanying a status change (required or optional) is
|
||||
// persisted in the SAME transaction as the status update below, so
|
||||
// a failure partway through can't leave an orphaned "reason"
|
||||
// comment attached with no matching status change — the two
|
||||
// previously ran as separate, non-transactional HTTP calls from
|
||||
// the client (add_comment.php then update_ticket.php).
|
||||
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
|
||||
$result = null;
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
|
||||
$commentResult = $this->commentModel->addComment($id, [
|
||||
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
|
||||
'comment_text' => $statusChangeComment,
|
||||
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||
], $this->userId);
|
||||
if (empty($commentResult['success'])) {
|
||||
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if (!$visResult) {
|
||||
throw new Exception('Failed to update ticket visibility');
|
||||
}
|
||||
}
|
||||
|
||||
$this->conn->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
$response = ['success' => false, 'error' => $e->getMessage()];
|
||||
if (is_array($result) && !empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (isset($data['visibility']) && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Log ticket update to audit log — only the changed fields (delta)
|
||||
if ($this->userId) {
|
||||
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
|
||||
$delta = [];
|
||||
foreach ($trackFields as $field) {
|
||||
$oldVal = (string)($currentTicket[$field] ?? '');
|
||||
$newVal = (string)($updateData[$field] ?? '');
|
||||
if ($oldVal !== $newVal) {
|
||||
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
|
||||
}
|
||||
}
|
||||
if (!empty($delta)) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify on status change (global notify list + watchers)
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
|
||||
NotificationHelper::sendStatusChangeNotification(
|
||||
$id,
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$updateData['title'],
|
||||
$changedBy,
|
||||
$currentTicket['visibility'] ?? 'public'
|
||||
);
|
||||
NotificationHelper::notifyWatchers(
|
||||
$this->conn,
|
||||
$id,
|
||||
$updateData['title'],
|
||||
'status_changed',
|
||||
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||
(int)$this->userId,
|
||||
$currentTicket['visibility'] ?? 'public'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
'priority' => $updateData['priority'],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'message' => 'Ticket updated successfully',
|
||||
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
|
||||
];
|
||||
}
|
||||
}
|
||||
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
|
||||
|
||||
// Use centralized database connection
|
||||
$conn = Database::getConnection();
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "lotusguild/tinker-tickets",
|
||||
"description": "Tinker Tickets. Composer is used ONLY by the MCP endpoint (mcp/); nothing else may require vendor/autoload.php.",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-openssl": "*",
|
||||
"firebase/php-jwt": "^7.0",
|
||||
"laminas/laminas-httphandlerrunner": "^2.12",
|
||||
"mcp/sdk": "0.8.1",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"nyholm/psr7-server": "^1.1",
|
||||
"psr/simple-cache": "^3.0",
|
||||
"symfony/cache": "^7.3",
|
||||
"symfony/http-client": "^7.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"TinkerTickets\\Mcp\\": "mcp/src/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"platform": {
|
||||
"php": "8.2.0"
|
||||
},
|
||||
"sort-packages": true,
|
||||
"optimize-autoloader": true,
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2421
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,14 @@ $GLOBALS['config'] = [
|
||||
explode(',', $envVars['ALLOWED_HOSTS'] ?? 'localhost,127.0.0.1')
|
||||
)),
|
||||
|
||||
// MCP endpoint (mcp/server.php). MCP_RESOURCE_URL is this server's
|
||||
// canonical URL: access tokens must carry it as their audience, so a
|
||||
// beta token can't be replayed against prod. MCP_OAUTH_ISSUER is the
|
||||
// Authelia issuer that signs those tokens.
|
||||
'MCP_RESOURCE_URL' => $envVars['MCP_RESOURCE_URL']
|
||||
?? (!empty($envVars['APP_DOMAIN']) ? 'https://' . $envVars['APP_DOMAIN'] . '/mcp' : null),
|
||||
'MCP_OAUTH_ISSUER' => $envVars['MCP_OAUTH_ISSUER'] ?? 'https://auth.lotusguild.org',
|
||||
|
||||
// Session settings
|
||||
'SESSION_TIMEOUT' => 18000, // 5 hours in seconds
|
||||
'SESSION_REGENERATE_INTERVAL' => 300, // Regenerate session ID every 5 minutes
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Partial ticket updates for the web UI (api/update_ticket.php) and the MCP
|
||||
* update_status tool: workflow-validated status transitions (including
|
||||
* requires_comment, with the comment persisted in the same transaction),
|
||||
* priority/field edits, visibility changes (admin or creator only), audit
|
||||
* log delta, and status-change notifications. Returns result arrays; callers
|
||||
* own HTTP concerns (sessions, CSRF, response codes) and must invalidate the
|
||||
* stats cache on success.
|
||||
*
|
||||
* Moved verbatim out of api/update_ticket.php so the MCP endpoint runs the
|
||||
* exact same code path as the web UI (tinker_tickets#111).
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
class ApiTicketController
|
||||
{
|
||||
private $conn;
|
||||
private $ticketModel;
|
||||
private $commentModel;
|
||||
private $auditLog;
|
||||
private $workflowModel;
|
||||
private $userId;
|
||||
private $isAdmin;
|
||||
private $currentUser;
|
||||
|
||||
public function __construct($conn, $userId = null, $isAdmin = false, $currentUser = [])
|
||||
{
|
||||
$this->conn = $conn;
|
||||
$this->ticketModel = new TicketModel($conn);
|
||||
$this->commentModel = new CommentModel($conn);
|
||||
$this->auditLog = new AuditLogModel($conn);
|
||||
$this->workflowModel = new WorkflowModel($conn);
|
||||
$this->userId = $userId;
|
||||
$this->isAdmin = $isAdmin;
|
||||
$this->currentUser = $currentUser;
|
||||
}
|
||||
|
||||
public function update($id, $data)
|
||||
{
|
||||
// First, get the current ticket data to fill in missing fields
|
||||
$currentTicket = $this->ticketModel->getTicketById($id);
|
||||
if (!$currentTicket) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Ticket not found'
|
||||
];
|
||||
}
|
||||
|
||||
// Visibility check: return 404 for tickets the user cannot access
|
||||
if (!$this->ticketModel->canUserAccessTicket($currentTicket, $this->currentUser)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Ticket not found',
|
||||
'http_status' => 404
|
||||
];
|
||||
}
|
||||
|
||||
// Any authenticated team member can update tickets.
|
||||
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$updateData = [
|
||||
'ticket_id' => $id,
|
||||
'title' => $data['title'] ?? $currentTicket['title'],
|
||||
'description' => $data['description'] ?? $currentTicket['description'],
|
||||
'category' => $data['category'] ?? $currentTicket['category'],
|
||||
'type' => $data['type'] ?? $currentTicket['type'],
|
||||
'status' => $data['status'] ?? $currentTicket['status'],
|
||||
'priority' => isset($data['priority']) ? (int)$data['priority'] : (int)$currentTicket['priority']
|
||||
];
|
||||
|
||||
// Validate required fields
|
||||
if (empty($updateData['title'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Title cannot be empty',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
|
||||
// Validate priority range
|
||||
if ($updateData['priority'] < 1 || $updateData['priority'] > 5) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Priority must be between 1 and 5',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
|
||||
// Validate visibility BEFORE any DB write so a bad payload can't leave the
|
||||
// ticket half-updated (core fields committed but request reported as failed).
|
||||
$visibilityGroups = null;
|
||||
if (isset($data['visibility'])) {
|
||||
$visibilityGroups = $data['visibility_groups'] ?? null;
|
||||
// Convert array to comma-separated string if needed
|
||||
if (is_array($visibilityGroups)) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $visibilityGroups));
|
||||
}
|
||||
|
||||
// Authorization: only an admin or the ticket's creator may change
|
||||
// visibility. Enforce only when the requested visibility actually
|
||||
// differs so ordinary edits that re-send the same value aren't blocked.
|
||||
$currentVisibility = $currentTicket['visibility'] ?? 'public';
|
||||
$currentGroups = $currentTicket['visibility_groups'] ?? null;
|
||||
$groupsProvided = array_key_exists('visibility_groups', $data);
|
||||
$visibilityChanged = ($data['visibility'] !== $currentVisibility)
|
||||
|| ($groupsProvided && (string)$visibilityGroups !== (string)$currentGroups);
|
||||
if ($visibilityChanged) {
|
||||
$isCreator = $this->userId !== null
|
||||
&& (int)($currentTicket['created_by'] ?? 0) === (int)$this->userId;
|
||||
if (!$this->isAdmin && !$isCreator) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'You do not have permission to change ticket visibility',
|
||||
'http_status' => 403
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Internal visibility requires at least one group
|
||||
if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Internal visibility requires at least one group to be specified',
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Validate status transition using workflow model
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$allowed = $this->workflowModel->isTransitionAllowed(
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$this->isAdmin
|
||||
);
|
||||
|
||||
if (!$allowed) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Status transition not allowed: ' . $currentTicket['status'] . ' → ' . $updateData['status']
|
||||
];
|
||||
}
|
||||
|
||||
// Enforce requires_comment transitions server-side.
|
||||
if ($this->workflowModel->transitionRequiresComment($currentTicket['status'], $updateData['status'])) {
|
||||
$statusChangeComment = trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
if ($statusChangeComment === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'A comment is required for this status change',
|
||||
'requires_comment' => true,
|
||||
'http_status' => 400
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A comment accompanying a status change (required or optional) is
|
||||
// persisted in the SAME transaction as the status update below, so
|
||||
// a failure partway through can't leave an orphaned "reason"
|
||||
// comment attached with no matching status change — the two
|
||||
// previously ran as separate, non-transactional HTTP calls from
|
||||
// the client (add_comment.php then update_ticket.php).
|
||||
$statusChangeComment = $statusChangeComment ?? trim((string)($data['comment'] ?? $data['comment_text'] ?? ''));
|
||||
|
||||
$result = null;
|
||||
$this->conn->begin_transaction();
|
||||
try {
|
||||
if ($statusChangeComment !== '' && $currentTicket['status'] !== $updateData['status']) {
|
||||
$commentResult = $this->commentModel->addComment($id, [
|
||||
'user_name' => $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? 'User',
|
||||
'comment_text' => $statusChangeComment,
|
||||
'markdown_enabled' => !empty($data['markdown_enabled']),
|
||||
], $this->userId);
|
||||
if (empty($commentResult['success'])) {
|
||||
throw new Exception($commentResult['error'] ?? 'Failed to add comment');
|
||||
}
|
||||
}
|
||||
|
||||
// Update ticket with user tracking and optional optimistic locking
|
||||
$expectedUpdatedAt = $data['expected_updated_at'] ?? null;
|
||||
$result = $this->ticketModel->updateTicket($updateData, $this->userId, $expectedUpdatedAt);
|
||||
if (!$result['success']) {
|
||||
throw new Exception($result['error'] ?? 'Failed to update ticket in database');
|
||||
}
|
||||
|
||||
// Handle visibility update if provided (already validated above)
|
||||
if (isset($data['visibility'])) {
|
||||
$visResult = $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId);
|
||||
if (!$visResult) {
|
||||
throw new Exception('Failed to update ticket visibility');
|
||||
}
|
||||
}
|
||||
|
||||
$this->conn->commit();
|
||||
} catch (Exception $e) {
|
||||
$this->conn->rollback();
|
||||
$response = ['success' => false, 'error' => $e->getMessage()];
|
||||
if (is_array($result) && !empty($result['conflict'])) {
|
||||
$response['conflict'] = true;
|
||||
$response['current_updated_at'] = $result['current_updated_at'] ?? null;
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (isset($data['visibility']) && $this->userId) {
|
||||
$this->auditLog->log(
|
||||
$this->userId,
|
||||
'update',
|
||||
'ticket',
|
||||
(string)$id,
|
||||
[
|
||||
'field' => 'visibility',
|
||||
'from' => $currentTicket['visibility'] ?? 'public',
|
||||
'to' => $data['visibility'],
|
||||
'groups' => $visibilityGroups
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Log ticket update to audit log — only the changed fields (delta)
|
||||
if ($this->userId) {
|
||||
$trackFields = ['title', 'priority', 'status', 'description', 'category', 'type'];
|
||||
$delta = [];
|
||||
foreach ($trackFields as $field) {
|
||||
$oldVal = (string)($currentTicket[$field] ?? '');
|
||||
$newVal = (string)($updateData[$field] ?? '');
|
||||
if ($oldVal !== $newVal) {
|
||||
$delta[$field] = ['from' => $oldVal, 'to' => $newVal];
|
||||
}
|
||||
}
|
||||
if (!empty($delta)) {
|
||||
$this->auditLog->logTicketUpdate($this->userId, $id, $delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify on status change (global notify list + watchers)
|
||||
if ($currentTicket['status'] !== $updateData['status']) {
|
||||
$changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null;
|
||||
NotificationHelper::sendStatusChangeNotification(
|
||||
$id,
|
||||
$currentTicket['status'],
|
||||
$updateData['status'],
|
||||
$updateData['title'],
|
||||
$changedBy,
|
||||
$currentTicket['visibility'] ?? 'public'
|
||||
);
|
||||
NotificationHelper::notifyWatchers(
|
||||
$this->conn,
|
||||
$id,
|
||||
$updateData['title'],
|
||||
'status_changed',
|
||||
['old_status' => $currentTicket['status'], 'new_status' => $updateData['status'], 'changed_by' => $changedBy],
|
||||
(int)$this->userId,
|
||||
$currentTicket['visibility'] ?? 'public'
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $updateData['status'],
|
||||
'priority' => $updateData['priority'],
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
'message' => 'Ticket updated successfully',
|
||||
'csrf_token' => $GLOBALS['newCsrfToken'] ?? null
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,14 @@ class TicketController
|
||||
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||
|
||||
// Get timeline for this ticket
|
||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||
// Get the newest timeline events for this ticket. One extra row is
|
||||
// fetched only to tell the view that older events were left out.
|
||||
$timelineLimit = 500;
|
||||
$timeline = $this->auditLogModel->getTicketTimeline($id, $timelineLimit + 1);
|
||||
$timelineTruncated = count($timeline) > $timelineLimit;
|
||||
if ($timelineTruncated) {
|
||||
array_pop($timeline);
|
||||
}
|
||||
|
||||
// Get all users for assignment dropdown
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
@@ -101,115 +107,27 @@ class TicketController
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle visibility groups (comes as array from checkboxes)
|
||||
$visibilityGroups = null;
|
||||
if (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups'])) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $_POST['visibility_groups']));
|
||||
}
|
||||
|
||||
// Honor the posted status, validated against the app's canonical list
|
||||
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
$status = $_POST['status'] ?? 'Open';
|
||||
if (!in_array($status, $validStatuses, true)) {
|
||||
$status = 'Open';
|
||||
}
|
||||
|
||||
$ticketData = [
|
||||
'title' => trim($_POST['title'] ?? ''),
|
||||
// Validation (incl. required custom fields), creation, audit log,
|
||||
// stats cache, custom field values, duplicate link and notification
|
||||
// live in TicketCreationService so the MCP create_ticket tool runs
|
||||
// the same code path.
|
||||
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
|
||||
$result = TicketCreationService::create($this->conn, $currentUser ?? [], [
|
||||
'title' => $_POST['title'] ?? '',
|
||||
'description' => $_POST['description'] ?? '',
|
||||
'priority' => $_POST['priority'] ?? '4',
|
||||
'category' => $_POST['category'] ?? 'General',
|
||||
'type' => $_POST['type'] ?? 'Issue',
|
||||
'status' => $status,
|
||||
'status' => $_POST['status'] ?? 'Open',
|
||||
'visibility' => $_POST['visibility'] ?? 'public',
|
||||
'visibility_groups' => $visibilityGroups,
|
||||
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
|
||||
];
|
||||
|
||||
// Validate input (server-side; form is novalidate)
|
||||
if ($ticketData['title'] === '') {
|
||||
$error = "Title is required";
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
if (trim($ticketData['description']) === '') {
|
||||
$error = "Description is required";
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$conn = $this->conn; // Make $conn available to view
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
// Custom fields applicable to the submitted category — validate
|
||||
// is_required server-side (the form is novalidate, and a field
|
||||
// hidden by the client-side category toggle must not silently
|
||||
// bypass a requirement that applies to the category actually
|
||||
// submitted).
|
||||
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
|
||||
$applicableFieldDefs = array_filter(
|
||||
$allCustomFieldDefs,
|
||||
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
|
||||
);
|
||||
$customFieldsToSave = [];
|
||||
foreach ($applicableFieldDefs as $def) {
|
||||
$fieldId = (int)$def['field_id'];
|
||||
$raw = $submittedCustomFields[$fieldId] ?? null;
|
||||
$normalized = $def['field_type'] === 'checkbox'
|
||||
? (!empty($raw) ? '1' : '0')
|
||||
: (is_scalar($raw) ? trim((string)$raw) : '');
|
||||
|
||||
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||
$error = $def['field_label'] . ' is required';
|
||||
$templates = $this->templateModel->getAllTemplates();
|
||||
$allUsers = $this->userModel->getAllUsers();
|
||||
$conn = $this->conn;
|
||||
include dirname(__DIR__) . '/views/CreateTicketView.php';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($normalized !== '') {
|
||||
$customFieldsToSave[$fieldId] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket with user tracking
|
||||
$result = $this->ticketModel->createTicket($ticketData, $userId);
|
||||
'visibility_groups' => (isset($_POST['visibility_groups']) && is_array($_POST['visibility_groups']))
|
||||
? $_POST['visibility_groups'] : null,
|
||||
'assigned_to' => $_POST['assigned_to'] ?? null,
|
||||
'custom_fields' => $_POST['custom_fields'] ?? [],
|
||||
'link_duplicate_of' => $_POST['link_duplicate_of'] ?? '',
|
||||
]);
|
||||
|
||||
if ($result['success']) {
|
||||
// Log ticket creation to audit log
|
||||
if (isset($GLOBALS['auditLog']) && $userId) {
|
||||
$GLOBALS['auditLog']->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Ticket counts changed — invalidate the cached dashboard stats
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
(new StatsModel($this->conn))->invalidateCache();
|
||||
|
||||
// Persist custom field values for the fields applicable to
|
||||
// this ticket's category
|
||||
if (!empty($customFieldsToSave)) {
|
||||
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
|
||||
}
|
||||
|
||||
// Auto-link as duplicate if requested from create form
|
||||
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
|
||||
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, 'duplicates', ?)";
|
||||
$depStmt = $this->conn->prepare($depSql);
|
||||
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
|
||||
$depStmt->execute();
|
||||
$depStmt->close();
|
||||
}
|
||||
|
||||
// Send Matrix notification for new ticket
|
||||
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||
|
||||
// Redirect to the new ticket
|
||||
header("Location: " . $GLOBALS['config']['BASE_URL'] . "/ticket/" . $result['ticket_id']);
|
||||
exit;
|
||||
|
||||
+32
-4
@@ -159,6 +159,23 @@ function generateTicketHash($data)
|
||||
}
|
||||
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
|
||||
$issueSubtype = 'health_err';
|
||||
} else {
|
||||
// Any other warning gets its own ticket. Previously every other
|
||||
// HEALTH_WARN shared one empty subtype, so unrelated warnings
|
||||
// (noout, nearfull, insecure keys, ...) collapsed onto a single
|
||||
// ticket and overwrote its title in turn every run (#112).
|
||||
// Prefer Ceph's own health-check code when the reporter sends
|
||||
// it; otherwise key on the warning text with counts stripped.
|
||||
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
|
||||
if ($checkCode !== '' && preg_match('/^[A-Z0-9_]{1,64}$/', $checkCode)) {
|
||||
$issueSubtype = 'check_' . strtolower($checkCode);
|
||||
} elseif (preg_match('/HEALTH_WARN:\s*([^\[]+)/i', $title, $warnMatch)) {
|
||||
$warnText = strtolower(explode(':', $warnMatch[1])[0]);
|
||||
$warnText = trim(preg_replace('/[^a-z]+/', '_', $warnText), '_');
|
||||
if ($warnText !== '') {
|
||||
$issueSubtype = 'warn_' . $warnText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,10 +345,17 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
||||
$commentStmt->close();
|
||||
}
|
||||
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||
array_diff_key($changes, ['description_refreshed' => true]),
|
||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||
));
|
||||
// Audit only real title/priority changes. The description is
|
||||
// refreshed on every run, and logging that alone wrote a
|
||||
// reason-only row every few minutes per open ticket — 50k+ rows
|
||||
// on one ticket, which broke its page (tinker_tickets#112).
|
||||
$auditedChanges = array_diff_key($changes, ['description_refreshed' => true]);
|
||||
if (!empty($auditedChanges)) {
|
||||
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
|
||||
$auditedChanges,
|
||||
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
|
||||
));
|
||||
}
|
||||
|
||||
// Only notify on priority escalation — title-only updates (e.g. rising
|
||||
// Power_On_Hours counter) should not generate a Matrix ping every hour.
|
||||
@@ -581,4 +605,8 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Internal server error']);
|
||||
}
|
||||
// Done — only the deadlock `continue` above may run the loop again.
|
||||
// Falling through re-entered it on a closed connection, appending a
|
||||
// 500 error body after the success response for every new ticket.
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Who may use Tinker Tickets at all. Shared by the web login
|
||||
* (AuthMiddleware, from Authelia's Remote-Groups header) and the MCP endpoint
|
||||
* (from the OAuth access token's groups claim), so both entry points enforce
|
||||
* one rule instead of two copies that can drift apart.
|
||||
*/
|
||||
class AccessPolicy
|
||||
{
|
||||
/** Membership in any of these grants access. */
|
||||
private const REQUIRED_GROUPS = ['admin', 'employee'];
|
||||
|
||||
/**
|
||||
* @param string $groups Comma-separated group names (Remote-Groups format)
|
||||
*/
|
||||
public static function hasAppAccess(string $groups): bool
|
||||
{
|
||||
if ($groups === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter to safe characters only to prevent header injection attacks
|
||||
$userGroups = array_filter(
|
||||
array_map('trim', explode(',', strtolower($groups))),
|
||||
fn($g) => preg_match('/^[a-z0-9_\-]+$/', $g)
|
||||
);
|
||||
|
||||
return !empty(array_intersect($userGroups, self::REQUIRED_GROUPS));
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* MCP endpoint (Streamable HTTP), OAuth-protected by Authelia. See issue #111.
|
||||
*
|
||||
* Serves /mcp and the RFC 9728 Protected Resource Metadata paths (nginx routes
|
||||
* all of them here). This is the ONLY file allowed to load vendor/autoload.php.
|
||||
*
|
||||
* Identity comes exclusively from the validated access token. Never read
|
||||
* Remote-User / Remote-* headers or $_SESSION for identity here: this location
|
||||
* is exempt from Authelia forward-auth at the proxy, so those headers are
|
||||
* client-controlled on this path.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/middleware/RateLimitMiddleware.php';
|
||||
RateLimitMiddleware::apply('api', false);
|
||||
|
||||
require_once dirname(__DIR__) . '/config/config.php';
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
|
||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
|
||||
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
|
||||
require_once dirname(__DIR__) . '/services/CommentService.php';
|
||||
require_once dirname(__DIR__) . '/services/AssignmentService.php';
|
||||
require_once dirname(__DIR__) . '/services/DependencyService.php';
|
||||
require_once dirname(__DIR__) . '/services/SimilarTicketService.php';
|
||||
|
||||
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Transport\Http\Middleware\AuthorizationMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
||||
use Mcp\Server\Transport\Http\Middleware\ProtectedResourceMetadataMiddleware;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwksProvider;
|
||||
use Mcp\Server\Transport\Http\OAuth\JwtTokenValidator;
|
||||
use Mcp\Server\Transport\Http\OAuth\OidcDiscovery;
|
||||
use Mcp\Server\Transport\Http\OAuth\ProtectedResourceMetadata;
|
||||
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
|
||||
use Symfony\Component\Cache\Psr16Cache;
|
||||
use TinkerTickets\Mcp\Auth\IdentityMiddleware;
|
||||
use TinkerTickets\Mcp\Auth\ToolScopeMiddleware;
|
||||
use TinkerTickets\Mcp\ToolCatalog;
|
||||
|
||||
$resourceUrl = $GLOBALS['config']['MCP_RESOURCE_URL'] ?? null;
|
||||
$issuer = $GLOBALS['config']['MCP_OAUTH_ISSUER'] ?? null;
|
||||
if (empty($resourceUrl) || empty($issuer)) {
|
||||
http_response_code(503);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'MCP endpoint is not configured (MCP_RESOURCE_URL / MCP_OAUTH_ISSUER)']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$psr17 = new Psr17Factory();
|
||||
$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals();
|
||||
|
||||
// ServerRequestCreator adds Host both from the URI and from the request
|
||||
// headers, so getHeaderLine('Host') comes back as "h, h" under PHP-FPM, which
|
||||
// the DNS-rebinding check below then rejects. Collapse to the single value the
|
||||
// client actually sent.
|
||||
$clientHost = $request->getHeader('Host')[0] ?? '';
|
||||
if ($clientHost !== '') {
|
||||
$request = $request->withHeader('Host', $clientHost);
|
||||
}
|
||||
|
||||
// TLS terminates at the reverse proxy, so PHP sees plain http and a Host header
|
||||
// the client controls. The SDK derives the resource_metadata URL in its 401
|
||||
// challenge from the request URI, so pin scheme/host/port to the configured
|
||||
// canonical URL instead of anything the request claims. preserveHost keeps
|
||||
// the client's real Host header for the DNS-rebinding check below; without
|
||||
// it withUri() would overwrite Host and make that check a no-op.
|
||||
$canonical = parse_url($resourceUrl);
|
||||
$request = $request->withUri(
|
||||
$request->getUri()
|
||||
->withScheme($canonical['scheme'])
|
||||
->withHost($canonical['host'])
|
||||
->withPort($canonical['port'] ?? null),
|
||||
true
|
||||
);
|
||||
|
||||
// Cache OIDC discovery + JWKS so every MCP call isn't two extra round trips to
|
||||
// Authelia. Outside the webroot on purpose.
|
||||
$cache = new Psr16Cache(new FilesystemAdapter('tinker_mcp', 3600, sys_get_temp_dir() . '/tinker_mcp_cache'));
|
||||
|
||||
$validator = new JwtTokenValidator(
|
||||
issuer: $issuer,
|
||||
audience: $resourceUrl,
|
||||
jwksProvider: new JwksProvider(new OidcDiscovery(cache: $cache), cache: $cache),
|
||||
// Authelia puts scopes in an `scp` array, not the standard `scope` string
|
||||
// (verified in #111 phase 1). With the default, every scope check fails.
|
||||
scopeClaim: 'scp',
|
||||
);
|
||||
|
||||
$resourcePath = $canonical['path'] ?? '';
|
||||
$metadata = new ProtectedResourceMetadata(
|
||||
authorizationServers: [$issuer],
|
||||
scopesSupported: ['tickets:read', 'tickets:write'],
|
||||
resource: $resourceUrl,
|
||||
resourceName: 'Tinker Tickets',
|
||||
// RFC 9728 path-suffixed form first (used in the WWW-Authenticate
|
||||
// challenge), plus the root form some clients probe.
|
||||
metadataPaths: array_values(array_unique([
|
||||
'/.well-known/oauth-protected-resource' . $resourcePath,
|
||||
'/.well-known/oauth-protected-resource',
|
||||
])),
|
||||
);
|
||||
|
||||
$conn = Database::getConnection();
|
||||
|
||||
$server = ToolCatalog::register(
|
||||
Server::builder()
|
||||
->setServerInfo('Tinker Tickets', '1.0.0')
|
||||
// Handshake-era clients (pre-2026-07-28) still use protocol sessions.
|
||||
->setSession(new FileSessionStore(sys_get_temp_dir() . '/tinker_mcp_sessions')),
|
||||
$conn
|
||||
)->build();
|
||||
|
||||
$transport = new StreamableHttpTransport(
|
||||
$request,
|
||||
middleware: [
|
||||
// CORS: SDK default (no Access-Control-Allow-Origin, so cross-origin
|
||||
// browser calls are refused). Host allowlist: only the canonical
|
||||
// hostname, which also refuses direct-by-IP access.
|
||||
new CorsMiddleware(),
|
||||
new DnsRebindingProtectionMiddleware([$canonical['host']]),
|
||||
new ProtectedResourceMetadataMiddleware($metadata),
|
||||
new AuthorizationMiddleware($validator, $metadata),
|
||||
// Identity comes from the validated token's server-side request
|
||||
// attributes, never from client-writable JSON-RPC `_meta` (so the
|
||||
// SDK's OAuthRequestMetaMiddleware is intentionally not used).
|
||||
new IdentityMiddleware($conn, $psr17, $psr17),
|
||||
new ToolScopeMiddleware(
|
||||
$psr17,
|
||||
$canonical['scheme'] . '://' . $canonical['host']
|
||||
. (isset($canonical['port']) ? ':' . $canonical['port'] : '')
|
||||
. $metadata->getPrimaryMetadataPath()
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
try {
|
||||
$response = $server->run($transport);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('mcp/server.php: ' . $e::class . ': ' . $e->getMessage());
|
||||
$response = $psr17->createResponse(500)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($psr17->createStream(json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => null,
|
||||
'error' => ['code' => -32603, 'message' => 'Internal error'],
|
||||
])));
|
||||
}
|
||||
|
||||
(new SapiEmitter())->emit($response);
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Auth;
|
||||
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Maps a validated access token to a Tinker Tickets user, applying exactly
|
||||
* the same rules as the web login (AuthMiddleware): the shared
|
||||
* AccessPolicy::hasAppAccess() group check, then
|
||||
* UserModel::syncUserFromAuthelia() to create/update the user row and derive
|
||||
* is_admin from groups.
|
||||
*
|
||||
* Must run after the SDK's AuthorizationMiddleware, which has already
|
||||
* verified the token's signature, issuer, audience and expiry and attached
|
||||
* its claims as `oauth.claims` / `oauth.scopes` request attributes.
|
||||
*/
|
||||
final class IdentityMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly \mysqli $conn,
|
||||
private readonly ResponseFactoryInterface $responseFactory,
|
||||
private readonly StreamFactoryInterface $streamFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$claims = $request->getAttribute('oauth.claims');
|
||||
$scopes = $request->getAttribute('oauth.scopes') ?? [];
|
||||
if (!is_array($claims)) {
|
||||
// Only reachable if the stack is misordered; never serve anonymously.
|
||||
return $this->deny(500, 'Identity unavailable');
|
||||
}
|
||||
|
||||
$username = is_string($claims['preferred_username'] ?? null) ? trim($claims['preferred_username']) : '';
|
||||
if ($username === '') {
|
||||
return $this->deny(403, 'Access token has no preferred_username claim');
|
||||
}
|
||||
|
||||
$groupsClaim = $claims['groups'] ?? [];
|
||||
$groups = implode(',', array_filter(
|
||||
is_array($groupsClaim) ? $groupsClaim : [$groupsClaim],
|
||||
'is_string'
|
||||
));
|
||||
|
||||
if (!\AccessPolicy::hasAppAccess($groups)) {
|
||||
return $this->deny(403, 'Your account is not permitted to use Tinker Tickets');
|
||||
}
|
||||
|
||||
$user = (new \UserModel($this->conn))->syncUserFromAuthelia(
|
||||
$username,
|
||||
is_string($claims['name'] ?? null) ? $claims['name'] : '',
|
||||
is_string($claims['email'] ?? null) ? $claims['email'] : '',
|
||||
$groups
|
||||
);
|
||||
|
||||
McpIdentity::set($user, array_values(array_filter($scopes, 'is_string')));
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function deny(int $status, string $message): ResponseInterface
|
||||
{
|
||||
return $this->responseFactory->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withBody($this->streamFactory->createStream(json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => null,
|
||||
'error' => ['code' => -32001, 'message' => $message],
|
||||
])));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Auth;
|
||||
|
||||
/**
|
||||
* The Tinker Tickets user an MCP request runs as, plus the token's scopes.
|
||||
*
|
||||
* Set once per HTTP request by IdentityMiddleware from the *validated* access
|
||||
* token, which is held as server-side PSR-7 request attributes. It is
|
||||
* deliberately NOT read from the JSON-RPC `_meta` field: clients can write
|
||||
* to `_meta`, and the SDK's OAuthRequestMetaMiddleware only overwrites the
|
||||
* keys the validator happens to set.
|
||||
*
|
||||
* Static state is request-scoped here: PHP-FPM serves one HTTP request per
|
||||
* process lifecycle, and the whole MCP call (including SDK fibers) runs
|
||||
* inside it.
|
||||
*/
|
||||
final class McpIdentity
|
||||
{
|
||||
public const SCOPE_READ = 'tickets:read';
|
||||
public const SCOPE_WRITE = 'tickets:write';
|
||||
|
||||
private static ?array $user = null;
|
||||
/** @var list<string> */
|
||||
private static array $scopes = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
* @param list<string> $scopes
|
||||
*/
|
||||
public static function set(array $user, array $scopes): void
|
||||
{
|
||||
self::$user = $user;
|
||||
self::$scopes = $scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function user(): array
|
||||
{
|
||||
if (self::$user === null) {
|
||||
// Unreachable if the middleware stack is intact; fail closed.
|
||||
throw new \LogicException('MCP identity requested before IdentityMiddleware ran');
|
||||
}
|
||||
return self::$user;
|
||||
}
|
||||
|
||||
/** tickets:write implies tickets:read (scope hierarchy, MCP spec §Scope Challenge Handling). */
|
||||
public static function canRead(array $scopes): bool
|
||||
{
|
||||
return in_array(self::SCOPE_READ, $scopes, true) || in_array(self::SCOPE_WRITE, $scopes, true);
|
||||
}
|
||||
|
||||
public static function canWrite(array $scopes): bool
|
||||
{
|
||||
return in_array(self::SCOPE_WRITE, $scopes, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Auth;
|
||||
|
||||
use Psr\Http\Message\ResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use TinkerTickets\Mcp\ToolCatalog;
|
||||
|
||||
/**
|
||||
* Per-operation scope enforcement, before anything is dispatched.
|
||||
*
|
||||
* - Lifecycle messages (initialize, ping, notifications/*, server/discover)
|
||||
* need only a valid token.
|
||||
* - tools/call on a write tool needs tickets:write.
|
||||
* - Everything else (tools/list, read tools, ...) needs tickets:read, which
|
||||
* tickets:write implies.
|
||||
*
|
||||
* Denials use the MCP spec's step-up challenge: HTTP 403 +
|
||||
* WWW-Authenticate: Bearer error="insufficient_scope", listing every scope
|
||||
* the request needs in one go.
|
||||
*/
|
||||
final class ToolScopeMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private const LIFECYCLE_METHODS = ['initialize', 'ping', 'server/discover'];
|
||||
|
||||
public function __construct(
|
||||
private readonly ResponseFactoryInterface $responseFactory,
|
||||
private readonly string $resourceMetadataUrl,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
if ($request->getMethod() !== 'POST') {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$payload = json_decode((string)$request->getBody(), true);
|
||||
$request->getBody()->rewind();
|
||||
if (!is_array($payload)) {
|
||||
return $handler->handle($request); // the SDK answers malformed JSON-RPC itself
|
||||
}
|
||||
|
||||
$messages = array_is_list($payload) ? $payload : [$payload];
|
||||
$needWrite = false;
|
||||
$needRead = false;
|
||||
foreach ($messages as $message) {
|
||||
if (!is_array($message) || !is_string($message['method'] ?? null)) {
|
||||
continue; // responses to server->client requests carry no method
|
||||
}
|
||||
$method = $message['method'];
|
||||
if (in_array($method, self::LIFECYCLE_METHODS, true) || str_starts_with($method, 'notifications/')) {
|
||||
continue;
|
||||
}
|
||||
if ($method === 'tools/call' && ToolCatalog::isWriteTool((string)($message['params']['name'] ?? ''))) {
|
||||
$needWrite = true;
|
||||
} else {
|
||||
$needRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scopes = $request->getAttribute('oauth.scopes') ?? [];
|
||||
if ($needWrite && !McpIdentity::canWrite($scopes)) {
|
||||
return $this->insufficient(McpIdentity::SCOPE_WRITE, 'This operation requires the tickets:write scope.');
|
||||
}
|
||||
if ($needRead && !McpIdentity::canRead($scopes)) {
|
||||
return $this->insufficient(
|
||||
$needWrite ? McpIdentity::SCOPE_WRITE : McpIdentity::SCOPE_READ,
|
||||
'This operation requires the tickets:read scope.'
|
||||
);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function insufficient(string $scope, string $description): ResponseInterface
|
||||
{
|
||||
return $this->responseFactory->createResponse(403)->withHeader(
|
||||
'WWW-Authenticate',
|
||||
sprintf(
|
||||
'Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="%s"',
|
||||
$scope,
|
||||
$this->resourceMetadataUrl,
|
||||
$description
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp;
|
||||
|
||||
use Mcp\Schema\ToolAnnotations;
|
||||
use Mcp\Server\Builder;
|
||||
use TinkerTickets\Mcp\Tools\TicketReadTools;
|
||||
use TinkerTickets\Mcp\Tools\TicketWriteTools;
|
||||
|
||||
/**
|
||||
* The single list of MCP tools: registration, plus which ones need the
|
||||
* tickets:write scope (read by ToolScopeMiddleware). Keeping both here means
|
||||
* a new write tool can't be registered without also being scope-gated.
|
||||
*/
|
||||
final class ToolCatalog
|
||||
{
|
||||
/** Tool names that mutate data and require tickets:write. */
|
||||
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket', 'link_tickets', 'unlink_tickets'];
|
||||
|
||||
public static function isWriteTool(string $name): bool
|
||||
{
|
||||
return in_array($name, self::WRITE_TOOLS, true);
|
||||
}
|
||||
|
||||
public static function register(Builder $builder, \mysqli $conn): Builder
|
||||
{
|
||||
$read = new TicketReadTools($conn);
|
||||
$write = new TicketWriteTools($conn);
|
||||
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
|
||||
// Writes change tickets (and notify people); only unlink_tickets removes anything.
|
||||
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
|
||||
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
|
||||
$remove = new ToolAnnotations(readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false);
|
||||
|
||||
return $builder
|
||||
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
|
||||
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
|
||||
->addTool([$read, 'findSimilarTickets'], 'find_similar_tickets', 'Find similar tickets', null, $readOnly)
|
||||
->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
|
||||
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
|
||||
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
|
||||
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update)
|
||||
->addTool([$write, 'linkTickets'], 'link_tickets', 'Link tickets', null, $additive)
|
||||
->addTool([$write, 'unlinkTickets'], 'unlink_tickets', 'Unlink tickets', null, $remove);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Tools;
|
||||
|
||||
use Mcp\Exception\ToolCallException;
|
||||
use TinkerTickets\Mcp\Auth\McpIdentity;
|
||||
|
||||
/**
|
||||
* Read-only tools. Everything goes through the same model methods and
|
||||
* visibility checks the web UI uses, as the signed-in user, so MCP can never
|
||||
* show more than that user could see in their browser.
|
||||
*/
|
||||
final class TicketReadTools
|
||||
{
|
||||
private const MAX_COMMENTS = 200;
|
||||
|
||||
public function __construct(private readonly \mysqli $conn)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Search and list tickets you can see (same visibility rules as the web UI).
|
||||
*
|
||||
* @param string|null $query Free-text search over ticket titles and descriptions.
|
||||
* @param string|null $status Comma-separated statuses, e.g. "Open,In Progress". Omit for every status except Closed; use "all" for every status.
|
||||
* @param int|null $priority Exact priority: 1 (critical) to 5 (minimal).
|
||||
* @param string|null $category Exact category name.
|
||||
* @param string|null $assignee "me", "unassigned", or a username.
|
||||
* @param int $page Page number, starting at 1.
|
||||
* @param int $limit Tickets per page, 1-50.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function searchTickets(
|
||||
?string $query = null,
|
||||
?string $status = null,
|
||||
?int $priority = null,
|
||||
?string $category = null,
|
||||
?string $assignee = null,
|
||||
int $page = 1,
|
||||
int $limit = 20,
|
||||
): array {
|
||||
$user = McpIdentity::user();
|
||||
|
||||
$allStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
if ($status === null || trim($status) === '') {
|
||||
$statusFilter = implode(',', array_filter($allStatuses, fn($s) => $s !== 'Closed'));
|
||||
} elseif (strtolower(trim($status)) === 'all') {
|
||||
$statusFilter = null;
|
||||
} else {
|
||||
$requested = array_map('trim', explode(',', $status));
|
||||
$unknown = array_diff($requested, $allStatuses);
|
||||
if ($unknown) {
|
||||
throw new ToolCallException('Unknown status: ' . implode(', ', $unknown)
|
||||
. '. Valid statuses: ' . implode(', ', $allStatuses));
|
||||
}
|
||||
$statusFilter = implode(',', $requested);
|
||||
}
|
||||
|
||||
$filters = [];
|
||||
if ($priority !== null) {
|
||||
if ($priority < 1 || $priority > 5) {
|
||||
throw new ToolCallException('priority must be between 1 and 5');
|
||||
}
|
||||
$filters['priority_min'] = $priority;
|
||||
$filters['priority_max'] = $priority;
|
||||
}
|
||||
if ($assignee !== null && trim($assignee) !== '') {
|
||||
$filters['assigned_to'] = $this->resolveAssigneeFilter(trim($assignee), $user);
|
||||
}
|
||||
|
||||
$page = max(1, $page);
|
||||
$limit = min(50, max(1, $limit));
|
||||
|
||||
$result = (new \TicketModel($this->conn))->getAllTickets(
|
||||
$page,
|
||||
$limit,
|
||||
$statusFilter,
|
||||
'updated_at',
|
||||
'desc',
|
||||
($category !== null && trim($category) !== '') ? trim($category) : null,
|
||||
null,
|
||||
($query !== null && trim($query) !== '') ? trim($query) : null,
|
||||
$filters,
|
||||
$user
|
||||
);
|
||||
|
||||
return [
|
||||
'tickets' => array_map([$this, 'summarize'], $result['tickets']),
|
||||
'page' => $result['current_page'],
|
||||
'pages' => $result['pages'],
|
||||
'total' => $result['total'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one ticket's full details and its comments.
|
||||
*
|
||||
* `links` lists the ticket's relationships from this ticket's point of view:
|
||||
* blocks, blocked_by, relates_to, duplicates (this ticket duplicates the
|
||||
* other) and duplicated_by (the other ticket duplicates this one). `blocked`
|
||||
* is true while any blocked_by ticket is not Closed.
|
||||
*
|
||||
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
|
||||
* @param bool $include_comments Include the ticket's comments, newest first.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getTicket(string $ticket_id, bool $include_comments = true): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$ticketModel = new \TicketModel($this->conn);
|
||||
|
||||
$ticketId = trim($ticket_id);
|
||||
$ticket = preg_match('/^\d+$/', $ticketId) ? $ticketModel->getTicketById($ticketId) : null;
|
||||
// Same "not found" for missing and not-visible, so a restricted
|
||||
// ticket's existence isn't revealed.
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
|
||||
throw new ToolCallException("Ticket {$ticketId} not found");
|
||||
}
|
||||
|
||||
$details = $this->summarize($ticket) + [
|
||||
'description' => $ticket['description'] ?? '',
|
||||
'visibility_groups' => $ticket['visibility_groups'] ?? null,
|
||||
'closed_at' => $ticket['closed_at'] ?? null,
|
||||
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
|
||||
];
|
||||
|
||||
$details['links'] = $this->links($ticketId, $user);
|
||||
$details['blocked'] = (bool)array_filter(
|
||||
$details['links'],
|
||||
fn(array $l) => $l['relation'] === 'blocked_by' && $l['status'] !== 'Closed'
|
||||
);
|
||||
|
||||
if ($include_comments) {
|
||||
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
|
||||
$details['comment_count'] = count($comments);
|
||||
$details['comments'] = array_map(fn(array $c) => [
|
||||
'comment_id' => (int)$c['comment_id'],
|
||||
'author' => $c['display_name'] ?? $c['username'] ?? $c['user_name'] ?? null,
|
||||
'created_at' => $c['created_at'] ?? null,
|
||||
'reply_to' => isset($c['parent_comment_id']) ? (int)$c['parent_comment_id'] : null,
|
||||
'text' => $c['comment_text'] ?? '',
|
||||
], array_slice($comments, 0, self::MAX_COMMENTS));
|
||||
if (count($comments) > self::MAX_COMMENTS) {
|
||||
$details['comments_truncated'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find open tickets with titles similar to the given text, or to an
|
||||
* existing ticket's title — the same check as the "possible duplicates"
|
||||
* list on the ticket page. Title matching only; use search_tickets for
|
||||
* keyword search over descriptions. To record a duplicate, call
|
||||
* link_tickets with relation "duplicates".
|
||||
*
|
||||
* @param string|null $title Title text to compare against (at least 5 characters).
|
||||
* @param string|null $ticket_id Or: an existing ticket whose title to compare; it is left out of the results.
|
||||
* @param int $limit Maximum matches, 1-10.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function findSimilarTickets(?string $title = null, ?string $ticket_id = null, int $limit = 5): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$excludeId = null;
|
||||
|
||||
if ($ticket_id !== null && trim($ticket_id) !== '') {
|
||||
$ticketModel = new \TicketModel($this->conn);
|
||||
$excludeId = trim($ticket_id);
|
||||
$ticket = preg_match('/^\d+$/', $excludeId) ? $ticketModel->getTicketById($excludeId) : null;
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
|
||||
throw new ToolCallException("Ticket {$excludeId} not found");
|
||||
}
|
||||
$title = (string)$ticket['title'];
|
||||
}
|
||||
if ($title === null || strlen(trim($title)) < 5) {
|
||||
throw new ToolCallException('Provide a title of at least 5 characters, or a ticket_id');
|
||||
}
|
||||
|
||||
$limit = min(10, max(1, $limit));
|
||||
// One extra so excluding the ticket itself still leaves $limit matches.
|
||||
$matches = \SimilarTicketService::find($this->conn, $user, $title, $limit + 1);
|
||||
$matches = array_values(array_filter($matches, fn(array $m) => (string)$m['ticket_id'] !== $excludeId));
|
||||
|
||||
return [
|
||||
'title' => trim($title),
|
||||
'matches' => array_map(fn(array $m) => [
|
||||
'ticket_id' => (string)$m['ticket_id'],
|
||||
'title' => $m['title'],
|
||||
'status' => $m['status'],
|
||||
'priority' => (int)$m['priority'],
|
||||
'similarity' => (int)$m['similarity'],
|
||||
'url' => \UrlHelper::ticketUrl((string)$m['ticket_id']),
|
||||
], array_slice($matches, 0, $limit)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ticket's links in both directions, each phrased from this ticket's
|
||||
* side. Linked tickets the user can't see are left out by the service.
|
||||
*
|
||||
* @param array<string, mixed> $user
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function links(string $ticketId, array $user): array
|
||||
{
|
||||
$result = \DependencyService::list($this->conn, $user, $ticketId);
|
||||
if (!$result['success']) {
|
||||
throw new ToolCallException($result['error']);
|
||||
}
|
||||
|
||||
// Stored as "<dependent> <type> <this ticket>"; restate from this side.
|
||||
$reverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to', 'duplicates' => 'duplicated_by'];
|
||||
$rows = [];
|
||||
foreach ($result['dependencies'] as $type => $deps) {
|
||||
foreach ($deps as $d) {
|
||||
$rows[] = [$type, (string)$d['depends_on_id'], $d];
|
||||
}
|
||||
}
|
||||
foreach ($result['dependents'] as $d) {
|
||||
$rows[] = [$reverse[$d['dependency_type']] ?? $d['dependency_type'], (string)$d['ticket_id'], $d];
|
||||
}
|
||||
|
||||
return array_map(fn(array $r) => [
|
||||
'relation' => $r[0],
|
||||
'ticket_id' => $r[1],
|
||||
'title' => $r[2]['title'] ?? null,
|
||||
'status' => $r[2]['status'] ?? null,
|
||||
'priority' => isset($r[2]['priority']) ? (int)$r[2]['priority'] : null,
|
||||
'url' => \UrlHelper::ticketUrl($r[1]),
|
||||
], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
private function resolveAssigneeFilter(string $assignee, array $user): int|string
|
||||
{
|
||||
$lower = strtolower($assignee);
|
||||
if ($lower === 'me') {
|
||||
return (int)$user['user_id'];
|
||||
}
|
||||
if ($lower === 'unassigned') {
|
||||
return 'unassigned';
|
||||
}
|
||||
$match = (new \UserModel($this->conn))->getUserByUsername($assignee);
|
||||
if (!$match) {
|
||||
throw new ToolCallException("Unknown user: {$assignee}");
|
||||
}
|
||||
return (int)$match['user_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $t
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summarize(array $t): array
|
||||
{
|
||||
return [
|
||||
'ticket_id' => (string)$t['ticket_id'],
|
||||
'title' => $t['title'] ?? '',
|
||||
'status' => $t['status'] ?? null,
|
||||
'priority' => isset($t['priority']) ? (int)$t['priority'] : null,
|
||||
'category' => $t['category'] ?? null,
|
||||
'type' => $t['type'] ?? null,
|
||||
'visibility' => $t['visibility'] ?? 'public',
|
||||
'assigned_to' => $t['assigned_display_name'] ?? $t['assigned_username'] ?? null,
|
||||
'created_by' => $t['creator_display_name'] ?? $t['creator_username'] ?? null,
|
||||
'created_at' => $t['created_at'] ?? null,
|
||||
'updated_at' => $t['updated_at'] ?? null,
|
||||
'url' => \UrlHelper::ticketUrl((string)$t['ticket_id']),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace TinkerTickets\Mcp\Tools;
|
||||
|
||||
use Mcp\Exception\ToolCallException;
|
||||
use TinkerTickets\Mcp\Auth\McpIdentity;
|
||||
|
||||
/**
|
||||
* Write tools. Each one is a thin adapter over the exact code path the web UI
|
||||
* uses (TicketCreationService, CommentService, ApiTicketController,
|
||||
* AssignmentService, DependencyService), run as the signed-in user, so permissions, workflow
|
||||
* rules, audit entries, notifications and stats-cache invalidation are
|
||||
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
|
||||
*/
|
||||
final class TicketWriteTools
|
||||
{
|
||||
private const VISIBILITIES = ['public', 'internal', 'confidential'];
|
||||
|
||||
public function __construct(private readonly \mysqli $conn)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ticket as you. It starts in the Open status.
|
||||
*
|
||||
* @param string $title Short summary of the issue.
|
||||
* @param string $description Full description (markdown supported).
|
||||
* @param int $priority 1 (critical) to 5 (minimal). Default 4.
|
||||
* @param string $category Ticket category, e.g. "General", "Hardware", "Network".
|
||||
* @param string $type Ticket type, e.g. "Issue", "Task", "Request".
|
||||
* @param string $visibility "public" (everyone), "internal" (only the listed groups), or "confidential" (only you, the assignee and admins).
|
||||
* @param string|null $visibility_groups Comma-separated group names; required when visibility is "internal".
|
||||
* @param string|null $assignee Username to assign to, or "me". Omit to leave unassigned.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createTicket(
|
||||
string $title,
|
||||
string $description,
|
||||
int $priority = 4,
|
||||
string $category = 'General',
|
||||
string $type = 'Issue',
|
||||
string $visibility = 'public',
|
||||
?string $visibility_groups = null,
|
||||
?string $assignee = null,
|
||||
): array {
|
||||
$user = McpIdentity::user();
|
||||
|
||||
// The web form constrains these with dropdowns; an API caller can send
|
||||
// anything, so validate them here before handing off.
|
||||
if ($priority < 1 || $priority > 5) {
|
||||
throw new ToolCallException('priority must be between 1 and 5');
|
||||
}
|
||||
if (!in_array($visibility, self::VISIBILITIES, true)) {
|
||||
throw new ToolCallException('visibility must be one of: ' . implode(', ', self::VISIBILITIES));
|
||||
}
|
||||
|
||||
$assignedTo = null;
|
||||
if ($assignee !== null && trim($assignee) !== '') {
|
||||
$assignedTo = $this->resolveUserId(trim($assignee), $user);
|
||||
}
|
||||
|
||||
$result = \TicketCreationService::create($this->conn, $user, [
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'priority' => (string)$priority,
|
||||
'category' => $category,
|
||||
'type' => $type,
|
||||
'status' => 'Open',
|
||||
'visibility' => $visibility,
|
||||
'visibility_groups' => $visibility_groups,
|
||||
'assigned_to' => $assignedTo,
|
||||
]);
|
||||
if (!$result['success']) {
|
||||
throw new ToolCallException($result['error']);
|
||||
}
|
||||
|
||||
return [
|
||||
'ticket_id' => (string)$result['ticket_id'],
|
||||
'url' => \UrlHelper::ticketUrl((string)$result['ticket_id']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a comment on a ticket as you. @username mentions notify that user.
|
||||
*
|
||||
* @param string $ticket_id The ticket ID.
|
||||
* @param string $text The comment text.
|
||||
* @param bool $markdown Render the comment as markdown.
|
||||
* @param int|null $reply_to comment_id of the comment you are replying to, if any.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function addComment(string $ticket_id, string $text, bool $markdown = true, ?int $reply_to = null): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$result = \CommentService::addComment($this->conn, $user, [
|
||||
'ticket_id' => $ticket_id,
|
||||
'comment_text' => $text,
|
||||
'markdown_enabled' => $markdown,
|
||||
'parent_comment_id' => $reply_to,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
throw new ToolCallException($this->errorMessage($result, $ticket_id));
|
||||
}
|
||||
|
||||
return [
|
||||
'comment_id' => (int)$result['comment_id'],
|
||||
'ticket_id' => trim($ticket_id),
|
||||
'mentions' => $result['mentions'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a ticket's status. Transitions follow the Workflow Designer rules;
|
||||
* some transitions (e.g. closing) require a comment, which is posted as the reason.
|
||||
*
|
||||
* @param string $ticket_id The ticket ID.
|
||||
* @param string $status New status, e.g. "Open", "Pending", "In Progress", "Closed".
|
||||
* @param string|null $comment Reason for the change; required for transitions that need one.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateStatus(string $ticket_id, string $status, ?string $comment = null): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
if (!in_array($status, $validStatuses, true)) {
|
||||
throw new ToolCallException('Unknown status. Valid statuses: ' . implode(', ', $validStatuses));
|
||||
}
|
||||
|
||||
$ticketId = trim($ticket_id);
|
||||
if (!ctype_digit($ticketId)) {
|
||||
throw new ToolCallException("Ticket {$ticketId} not found");
|
||||
}
|
||||
|
||||
$controller = new \ApiTicketController($this->conn, (int)$user['user_id'], !empty($user['is_admin']), $user);
|
||||
$data = ['status' => $status];
|
||||
if ($comment !== null && trim($comment) !== '') {
|
||||
$data['comment'] = $comment;
|
||||
$data['markdown_enabled'] = true;
|
||||
}
|
||||
$result = $controller->update($ticketId, $data);
|
||||
if (empty($result['success'])) {
|
||||
if (!empty($result['requires_comment'])) {
|
||||
throw new ToolCallException('This status change requires a comment explaining why. Call update_status again with a comment.');
|
||||
}
|
||||
throw new ToolCallException($this->errorMessage($result, $ticketId));
|
||||
}
|
||||
|
||||
// api/update_ticket.php invalidates the dashboard stats cache after a
|
||||
// successful update (outside the controller); do the same here.
|
||||
(new \StatsModel($this->conn))->invalidateCache();
|
||||
|
||||
return ['ticket_id' => $ticketId, 'status' => $result['status']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a ticket to someone, or unassign it. Only admins, the ticket's
|
||||
* creator, or its current assignee may do this.
|
||||
*
|
||||
* @param string $ticket_id The ticket ID.
|
||||
* @param string $assignee Username to assign to, "me", or "unassigned".
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assignTicket(string $ticket_id, string $assignee): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
$target = trim($assignee);
|
||||
$assignedTo = strtolower($target) === 'unassigned' || $target === ''
|
||||
? null
|
||||
: $this->resolveUserId($target, $user);
|
||||
|
||||
$result = \AssignmentService::assign($this->conn, $user, [
|
||||
'ticket_id' => $ticket_id,
|
||||
'assigned_to' => $assignedTo,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
throw new ToolCallException($this->errorMessage($result, $ticket_id));
|
||||
}
|
||||
|
||||
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* Link two tickets, as shown on the ticket page's Dependencies tab. Read it
|
||||
* as "ticket_id <relation> other_ticket_id":
|
||||
* - blocks: ticket_id must be done before other_ticket_id.
|
||||
* - blocked_by: ticket_id is waiting on other_ticket_id.
|
||||
* - relates_to: the tickets are related.
|
||||
* - duplicates: ticket_id is a duplicate of other_ticket_id (the original).
|
||||
* This only records the link; close the duplicate separately with
|
||||
* update_status if that's wanted.
|
||||
* Circular blocking chains and links that already exist (from either side)
|
||||
* are rejected.
|
||||
*
|
||||
* @param string $ticket_id The ticket the relation is stated from.
|
||||
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
|
||||
* @param string $other_ticket_id The other ticket.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function linkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
|
||||
|
||||
$result = \DependencyService::add($this->conn, $user, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $otherId,
|
||||
'dependency_type' => $relation,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
if (($result['error'] ?? '') === 'Target ticket not found') {
|
||||
throw new ToolCallException("Ticket {$otherId} not found");
|
||||
}
|
||||
throw new ToolCallException($this->errorMessage($result, $ticketId));
|
||||
}
|
||||
|
||||
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a link between two tickets. Takes the same arguments as
|
||||
* link_tickets; a link recorded from the other ticket's side ("B blocked_by A"
|
||||
* for "A blocks B") is found and removed too.
|
||||
*
|
||||
* @param string $ticket_id The ticket the relation is stated from.
|
||||
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
|
||||
* @param string $other_ticket_id The other ticket.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function unlinkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
|
||||
{
|
||||
$user = McpIdentity::user();
|
||||
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
|
||||
|
||||
// The same relationship can be stored from either ticket. duplicates
|
||||
// has no inverse type, so it is only ever stored one way.
|
||||
$inverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
|
||||
$attempts = [[$ticketId, $otherId, $relation]];
|
||||
if (isset($inverse[$relation])) {
|
||||
$attempts[] = [$otherId, $ticketId, $inverse[$relation]];
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
foreach ($attempts as [$from, $to, $type]) {
|
||||
$result = \DependencyService::remove($this->conn, $user, [
|
||||
'ticket_id' => $from,
|
||||
'depends_on_id' => $to,
|
||||
'dependency_type' => $type,
|
||||
]);
|
||||
if (empty($result['success'])) {
|
||||
throw new ToolCallException($this->errorMessage($result, $from));
|
||||
}
|
||||
$removed += (int)$result['removed'];
|
||||
}
|
||||
if ($removed === 0) {
|
||||
throw new ToolCallException("No {$relation} link between {$ticketId} and {$otherId}");
|
||||
}
|
||||
|
||||
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId, 'removed' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string}
|
||||
*/
|
||||
private function linkArgs(string $ticketId, string $relation, string $otherId): array
|
||||
{
|
||||
$ticketId = trim($ticketId);
|
||||
$otherId = trim($otherId);
|
||||
$relation = strtolower(trim($relation));
|
||||
if (!in_array($relation, \DependencyService::TYPES, true)) {
|
||||
throw new ToolCallException('relation must be one of: ' . implode(', ', \DependencyService::TYPES));
|
||||
}
|
||||
foreach ([$ticketId, $otherId] as $id) {
|
||||
if (!ctype_digit($id)) {
|
||||
throw new ToolCallException("Ticket {$id} not found");
|
||||
}
|
||||
}
|
||||
if ($ticketId === $otherId) {
|
||||
throw new ToolCallException('A ticket cannot be linked to itself');
|
||||
}
|
||||
return [$ticketId, $otherId, $relation];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
private function resolveUserId(string $username, array $user): int
|
||||
{
|
||||
if (strtolower($username) === 'me') {
|
||||
return (int)$user['user_id'];
|
||||
}
|
||||
$match = (new \UserModel($this->conn))->getUserByUsername($username);
|
||||
if (!$match) {
|
||||
throw new ToolCallException("Unknown user: {$username}");
|
||||
}
|
||||
return (int)$match['user_id'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a shared-service failure to a tool error. A ticket the user can't
|
||||
* see reads as "not found" (like get_ticket), never "access denied", so a
|
||||
* restricted ticket's existence isn't revealed.
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function errorMessage(array $result, string $ticketId): string
|
||||
{
|
||||
$error = (string)($result['error'] ?? 'Request failed');
|
||||
if (in_array($error, ['Access denied', 'Ticket not found', 'Invalid ticket ID', 'Ticket ID required'], true)) {
|
||||
return 'Ticket ' . trim($ticketId) . ' not found';
|
||||
}
|
||||
return $error;
|
||||
}
|
||||
}
|
||||
@@ -263,21 +263,8 @@ class AuthMiddleware
|
||||
*/
|
||||
private function checkGroupAccess($groups)
|
||||
{
|
||||
if (empty($groups)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for admin or employee group membership
|
||||
// Filter to safe characters only to prevent header injection attacks
|
||||
$userGroups = array_filter(
|
||||
array_map('trim', explode(',', strtolower($groups))),
|
||||
function ($g) {
|
||||
return preg_match('/^[a-z0-9_\-]+$/', $g);
|
||||
}
|
||||
);
|
||||
$requiredGroups = ['admin', 'employee'];
|
||||
|
||||
return !empty(array_intersect($userGroups, $requiredGroups));
|
||||
require_once dirname(__DIR__) . '/helpers/AccessPolicy.php';
|
||||
return AccessPolicy::hasAppAccess((string)($groups ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -561,20 +561,27 @@ class AuditLogModel
|
||||
* Get formatted timeline for a specific ticket
|
||||
* Includes all ticket updates and comments
|
||||
*
|
||||
* Returns only the newest $limit events: a ticket touched by an automated
|
||||
* reporter can accumulate tens of thousands of rows, and loading them all
|
||||
* exhausted PHP memory on the ticket page (tinker_tickets#112).
|
||||
*
|
||||
* @param string $ticketId Ticket ID
|
||||
* @param int $limit Maximum number of events (newest first)
|
||||
* @return array Timeline events
|
||||
*/
|
||||
public function getTicketTimeline($ticketId)
|
||||
public function getTicketTimeline($ticketId, $limit = self::DEFAULT_LIMIT)
|
||||
{
|
||||
$limit = $this->validateLimit((int)$limit, self::EXPORT_LIMIT);
|
||||
$stmt = $this->conn->prepare(
|
||||
"SELECT al.*, u.username, u.display_name
|
||||
FROM audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.user_id
|
||||
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
|
||||
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
|
||||
ORDER BY al.created_at DESC"
|
||||
ORDER BY al.created_at DESC, al.audit_id DESC
|
||||
LIMIT ?"
|
||||
);
|
||||
$stmt->bind_param("ss", $ticketId, $ticketId);
|
||||
$stmt->bind_param("ssi", $ticketId, $ticketId, $limit);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
|
||||
|
||||
@@ -219,14 +219,14 @@ class DependencyModel
|
||||
* Remove a dependency
|
||||
*
|
||||
* @param int $dependencyId Dependency ID
|
||||
* @return bool Success status
|
||||
* @return int|false Rows removed (0 if it no longer existed), or false on failure
|
||||
*/
|
||||
public function removeDependency($dependencyId)
|
||||
{
|
||||
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("i", $dependencyId);
|
||||
$result = $stmt->execute();
|
||||
$result = $stmt->execute() ? $stmt->affected_rows : false;
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
@@ -237,7 +237,7 @@ class DependencyModel
|
||||
* @param string $ticketId Source ticket ID
|
||||
* @param string $dependsOnId Target ticket ID
|
||||
* @param string $type Dependency type
|
||||
* @return bool Success status
|
||||
* @return int|false Rows removed (0 if no such link), or false on failure
|
||||
*/
|
||||
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
|
||||
{
|
||||
@@ -245,7 +245,7 @@ class DependencyModel
|
||||
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
|
||||
$stmt = $this->conn->prepare($sql);
|
||||
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
|
||||
$result = $stmt->execute();
|
||||
$result = $stmt->execute() ? $stmt->affected_rows : false;
|
||||
$stmt->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Assigning / unassigning a ticket: access check, permission (admin, creator,
|
||||
* or current assignee), audit log, optional Matrix assignment notification,
|
||||
* stats-cache invalidation.
|
||||
*
|
||||
* Shared by the web UI (api/assign_ticket.php) and the MCP assign_ticket tool
|
||||
* so both run one code path (tinker_tickets#111). Extracted verbatim from
|
||||
* assign_ticket.php. Returns result arrays; failures carry 'http_status'.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/UserModel.php';
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||
|
||||
class AssignmentService
|
||||
{
|
||||
/**
|
||||
* @param array $currentUser Authenticated user row (user_id, username, display_name, is_admin, ...)
|
||||
* @param array $data ticket_id, assigned_to (user_id; null/'' to unassign)
|
||||
*/
|
||||
public static function assign(mysqli $conn, array $currentUser, array $data): array
|
||||
{
|
||||
$userId = $currentUser['user_id'];
|
||||
$isAdmin = $currentUser['is_admin'] ?? false;
|
||||
|
||||
$ticketIdRaw = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
$assignedTo = $data['assigned_to'] ?? null;
|
||||
|
||||
if (!ctype_digit($ticketIdRaw) || (int)$ticketIdRaw <= 0) {
|
||||
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
|
||||
}
|
||||
$ticketId = $ticketIdRaw;
|
||||
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLogModel = new AuditLogModel($conn);
|
||||
$userModel = new UserModel($conn);
|
||||
|
||||
// Verify ticket exists and user can access it
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
|
||||
}
|
||||
|
||||
// Authorization: only admins or the ticket creator/assignee can reassign
|
||||
if (!$isAdmin && (int)$ticket['created_by'] !== (int)$userId && (int)$ticket['assigned_to'] !== (int)$userId) {
|
||||
return ['success' => false, 'error' => 'Permission denied', 'http_status' => 403];
|
||||
}
|
||||
|
||||
if ($assignedTo === null || $assignedTo === '') {
|
||||
// Unassign ticket
|
||||
$success = $ticketModel->unassignTicket($ticketId, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'unassign', 'ticket', $ticketId);
|
||||
}
|
||||
} else {
|
||||
// Validate assigned_to is a valid user ID
|
||||
$assignedTo = (int)$assignedTo;
|
||||
$targetUser = $userModel->getUserById($assignedTo);
|
||||
if (!$targetUser) {
|
||||
return ['success' => false, 'error' => 'Invalid user ID', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Assign ticket
|
||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||
if ($success) {
|
||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
||||
|
||||
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) {
|
||||
$changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||
$assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null;
|
||||
$assigneeMatrix = isset($targetUser['username'])
|
||||
? SynapseHelper::resolveUsername($targetUser['username'])
|
||||
: null;
|
||||
NotificationHelper::sendAssignmentNotification(
|
||||
$ticketId,
|
||||
$ticket['title'] ?? "Ticket #{$ticketId}",
|
||||
$assigneeName,
|
||||
$assigneeMatrix,
|
||||
$changedByDisplay,
|
||||
$ticket['visibility'] ?? 'public'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
return ['success' => false, 'error' => 'Failed to update ticket assignment', 'http_status' => 500];
|
||||
}
|
||||
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
return ['success' => true];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Adding a comment to a ticket: validation, access check, reply-parent check,
|
||||
* @mention extraction (audit-logged, and notified only to mentioned users who
|
||||
* can see the ticket), comment + watcher notifications.
|
||||
*
|
||||
* Shared by the web UI (api/add_comment.php) and the MCP add_comment tool so
|
||||
* both run one code path (tinker_tickets#111). Extracted verbatim from
|
||||
* add_comment.php. Returns result arrays; on validation failure the array
|
||||
* carries 'http_status' for HTTP callers. Callers own sessions/CSRF/responses.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||
|
||||
class CommentService
|
||||
{
|
||||
/**
|
||||
* @param array $currentUser Authenticated user row (user_id, username, display_name, groups, is_admin)
|
||||
* @param array $data ticket_id, comment_text, optional markdown_enabled / parent_comment_id
|
||||
*/
|
||||
public static function addComment(mysqli $conn, array $currentUser, array $data): array
|
||||
{
|
||||
$userId = $currentUser['user_id'];
|
||||
|
||||
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
|
||||
if (!ctype_digit($ticketId) || (int)$ticketId <= 0) {
|
||||
return ['success' => false, 'error' => 'Invalid ticket ID', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Reject empty/whitespace-only comments
|
||||
$commentTextRaw = isset($data['comment_text']) ? trim((string)$data['comment_text']) : '';
|
||||
if ($commentTextRaw === '') {
|
||||
return ['success' => false, 'error' => 'Comment text cannot be empty', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Persist the trimmed text (not the raw client value) — matches update_comment.php
|
||||
// and keeps stored comment_text free of leading whitespace that could shift a
|
||||
// markdown-enabled comment's first line out of column 0 on reload.
|
||||
$data['comment_text'] = $commentTextRaw;
|
||||
|
||||
// Never trust a client-supplied display name — always attribute the comment to
|
||||
// the authenticated user.
|
||||
$data['user_name'] = $currentUser['display_name'] ?? $currentUser['username'] ?? 'User';
|
||||
|
||||
// Verify user can access the ticket before allowing a comment
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
|
||||
}
|
||||
if (!$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
|
||||
}
|
||||
|
||||
// Initialize models
|
||||
$commentModel = new CommentModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
|
||||
// If replying, the parent comment must belong to this same (accessible) ticket.
|
||||
if (isset($data['parent_comment_id']) && $data['parent_comment_id'] !== null && $data['parent_comment_id'] !== '') {
|
||||
$parentComment = $commentModel->getCommentById((int)$data['parent_comment_id']);
|
||||
if (!$parentComment || (string)$parentComment['ticket_id'] !== (string)$ticketId) {
|
||||
return ['success' => false, 'error' => 'Invalid parent comment', 'http_status' => 400];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract @mentions from comment text
|
||||
$mentions = $commentModel->extractMentions($data['comment_text'] ?? '');
|
||||
$mentionedUsers = [];
|
||||
if (!empty($mentions)) {
|
||||
$mentionedUsers = $commentModel->getMentionedUsers($mentions);
|
||||
}
|
||||
|
||||
// Add comment with user tracking
|
||||
$result = $commentModel->addComment($ticketId, $data, $userId);
|
||||
|
||||
// Log comment creation to audit log
|
||||
if ($result['success'] && isset($result['comment_id'])) {
|
||||
$auditLog->logCommentCreate($userId, $result['comment_id'], $ticketId);
|
||||
|
||||
// Log mentions to audit log
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$auditLog->log(
|
||||
$userId,
|
||||
'mention',
|
||||
'user',
|
||||
(string)$mentionedUser['user_id'],
|
||||
[
|
||||
'ticket_id' => $ticketId,
|
||||
'comment_id' => $result['comment_id'],
|
||||
'mentioned_username' => $mentionedUser['username']
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Matrix notifications
|
||||
$authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null;
|
||||
$commentText = $data['comment_text'] ?? '';
|
||||
$ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}";
|
||||
$ticketVisibility = $ticket['visibility'] ?? 'public';
|
||||
|
||||
// @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API.
|
||||
// Only notify mentioned users who actually have access to this ticket;
|
||||
// otherwise a mention would DM them the ticket's title and comment text
|
||||
// even though canUserAccessTicket() would deny them the ticket itself.
|
||||
$accessibleMentionedUsers = array_filter(
|
||||
$mentionedUsers,
|
||||
fn($u) => $ticketModel->canUserAccessTicket($ticket, $u)
|
||||
);
|
||||
if (!empty($accessibleMentionedUsers)) {
|
||||
$mentionedUsernames = array_column($accessibleMentionedUsers, 'username');
|
||||
$mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames);
|
||||
if (!empty($mentionedMatrixIds)) {
|
||||
NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds);
|
||||
}
|
||||
}
|
||||
|
||||
// General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS)
|
||||
if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) {
|
||||
NotificationHelper::sendCommentNotification(
|
||||
$ticketId,
|
||||
$ticketTitle,
|
||||
$commentText,
|
||||
$authorDisplay,
|
||||
$ticketVisibility !== 'public',
|
||||
$ticketVisibility
|
||||
);
|
||||
}
|
||||
|
||||
// Notify watchers of the new comment
|
||||
NotificationHelper::notifyWatchers(
|
||||
$conn,
|
||||
$ticketId,
|
||||
$ticketTitle,
|
||||
'comment_added',
|
||||
['author' => $authorDisplay, 'preview' => mb_strimwidth($commentText, 0, 200, '…')],
|
||||
(int)$userId,
|
||||
$ticketVisibility
|
||||
);
|
||||
|
||||
// Add mentioned users to result for frontend
|
||||
$result['mentions'] = array_map(function ($u) {
|
||||
return $u['username'];
|
||||
}, $mentionedUsers);
|
||||
}
|
||||
|
||||
// Add user info to result for frontend avatar rendering
|
||||
if ($result['success']) {
|
||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||
$result['user_id'] = $userId;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Listing, adding and removing ticket dependencies (blocks / blocked_by /
|
||||
* relates_to / duplicates): access checks on the tickets involved,
|
||||
* DependencyModel's duplicate/inverse/cycle checks, and audit logging.
|
||||
*
|
||||
* Shared by the web UI (api/ticket_dependencies.php) and the MCP
|
||||
* link_tickets / unlink_tickets / get_ticket tools so both run one code path.
|
||||
* Extracted from ticket_dependencies.php with the same checks, order and
|
||||
* error messages. Returns result arrays; failures carry 'http_status' for
|
||||
* HTTP callers. Callers own sessions/CSRF/responses.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/DependencyModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
class DependencyService
|
||||
{
|
||||
public const TYPES = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
|
||||
|
||||
/**
|
||||
* Links from and to a ticket, limited to linked tickets the user can see.
|
||||
*
|
||||
* @param array $currentUser Authenticated user row (user_id, groups, is_admin)
|
||||
* @return array ['success' => true, 'dependencies' => [type => rows], 'dependents' => rows]
|
||||
*/
|
||||
public static function list(mysqli $conn, array $currentUser, $ticketId): array
|
||||
{
|
||||
if (!$ticketId) {
|
||||
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Verify user can access this ticket
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$ticket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
|
||||
}
|
||||
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
[$userId, $userGroups, $isAdmin] = self::viewer($currentUser);
|
||||
try {
|
||||
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
|
||||
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
|
||||
} catch (Exception $e) {
|
||||
error_log('DependencyService::list query error: ' . $e->getMessage());
|
||||
return ['success' => false, 'error' => 'Failed to retrieve dependencies', 'http_status' => 500];
|
||||
}
|
||||
|
||||
return ['success' => true, 'dependencies' => $dependencies, 'dependents' => $dependents];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data ticket_id, depends_on_id, dependency_type (default "blocks")
|
||||
* @return array ['success' => true, 'dependency_id' => int] or an error
|
||||
*/
|
||||
public static function add(mysqli $conn, array $currentUser, array $data): array
|
||||
{
|
||||
$ticketId = $data['ticket_id'] ?? null;
|
||||
$dependsOnId = $data['depends_on_id'] ?? null;
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
if (!$ticketId || !$dependsOnId) {
|
||||
return ['success' => false, 'error' => 'Both ticket_id and depends_on_id are required', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Verify user can access both tickets before creating dependency
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
|
||||
}
|
||||
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
|
||||
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Target ticket not found', 'http_status' => 404];
|
||||
}
|
||||
|
||||
$result = (new DependencyModel($conn))->addDependency($ticketId, $dependsOnId, $type, $currentUser['user_id']);
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error'], 'http_status' => 400];
|
||||
}
|
||||
|
||||
(new AuditLogModel($conn))->log($currentUser['user_id'], 'create', 'dependency', (string)$result['dependency_id'], [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one link, by dependency_id or by (ticket_id, depends_on_id, dependency_type).
|
||||
*
|
||||
* @return array ['success' => true, 'removed' => rows deleted] or an error
|
||||
*/
|
||||
public static function remove(mysqli $conn, array $currentUser, array $data): array
|
||||
{
|
||||
$dependencyModel = new DependencyModel($conn);
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$auditLog = new AuditLogModel($conn);
|
||||
$dependencyId = $data['dependency_id'] ?? null;
|
||||
|
||||
// Alternative: delete by ticket IDs
|
||||
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
|
||||
$ticketId = $data['ticket_id'];
|
||||
$dependsOnId = $data['depends_on_id'];
|
||||
$type = $data['dependency_type'] ?? 'blocks';
|
||||
|
||||
// Validate dependency type
|
||||
if (!in_array($type, self::TYPES, true)) {
|
||||
return ['success' => false, 'error' => 'Invalid dependency type', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Verify user can access the source ticket
|
||||
$srcTicket = $ticketModel->getTicketById($ticketId);
|
||||
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
|
||||
}
|
||||
|
||||
$removed = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
|
||||
if ($removed === false) {
|
||||
return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400];
|
||||
}
|
||||
// Only audit a removal that happened (the web UI treats removing
|
||||
// a link that is already gone as success).
|
||||
if ($removed > 0) {
|
||||
$auditLog->log($currentUser['user_id'], 'delete', 'dependency', null, [
|
||||
'ticket_id' => $ticketId,
|
||||
'depends_on_id' => $dependsOnId,
|
||||
'type' => $type
|
||||
]);
|
||||
}
|
||||
return ['success' => true, 'removed' => $removed];
|
||||
}
|
||||
|
||||
if (!$dependencyId) {
|
||||
return ['success' => false, 'error' => 'Dependency ID or ticket IDs required', 'http_status' => 400];
|
||||
}
|
||||
|
||||
// Look up dependency to verify ticket access before deletion
|
||||
$depLookupStmt = $conn->prepare("SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?");
|
||||
$depLookupStmt->bind_param("i", $dependencyId);
|
||||
$depLookupStmt->execute();
|
||||
$depRow = $depLookupStmt->get_result()->fetch_assoc();
|
||||
$depLookupStmt->close();
|
||||
|
||||
if (!$depRow) {
|
||||
return ['success' => false, 'error' => 'Dependency not found', 'http_status' => 404];
|
||||
}
|
||||
|
||||
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
|
||||
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
|
||||
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
|
||||
}
|
||||
|
||||
$removed = $dependencyModel->removeDependency($dependencyId);
|
||||
if ($removed === false) {
|
||||
return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400];
|
||||
}
|
||||
$auditLog->log($currentUser['user_id'], 'delete', 'dependency', (string)$dependencyId);
|
||||
return ['success' => true, 'removed' => $removed];
|
||||
}
|
||||
|
||||
/**
|
||||
* users.groups is a comma-separated string; the dependency model expects an array.
|
||||
*
|
||||
* @return array{0:mixed,1:array,2:bool}
|
||||
*/
|
||||
private static function viewer(array $currentUser): array
|
||||
{
|
||||
return [
|
||||
$currentUser['user_id'] ?? null,
|
||||
array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? '')))),
|
||||
(bool)($currentUser['is_admin'] ?? false),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Finding open tickets whose titles look like a given title (LIKE + SOUNDEX +
|
||||
* word overlap), limited to tickets the user can see, best match first.
|
||||
*
|
||||
* Shared by the web UI (api/check_duplicates.php, the ticket page's
|
||||
* "possible duplicates" list) and the MCP find_similar_tickets tool.
|
||||
* Extracted from check_duplicates.php.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
|
||||
class SimilarTicketService
|
||||
{
|
||||
/**
|
||||
* @param array $currentUser Authenticated user row (for visibility)
|
||||
* @return array Matches: ticket_id, title, status, priority, created_at, similarity (0-100)
|
||||
*/
|
||||
public static function find(mysqli $conn, array $currentUser, string $title, int $limit = 5): array
|
||||
{
|
||||
$title = trim($title);
|
||||
|
||||
if (strlen($title) < 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Search for similar titles
|
||||
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
|
||||
$duplicates = [];
|
||||
|
||||
// Prepare search term for LIKE
|
||||
$searchTerm = '%' . $title . '%';
|
||||
$titleWords = self::words($title);
|
||||
|
||||
// Build visibility filter so users only see titles they have access to
|
||||
$ticketModel = new TicketModel($conn);
|
||||
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
|
||||
|
||||
// Candidates: the whole title as a substring, a SOUNDEX match, or any
|
||||
// significant word (4+ letters) in common. The scoring below decides
|
||||
// what counts as similar; without the word candidates its
|
||||
// word-overlap branch could never match anything, so e.g. "Printer
|
||||
// jammed again" never surfaced "Printer is jammed".
|
||||
$words = array_slice(array_values(array_filter($titleWords, fn($w) => mb_strlen($w) >= 4)), 0, 8);
|
||||
$wordSql = str_repeat(' OR t.title LIKE ?', count($words));
|
||||
$wordParams = array_map(fn($w) => '%' . addcslashes($w, '%_\\') . '%', $words);
|
||||
|
||||
// Aliased as `t`: the visibility filter's SQL refers to t.* columns.
|
||||
// Without the alias the query failed for every non-admin and the
|
||||
// error was swallowed below, so they never saw any matches.
|
||||
// SOUNDEX is compared in SQL on both sides: PHP's soundex() keeps only
|
||||
// 4 characters (effectively the first word), which never equalled
|
||||
// MariaDB's full-length code in the WHERE and, in the scoring, made any
|
||||
// two titles sharing a first word score as "sounds alike".
|
||||
$sql = "SELECT t.ticket_id, t.title, t.status, t.priority, t.created_at,
|
||||
SOUNDEX(t.title) = SOUNDEX(?) AS sounds_alike
|
||||
FROM tickets t
|
||||
WHERE (
|
||||
t.title LIKE ?
|
||||
OR SOUNDEX(t.title) = SOUNDEX(?){$wordSql}
|
||||
)
|
||||
AND t.status != 'Closed'
|
||||
AND ({$visFilter['sql']})
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50";
|
||||
|
||||
$types = "sss" . str_repeat('s', count($words)) . $visFilter['types'];
|
||||
$params = array_merge([$title, $searchTerm, $title], $wordParams, $visFilter['params']);
|
||||
|
||||
// Duplicate detection is advisory (it must not block ticket creation), so on any
|
||||
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
|
||||
// mysqli may throw (default exception mode) or return false depending on config.
|
||||
try {
|
||||
$stmt = $conn->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new RuntimeException('prepare failed: ' . $conn->error);
|
||||
}
|
||||
if (!empty($params)) {
|
||||
$stmt->bind_param($types, ...$params);
|
||||
}
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
if ($result === false) {
|
||||
// Non-exception mysqli mode: execute/get_result return false instead of
|
||||
// throwing. Treat as a query failure so we don't fatal on $result below.
|
||||
throw new RuntimeException('query failed: ' . $conn->error);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('check_duplicates: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
// Calculate similarity score
|
||||
$similarity = 0;
|
||||
|
||||
// Check for exact substring match
|
||||
if (stripos($row['title'], $title) !== false) {
|
||||
$similarity = 90;
|
||||
// Check SOUNDEX match
|
||||
} elseif (!empty($row['sounds_alike'])) {
|
||||
$similarity = 70;
|
||||
// Check word overlap
|
||||
} else {
|
||||
// At least two shared words: one common word (e.g. the
|
||||
// "[problem]" tag every automated ticket carries) is not
|
||||
// similarity, however short the searched title is.
|
||||
$matchingWords = array_intersect($titleWords, self::words($row['title']));
|
||||
if (count($matchingWords) >= 2) {
|
||||
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
|
||||
}
|
||||
}
|
||||
|
||||
if ($similarity >= 30) {
|
||||
$duplicates[] = [
|
||||
'ticket_id' => $row['ticket_id'],
|
||||
'title' => $row['title'],
|
||||
'status' => $row['status'],
|
||||
'priority' => $row['priority'],
|
||||
'created_at' => $row['created_at'],
|
||||
'similarity' => round($similarity)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
|
||||
// Sort by similarity descending
|
||||
usort($duplicates, function ($a, $b) {
|
||||
return $b['similarity'] - $a['similarity'];
|
||||
});
|
||||
|
||||
// Keep the best matches
|
||||
return array_slice($duplicates, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct lowercase words of a title, split on anything that isn't a
|
||||
* letter or digit (so "[ceph]" and "ceph" are the same word).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function words(string $title): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(
|
||||
preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($title)),
|
||||
fn($w) => $w !== ''
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Creating a ticket as a user: required-field and required-custom-field
|
||||
* validation, TicketModel::createTicket (which enforces visibility rules),
|
||||
* audit log, stats-cache invalidation, custom field values, optional
|
||||
* "duplicates" link, and the new-ticket Matrix notification.
|
||||
*
|
||||
* Shared by the web UI (TicketController::create) and the MCP create_ticket
|
||||
* tool so both run one code path (tinker_tickets#111). Extracted from
|
||||
* TicketController::create with the same checks, order and error messages.
|
||||
* Callers own request parsing, CSRF, and rendering.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
|
||||
require_once dirname(__DIR__) . '/models/StatsModel.php';
|
||||
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||
|
||||
class TicketCreationService
|
||||
{
|
||||
/**
|
||||
* @param array $currentUser Authenticated user row
|
||||
* @param array $input title, description, priority, category, type, status,
|
||||
* visibility, visibility_groups (string or list), assigned_to,
|
||||
* custom_fields (field_id => value), link_duplicate_of
|
||||
*
|
||||
* @return array ['success' => true, 'ticket_id' => ...] or ['success' => false, 'error' => ...]
|
||||
*/
|
||||
public static function create(mysqli $conn, array $currentUser, array $input): array
|
||||
{
|
||||
$userId = $currentUser['user_id'] ?? null;
|
||||
|
||||
// Handle visibility groups (a list from the web form's checkboxes, or a string)
|
||||
$visibilityGroups = null;
|
||||
if (isset($input['visibility_groups']) && is_array($input['visibility_groups'])) {
|
||||
$visibilityGroups = implode(',', array_map('trim', $input['visibility_groups']));
|
||||
} elseif (isset($input['visibility_groups']) && is_string($input['visibility_groups']) && trim($input['visibility_groups']) !== '') {
|
||||
$visibilityGroups = trim($input['visibility_groups']);
|
||||
}
|
||||
|
||||
// Honor the posted status, validated against the app's canonical list
|
||||
$validStatuses = $GLOBALS['config']['TICKET_STATUSES'] ?? ['Open', 'Pending', 'In Progress', 'Closed'];
|
||||
$status = $input['status'] ?? 'Open';
|
||||
if (!in_array($status, $validStatuses, true)) {
|
||||
$status = 'Open';
|
||||
}
|
||||
|
||||
$ticketData = [
|
||||
'title' => trim($input['title'] ?? ''),
|
||||
'description' => $input['description'] ?? '',
|
||||
'priority' => $input['priority'] ?? '4',
|
||||
'category' => $input['category'] ?? 'General',
|
||||
'type' => $input['type'] ?? 'Issue',
|
||||
'status' => $status,
|
||||
'visibility' => $input['visibility'] ?? 'public',
|
||||
'visibility_groups' => $visibilityGroups,
|
||||
'assigned_to' => !empty($input['assigned_to']) ? $input['assigned_to'] : null
|
||||
];
|
||||
|
||||
if ($ticketData['title'] === '') {
|
||||
return ['success' => false, 'error' => 'Title is required'];
|
||||
}
|
||||
|
||||
if (trim($ticketData['description']) === '') {
|
||||
return ['success' => false, 'error' => 'Description is required'];
|
||||
}
|
||||
|
||||
// Custom fields applicable to the submitted category — validate
|
||||
// is_required server-side (the form is novalidate, and a field
|
||||
// hidden by the client-side category toggle must not silently
|
||||
// bypass a requirement that applies to the category actually
|
||||
// submitted).
|
||||
$customFieldModel = new CustomFieldModel($conn);
|
||||
$allCustomFieldDefs = $customFieldModel->getAllDefinitions(null, true);
|
||||
$submittedCustomFields = is_array($input['custom_fields'] ?? null) ? $input['custom_fields'] : [];
|
||||
$applicableFieldDefs = array_filter(
|
||||
$allCustomFieldDefs,
|
||||
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
|
||||
);
|
||||
$customFieldsToSave = [];
|
||||
foreach ($applicableFieldDefs as $def) {
|
||||
$fieldId = (int)$def['field_id'];
|
||||
$raw = $submittedCustomFields[$fieldId] ?? null;
|
||||
$normalized = $def['field_type'] === 'checkbox'
|
||||
? (!empty($raw) ? '1' : '0')
|
||||
: (is_scalar($raw) ? trim((string)$raw) : '');
|
||||
|
||||
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
|
||||
return ['success' => false, 'error' => $def['field_label'] . ' is required'];
|
||||
}
|
||||
|
||||
if ($normalized !== '') {
|
||||
$customFieldsToSave[$fieldId] = $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Create ticket with user tracking
|
||||
$result = (new TicketModel($conn))->createTicket($ticketData, $userId);
|
||||
if (!$result['success']) {
|
||||
return ['success' => false, 'error' => $result['error']];
|
||||
}
|
||||
|
||||
// Log ticket creation to audit log
|
||||
if ($userId) {
|
||||
(new AuditLogModel($conn))->logTicketCreate($userId, $result['ticket_id'], $ticketData);
|
||||
}
|
||||
|
||||
// Ticket counts changed — invalidate the cached dashboard stats
|
||||
(new StatsModel($conn))->invalidateCache();
|
||||
|
||||
// Persist custom field values for the fields applicable to this ticket's category
|
||||
if (!empty($customFieldsToSave)) {
|
||||
$customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
|
||||
}
|
||||
|
||||
// Auto-link as duplicate if requested
|
||||
$linkDupOfRaw = trim((string)($input['link_duplicate_of'] ?? ''));
|
||||
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
|
||||
$depSql = "INSERT IGNORE INTO ticket_dependencies (ticket_id, depends_on_id, dependency_type, created_by)
|
||||
VALUES (?, ?, 'duplicates', ?)";
|
||||
$depStmt = $conn->prepare($depSql);
|
||||
$depStmt->bind_param("ssi", $result['ticket_id'], $linkDupOfRaw, $userId);
|
||||
$depStmt->execute();
|
||||
$depStmt->close();
|
||||
}
|
||||
|
||||
// Send Matrix notification for new ticket
|
||||
NotificationHelper::sendTicketNotification($result['ticket_id'], $ticketData, 'manual');
|
||||
|
||||
return ['success' => true, 'ticket_id' => $result['ticket_id']];
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</div>
|
||||
<?php endforeach ?>
|
||||
</div>
|
||||
<?php if (!empty($timelineTruncated)) : ?>
|
||||
<div class="lt-empty lt-text-xs">Showing the <?= count($timeline) ?> most recent events; older activity is in the audit log.</div>
|
||||
<?php endif ?>
|
||||
<?php endif ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user