Add assignment dropdown on ticket creation and fix Discord webhook URLs

- Add APP_DOMAIN config for correct Discord webhook ticket links
- Add "Assign To" dropdown on create ticket form
- Update TicketModel.createTicket() to support assigned_to field
- Update documentation for APP_DOMAIN requirement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 10:24:00 -05:00
parent e8b2f670b9
commit 019eaf8980
9 changed files with 56 additions and 7 deletions

View File

@@ -10,5 +10,13 @@ DB_NAME=ticketing_system
# Discord Webhook (optional - for notifications) # Discord Webhook (optional - for notifications)
DISCORD_WEBHOOK_URL= DISCORD_WEBHOOK_URL=
# Application Domain (required for Discord webhook links)
# Set this to your public domain (e.g., t.lotusguild.org)
APP_DOMAIN=
# Allowed Hosts for HTTP_HOST validation (comma-separated)
# Include all domains that can access this application
ALLOWED_HOSTS=localhost,127.0.0.1
# Timezone (default: America/New_York) # Timezone (default: America/New_York)
TIMEZONE=America/New_York TIMEZONE=America/New_York

View File

@@ -241,6 +241,7 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade
10. **API routing**: All API endpoints must be added to `index.php` router 10. **API routing**: All API endpoints must be added to `index.php` router
11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again 11. **Session in APIs**: RateLimitMiddleware starts session; don't call session_start() again
12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables 12. **Database collation**: Use `utf8mb4_general_ci` (not unicode_ci) for new tables
13. **Discord webhook URLs**: Set `APP_DOMAIN` in `.env` for correct ticket URLs in Discord notifications
13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files 13. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute 14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
15. **Visibility validation**: Internal visibility requires groups; code validates this 15. **Visibility validation**: Internal visibility requires groups; code validates this

View File

@@ -187,9 +187,12 @@ DB_USER=tinkertickets
DB_PASS=your_password DB_PASS=your_password
DB_NAME=ticketing_system DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=t.lotusguild.org
TIMEZONE=America/New_York TIMEZONE=America/New_York
``` ```
**Note**: `APP_DOMAIN` is required for Discord webhook ticket links to work correctly. Without it, links will default to localhost.
### 2. Cron Jobs ### 2. Cron Jobs
Add to crontab for recurring tickets: Add to crontab for recurring tickets:

View File

@@ -643,6 +643,10 @@ function loadTemplate() {
document.getElementById('priority').value = '4'; document.getElementById('priority').value = '4';
document.getElementById('category').value = 'General'; document.getElementById('category').value = 'General';
document.getElementById('type').value = 'Issue'; document.getElementById('type').value = 'Issue';
const assignedToSelect = document.getElementById('assigned_to');
if (assignedToSelect) {
assignedToSelect.value = '';
}
return; return;
} }

View File

