EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
tickets.js
Go to the documentation of this file.
1/**
2 * @file tickets.js
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.
7 *
8 * **Core Features:**
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
19 *
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
26 * @requires express
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
33 * @date 2026-02-10
34 * @since 2.0.0
35 */
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');
43
44// Apply authentication and tenant context to all routes
45router.use(authenticateToken, setTenantContext);
46
47/**
48 * @api {get} /tickets/count Get open ticket count
49 * @apiName GetTicketCount
50 * @apiGroup Tickets
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
53 * indexed fields.
54 *
55 * **Performance:**
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:
65 * HTTP/1.1 200 OK
66 * {
67 * "activeTickets": 17
68 * }
69 * @since 2.0.0
70 * @see {@link module:routes/tickets.listTickets} for full ticket list
71 */
72router.get('/count', async (req, res) => {
73 try {
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'];
77
78 const result = await pool.query(
79 `SELECT COUNT(*)::int AS open_count FROM tickets ${whereClause}`,
80 queryParams
81 );
82 res.json({ activeTickets: result.rows[0].open_count });
83 } catch (err) {
84 console.error('Error fetching ticket count:', err);
85 res.status(500).json({ error: 'Server error', details: err.message });
86 }
87});
88
89/**
90 * @api {get} /tickets/:id Get ticket details
91 * @apiName GetTicket
92 * @apiGroup Tickets
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.
96 *
97 * **Included Data:**
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
103 *
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:
120 * HTTP/1.1 200 OK
121 * {
122 * "ticket": {
123 * "ticket_id": 123,
124 * "title": "Email server down",
125 * "description": "Exchange server not responding",
126 * "status": "open",
127 * "priority": "high",
128 * "assigned_to": 5,
129 * "assigned_to_name": "John Technician",
130 * "customer_id": 42,
131 * "customer_name": "Acme Corp",
132 * "sla_hours": 24,
133 * "sla_breach_at": "2026-02-11T10:30:00Z",
134 * "sla_remaining_seconds": 43200,
135 * "sla_status": "ok"
136 * },
137 * "notes": [
138 * {
139 * "note_id": 1,
140 * "content": "Checking server logs...",
141 * "author_name": "John Technician",
142 * "is_private": false,
143 * "created_at": "2026-02-10T11:00:00Z"
144 * }
145 * ],
146 * "timeEntries": [
147 * {
148 * "entry_id": 1,
149 * "user_name": "John Technician",
150 * "duration_minutes": 45,
151 * "billable": true,
152 * "start_time": "2026-02-10T10:30:00Z"
153 * }
154 * ]
155 * }
156 * @since 2.0.0
157 * @see {@link module:routes/tickets.updateTicket} for updating ticket
158 * @see {@link module:routes/tickets.addNote} for adding notes
159 */
160router.get('/:id', async (req, res) => {
161 const { id } = req.params;
162 try {
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];
167
168 const ticketRes = await pool.query(`
169 SELECT
170 t.*, u.name as assigned_to_name, c.name as customer_name,
171 24 AS sla_hours,
172 (t.created_at + (24 * INTERVAL '1 hour')) AS sla_breach_at,
173 CASE
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,
177 CASE
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'
181 ELSE 'ok'
182 END AS sla_status
183 FROM tickets t
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 ')}
187 `, queryParams);
188
189 if (ticketRes.rows.length === 0) {
190 return res.status(404).send('Ticket not found');
191 }
192
193 const notesRes = await pool.query(`
194 SELECT n.*, u.name as author_name
195 FROM ticket_notes n
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
199 `, [id]);
200
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
207 `, [id]);
208
209 res.json({
210 ticket: ticketRes.rows[0],
211 notes: notesRes.rows,
212 timeEntries: timeRes.rows
213 });
214 } catch (err) {
215 console.error('Error fetching ticket:', err);
216 res.status(500).json({ error: 'Server error', details: err.message });
217 }
218});
219
220/**
221 * @api {get} /tickets List all tickets
222 * @apiName ListTickets
223 * @apiGroup Tickets
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).
228 *
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
238 *
239 * **SLA Filtering:**
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:
261 * HTTP/1.1 200 OK
262 * {
263 * "tickets": [
264 * {
265 * "ticket_id": 125,
266 * "title": "Printer offline",
267 * "status": "open",
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"
274 * }
275 * ],
276 * "total": 48
277 * }
278 * @since 2.0.0
279 * @see {@link module:routes/tickets.getTicket} for single ticket details
280 */
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;
291
292 try {
293 const { clause: tenantClause, params: tenantParams, nextParamIndex } = getTenantFilter(req, 't');
294 const conditions = [];
295 const params = [...tenantParams];
296
297 if (tenantClause) conditions.push(tenantClause);
298 let paramIdx = nextParamIndex;
299
300 if (search) {
301 params.push(`%${search}%`);
302 conditions.push(`(t.title ILIKE $${paramIdx} OR t.description ILIKE $${paramIdx} OR c.name ILIKE $${paramIdx})`);
303 paramIdx++;
304 }
305
306 if (status && status !== 'all') {
307 params.push(status);
308 conditions.push(`t.status = $${paramIdx}`);
309 paramIdx++;
310 }
311
312 if (priority && priority !== 'all') {
313 params.push(priority);
314 conditions.push(`t.priority = $${paramIdx}`);
315 paramIdx++;
316 }
317
318 if (dateFrom) {
319 params.push(dateFrom);
320 conditions.push(`t.created_at >= $${paramIdx}`);
321 paramIdx++;
322 }
323 if (dateTo) {
324 params.push(dateTo);
325 conditions.push(`t.created_at <= $${paramIdx}`);
326 paramIdx++;
327 }
328
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'`);
339 }
340 }
341
342 const whereClause = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : '';
343
344 const query = `
345 SELECT
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,
349 24 AS sla_hours,
350 (t.created_at + (24 * INTERVAL '1 hour')) AS sla_breach_at,
351 CASE
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,
355 CASE
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'
359 ELSE 'ok'
360 END AS sla_status
361 FROM tickets t
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
365 ${whereClause}
366 ORDER BY t.ticket_id DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}
367 `;
368
369 const countQuery = `
370 SELECT COUNT(*) FROM tickets t
371 LEFT JOIN customers c ON t.customer_id = c.customer_id
372 ${whereClause}
373 `;
374
375 const queryParams = [...params, limit, offset];
376
377 const [ticketsResult, countResult] = await Promise.all([
378 pool.query(query, queryParams),
379 pool.query(countQuery, params)
380 ]);
381
382 res.json({
383 tickets: ticketsResult.rows,
384 total: parseInt(countResult.rows[0].count)
385 });
386 } catch (err) {
387 console.error('Error fetching tickets:', err);
388 res.status(500).send('Server error');
389 }
390});
391
392/**
393 * @api {post} /tickets Create new ticket
394 * @apiName CreateTicket
395 * @apiGroup Tickets
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.
399 *
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
405 *
406 * **Notifications:**
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:
422 * curl -X POST \
423 * -H "Authorization: Bearer eyJhbGc..." \
424 * -H "Content-Type: application/json" \
425 * -d '{
426 * "title": "Network connectivity issues",
427 * "description": "Users unable to access file shares",
428 * "priority": "high",
429 * "customer_id": 42,
430 * "assigned_to": 5
431 * }' \
432 * "https://api.everydaytech.au/tickets"
433 * @apiExample {json} Success Response:
434 * HTTP/1.1 201 Created
435 * {
436 * "ticket_id": 126,
437 * "title": "Network connectivity issues",
438 * "description": "Users unable to access file shares",
439 * "status": "open",
440 * "priority": "high",
441 * "assigned_to": 5,
442 * "customer_id": 42,
443 * "tenant_id": 1,
444 * "created_at": "2026-02-10T14:30:00Z",
445 * "updated_at": "2026-02-10T14:30:00Z"
446 * }
447 * @since 2.0.0
448 * @see {@link module:routes/tickets.updateTicket} for updating tickets
449 * @see {@link module:utils/notificationHelper.notifyTicketCreated} for notification logic
450 */
451router.post('/', async (req, res) => {
452 const { title, description, status, priority, assigned_to, customer_id } = req.body;
453
454 try {
455 const tenant_id = req.tenant && req.tenant.id ? req.tenant.id : null;
456
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]
461 );
462
463 const ticket = result.rows[0];
464
465 // Create notification for ticket creation
466 try {
467 await notifyTicketCreated(ticket);
468 } catch (notifError) {
469 console.error('Error creating ticket notification:', notifError);
470 // Don't fail ticket creation if notification fails
471 }
472
473 res.status(201).json(ticket);
474 } catch (err) {
475 console.error('Error creating ticket:', err);
476 res.status(500).send('Failed to create ticket');
477 }
478});
479
480/**
481 * @api {put} /tickets/:id Update ticket
482 * @apiName UpdateTicket
483 * @apiGroup Tickets
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.
487 *
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
495 *
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:
512 * curl -X PUT \
513 * -H "Authorization: Bearer eyJhbGc..." \
514 * -H "Content-Type: application/json" \
515 * -d '{
516 * "title": "Network connectivity issues - RESOLVED",
517 * "description": "Users unable to access file shares. Fixed by restarting switch.",
518 * "status": "closed",
519 * "priority": "high",
520 * "assigned_to": 5,
521 * "customer_id": 42
522 * }' \
523 * "https://api.everydaytech.au/tickets/126"
524 * @apiExample {json} Success Response:
525 * HTTP/1.1 200 OK
526 * {
527 * "ticket_id": 126,
528 * "title": "Network connectivity issues - RESOLVED",
529 * "status": "closed",
530 * "updated_at": "2026-02-10T16:45:00Z",
531 * ...
532 * }
533 * @since 2.0.0
534 * @see {@link module:routes/tickets.getTicket} for retrieving current ticket state
535 */
536router.put('/:id', async (req, res) => {
537 const { id } = req.params;
538 const { title, description, status, priority, assigned_to, customer_id } = req.body;
539
540 try {
541 const { clause, params, nextParamIndex } = getTenantFilter(req);
542
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
547
548 const updateParams = [title, description, status, priority, assigned_to, customer_id];
549
550 // Add tenant filter params (if any)
551 const tenantParamStart = 7; // After the 6 SET params
552 let ticketIdParamIndex;
553
554 if (clause) {
555 updateParams.push(...params);
556 ticketIdParamIndex = tenantParamStart + params.length;
557 } else {
558 ticketIdParamIndex = 7;
559 }
560
561 updateParams.push(id);
562
563 // Build WHERE clause with proper parameter indices
564 const whereClauses = [`ticket_id = $${ticketIdParamIndex}`];
565 if (clause) {
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}`;
569 });
570 whereClauses.push(adjustedClause);
571 }
572
573 const result = await pool.query(
574 `UPDATE tickets
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 ')}
578 RETURNING *`,
579 updateParams
580 );
581
582 if (result.rows.length === 0) {
583 return res.status(404).send('Ticket not found');
584 }
585
586 res.json(result.rows[0]);
587 } catch (err) {
588 console.error('Error updating ticket:', err);
589 res.status(500).send('Failed to update ticket');
590 }
591});
592
593/**
594 * @api {post} /tickets/:id/notes Add note to ticket
595 * @apiName AddTicketNote
596 * @apiGroup Tickets
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.
600 *
601 * **Note Types:**
602 * - Public notes: Visible to customers (is_private = false)
603 * - Private notes: Internal only, hidden from customers (is_private = true)
604 *
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
609 *
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:
624 * curl -X POST \
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:
630 * curl -X POST \
631 * -H "Authorization: Bearer eyJhbGc..." \
632 * -H "Content-Type: application/json" \
633 * -d '{
634 * "content": "Troubleshooting network connectivity",
635 * "is_private": false,
636 * "duration_minutes": 45,
637 * "billable": true
638 * }' \
639 * "https://api.everydaytech.au/tickets/126/notes"
640 * @apiExample {json} Success Response:
641 * HTTP/1.1 200 OK
642 * {
643 * "note_id": 89,
644 * "ticket_id": 126,
645 * "user_id": 5,
646 * "content": "Rebooted server, issue resolved",
647 * "is_private": false,
648 * "created_at": "2026-02-10T16:30:00Z"
649 * }
650 * @since 2.0.0
651 * @see {@link module:routes/tickets.addTimeEntry} for standalone time tracking
652 */
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;
657
658 try {
659 const client = await pool.connect();
660
661 try {
662 await client.query('BEGIN');
663
664 const noteResult = await client.query(
665 `INSERT INTO ticket_notes (ticket_id, user_id, content, is_private)
666 VALUES ($1, $2, $3, $4)
667 RETURNING *`,
668 [id, user_id, content, is_private]
669 );
670
671 if ((start_time && end_time) || (duration_minutes && Number(duration_minutes) > 0)) {
672 let startISO = start_time;
673 let endISO = end_time;
674 let mins = 0;
675
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));
680 } else {
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);
687 }
688
689 await client.query(
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]
693 );
694 }
695
696 await client.query('COMMIT');
697 res.json(noteResult.rows[0]);
698 } catch (err) {
699 await client.query('ROLLBACK');
700 throw err;
701 } finally {
702 client.release();
703 }
704 } catch (err) {
705 console.error('Error adding note:', err);
706 res.status(500).send('Failed to add note');
707 }
708});
709
710/**
711 * @api {post} /tickets/:id/time Add time entry to ticket
712 * @apiName AddTimeEntry
713 * @apiGroup Tickets
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.
717 *
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
724 *
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:
739 * curl -X POST \
740 * -H "Authorization: Bearer eyJhbGc..." \
741 * -H "Content-Type: application/json" \
742 * -d '{
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",
747 * "billable": true
748 * }' \
749 * "https://api.everydaytech.au/tickets/126/time"
750 * @apiExample {json} Success Response:
751 * HTTP/1.1 201 Created
752 * {
753 * "entry_id": 234,
754 * "ticket_id": 126,
755 * "user_id": 5,
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",
760 * "billable": true,
761 * "created_at": "2026-02-10T15:30:00Z"
762 * }
763 * @since 2.0.0
764 * @see {@link module:routes/tickets.addNote} for adding note with time tracking
765 */
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;
770
771 try {
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
776 )
777 VALUES ($1, $2, $3, $4, $5, $6, $7)
778 RETURNING *`,
779 [id, user_id, start_time, end_time, duration_minutes, description, billable]
780 );
781
782 res.status(201).json(result.rows[0]);
783 } catch (err) {
784 console.error('Error adding time entry:', err);
785 res.status(500).send('Failed to add time entry');
786 }
787});
788
789/**
790 * @api {delete} /tickets/:id Delete ticket
791 * @apiName DeleteTicket
792 * @apiGroup Tickets
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.
796 *
797 * **Admin Only:**
798 * Requires admin role via requireAdmin middleware. Regular users cannot delete tickets.
799 *
800 * **Cascade Deletion:**
801 * Automatically deletes:
802 * - All ticket notes
803 * - All time entries
804 * - The ticket record itself
805 *
806 * **Best Practice:**
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:
817 * curl -X DELETE \
818 * -H "Authorization: Bearer eyJhbGc..." \
819 * "https://api.everydaytech.au/tickets/126"
820 * @apiExample {json} Success Response:
821 * HTTP/1.1 200 OK
822 * {
823 * "message": "Ticket deleted successfully",
824 * "deleted_ticket": {
825 * "ticket_id": 126,
826 * "title": "Test ticket for deletion",
827 * "status": "closed"
828 * }
829 * }
830 * @apiExample {json} Error Response (Not Admin):
831 * HTTP/1.1 403 Forbidden
832 * {
833 * "error": "Admin access required"
834 * }
835 * @since 2.0.0
836 * @see {@link module:routes/tickets.updateTicket} for closing tickets (preferred)
837 */
838router.delete('/:id', requireAdmin, async (req, res) => {
839 const ticketId = req.params.id;
840
841 try {
842 const { clause: tenantClause, params: tenantParams } = getTenantFilter(req, 't');
843
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];
847
848 if (tenantClause) {
849 const adjustedClause = tenantClause.replace(/\$(\d+)/g, (m, n) => `$${parseInt(n, 10) + 1}`);
850 checkQuery += ` AND ${adjustedClause}`;
851 checkParams.push(...tenantParams);
852 }
853
854 const checkResult = await pool.query(checkQuery, checkParams);
855
856 if (checkResult.rows.length === 0) {
857 return res.status(404).json({ error: 'Ticket not found' });
858 }
859
860 const ticket = checkResult.rows[0];
861
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
864
865 // Delete associated ticket data first (due to foreign key constraints)
866 try {
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');
871 }
872
873 // Delete the ticket
874 const result = await pool.query(
875 'DELETE FROM tickets WHERE ticket_id = $1 RETURNING *',
876 [ticketId]
877 );
878
879 console.log(`[Tickets] Ticket deleted: ID ${ticketId}, Title: "${ticket.title}", User: ${req.user?.user_id}, Tenant: ${req.tenant?.id}`);
880
881 res.json({
882 message: 'Ticket deleted successfully',
883 deleted_ticket: result.rows[0]
884 });
885 } catch (err) {
886 console.error('Error deleting ticket:', err);
887 res.status(500).json({ error: 'Failed to delete ticket', details: err.message });
888 }
889});
890
891module.exports = router;