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_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=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
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
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
14. **CSP Nonces**: All inline scripts require `nonce="<?php echo $nonce; ?>"` attribute
15. **Visibility validation**: Internal visibility requires groups; code validates this

View File

@@ -187,9 +187,12 @@ DB_USER=tinkertickets
DB_PASS=your_password
DB_NAME=ticketing_system
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
APP_DOMAIN=t.lotusguild.org
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
Add to crontab for recurring tickets:

View File

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

View File

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

View File

@@ -376,8 +376,8 @@ class TicketModel {
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, assigned_to, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
@@ -388,6 +388,7 @@ class TicketModel {
$type = $ticketData['type'] ?? 'Issue';
$visibility = $ticketData['visibility'] ?? 'public';
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
$assignedTo = !empty($ticketData['assigned_to']) ? (int)$ticketData['assigned_to'] : null;
// Validate visibility
$allowedVisibilities = ['public', 'internal', 'confidential'];
@@ -409,7 +410,7 @@ class TicketModel {
}
$stmt->bind_param(
"sssssssiss",
"sssssssiiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
@@ -418,6 +419,7 @@ class TicketModel {
$category,
$type,
$createdBy,
$assignedTo,
$visibility,
$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="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">
<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; ?>">
// CSRF Token for AJAX requests
window.CSRF_TOKEN = '<?php echo CsrfMiddleware::getToken(); ?>';
@@ -166,6 +166,32 @@ $nonce = SecurityHeadersMiddleware::getNonce();
<!-- DIVIDER -->
<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 -->
<div class="ascii-section-header">Visibility Settings</div>
<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/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/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; ?>">
// CSRF Token for AJAX requests
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">
<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/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; ?>">
// CSRF Token for AJAX requests