@@ -108,13 +108,15 @@ class TicketController {
'category' => $_POST['category'] ?? 'General', 'category' => $_POST['category'] ?? 'General',
'type' => $_POST['type'] ?? 'Issue', 'type' => $_POST['type'] ?? 'Issue',
'visibility' => $_POST['visibility'] ?? 'public', 'visibility' => $_POST['visibility'] ?? 'public',
'visibility_groups' => $visibilityGroups 'visibility_groups' => $visibilityGroups,
'assigned_to' => !empty($_POST['assigned_to']) ? $_POST['assigned_to'] : null
]; ];
// Validate input // Validate input
if (empty($ticketData['title'])) { if (empty($ticketData['title'])) {
$error = "Title is required"; $error = "Title is required";
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view $conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
@@ -138,6 +140,7 @@ class TicketController {
} else { } else {
$error = $result['error']; $error = $result['error'];
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view $conn = $this->conn; // Make $conn available to view
include dirname(__DIR__) . '/views/CreateTicketView.php'; include dirname(__DIR__) . '/views/CreateTicketView.php';
return; return;
@@ -145,6 +148,8 @@ class TicketController {
} else { } else {
// Get all templates for the template selector // Get all templates for the template selector
$templates = $this->templateModel->getAllTemplates(); $templates = $this->templateModel->getAllTemplates();
// Get all users for assignment dropdown
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn; // Make $conn available to view $conn = $this->conn; // Make $conn available to view
// Display the create ticket form // Display the create ticket form

View File

@@ -376,8 +376,8 @@ class TicketModel {
error_log("Ticket ID generation used fallback after {$maxAttempts} random attempts"); error_log("Ticket ID generation used fallback after {$maxAttempts} random attempts");
} }
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups) $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, assigned_to, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql); $stmt = $this->conn->prepare($sql);
@@ -388,6 +388,7 @@ class TicketModel {
$type = $ticketData['type'] ?? 'Issue'; $type = $ticketData['type'] ?? 'Issue';
$visibility = $ticketData['visibility'] ?? 'public'; $visibility = $ticketData['visibility'] ?? 'public';
$visibilityGroups = $ticketData['visibility_groups'] ?? null; $visibilityGroups = $ticketData['visibility_groups'] ?? null;
$assignedTo = !empty($ticketData['assigned_to']) ? (int)$ticketData['assigned_to'] : null;
// Validate visibility // Validate visibility
$allowedVisibilities = ['public', 'internal', 'confidential']; $allowedVisibilities = ['public', 'internal', 'confidential'];
@@ -409,7 +410,7 @@ class TicketModel {
} }
$stmt->bind_param( $stmt->bind_param(
"sssssssiss", "sssssssiiss",
$ticket_id, $ticket_id,
$ticketData['title'], $ticketData['title'],
$ticketData['description'], $ticketData['description'],
@@ -418,6 +419,7 @@ class TicketModel {
$category, $category,
$type, $type,
$createdBy, $createdBy,
$assignedTo,
$visibility, $visibility,
$visibilityGroups $visibilityGroups
); );

View File

@@ -13,7 +13,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png"> <link rel="icon" type="image/png" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/images/favicon.png">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/dashboard.css?v=20260126c">
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260124e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260124e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -166,6 +166,32 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- DIVIDER --> <!-- DIVIDER -->
<div class="ascii-divider"></div> <div class="ascii-divider"></div>
<!-- SECTION 4b: Assignment -->
<div class="ascii-section-header">Assignment</div>
<div class="ascii-content">
<div class="ascii-frame-inner">
<div class="detail-group">
<label for="assigned_to">Assign To (Optional)</label>
<select id="assigned_to" name="assigned_to" class="editable">
<option value="">-- Unassigned --</option>
<?php if (isset($allUsers) && !empty($allUsers)): ?>
<?php foreach ($allUsers as $user): ?>
<option value="<?php echo $user['user_id']; ?>">
<?php echo htmlspecialchars($user['display_name'] ?? $user['username']); ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
<p style="color: var(--terminal-green); font-size: 0.85rem; margin-top: 0.5rem; font-family: var(--font-mono);">
Select a user to assign this ticket to
</p>
</div>
</div>
</div>
<!-- DIVIDER -->
<div class="ascii-divider"></div>
<!-- SECTION 5: Visibility Settings --> <!-- SECTION 5: Visibility Settings -->
<div class="ascii-section-header">Visibility Settings</div> <div class="ascii-section-header">Visibility Settings</div>
<div class="ascii-content"> <div class="ascii-content">

View File

@@ -16,7 +16,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ascii-banner.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>'; window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';

View File

@@ -54,7 +54,7 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e"> <link rel="stylesheet" href="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/css/ticket.css?v=20260131e">
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/toast.js"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/markdown.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/dashboard.js?v=20260205"></script>
<script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script> <script nonce="<?php echo $nonce; ?>" src="<?php echo $GLOBALS['config']['ASSETS_URL']; ?>/js/ticket.js?v=20260131e"></script>
<script nonce="<?php echo $nonce; ?>"> <script nonce="<?php echo $nonce; ?>">
// CSRF Token for AJAX requests // CSRF Token for AJAX requests