3 * @brief Ticket Management API Routes
4 * @description Comprehensive ticketing system supporting ITIL-style ticket management
5 * for MSP/PSA operations. Includes ticket lifecycle management, SLA tracking, time
6 * tracking, note management, and multi-tenant isolation.
9 * - Full CRUD operations for tickets
10 * - Status workflow: open → in-progress → closed
11 * - Priority levels: low, medium, high, critical
12 * - SLA breach tracking (default 24h, configurable per customer)
13 * - Multi-tenant isolation (customer-level and MSP-level)
14 * - Assignment to technicians/users
15 * - Time entry tracking (billable/non-billable)
16 * - Internal and customer-facing notes
17 * - Advanced filtering and search
18 * - Notification system integration
20 * **Database Schema:**
21 * - tickets: Main ticket records
22 * - ticket_notes: Comments and updates
23 * - ticket_time_entries: Time tracking for billing
24 * - Foreign keys: customers, users, tenants
25 * @module routes/tickets
27 * @requires services/db
28 * @requires middleware/auth
29 * @requires middleware/adminOnly
30 * @requires middleware/tenant
31 * @requires utils/notificationHelper
32 * @author RMM-PSA Platform
36const express = require('express');
37const router = express.Router();
38const pool = require('../services/db');
39const authenticateToken = require('../middleware/auth');
40const requireAdmin = require('../middleware/adminOnly');
41const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
42const { notifyTicketCreated } = require('../utils/notificationHelper');
44// Apply authentication and tenant context to all routes
45router.use(authenticateToken, setTenantContext);
48 * @api {get} /tickets/count Get open ticket count
49 * @apiName GetTicketCount
51 * @apiDescription Returns the count of tickets with status='open' for the current tenant.
52 * Useful for dashboard widgets and notification badges. Fast query optimized with
56 * - Uses COUNT(*) with status index
57 * - Returns integer count only
58 * - Multi-tenant filtered automatically
59 * @apiHeader {string} Authorization Bearer JWT token with tenant context
60 * @apiSuccess {number} activeTickets Count of open tickets
61 * @apiExample {curl} Example Request:
62 * curl -H "Authorization: Bearer eyJhbGc..." \
63 * "https://api.everydaytech.au/tickets/count"
64 * @apiExample {json} Success Response:
70 * @see {@link module:routes/tickets.listTickets} for full ticket list
72router.get('/count', async (req, res) => {
74 const { clause, params } = getTenantFilter(req);
75 const whereClause = clause ? `WHERE ${clause} AND status = $${params.length + 1}` : 'WHERE status = $1';
76 const queryParams = clause ? [...params, 'open'] : ['open'];
78 const result = await pool.query(
79 `SELECT COUNT(*)::int AS open_count FROM tickets ${whereClause}`,
82 res.json({ activeTickets: result.rows[0].open_count });
84 console.error('Error fetching ticket count:', err);
85 res.status(500).json({ error: 'Server error', details: err.message });
90 * @api {get} /tickets/:id Get ticket details
93 * @apiDescription Retrieves complete ticket information including all notes and time
94 * entries. Includes SLA calculations, assignment details, customer information, and
95 * related data. Returns 404 if ticket not found or not accessible by current tenant.
98 * - Full ticket details (title, description, status, priority)
99 * - SLA information (breach time, remaining seconds, status)
100 * - Assigned user and customer names
101 * - All ticket notes with author information
102 * - Complete time entry history
104 * **SLA Calculations:**
105 * - sla_hours: 24 (default, can be overridden per customer)
106 * - sla_breach_at: Timestamp when ticket breaches SLA
107 * - sla_remaining_seconds: Seconds until breach (null if closed)
108 * - sla_status: "ok" | "due_soon" | "overdue" | "closed"
109 * @apiHeader {string} Authorization Bearer JWT token with tenant context
110 * @apiParam {number} id Ticket ID (URL parameter)
111 * @apiSuccess {object} ticket Complete ticket object with SLA info
112 * @apiSuccess {Array} notes All ticket notes ordered by created_at DESC
113 * @apiSuccess {Array} timeEntries All time entries ordered by start_time DESC
114 * @apiError {number} 404 Ticket not found or access denied
115 * @apiError {number} 500 Server error
116 * @apiExample {curl} Example Request:
117 * curl -H "Authorization: Bearer eyJhbGc..." \
118 * "https://api.everydaytech.au/tickets/123"
119 * @apiExample {json} Success Response:
124 * "title": "Email server down",
125 * "description": "Exchange server not responding",
127 * "priority": "high",
129 * "assigned_to_name": "John Technician",
131 * "customer_name": "Acme Corp",
133 * "sla_breach_at": "2026-02-11T10:30:00Z",
134 * "sla_remaining_seconds": 43200,
140 * "content": "Checking server logs...",
141 * "author_name": "John Technician",
142 * "is_private": false,
143 * "created_at": "2026-02-10T11:00:00Z"
149 * "user_name": "John Technician",
150 * "duration_minutes": 45,
152 * "start_time": "2026-02-10T10:30:00Z"
157 * @see {@link module:routes/tickets.updateTicket} for updating ticket
158 * @see {@link module:routes/tickets.addNote} for adding notes
160router.get('/:id', async (req, res) => {
161 const { id } = req.params;
163 const { clause, params, nextParamIndex } = getTenantFilter(req, 't');
164 const whereClauses = [`t.ticket_id = $${nextParamIndex}`];
165 if (clause) whereClauses.push(clause);
166 const queryParams = clause ? [...params, id] : [id];
168 const ticketRes = await pool.query(`
170 t.*, u.name as assigned_to_name, c.name as customer_name,
172 (t.created_at + (24 * INTERVAL '1 hour')) AS sla_breach_at,
174 WHEN t.status = 'closed' THEN NULL
175 ELSE EXTRACT(EPOCH FROM ((t.created_at + (24 * INTERVAL '1 hour')) - NOW()))::bigint
176 END AS sla_remaining_seconds,
178 WHEN t.status = 'closed' THEN 'closed'
179 WHEN (t.created_at + (24 * INTERVAL '1 hour')) < NOW() THEN 'overdue'
180 WHEN (t.created_at + (24 * INTERVAL '1 hour')) < NOW() + INTERVAL '2 hours' THEN 'due_soon'
184 LEFT JOIN users u ON t.assigned_to = u.user_id
185 LEFT JOIN customers c ON t.customer_id = c.customer_id
186 WHERE ${whereClauses.join(' AND ')}
189 if (ticketRes.rows.length === 0) {
190 return res.status(404).send('Ticket not found');
193 const notesRes = await pool.query(`
194 SELECT n.*, u.name as author_name
196 LEFT JOIN users u ON n.user_id = u.user_id
197 WHERE n.ticket_id = $1
198 ORDER BY n.created_at DESC
201 const timeRes = await pool.query(`
202 SELECT t.*, u.name as user_name
203 FROM ticket_time_entries t
204 LEFT JOIN users u ON t.user_id = u.user_id
205 WHERE t.ticket_id = $1
206 ORDER BY t.start_time DESC
210 ticket: ticketRes.rows[0],
211 notes: notesRes.rows,
212 timeEntries: timeRes.rows
215 console.error('Error fetching ticket:', err);
216 res.status(500).json({ error: 'Server error', details: err.message });
221 * @api {get} /tickets List all tickets
222 * @apiName ListTickets
224 * @apiDescription Retrieves paginated list of tickets with advanced filtering capabilities.
225 * Supports search, status filtering, priority filtering, SLA filtering, and date range
226 * queries. Includes computed SLA fields for dashboard views. Results are tenant-isolated
227 * and ordered by ticket_id DESC (newest first).
229 * **Query Parameters:**
230 * - page (default: 1): Page number for pagination
231 * - limit (default: 10): Results per page
232 * - search: Search in title, description, or customer name
233 * - status: Filter by status (open, in-progress, closed, all)
234 * - priority: Filter by priority (low, medium, high, critical, all)
235 * - sla_filter: Filter by SLA status (overdue, due_today, due_this_week, all)
236 * - date_from: Filter tickets created after this date
237 * - date_to: Filter tickets created before this date
240 * - overdue: SLA breach time passed and status != closed
241 * - due_today: SLA breach date is today
242 * - due_this_week: SLA breach is within current week
243 * @apiHeader {string} Authorization Bearer JWT token with tenant context
244 * @apiParam {number} [page=1] Page number
245 * @apiParam {number} [limit=10] Results per page
246 * @apiParam {string} [search] Search query
247 * @apiParam {string} [status] Status filter
248 * @apiParam {string} [priority] Priority filter
249 * @apiParam {string} [sla_filter] SLA status filter
250 * @apiParam {string} [date_from] Start date (ISO 8601)
251 * @apiParam {string} [date_to] End date (ISO 8601)
252 * @apiSuccess {Array} tickets Array of ticket objects with SLA info
253 * @apiSuccess {number} total Total count of matching tickets
254 * @apiExample {curl} List Open High Priority Tickets:
255 * curl -H "Authorization: Bearer eyJhbGc..." \
256 * "https://api.everydaytech.au/tickets?status=open&priority=high&page=1&limit=20"
257 * @apiExample {curl} Search Overdue Tickets:
258 * curl -H "Authorization: Bearer eyJhbGc..." \
259 * "https://api.everydaytech.au/tickets?search=email&sla_filter=overdue"
260 * @apiExample {json} Success Response:
266 * "title": "Printer offline",
268 * "priority": "medium",
269 * "customer_name": "Acme Corp",
270 * "assigned_to": "Jane Tech",
271 * "sla_status": "ok",
272 * "sla_remaining_seconds": 72000,
273 * "created_at": "2026-02-10T08:00:00Z"
279 * @see {@link module:routes/tickets.getTicket} for single ticket details
281router.get('/', async (req, res) => {
282 const page = parseInt(req.query.page) || 1;
283 const limit = parseInt(req.query.limit) || 10;
284 const offset = (page - 1) * limit;
285 const search = req.query.search;
286 const status = req.query.status;
287 const priority = req.query.priority;
288 const slaFilter = req.query.sla_filter;
289 const dateFrom = req.query.date_from;
290 const dateTo = req.query.date_to;
293 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 't');
294 const conditions = [];
295 const params = [...tenantParams];
297 if (tenantClause) conditions.push(tenantClause);
298 let paramIdx = nextParamIndex;
301 params.push(`%${search}%`);
302 conditions.push(`(t.title ILIKE $${paramIdx} OR t.description ILIKE $${paramIdx} OR c.name ILIKE $${paramIdx})`);
306 if (status && status !== 'all') {
308 conditions.push(`t.status = $${paramIdx}`);
312 if (priority && priority !== 'all') {
313 params.push(priority);
314 conditions.push(`t.priority = $${paramIdx}`);
319 params.push(dateFrom);
320 conditions.push(`t.created_at >= $${paramIdx}`);
325 conditions.push(`t.created_at <= $${paramIdx}`);
329 // SLA filter using computed SLA breach datetime (default 24h). Once contracts.sla_hours exists, this can be tied per customer.
330 if (slaFilter && slaFilter !== 'all') {
331 // Reuse the same expression we compute in SELECT for SLA breach time
332 const breachExpr = `(t.created_at + (24 * INTERVAL '1 hour'))`;
333 if (slaFilter === 'overdue') {
334 conditions.push(`${breachExpr} < NOW() AND t.status != 'closed'`);
335 } else if (slaFilter === 'due_today') {
336 conditions.push(`DATE(${breachExpr}) = CURRENT_DATE`);
337 } else if (slaFilter === 'due_this_week') {
338 conditions.push(`${breachExpr} >= date_trunc('week', NOW()) AND ${breachExpr} < date_trunc('week', NOW()) + INTERVAL '7 days'`);
342 const whereClause = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
346 t.ticket_id, t.title, t.status, t.priority, t.created_at, t.updated_at,
347 t.customer_id, t.tenant_id, tn.name AS tenant_name,
348 u.name AS assigned_to, c.name AS customer_name,
350 (t.created_at + (24 * INTERVAL '1 hour')) AS sla_breach_at,
352 WHEN t.status = 'closed' THEN NULL
353 ELSE EXTRACT(EPOCH FROM ((t.created_at + (24 * INTERVAL '1 hour')) - NOW()))::bigint
354 END AS sla_remaining_seconds,
356 WHEN t.status = 'closed' THEN 'closed'
357 WHEN (t.created_at + (24 * INTERVAL '1 hour')) < NOW() THEN 'overdue'
358 WHEN (t.created_at + (24 * INTERVAL '1 hour')) < NOW() + INTERVAL '2 hours' THEN 'due_soon'
362 LEFT JOIN users u ON t.assigned_to = u.user_id
363 LEFT JOIN customers c ON t.customer_id = c.customer_id
364 LEFT JOIN tenants tn ON t.tenant_id = tn.tenant_id
366 ORDER BY t.ticket_id DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
370 SELECT COUNT(*) FROM tickets t
371 LEFT JOIN customers c ON t.customer_id = c.customer_id
375 const queryParams = [...params, limit, offset];
377 const [ticketsResult, countResult] = await Promise.all([
378 pool.query(query, queryParams),
379 pool.query(countQuery, params)
383 tickets: ticketsResult.rows,
384 total: parseInt(countResult.rows[0].count)
387 console.error('Error fetching tickets:', err);
388 res.status(500).send('Server error');
393 * @api {post} /tickets Create new ticket
394 * @apiName CreateTicket
396 * @apiDescription Creates a new support ticket in the system. Automatically sets tenant
397 * context, generates creation timestamp, and triggers notification to assigned user.
398 * Returns the complete newly created ticket object.
400 * **Default Values:**
401 * - status: 'open' (if not provided)
402 * - priority: 'medium' (if not provided)
403 * - tenant_id: Extracted from JWT token
404 * - created_at: Current timestamp
407 * Automatically sends notification to assigned user (if specified) via the
408 * notification helper system.
409 * @apiHeader {string} Authorization Bearer JWT token with tenant context
410 * @apiHeader {string} Content-Type application/json
411 * @apiParam {string} title Ticket title/subject (required)
412 * @apiParam {string} description Full ticket description (required)
413 * @apiParam {string} [status=open] Ticket status (open, in-progress, closed)
414 * @apiParam {string} [priority=medium] Priority level (low, medium, high, critical)
415 * @apiParam {number} [assigned_to] User ID to assign ticket to
416 * @apiParam {number} customer_id Customer ID associated with ticket (required)
417 * @apiSuccess {Object} ticket Complete newly created ticket object
418 * @apiSuccess {number} ticket.ticket_id Auto-generated ticket ID
419 * @apiError {number} 400 Missing required fields
420 * @apiError {number} 500 Failed to create ticket
421 * @apiExample {curl} Example Request:
423 * -H "Authorization: Bearer eyJhbGc..." \
424 * -H "Content-Type: application/json" \
426 * "title": "Network connectivity issues",
427 * "description": "Users unable to access file shares",
428 * "priority": "high",
432 * "https://api.everydaytech.au/tickets"
433 * @apiExample {json} Success Response:
434 * HTTP/1.1 201 Created
437 * "title": "Network connectivity issues",
438 * "description": "Users unable to access file shares",
440 * "priority": "high",
444 * "created_at": "2026-02-10T14:30:00Z",
445 * "updated_at": "2026-02-10T14:30:00Z"
448 * @see {@link module:routes/tickets.updateTicket} for updating tickets
449 * @see {@link module:utils/notificationHelper.notifyTicketCreated} for notification logic
451router.post('/', async (req, res) => {
452 const { title, description, status, priority, assigned_to, customer_id } = req.body;
455 const tenant_id = req.tenant && req.tenant.id ? req.tenant.id : null;
457 const result = await pool.query(
458 `INSERT INTO tickets (title, description, status, priority, assigned_to, customer_id, tenant_id)
459 VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
460 [title, description, status || 'open', priority || 'medium', assigned_to, customer_id, tenant_id]
463 const ticket = result.rows[0];
465 // Create notification for ticket creation
467 await notifyTicketCreated(ticket);
468 } catch (notifError) {
469 console.error('Error creating ticket notification:', notifError);
470 // Don't fail ticket creation if notification fails
473 res.status(201).json(ticket);
475 console.error('Error creating ticket:', err);
476 res.status(500).send('Failed to create ticket');
481 * @api {put} /tickets/:id Update ticket
482 * @apiName UpdateTicket
484 * @apiDescription Updates an existing ticket's details. Sets updated_at timestamp
485 * automatically. All fields are required in request body - provide existing values
486 * for fields that should not change. Tenant isolation enforced automatically.
488 * **Updatable Fields:**
489 * - title: Ticket subject
490 * - description: Full description
491 * - status: Workflow status (open, in-progress, closed)
492 * - priority: Priority level
493 * - assigned_to: Assigned user ID
494 * - customer_id: Associated customer
496 * **Status Workflow:**
497 * Typical flow: open → in-progress → closed
498 * Can be changed to any status at any time (no enforced workflow constraints)
499 * @apiHeader {string} Authorization Bearer JWT token with tenant context
500 * @apiHeader {string} Content-Type application/json
501 * @apiParam {number} id Ticket ID (URL parameter)
502 * @apiParam {string} title Updated ticket title
503 * @apiParam {string} description Updated description
504 * @apiParam {string} status Updated status
505 * @apiParam {string} priority Updated priority
506 * @apiParam {number} assigned_to Updated assigned user ID
507 * @apiParam {number} customer_id Updated customer ID
508 * @apiSuccess {Object} ticket Updated ticket object with new updated_at timestamp
509 * @apiError {number} 404 Ticket not found or access denied
510 * @apiError {number} 500 Failed to update ticket
511 * @apiExample {curl} Example Request:
513 * -H "Authorization: Bearer eyJhbGc..." \
514 * -H "Content-Type: application/json" \
516 * "title": "Network connectivity issues - RESOLVED",
517 * "description": "Users unable to access file shares. Fixed by restarting switch.",
518 * "status": "closed",
519 * "priority": "high",
523 * "https://api.everydaytech.au/tickets/126"
524 * @apiExample {json} Success Response:
528 * "title": "Network connectivity issues - RESOLVED",
529 * "status": "closed",
530 * "updated_at": "2026-02-10T16:45:00Z",
534 * @see {@link module:routes/tickets.getTicket} for retrieving current ticket state
536router.put('/:id', async (req, res) => {
537 const { id } = req.params;
538 const { title, description, status, priority, assigned_to, customer_id } = req.body;
541 const { clause, params, nextParamIndex } = getTenantFilter(req);
543 // Build the query with proper parameter indexing
544 // SET clause uses $1 through $6
545 // Tenant filter starts at $7 (nextParamIndex would be 7 if tenant exists)
546 // Ticket ID comes after tenant params
548 const updateParams = [title, description, status, priority, assigned_to, customer_id];
550 // Add tenant filter params (if any)
551 const tenantParamStart = 7; // After the 6 SET params
552 let ticketIdParamIndex;
555 updateParams.push(...params);
556 ticketIdParamIndex = tenantParamStart + params.length;
558 ticketIdParamIndex = 7;
561 updateParams.push(id);
563 // Build WHERE clause with proper parameter indices
564 const whereClauses = [`ticket_id = $${ticketIdParamIndex}`];
566 // Replace $1 in tenant clause with correct param index
567 const adjustedClause = clause.replace(/\$(\d+)/g, (match, num) => {
568 return `$${tenantParamStart + parseInt(num) - 1}`;
570 whereClauses.push(adjustedClause);
573 const result = await pool.query(
575 SET title = $1, description = $2, status = $3, priority = $4,
576 assigned_to = $5, customer_id = $6, updated_at = CURRENT_TIMESTAMP
577 WHERE ${whereClauses.join(' AND ')}
582 if (result.rows.length === 0) {
583 return res.status(404).send('Ticket not found');
586 res.json(result.rows[0]);
588 console.error('Error updating ticket:', err);
589 res.status(500).send('Failed to update ticket');
594 * @api {post} /tickets/:id/notes Add note to ticket
595 * @apiName AddTicketNote
597 * @apiDescription Adds a note (comment) to a ticket with optional time tracking. If time
598 * tracking parameters are provided, creates both a note and a time entry in a single
599 * transaction. Useful for documenting work performed while tracking billable hours.
602 * - Public notes: Visible to customers (is_private = false)
603 * - Private notes: Internal only, hidden from customers (is_private = true)
605 * **Time Tracking Options:**
606 * 1. Provide start_time and end_time - calculates duration automatically
607 * 2. Provide end_time and duration_minutes - calculates start_time
608 * 3. Provide only duration_minutes - uses current time as end, calculates start
610 * **Transaction Safety:**
611 * Note and time entry created in a database transaction - both succeed or both fail.
612 * @apiHeader {string} Authorization Bearer JWT token with tenant context
613 * @apiHeader {string} Content-Type application/json
614 * @apiParam {number} id Ticket ID (URL parameter)
615 * @apiParam {string} content Note content/message (required)
616 * @apiParam {boolean} [is_private=false] Whether note is internal only
617 * @apiParam {string} [start_time] ISO timestamp for time entry start
618 * @apiParam {string} [end_time] ISO timestamp for time entry end
619 * @apiParam {number} [duration_minutes] Duration in minutes for time entry
620 * @apiParam {boolean} [billable=false] Whether time entry is billable
621 * @apiSuccess {Object} note Newly created note object
622 * @apiError {number} 500 Failed to add note or time entry
623 * @apiExample {curl} Simple Note:
625 * -H "Authorization: Bearer eyJhbGc..." \
626 * -H "Content-Type: application/json" \
627 * -d '{"content": "Rebooted server, issue resolved", "is_private": false}' \
628 * "https://api.everydaytech.au/tickets/126/notes"
629 * @apiExample {curl} Note with Time Tracking:
631 * -H "Authorization: Bearer eyJhbGc..." \
632 * -H "Content-Type: application/json" \
634 * "content": "Troubleshooting network connectivity",
635 * "is_private": false,
636 * "duration_minutes": 45,
639 * "https://api.everydaytech.au/tickets/126/notes"
640 * @apiExample {json} Success Response:
646 * "content": "Rebooted server, issue resolved",
647 * "is_private": false,
648 * "created_at": "2026-02-10T16:30:00Z"
651 * @see {@link module:routes/tickets.addTimeEntry} for standalone time tracking
653router.post('/:id/notes', async (req, res) => {
654 const { id } = req.params;
655 const { content, is_private, start_time, end_time, billable, duration_minutes } = req.body;
656 const user_id = req.user.user_id;
659 const client = await pool.connect();
662 await client.query('BEGIN');
664 const noteResult = await client.query(
665 `INSERT INTO ticket_notes (ticket_id, user_id, content, is_private)
666 VALUES ($1, $2, $3, $4)
668 [id, user_id, content, is_private]
671 if ((start_time && end_time) || (duration_minutes && Number(duration_minutes) > 0)) {
672 let startISO = start_time;
673 let endISO = end_time;
676 if (startISO && endISO) {
677 const start = new Date(startISO);
678 const end = new Date(endISO);
679 mins = Math.max(1, Math.round((end - start) / 60000));
681 const end = endISO ? new Date(endISO) : new Date();
682 const minsIn = parseInt(duration_minutes, 10);
683 const start = new Date(end.getTime() - Math.max(1, minsIn) * 60000);
684 startISO = start.toISOString();
685 endISO = end.toISOString();
686 mins = Math.max(1, minsIn);
690 `INSERT INTO ticket_time_entries (ticket_id, user_id, start_time, end_time, duration_minutes, description, billable)
691 VALUES ($1, $2, $3, $4, $5, $6, $7)`,
692 [id, user_id, startISO, endISO, mins, content, billable || false]
696 await client.query('COMMIT');
697 res.json(noteResult.rows[0]);
699 await client.query('ROLLBACK');
705 console.error('Error adding note:', err);
706 res.status(500).send('Failed to add note');
711 * @api {post} /tickets/:id/time Add time entry to ticket
712 * @apiName AddTimeEntry
714 * @apiDescription Adds a time tracking entry to a ticket for billing and reporting.
715 * Records work performed with start/end times, duration, and billable status.
716 * Used for accurate time tracking and invoice generation.
718 * **Time Entry Fields:**
719 * - start_time: When work began (ISO timestamp)
720 * - end_time: When work ended (ISO timestamp)
721 * - duration_minutes: Total minutes worked
722 * - description: What work was performed
723 * - billable: Whether to bill customer for this time
725 * **Billing Integration:**
726 * Billable time entries are aggregated for invoice generation and contract hour
727 * consumption tracking.
728 * @apiHeader {string} Authorization Bearer JWT token with tenant context
729 * @apiHeader {string} Content-Type application/json
730 * @apiParam {number} id Ticket ID (URL parameter)
731 * @apiParam {string} start_time ISO timestamp when work started (required)
732 * @apiParam {string} end_time ISO timestamp when work ended (required)
733 * @apiParam {number} duration_minutes Duration in minutes (required)
734 * @apiParam {string} description Work performed description (required)
735 * @apiParam {boolean} billable Whether time is billable (required)
736 * @apiSuccess {Object} entry Newly created time entry object
737 * @apiError {number} 500 Failed to add time entry
738 * @apiExample {curl} Example Request:
740 * -H "Authorization: Bearer eyJhbGc..." \
741 * -H "Content-Type: application/json" \
743 * "start_time": "2026-02-10T14:00:00Z",
744 * "end_time": "2026-02-10T15:30:00Z",
745 * "duration_minutes": 90,
746 * "description": "Network troubleshooting and switch configuration",
749 * "https://api.everydaytech.au/tickets/126/time"
750 * @apiExample {json} Success Response:
751 * HTTP/1.1 201 Created
756 * "start_time": "2026-02-10T14:00:00Z",
757 * "end_time": "2026-02-10T15:30:00Z",
758 * "duration_minutes": 90,
759 * "description": "Network troubleshooting and switch configuration",
761 * "created_at": "2026-02-10T15:30:00Z"
764 * @see {@link module:routes/tickets.addNote} for adding note with time tracking
766router.post('/:id/time', async (req, res) => {
767 const { id } = req.params;
768 const { start_time, end_time, duration_minutes, description, billable } = req.body;
769 const user_id = req.user.user_id;
772 const result = await pool.query(
773 `INSERT INTO ticket_time_entries (
774 ticket_id, user_id, start_time, end_time,
775 duration_minutes, description, billable
777 VALUES ($1, $2, $3, $4, $5, $6, $7)
779 [id, user_id, start_time, end_time, duration_minutes, description, billable]
782 res.status(201).json(result.rows[0]);
784 console.error('Error adding time entry:', err);
785 res.status(500).send('Failed to add time entry');
790 * @api {delete} /tickets/:id Delete ticket
791 * @apiName DeleteTicket
793 * @apiDescription Permanently deletes a ticket and all associated data (notes and time
794 * entries). Admin-only operation. Use with caution as this action cannot be undone.
795 * Typically used for data cleanup, spam removal, or test ticket deletion.
798 * Requires admin role via requireAdmin middleware. Regular users cannot delete tickets.
800 * **Cascade Deletion:**
801 * Automatically deletes:
804 * - The ticket record itself
807 * Consider closing tickets instead of deleting them to preserve historical data
808 * and audit trails. Deletion should be reserved for cleanup operations.
809 * @apiHeader {string} Authorization Bearer JWT token with admin permissions
810 * @apiParam {number} id Ticket ID to delete (URL parameter)
811 * @apiSuccess {string} message Success confirmation message
812 * @apiSuccess {object} deleted_ticket The deleted ticket object
813 * @apiError {number} 403 Forbidden - Admin role required
814 * @apiError {number} 404 Ticket not found or access denied
815 * @apiError {number} 500 Failed to delete ticket
816 * @apiExample {curl} Example Request:
818 * -H "Authorization: Bearer eyJhbGc..." \
819 * "https://api.everydaytech.au/tickets/126"
820 * @apiExample {json} Success Response:
823 * "message": "Ticket deleted successfully",
824 * "deleted_ticket": {
826 * "title": "Test ticket for deletion",
830 * @apiExample {json} Error Response (Not Admin):
831 * HTTP/1.1 403 Forbidden
833 * "error": "Admin access required"
836 * @see {@link module:routes/tickets.updateTicket} for closing tickets (preferred)
838router.delete('/:id', requireAdmin, async (req, res) => {
839 const ticketId = req.params.id;
842 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 't');
844 // First check if ticket exists and user has access
845 let checkQuery = 'SELECT ticket_id, title, status FROM tickets t WHERE t.ticket_id = $1';
846 const checkParams = [ticketId];
849 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
850 checkQuery += ` AND ${adjustedClause}`;
851 checkParams.push(...tenantParams);
854 const checkResult = await pool.query(checkQuery, checkParams);
856 if (checkResult.rows.length === 0) {
857 return res.status(404).json({ error: 'Ticket not found' });
860 const ticket = checkResult.rows[0];
862 // Allow deletion of tickets (note: can delete any status for data cleanup)
863 // In production, you might want to restrict this based on business rules
865 // Delete associated ticket data first (due to foreign key constraints)
867 await pool.query('DELETE FROM ticket_time_entries WHERE ticket_id = $1', [ticketId]);
868 await pool.query('DELETE FROM ticket_notes WHERE ticket_id = $1', [ticketId]);
869 } catch (relatedErr) {
870 console.log('No ticket notes or time entries to delete or tables do not exist');
874 const result = await pool.query(
875 'DELETE FROM tickets WHERE ticket_id = $1 RETURNING *',
879 console.log(`[Tickets] Ticket deleted: ID ${ticketId}, Title: "${ticket.title}", User: ${req.user?.user_id}, Tenant: ${req.tenant?.id}`);
882 message: 'Ticket deleted successfully',
883 deleted_ticket: result.rows[0]
886 console.error('Error deleting ticket:', err);
887 res.status(500).json({ error: 'Failed to delete ticket', details: err.message });
891module.exports = router;