diff --git a/Claude.md b/Claude.md index 69d403c..120d324 100644 --- a/Claude.md +++ b/Claude.md @@ -17,6 +17,24 @@ - Comment Edit/Delete (owner or admin can modify their comments) - Markdown Tables Support, Auto-linking URLs in Comments +**Security Features** (January 2026): +- CSP with nonce-based script execution (no unsafe-inline) +- IP-based rate limiting (prevents session bypass attacks) +- Visibility checks on attachment downloads +- Unique ticket ID generation with collision prevention +- Internal visibility requires groups validation + +## Design Decisions + +**Not Planned / Out of Scope**: +- Email integration - Discord webhooks are the notification method for this system +- SLA management - Not required for internal infrastructure use +- Time tracking - Out of scope for current requirements +- OAuth2/External identity providers - Authelia is the only approved SSO method +- GraphQL API - REST API is sufficient for current needs + +**Wiki Documentation**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets + ## Project Overview Tinker Tickets is a feature-rich, self-hosted ticket management system built for managing data center infrastructure issues. It features SSO integration with Authelia/LLDAP, workflow management, Discord notifications, and a retro terminal-style web interface. @@ -54,6 +72,7 @@ Controllers → Models → Database │ ├── check_duplicates.php # GET: Check for duplicate tickets │ ├── delete_attachment.php # POST/DELETE: Delete attachment │ ├── delete_comment.php # POST: Delete comment (owner/admin) +│ ├── download_attachment.php # GET: Download attachment (with visibility check) │ ├── export_tickets.php # GET: Export tickets to CSV/JSON │ ├── generate_api_key.php # POST: Generate API key (admin) │ ├── get_template.php # GET: Fetch ticket template @@ -93,8 +112,8 @@ Controllers → Models → Database ├── middleware/ │ ├── AuthMiddleware.php # Authelia SSO integration │ ├── CsrfMiddleware.php # CSRF protection -│ ├── RateLimitMiddleware.php # API rate limiting -│ └── SecurityHeadersMiddleware.php # Security headers +│ ├── RateLimitMiddleware.php # Session + IP-based rate limiting +│ └── SecurityHeadersMiddleware.php # CSP with nonces, security headers ├── models/ │ ├── ApiKeyModel.php # API key generation/validation │ ├── AuditLogModel.php # Audit logging + timeline @@ -152,25 +171,41 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade ### Core Tables -- `tickets` - Core ticket data with assignment and visibility -- `ticket_comments` - Markdown-supported comments -- `ticket_attachments` - File attachment metadata -- `ticket_dependencies` - Ticket relationships -- `users` - User accounts synced from LLDAP (includes groups) -- `user_preferences` - User settings and preferences -- `audit_log` - Complete audit trail -- `status_transitions` - Workflow configuration -- `ticket_templates` - Reusable ticket templates -- `recurring_tickets` - Scheduled ticket definitions -- `custom_field_definitions` - Custom field schemas -- `custom_field_values` - Custom field data per ticket -- `saved_filters` - User-saved dashboard filters -- `bulk_operations` - Bulk operation tracking -- `api_keys` - API key storage with hashes +| Table | Description | +|-------|-------------| +| `tickets` | Core ticket data with assignment, visibility, and tracking | +| `ticket_comments` | Markdown-supported comments with user_id reference | +| `ticket_attachments` | File attachment metadata | +| `ticket_dependencies` | Ticket relationships (blocks, blocked_by, relates_to, duplicates) | +| `users` | User accounts synced from LLDAP (includes groups) | +| `user_preferences` | User settings and preferences | +| `audit_log` | Complete audit trail with indexed queries | +| `status_transitions` | Workflow configuration | +| `ticket_templates` | Reusable ticket templates | +| `recurring_tickets` | Scheduled ticket definitions | +| `custom_field_definitions` | Custom field schemas per category | +| `custom_field_values` | Custom field data per ticket | +| `saved_filters` | User-saved dashboard filters | +| `bulk_operations` | Bulk operation tracking | +| `api_keys` | API key storage with hashed keys | -### Ticket Visibility Columns -- `visibility` - ENUM('public', 'internal', 'confidential') -- `visibility_groups` - VARCHAR(500) comma-separated group names +### tickets Table Key Columns + +| Column | Type | Description | +|--------|------|-------------| +| `ticket_id` | varchar(9) | Unique 9-digit identifier | +| `visibility` | enum | 'public', 'internal', 'confidential' | +| `visibility_groups` | varchar(500) | Comma-separated group names (for internal) | +| `created_by` | int | Foreign key to users | +| `assigned_to` | int | Foreign key to users (nullable) | +| `updated_by` | int | Foreign key to users | +| `priority` | int | 1-5 (1=Critical, 5=Minimal) | +| `status` | varchar(20) | Open, Pending, In Progress, Closed | + +### Indexed Columns (for performance) + +- `tickets`: ticket_id (unique), status, priority, created_at, created_by, assigned_to, visibility +- `audit_log`: user_id, action_type, entity_type, created_at ## Dashboard Features @@ -187,9 +222,11 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade ## Ticket Visibility Levels - **Public**: All authenticated users can view -- **Internal**: Only users in specified groups can view +- **Internal**: Only users in specified groups can view (groups required) - **Confidential**: Only creator, assignee, and admins can view +**Important**: Internal visibility requires at least one group to be specified. Attempting to create/update a ticket with internal visibility but no groups will fail validation. + ## Important Notes for AI Assistants 1. **Session format**: `$_SESSION['user']['user_id']` (not `$_SESSION['user_id']`) @@ -205,6 +242,9 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade 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. **Ticket ID extraction**: Use `getTicketIdFromUrl()` helper in JS files +14. **CSP Nonces**: All inline scripts require `nonce=""` attribute +15. **Visibility validation**: Internal visibility requires groups; code validates this +16. **Rate limiting**: Both session-based AND IP-based limits are enforced ## File Reference Quick Guide @@ -212,14 +252,31 @@ All admin pages are accessible via the **Admin dropdown** in the dashboard heade |------|---------| | `index.php` | Main router for all routes | | `api/update_ticket.php` | Ticket updates with workflow + visibility | -| `models/TicketModel.php` | Ticket CRUD, visibility filtering | +| `api/download_attachment.php` | File downloads with visibility check | +| `api/bulk_operation.php` | Bulk operations with visibility filtering | +| `models/TicketModel.php` | Ticket CRUD, visibility filtering, collision-safe ID generation | | `models/ApiKeyModel.php` | API key generation and validation | +| `middleware/SecurityHeadersMiddleware.php` | CSP headers with nonce generation | +| `middleware/RateLimitMiddleware.php` | Session + IP-based rate limiting | | `assets/js/dashboard.js` | Dashboard UI, kanban, sidebar, bulk actions | | `assets/js/ticket.js` | Ticket UI, visibility editing | -| `assets/js/markdown.js` | Markdown parsing + ticket linking | +| `assets/js/markdown.js` | Markdown parsing + ticket linking (XSS-safe) | | `assets/css/dashboard.css` | Terminal styling, kanban, sidebar | +## Security Implementations + +| Feature | Implementation | +|---------|---------------| +| SQL Injection | All queries use prepared statements with parameter binding | +| XSS Prevention | HTML escaped in markdown parser, CSP with nonces | +| CSRF Protection | Token-based with constant-time comparison | +| Session Security | Fixation prevention, secure cookies, timeout | +| Rate Limiting | Session-based + IP-based (file storage) | +| File Security | Path traversal prevention, MIME validation | +| Visibility | Enforced on views, downloads, and bulk operations | + ## Repository - **Gitea**: https://code.lotusguild.org/LotusGuild/tinker_tickets -- **Production**: http://t.lotusguild.org +- **Production**: https://t.lotusguild.org +- **Wiki**: https://wiki.lotusguild.org/en/Services/service-tinker-tickets diff --git a/README.md b/README.md index 9a904e5..55f9105 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ A feature-rich PHP-based ticketing system designed for tracking and managing data center infrastructure issues with enterprise-grade workflow management and a retro terminal aesthetic. +**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets) + +## Design Decisions + +The following features are intentionally **not planned** for this system: +- **Email Integration**: Discord webhooks are the chosen notification method +- **SLA Management**: Not required for internal infrastructure use +- **Time Tracking**: Out of scope for current requirements +- **OAuth2/External Identity Providers**: Authelia is the only approved SSO method + ## Core Features ### Dashboard & Ticket Management @@ -104,12 +114,14 @@ Access all admin pages via the **Admin dropdown** in the dashboard header. | `?` | Show keyboard shortcuts help | ### Security Features -- **CSRF Protection**: Token-based protection on all forms -- **Rate Limiting**: API rate limiting to prevent abuse -- **Security Headers**: CSP, X-Frame-Options, X-Content-Type-Options -- **SQL Injection Prevention**: All queries use prepared statements -- **XSS Protection**: All output properly escaped +- **CSRF Protection**: Token-based protection with constant-time comparison +- **Rate Limiting**: Session-based AND IP-based rate limiting to prevent abuse +- **Security Headers**: CSP with nonces (no unsafe-inline), X-Frame-Options, X-Content-Type-Options +- **SQL Injection Prevention**: All queries use prepared statements with parameter binding +- **XSS Protection**: HTML escaped in markdown parser, CSP headers block inline scripts - **Audit Logging**: Complete audit trail of all actions +- **Visibility Enforcement**: Access checks on ticket views, downloads, and bulk operations +- **Collision-Safe IDs**: Ticket IDs verified unique before creation ## Technical Architecture diff --git a/api/bulk_operation.php b/api/bulk_operation.php index 3ffc7cb..8cd2c64 100644 --- a/api/bulk_operation.php +++ b/api/bulk_operation.php @@ -55,6 +55,9 @@ foreach ($ticketIds as $ticketId) { } } +// Create database connection (needed for visibility check) +require_once dirname(__DIR__) . '/models/TicketModel.php'; + // Create database connection $conn = new mysqli( $GLOBALS['config']['DB_HOST'], @@ -69,6 +72,33 @@ if ($conn->connect_error) { } $bulkOpsModel = new BulkOperationsModel($conn); +$ticketModel = new TicketModel($conn); + +// Verify user can access all tickets in the bulk operation +// (Admins can access all, but this is defense-in-depth) +$accessibleTicketIds = []; +$inaccessibleCount = 0; +$tickets = $ticketModel->getTicketsByIds($ticketIds); + +foreach ($ticketIds as $ticketId) { + $ticketId = trim($ticketId); + $ticket = $tickets[$ticketId] ?? null; + + if ($ticket && $ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { + $accessibleTicketIds[] = $ticketId; + } else { + $inaccessibleCount++; + } +} + +if (empty($accessibleTicketIds)) { + $conn->close(); + echo json_encode(['success' => false, 'error' => 'No accessible tickets in selection']); + exit; +} + +// Use only accessible ticket IDs +$ticketIds = $accessibleTicketIds; // Create bulk operation record $operationId = $bulkOpsModel->createBulkOperation($operationType, $ticketIds, $_SESSION['user']['user_id'], $parameters); @@ -90,11 +120,16 @@ if (isset($result['error'])) { 'error' => $result['error'] ]); } else { + $message = "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed"; + if ($inaccessibleCount > 0) { + $message .= " ($inaccessibleCount skipped - no access)"; + } echo json_encode([ 'success' => true, 'operation_id' => $operationId, 'processed' => $result['processed'], 'failed' => $result['failed'], - 'message' => "Bulk operation completed: {$result['processed']} succeeded, {$result['failed']} failed" + 'skipped' => $inaccessibleCount, + 'message' => $message ]); } diff --git a/api/download_attachment.php b/api/download_attachment.php index 4ae63c6..883362f 100644 --- a/api/download_attachment.php +++ b/api/download_attachment.php @@ -41,26 +41,42 @@ try { exit; } - // Verify the associated ticket exists (access control) + // Verify the associated ticket exists and user has access $conn = new mysqli( $GLOBALS['config']['DB_HOST'], $GLOBALS['config']['DB_USER'], $GLOBALS['config']['DB_PASS'], $GLOBALS['config']['DB_NAME'] ); - if (!$conn->connect_error) { - $ticketModel = new TicketModel($conn); - $ticket = $ticketModel->getTicketById($attachment['ticket_id']); - $conn->close(); - - if (!$ticket) { - http_response_code(404); - header('Content-Type: application/json'); - echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); - exit; - } + if ($conn->connect_error) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Database connection failed']); + exit; } + $ticketModel = new TicketModel($conn); + $ticket = $ticketModel->getTicketById($attachment['ticket_id']); + + if (!$ticket) { + $conn->close(); + http_response_code(404); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Associated ticket not found']); + exit; + } + + // Check if user has access to this ticket based on visibility settings + if (!$ticketModel->canUserAccessTicket($ticket, $_SESSION['user'])) { + $conn->close(); + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Access denied to this ticket']); + exit; + } + + $conn->close(); + // Build file path $uploadDir = $GLOBALS['config']['UPLOAD_DIR'] ?? dirname(__DIR__) . '/uploads'; $filePath = $uploadDir . '/' . $attachment['ticket_id'] . '/' . $attachment['filename']; diff --git a/api/update_ticket.php b/api/update_ticket.php index f83eb23..2bcaba7 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -151,6 +151,15 @@ try { if (is_array($visibilityGroups)) { $visibilityGroups = implode(',', array_map('trim', $visibilityGroups)); } + + // Validate internal visibility requires groups + if ($data['visibility'] === 'internal' && (empty($visibilityGroups) || trim($visibilityGroups) === '')) { + return [ + 'success' => false, + 'error' => 'Internal visibility requires at least one group to be specified' + ]; + } + $this->ticketModel->updateVisibility($id, $data['visibility'], $visibilityGroups, $this->userId); } diff --git a/index.php b/index.php index 6daa351..0569aae 100644 --- a/index.php +++ b/index.php @@ -307,14 +307,45 @@ switch (true) { 'to' => $_GET['date_to'] ?? date('Y-m-d') ]; + // Optimized query using LEFT JOINs with aggregated subqueries instead of correlated subqueries + // This eliminates N+1 query pattern and runs much faster with many users $sql = "SELECT u.user_id, u.username, u.display_name, u.is_admin, - (SELECT COUNT(*) FROM tickets t WHERE t.created_by = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_created, - (SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND t.status = 'Closed' AND DATE(t.updated_at) BETWEEN ? AND ?) as tickets_resolved, - (SELECT COUNT(*) FROM ticket_comments tc WHERE tc.user_id = u.user_id AND DATE(tc.created_at) BETWEEN ? AND ?) as comments_added, - (SELECT COUNT(*) FROM tickets t WHERE t.assigned_to = u.user_id AND DATE(t.created_at) BETWEEN ? AND ?) as tickets_assigned, - (SELECT MAX(al.created_at) FROM audit_log al WHERE al.user_id = u.user_id) as last_activity + COALESCE(tc.tickets_created, 0) as tickets_created, + COALESCE(tr.tickets_resolved, 0) as tickets_resolved, + COALESCE(cm.comments_added, 0) as comments_added, + COALESCE(ta.tickets_assigned, 0) as tickets_assigned, + al.last_activity FROM users u + LEFT JOIN ( + SELECT created_by, COUNT(*) as tickets_created + FROM tickets + WHERE DATE(created_at) BETWEEN ? AND ? + GROUP BY created_by + ) tc ON u.user_id = tc.created_by + LEFT JOIN ( + SELECT assigned_to, COUNT(*) as tickets_resolved + FROM tickets + WHERE status = 'Closed' AND DATE(updated_at) BETWEEN ? AND ? + GROUP BY assigned_to + ) tr ON u.user_id = tr.assigned_to + LEFT JOIN ( + SELECT user_id, COUNT(*) as comments_added + FROM ticket_comments + WHERE DATE(created_at) BETWEEN ? AND ? + GROUP BY user_id + ) cm ON u.user_id = cm.user_id + LEFT JOIN ( + SELECT assigned_to, COUNT(*) as tickets_assigned + FROM tickets + WHERE DATE(created_at) BETWEEN ? AND ? + GROUP BY assigned_to + ) ta ON u.user_id = ta.assigned_to + LEFT JOIN ( + SELECT user_id, MAX(created_at) as last_activity + FROM audit_log + GROUP BY user_id + ) al ON u.user_id = al.user_id ORDER BY tickets_created DESC, tickets_resolved DESC"; $stmt = $conn->prepare($sql); diff --git a/middleware/RateLimitMiddleware.php b/middleware/RateLimitMiddleware.php index 8dbfb02..b90850d 100644 --- a/middleware/RateLimitMiddleware.php +++ b/middleware/RateLimitMiddleware.php @@ -2,21 +2,127 @@ /** * Rate Limiting Middleware * - * Implements session-based rate limiting to prevent abuse. + * Implements both session-based and IP-based rate limiting to prevent abuse. + * IP-based limiting prevents attackers from bypassing limits by creating new sessions. */ class RateLimitMiddleware { // Default limits - const DEFAULT_LIMIT = 100; // requests per window - const API_LIMIT = 60; // API requests per window + const DEFAULT_LIMIT = 100; // requests per window (session) + const API_LIMIT = 60; // API requests per window (session) + const IP_LIMIT = 300; // IP-based requests per window (more generous) + const IP_API_LIMIT = 120; // IP-based API requests per window const WINDOW_SECONDS = 60; // 1 minute window + // Directory for IP rate limit storage + private static $rateLimitDir = null; + /** - * Check rate limit for current request + * Get the rate limit storage directory + * + * @return string Path to rate limit storage directory + */ + private static function getRateLimitDir() { + if (self::$rateLimitDir === null) { + self::$rateLimitDir = sys_get_temp_dir() . '/tinker_tickets_ratelimit'; + if (!is_dir(self::$rateLimitDir)) { + mkdir(self::$rateLimitDir, 0755, true); + } + } + return self::$rateLimitDir; + } + + /** + * Get the client's IP address + * + * @return string Client IP address + */ + private static function getClientIp() { + // Check for forwarded IP (behind proxy/load balancer) + $headers = ['HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP', 'HTTP_CLIENT_IP']; + foreach ($headers as $header) { + if (!empty($_SERVER[$header])) { + // Take the first IP in a comma-separated list + $ips = explode(',', $_SERVER[$header]); + $ip = trim($ips[0]); + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { + return $ip; + } + } + } + return $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + } + + /** + * Check IP-based rate limit + * + * @param string $type 'default' or 'api' + * @return bool True if request is allowed, false if rate limited + */ + private static function checkIpRateLimit($type = 'default') { + $ip = self::getClientIp(); + $limit = $type === 'api' ? self::IP_API_LIMIT : self::IP_LIMIT; + $now = time(); + + // Create a hash of the IP for the filename (security + filesystem safety) + $ipHash = md5($ip . '_' . $type); + $filePath = self::getRateLimitDir() . '/' . $ipHash . '.json'; + + // Load existing rate data + $rateData = ['count' => 0, 'window_start' => $now]; + if (file_exists($filePath)) { + $content = @file_get_contents($filePath); + if ($content !== false) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + $rateData = $decoded; + } + } + } + + // Check if window has expired + if ($now - $rateData['window_start'] >= self::WINDOW_SECONDS) { + $rateData = ['count' => 0, 'window_start' => $now]; + } + + // Increment count + $rateData['count']++; + + // Save updated data + @file_put_contents($filePath, json_encode($rateData), LOCK_EX); + + // Check if over limit + return $rateData['count'] <= $limit; + } + + /** + * Clean up old rate limit files (call periodically) + */ + public static function cleanupOldFiles() { + $dir = self::getRateLimitDir(); + $files = glob($dir . '/*.json'); + $now = time(); + $maxAge = self::WINDOW_SECONDS * 2; // Files older than 2 windows + + foreach ($files as $file) { + if ($now - filemtime($file) > $maxAge) { + @unlink($file); + } + } + } + + /** + * Check rate limit for current request (both session and IP) * * @param string $type 'default' or 'api' * @return bool True if request is allowed, false if rate limited */ public static function check($type = 'default') { + // First check IP-based rate limit (prevents session bypass) + if (!self::checkIpRateLimit($type)) { + return false; + } + + // Then check session-based rate limit if (session_status() === PHP_SESSION_NONE) { session_start(); } @@ -59,6 +165,11 @@ class RateLimitMiddleware { * @param string $type 'default' or 'api' */ public static function apply($type = 'default') { + // Periodically clean up old rate limit files (1% chance per request) + if (mt_rand(1, 100) === 1) { + self::cleanupOldFiles(); + } + if (!self::check($type)) { http_response_code(429); header('Content-Type: application/json'); diff --git a/middleware/SecurityHeadersMiddleware.php b/middleware/SecurityHeadersMiddleware.php index 27c0940..5390263 100644 --- a/middleware/SecurityHeadersMiddleware.php +++ b/middleware/SecurityHeadersMiddleware.php @@ -5,12 +5,29 @@ * Applies security-related HTTP headers to all responses. */ class SecurityHeadersMiddleware { + private static $nonce = null; + + /** + * Generate or retrieve the CSP nonce for this request + * + * @return string The nonce value + */ + public static function getNonce() { + if (self::$nonce === null) { + self::$nonce = base64_encode(random_bytes(16)); + } + return self::$nonce; + } + /** * Apply security headers to the response */ public static function apply() { + $nonce = self::getNonce(); + // Content Security Policy - restricts where resources can be loaded from - header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';"); + // Using nonce for inline scripts instead of unsafe-inline for better security + header("Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{$nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self';"); // Prevent clickjacking by disallowing framing header("X-Frame-Options: DENY"); diff --git a/models/TicketModel.php b/models/TicketModel.php index ff2c4ae..c9e1776 100644 --- a/models/TicketModel.php +++ b/models/TicketModel.php @@ -258,8 +258,35 @@ class TicketModel { } public function createTicket($ticketData, $createdBy = null) { - // Generate ticket ID (9-digit format with leading zeros) - $ticket_id = sprintf('%09d', mt_rand(1, 999999999)); + // Generate unique ticket ID (9-digit format with leading zeros) + // Loop until we find an ID that doesn't exist to prevent collisions + $maxAttempts = 10; + $attempts = 0; + $ticket_id = null; + + do { + $candidate_id = sprintf('%09d', mt_rand(100000000, 999999999)); + + // Check if this ID already exists + $checkSql = "SELECT ticket_id FROM tickets WHERE ticket_id = ? LIMIT 1"; + $checkStmt = $this->conn->prepare($checkSql); + $checkStmt->bind_param("s", $candidate_id); + $checkStmt->execute(); + $checkResult = $checkStmt->get_result(); + + if ($checkResult->num_rows === 0) { + $ticket_id = $candidate_id; + } + $checkStmt->close(); + $attempts++; + } while ($ticket_id === null && $attempts < $maxAttempts); + + if ($ticket_id === null) { + return [ + 'success' => false, + 'error' => 'Failed to generate unique ticket ID after ' . $maxAttempts . ' attempts' + ]; + } $sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; @@ -280,8 +307,16 @@ class TicketModel { $visibility = 'public'; } - // Clear visibility_groups if not internal - if ($visibility !== 'internal') { + // Validate internal visibility requires groups + if ($visibility === 'internal') { + if (empty($visibilityGroups) || trim($visibilityGroups) === '') { + return [ + 'success' => false, + 'error' => 'Internal visibility requires at least one group to be specified' + ]; + } + } else { + // Clear visibility_groups if not internal $visibilityGroups = null; } @@ -529,8 +564,13 @@ class TicketModel { $visibility = 'public'; } - // Clear visibility_groups if not internal - if ($visibility !== 'internal') { + // Validate internal visibility requires groups + if ($visibility === 'internal') { + if (empty($visibilityGroups) || trim($visibilityGroups) === '') { + return false; // Internal visibility requires groups + } + } else { + // Clear visibility_groups if not internal $visibilityGroups = null; } diff --git a/views/CreateTicketView.php b/views/CreateTicketView.php index 6e208f3..1d54aaa 100644 --- a/views/CreateTicketView.php +++ b/views/CreateTicketView.php @@ -1,5 +1,8 @@ @@ -10,13 +13,10 @@ - - +
@@ -239,7 +239,7 @@ - - - - - + + + + - - - + + + - - - - + + + + - - +