diff --git a/app.js b/app.js index 243a2d7..98eadc4 100644 --- a/app.js +++ b/app.js @@ -13,6 +13,7 @@ const { doubleCsrf } = require('csrf-csrf'); const sessionConfig = require('./config/session'); const errorHandler = require('./middleware/errorHandler'); const { i18next, middleware: i18nMiddleware } = require('./config/i18n'); +const { calculateTicketAge } = require('./utils/dateHelpers'); const publicRoutes = require('./routes/public'); const authRoutes = require('./routes/auth'); @@ -92,7 +93,7 @@ app.use(flash()); // Apply CSRF protection to all routes app.use(doubleCsrfProtection); -// Make CSRF token, flash messages, and i18n available to all views +// Make CSRF token, flash messages, i18n, and utilities available to all views app.use((req, res, next) => { res.locals.success_msg = req.flash('success_msg'); res.locals.error_msg = req.flash('error_msg'); @@ -104,6 +105,9 @@ app.use((req, res, next) => { res.locals.t = req.t; res.locals.language = req.language || 'el'; + // Make date helpers available for ticket age calculation + res.locals.calculateTicketAge = calculateTicketAge; + // Add request timing for debugging req.startTime = new Date(); diff --git a/locales/el/tickets.json b/locales/el/tickets.json index e1456dc..eafde2e 100644 --- a/locales/el/tickets.json +++ b/locales/el/tickets.json @@ -129,12 +129,37 @@ "actions": "Ενέργειες", "anonymous": "Ανώνυμος", "allTickets": "Όλα τα Αιτήματα", - "myTickets": "Τα Αιτήματά μου" + "myTickets": "Τα Αιτήματά μου", + "select": "Επιλογή", + "age": "Ηλικία", + "ageNew": "Νέο", + "ageDays": "{{count}} ημέρα", + "ageDays_plural": "{{count}} ημέρες", + "ageWeeks": "{{count}} εβδομάδα", + "ageWeeks_plural": "{{count}} εβδομάδες", + "quickAssign": "Γρήγορη Ανάθεση" }, "filters": { "title": "Φίλτρα" }, "search": { "placeholder": "Αναζήτηση αιτημάτων..." + }, + "bulkUpdate": { + "label": "Μαζική ενημέρωση επιλεγμένων:", + "statusNoChange": "Κατάσταση (χωρίς αλλαγή)", + "priorityNoChange": "Προτεραιότητα (χωρίς αλλαγή)", + "assignmentNoChange": "Ανάθεση (χωρίς αλλαγή)", + "unassign": "-- Χωρίς Ανάθεση --", + "updateSelected": "Ενημέρωση Επιλεγμένων", + "selectAtLeastOneTicket": "Παρακαλώ επιλέξτε τουλάχιστον ένα αίτημα", + "selectAtLeastOneField": "Παρακαλώ επιλέξτε τουλάχιστον ένα πεδίο για ενημέρωση (κατάσταση, προτεραιότητα ή ανάθεση)", + "confirmUpdate": "Ενημέρωση {{count}} επιλεγμένων αιτημάτων;" + }, + "pagination": { + "previous": "Προηγούμενη", + "next": "Επόμενη", + "pageInfo": "Σελίδα {{current}} από {{total}}", + "showingOfTotal": "Εμφάνιση {{count}} από {{total}} αιτημάτων" } } diff --git a/locales/en/tickets.json b/locales/en/tickets.json index 2ab8d6f..a9ab550 100644 --- a/locales/en/tickets.json +++ b/locales/en/tickets.json @@ -129,12 +129,37 @@ "actions": "Actions", "anonymous": "Anonymous", "allTickets": "All Tickets", - "myTickets": "My Tickets" + "myTickets": "My Tickets", + "select": "Select", + "age": "Age", + "ageNew": "New", + "ageDays": "{{count}} day old", + "ageDays_plural": "{{count}} days old", + "ageWeeks": "{{count}} week old", + "ageWeeks_plural": "{{count}} weeks old", + "quickAssign": "Quick Assign" }, "filters": { "title": "Filters" }, "search": { "placeholder": "Search tickets..." + }, + "bulkUpdate": { + "label": "Bulk update selected:", + "statusNoChange": "Status (no change)", + "priorityNoChange": "Priority (no change)", + "assignmentNoChange": "Assignment (no change)", + "unassign": "-- Unassign --", + "updateSelected": "Update Selected", + "selectAtLeastOneTicket": "Please select at least one ticket", + "selectAtLeastOneField": "Please select at least one field to update (status, priority, or assignment)", + "confirmUpdate": "Update {{count}} selected ticket(s)?" + }, + "pagination": { + "previous": "Previous", + "next": "Next", + "pageInfo": "Page {{current}} of {{total}}", + "showingOfTotal": "Showing {{count}} of {{total}} tickets" } } diff --git a/migrations/029_add_dashboard_indexes.sql b/migrations/029_add_dashboard_indexes.sql new file mode 100644 index 0000000..513f5a6 --- /dev/null +++ b/migrations/029_add_dashboard_indexes.sql @@ -0,0 +1,20 @@ +-- Migration 029: Add dashboard performance indexes +-- Purpose: Optimize dashboard queries for counts, pagination, and last comment retrieval + +-- Index for efficient last comment queries +-- Supports DISTINCT ON queries to get most recent comment per ticket +CREATE INDEX IF NOT EXISTS idx_comments_ticket_created +ON comments(ticket_id, created_at DESC); + +-- Indexes for count aggregations by status and priority +-- These speed up the GROUP BY queries for filter badge counts +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets(priority); + +-- Composite index for filtered pagination queries +-- Supports WHERE clauses with status/priority filters plus ORDER BY created_at +CREATE INDEX IF NOT EXISTS idx_tickets_status_created +ON tickets(status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_tickets_priority_created +ON tickets(priority, created_at DESC); diff --git a/models/Comment.js b/models/Comment.js index 5814416..cb546ea 100644 --- a/models/Comment.js +++ b/models/Comment.js @@ -148,6 +148,58 @@ class Comment { throw error; } } + + static async getLastCommentsByTicketIds(ticketIds) { + const startTime = Date.now(); + try { + if (!ticketIds || ticketIds.length === 0) { + logger.debug('Comment.getLastCommentsByTicketIds: Empty ticket IDs array'); + return []; + } + + logger.debug('Comment.getLastCommentsByTicketIds: Starting query', { + ticketCount: ticketIds.length, + }); + const result = await pool.query( + ` + SELECT DISTINCT ON (c.ticket_id) + c.ticket_id, + c.content, + u.username, + c.created_at + FROM comments c + JOIN users u ON c.user_id = u.id + WHERE c.ticket_id = ANY($1::int[]) + ORDER BY c.ticket_id, c.created_at DESC + `, + [ticketIds] + ); + + const duration = Date.now() - startTime; + + if (duration > 500) { + logger.warn('Comment.getLastCommentsByTicketIds: Slow query detected', { + ticketCount: ticketIds.length, + duration, + }); + } + + logger.debug('Comment.getLastCommentsByTicketIds: Query completed', { + ticketCount: ticketIds.length, + resultCount: result.rows.length, + duration, + }); + return result.rows; + } catch (error) { + logger.error('Comment.getLastCommentsByTicketIds: Database error', { + ticketCount: ticketIds?.length, + error: error.message, + stack: error.stack, + code: error.code, + }); + throw error; + } + } } module.exports = Comment; diff --git a/models/Ticket.js b/models/Ticket.js index 5907162..bf17b0b 100644 --- a/models/Ticket.js +++ b/models/Ticket.js @@ -324,6 +324,230 @@ class Ticket { throw error; } } + + static async getCountsByStatus() { + const startTime = Date.now(); + try { + logger.debug('Ticket.getCountsByStatus: Starting count query'); + const result = await pool.query(` + SELECT status, COUNT(*) as count + FROM tickets + GROUP BY status + `); + const duration = Date.now() - startTime; + + logger.debug('Ticket.getCountsByStatus: Query completed', { + duration, + groups: result.rows.length, + }); + return result.rows; + } catch (error) { + logger.error('Ticket.getCountsByStatus: Database error', { + error: error.message, + stack: error.stack, + code: error.code, + }); + throw error; + } + } + + static async getCountsByPriority() { + const startTime = Date.now(); + try { + logger.debug('Ticket.getCountsByPriority: Starting count query'); + const result = await pool.query(` + SELECT priority, COUNT(*) as count + FROM tickets + GROUP BY priority + `); + const duration = Date.now() - startTime; + + logger.debug('Ticket.getCountsByPriority: Query completed', { + duration, + groups: result.rows.length, + }); + return result.rows; + } catch (error) { + logger.error('Ticket.getCountsByPriority: Database error', { + error: error.message, + stack: error.stack, + code: error.code, + }); + throw error; + } + } + + static async bulkUpdate(ticketIds, updates) { + const startTime = Date.now(); + try { + logger.info('Ticket.bulkUpdate: Starting bulk update', { + ticketCount: ticketIds.length, + updates, + }); + + // Build dynamic UPDATE query + const fields = []; + const values = []; + let paramIndex = 1; + + if (updates.status !== undefined) { + fields.push(`status = $${paramIndex}`); + values.push(updates.status); + paramIndex++; + } + + if (updates.priority !== undefined) { + fields.push(`priority = $${paramIndex}`); + values.push(updates.priority); + paramIndex++; + } + + if (updates.assigned_to !== undefined) { + fields.push(`assigned_to = $${paramIndex}`); + values.push(updates.assigned_to); + paramIndex++; + } + + if (fields.length === 0) { + throw new Error('No fields to update'); + } + + fields.push('updated_at = CURRENT_TIMESTAMP'); + values.push(ticketIds); + + const result = await pool.query( + ` + UPDATE tickets + SET ${fields.join(', ')} + WHERE id = ANY($${paramIndex}::int[]) + RETURNING * + `, + values + ); + + const duration = Date.now() - startTime; + + if (duration > 1000) { + logger.warn('Ticket.bulkUpdate: Slow query detected', { + ticketCount: ticketIds.length, + duration, + }); + } + + logger.info('Ticket.bulkUpdate: Bulk update completed', { + ticketCount: result.rows.length, + duration, + }); + return result.rows; + } catch (error) { + logger.error('Ticket.bulkUpdate: Database error', { + ticketCount: ticketIds.length, + updates, + error: error.message, + stack: error.stack, + code: error.code, + }); + throw error; + } + } + + static async findAllPaginated(filters = {}, page = 1, limit = 50) { + const startTime = Date.now(); + try { + logger.debug('Ticket.findAllPaginated: Starting query', { filters, page, limit }); + + const offset = (page - 1) * limit; + const params = []; + const conditions = []; + let paramIndex = 1; + + // Build WHERE clause + if (filters.status) { + conditions.push(`t.status = $${paramIndex}`); + params.push(filters.status); + paramIndex++; + } + + if (filters.priority) { + conditions.push(`t.priority = $${paramIndex}`); + params.push(filters.priority); + paramIndex++; + } + + if (filters.search) { + conditions.push(`(t.title ILIKE $${paramIndex} OR t.description ILIKE $${paramIndex})`); + params.push(`%${filters.search}%`); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count for pagination + const countResult = await pool.query( + ` + SELECT COUNT(*) as total + FROM tickets t + ${whereClause} + `, + params + ); + + const totalCount = parseInt(countResult.rows[0].total); + const totalPages = Math.ceil(totalCount / limit); + + // Get paginated results + params.push(limit); + params.push(offset); + + const result = await pool.query( + ` + SELECT t.*, u.username as assigned_to_username + FROM tickets t + LEFT JOIN users u ON t.assigned_to = u.id + ${whereClause} + ORDER BY t.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `, + params + ); + + const duration = Date.now() - startTime; + + if (duration > 500) { + logger.warn('Ticket.findAllPaginated: Slow query detected', { filters, page, duration }); + } + + logger.debug('Ticket.findAllPaginated: Query completed', { + filters, + page, + rowCount: result.rows.length, + totalCount, + duration, + }); + + return { + tickets: result.rows, + pagination: { + currentPage: page, + totalPages, + totalCount, + limit, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }, + }; + } catch (error) { + logger.error('Ticket.findAllPaginated: Database error', { + filters, + page, + limit, + error: error.message, + stack: error.stack, + code: error.code, + }); + throw error; + } + } } module.exports = Ticket; diff --git a/public/js/admin-dashboard.js b/public/js/admin-dashboard.js new file mode 100644 index 0000000..5f119c7 --- /dev/null +++ b/public/js/admin-dashboard.js @@ -0,0 +1,70 @@ +/** + * Admin dashboard interactivity: bulk ticket selection/update and quick-assign. + * Uses event listeners instead of inline onclick/onchange (blocked by CSP). + * Confirmation/validation copy is read from data-* attributes so it stays + * translated via the EJS template rather than being hardcoded here. + */ +document.addEventListener('DOMContentLoaded', function () { + const selectAll = document.getElementById('selectAll'); + const checkboxes = document.querySelectorAll('.ticket-checkbox'); + const bulkBtn = document.getElementById('bulkUpdateBtn'); + const selectedCount = document.getElementById('selectedCount'); + const bulkForm = document.getElementById('bulkUpdateForm'); + + function updateBulkButton() { + if (!bulkBtn || !selectedCount) { + return; + } + const checked = document.querySelectorAll('.ticket-checkbox:checked'); + selectedCount.textContent = checked.length; + bulkBtn.disabled = checked.length === 0; + } + + if (selectAll) { + selectAll.addEventListener('change', function () { + checkboxes.forEach(function (cb) { + cb.checked = selectAll.checked; + }); + updateBulkButton(); + }); + } + + checkboxes.forEach(function (cb) { + cb.addEventListener('change', updateBulkButton); + }); + + if (bulkForm) { + bulkForm.addEventListener('submit', function (e) { + const checked = document.querySelectorAll('.ticket-checkbox:checked'); + if (checked.length === 0) { + e.preventDefault(); + window.alert(bulkForm.dataset.selectAtLeastOneTicket); + return; + } + + const status = bulkForm.querySelector('select[name="status"]').value; + const priority = bulkForm.querySelector('select[name="priority"]').value; + const assignedTo = bulkForm.querySelector('select[name="assigned_to"]').value; + + if (!status && !priority && assignedTo === '') { + e.preventDefault(); + window.alert(bulkForm.dataset.selectAtLeastOneField); + return; + } + + const confirmMessage = (bulkForm.dataset.confirmUpdateTemplate || '').replace( + '__COUNT__', + checked.length + ); + if (!window.confirm(confirmMessage)) { + e.preventDefault(); + } + }); + } + + document.querySelectorAll('.quick-assign-select').forEach(function (select) { + select.addEventListener('change', function () { + select.closest('form').submit(); + }); + }); +}); diff --git a/routes/admin.js b/routes/admin.js index 0385870..9f70f15 100644 --- a/routes/admin.js +++ b/routes/admin.js @@ -3,6 +3,7 @@ const router = express.Router(); const { requireAuth, requireAdmin } = require('../middleware/auth'); const { adminMutationLimiter } = require('../middleware/rateLimiter'); const Comment = require('../models/Comment'); +const User = require('../models/User'); const { validateRequest } = require('../middleware/validation'); const { TICKET_MESSAGES, COMMENT_MESSAGES } = require('../constants/messages'); const ticketService = require('../services/ticketService'); @@ -13,6 +14,8 @@ const { validateTicketId, validateTicketStatusUpdate, validateTicketPriorityUpdate, + validateBulkUpdate, + validateQuickAssign, } = require('../validators/ticketValidators'); const { validateAdminTicketCreation, @@ -27,13 +30,27 @@ router.use(requireAuth); router.get('/dashboard', async (req, res, next) => { try { - const tickets = await ticketService.getAllTickets(req.query); + const page = parseInt(req.query.page) || 1; + const dashboardData = await ticketService.getDashboardData(req.query, page); + + // Get active users for quick assign dropdown + const users = await User.findAllActive(); + res.render('admin/dashboard', { title: 'Admin Dashboard', - tickets, + tickets: dashboardData.tickets, + pagination: dashboardData.pagination, + statusCounts: dashboardData.statusCounts, + priorityCounts: dashboardData.priorityCounts, + lastComments: dashboardData.lastComments, + users: users, filters: req.query, }); } catch (error) { + logger.error('Error loading dashboard', { + error: error.message, + stack: error.stack, + }); next(error); } }); @@ -97,7 +114,13 @@ router.post( validateRequest, async (req, res, next) => { try { - await ticketService.updateTicket(req.params.id, req.body, req.session.user.id, req.ip, buildAuditContext(req)); + await ticketService.updateTicket( + req.params.id, + req.body, + req.session.user.id, + req.ip, + buildAuditContext(req) + ); successRedirect(req, res, TICKET_MESSAGES.UPDATED, `/admin/tickets/${req.params.id}`); } catch (error) { next(error); @@ -285,4 +308,125 @@ router.post( } ); +// Bulk update (status, priority, assignment) +router.post( + '/tickets/bulk-update', + requireAdmin, + adminMutationLimiter, + validateBulkUpdate, + validateRequest, + async (req, res, next) => { + try { + const { ticketIds, status, priority, assigned_to } = req.body; + + // Build updates object (only include provided fields) + // assigned_to === '' means "no change" (default select option); the + // dedicated __unassign__ sentinel is the only way to null it out, so + // the two blank-looking options in the form can't collide. + const updates = {}; + if (status) { + updates.status = status; + } + if (priority) { + updates.priority = priority; + } + if (assigned_to === '__unassign__') { + updates.assigned_to = null; + } else if (assigned_to) { + updates.assigned_to = parseInt(assigned_to, 10); + } + + await ticketService.bulkUpdateTickets( + ticketIds, + updates, + req.session.user.id, + req.ip, + buildAuditContext(req) + ); + + // Build description of what was updated + const updateParts = []; + if (status) { + updateParts.push(`status to ${status}`); + } + if (priority) { + updateParts.push(`priority to ${priority}`); + } + if ('assigned_to' in updates) { + updateParts.push(updates.assigned_to ? 'assignment' : 'unassigned'); + } + + successRedirect( + req, + res, + `Successfully updated ${ticketIds.length} ticket(s): ${updateParts.join(', ')}`, + '/admin/dashboard' + buildQueryString(req.query) + ); + } catch (error) { + logger.error('Error in bulk update', { + error: error.message, + ticketIds: req.body.ticketIds, + updates: { + status: req.body.status, + priority: req.body.priority, + assigned_to: req.body.assigned_to, + }, + }); + next(error); + } + } +); + +// Quick assign ticket +router.post( + '/tickets/:id/quick-assign', + requireAdmin, + adminMutationLimiter, + validateQuickAssign, + validateRequest, + async (req, res, next) => { + try { + await ticketService.updateTicket( + req.params.id, + { assigned_to: req.body.assigned_to || null }, + req.session.user.id, + req.ip, + buildAuditContext(req) + ); + + successRedirect( + req, + res, + 'Ticket assigned successfully', + '/admin/dashboard' + buildQueryString(req.query) + ); + } catch (error) { + logger.error('Error in quick assign', { + error: error.message, + ticketId: req.params.id, + assignedTo: req.body.assigned_to, + }); + next(error); + } + } +); + +// Helper function to preserve filters and pagination +function buildQueryString(filters) { + const params = new URLSearchParams(); + if (filters.status) { + params.append('status', filters.status); + } + if (filters.priority) { + params.append('priority', filters.priority); + } + if (filters.search) { + params.append('search', filters.search); + } + if (filters.page) { + params.append('page', filters.page); + } + return params.toString() ? '?' + params.toString() : ''; +} + module.exports = router; diff --git a/services/ticketService.js b/services/ticketService.js index fddb18b..d363871 100644 --- a/services/ticketService.js +++ b/services/ticketService.js @@ -3,6 +3,7 @@ const Comment = require('../models/Comment'); const User = require('../models/User'); const AuditLog = require('../models/AuditLog'); const logger = require('../utils/logger'); +const { TICKET_STATUS, TICKET_PRIORITY } = require('../constants/enums'); class TicketService { async createTicket(ticketData) { @@ -184,7 +185,11 @@ class TicketService { // AUTO-STATUS UPDATE: Admin adding PUBLIC comment → "waiting_on_department" // ONLY if: public comment AND ticket not closed AND has reporter_id (dept ticket) - if (visibilityType === 'public' && ticket.status !== 'closed' && ticket.reporter_id !== null) { + if ( + visibilityType === 'public' && + ticket.status !== 'closed' && + ticket.reporter_id !== null + ) { await this.updateTicket( ticketId, { status: 'waiting_on_department' }, @@ -220,6 +225,155 @@ class TicketService { throw error; } } + + async getDashboardData(filters = {}, page = 1, limit = 50) { + const startTime = Date.now(); + try { + // Parse page number + const pageNum = parseInt(page) || 1; + const pageLimit = parseInt(limit) || 50; + + logger.debug('ticketService.getDashboardData: Fetching dashboard data', { + filters, + page: pageNum, + limit: pageLimit, + }); + + // Parallel queries for performance + const [paginatedResult, statusCounts, priorityCounts] = await Promise.all([ + Ticket.findAllPaginated(filters, pageNum, pageLimit), + Ticket.getCountsByStatus(), + Ticket.getCountsByPriority(), + ]); + + const { tickets, pagination } = paginatedResult; + + // Get last comments for displayed tickets + const ticketIds = tickets.map((t) => t.id); + const lastComments = + ticketIds.length > 0 ? await Comment.getLastCommentsByTicketIds(ticketIds) : []; + + // Map comments to tickets + const commentMap = {}; + lastComments.forEach((comment) => { + commentMap[comment.ticket_id] = comment; + }); + + // Convert counts to object format + const statusCountMap = {}; + statusCounts.forEach(({ status, count }) => { + statusCountMap[status] = parseInt(count); + }); + + const priorityCountMap = {}; + priorityCounts.forEach(({ priority, count }) => { + priorityCountMap[priority] = parseInt(count); + }); + + const duration = Date.now() - startTime; + logger.debug('ticketService.getDashboardData: Dashboard data fetched', { + ticketCount: tickets.length, + totalTickets: pagination.totalCount, + page: pageNum, + duration, + }); + + return { + tickets, + pagination, + statusCounts: statusCountMap, + priorityCounts: priorityCountMap, + lastComments: commentMap, + }; + } catch (error) { + const duration = Date.now() - startTime; + logger.error('ticketService.getDashboardData: Failed to fetch dashboard data', { + filters, + page, + error: error.message, + stack: error.stack, + duration, + }); + throw error; + } + } + + async bulkUpdateTickets(ticketIds, updates, actorId, ipAddress, auditContext = {}) { + const startTime = Date.now(); + try { + logger.info('ticketService.bulkUpdateTickets: Bulk update initiated', { + ticketCount: ticketIds.length, + updates, + actorId, + }); + + // Validate inputs + if (!Array.isArray(ticketIds) || ticketIds.length === 0) { + throw new Error('No tickets selected'); + } + + if (ticketIds.length > 100) { + throw new Error('Cannot update more than 100 tickets at once'); + } + + // Validate status if provided + if (updates.status && !Object.values(TICKET_STATUS).includes(updates.status)) { + throw new Error('Invalid status'); + } + + // Validate priority if provided + if (updates.priority && !Object.values(TICKET_PRIORITY).includes(updates.priority)) { + throw new Error('Invalid priority'); + } + + // Validate assigned user if provided + if (updates.assigned_to !== undefined && updates.assigned_to !== null) { + const user = await User.findById(updates.assigned_to); + if (!user || user.status !== 'active') { + throw new Error('Invalid assigned user'); + } + } + + // Update tickets + const updatedTickets = await Ticket.bulkUpdate(ticketIds, updates); + + // Create audit log entry for each ticket + await Promise.all( + updatedTickets.map((ticket) => + AuditLog.create({ + actorId, + action: 'ticket_bulk_updated', + targetType: 'ticket', + targetId: ticket.id, + details: { ...updates, bulkOperation: true }, + ipAddress, + actorUsername: auditContext.actorUsername, + actorRole: auditContext.actorRole, + sessionHash: auditContext.sessionHash, + }) + ) + ); + + const duration = Date.now() - startTime; + logger.info('ticketService.bulkUpdateTickets: Bulk update completed', { + updatedCount: updatedTickets.length, + updates, + duration, + }); + + return updatedTickets; + } catch (error) { + const duration = Date.now() - startTime; + logger.error('ticketService.bulkUpdateTickets: Bulk update failed', { + ticketCount: ticketIds?.length, + updates, + error: error.message, + stack: error.stack, + duration, + }); + throw error; + } + } } module.exports = new TicketService(); diff --git a/tests/unit/utils/dateHelpers.test.js b/tests/unit/utils/dateHelpers.test.js new file mode 100644 index 0000000..8af8bc7 --- /dev/null +++ b/tests/unit/utils/dateHelpers.test.js @@ -0,0 +1,40 @@ +const { calculateTicketAge } = require('../../../utils/dateHelpers'); + +function hoursAgo(hours) { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + +describe('dateHelpers', () => { + describe('calculateTicketAge', () => { + it('returns unit "new" with success color for tickets under 24 hours old', () => { + const result = calculateTicketAge(hoursAgo(1)); + expect(result).toEqual({ unit: 'new', count: 0, color: 'success' }); + }); + + it('returns unit "days" with info color for tickets 1-3 days old', () => { + const result = calculateTicketAge(hoursAgo(48)); + expect(result.unit).toBe('days'); + expect(result.count).toBe(2); + expect(result.color).toBe('info'); + }); + + it('returns unit "days" with warning color for tickets 4-7 days old', () => { + const result = calculateTicketAge(hoursAgo(5 * 24)); + expect(result.unit).toBe('days'); + expect(result.count).toBe(5); + expect(result.color).toBe('warning'); + }); + + it('returns unit "weeks" with danger color for tickets over 7 days old', () => { + const result = calculateTicketAge(hoursAgo(20 * 24)); + expect(result.unit).toBe('weeks'); + expect(result.count).toBe(2); + expect(result.color).toBe('danger'); + }); + + it('accepts a string timestamp', () => { + const result = calculateTicketAge(new Date(hoursAgo(1)).toISOString()); + expect(result.unit).toBe('new'); + }); + }); +}); diff --git a/utils/dateHelpers.js b/utils/dateHelpers.js new file mode 100644 index 0000000..5874eb3 --- /dev/null +++ b/utils/dateHelpers.js @@ -0,0 +1,31 @@ +/** + * Date Helper Utilities + * Provides functions for calculating and formatting ticket ages + */ + +/** + * Calculate ticket age bucket from timestamp with a semantic color tone. + * Returns a translation unit + count rather than rendered text, so callers + * can localize via i18n (see tickets:table.age* keys). + * @param {Date|string} timestamp - Created timestamp + * @returns {Object} { unit: 'new'|'days'|'weeks', count: number, color: 'success'|'info'|'warning'|'danger' } + */ +function calculateTicketAge(timestamp) { + const now = new Date(); + const created = new Date(timestamp); + const diffMs = now - created; + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + + if (diffHours < 24) { + return { unit: 'new', count: 0, color: 'success' }; + } else if (diffDays <= 3) { + return { unit: 'days', count: diffDays, color: 'info' }; + } else if (diffDays <= 7) { + return { unit: 'days', count: diffDays, color: 'warning' }; + } + + return { unit: 'weeks', count: Math.floor(diffDays / 7), color: 'danger' }; +} + +module.exports = { calculateTicketAge }; diff --git a/validators/ticketValidators.js b/validators/ticketValidators.js index 12773ab..b949fe9 100644 --- a/validators/ticketValidators.js +++ b/validators/ticketValidators.js @@ -94,6 +94,38 @@ const validateTicketPriorityUpdate = [ .withMessage(VALIDATION_MESSAGES.PRIORITY_INVALID), ]; +const validateBulkUpdate = [ + body('ticketIds') + .isArray({ min: 1, max: 100 }) + .withMessage('Must select between 1 and 100 tickets'), + body('ticketIds.*').isInt({ min: 1 }).withMessage('Invalid ticket ID'), + body('status') + .optional({ checkFalsy: true }) + .isIn(Object.values(TICKET_STATUS)) + .withMessage(VALIDATION_MESSAGES.STATUS_INVALID), + body('priority') + .optional({ checkFalsy: true }) + .isIn(Object.values(TICKET_PRIORITY)) + .withMessage(VALIDATION_MESSAGES.PRIORITY_INVALID), + body('assigned_to') + .optional({ checkFalsy: true }) + .custom((value) => { + if (value === '__unassign__') { + return true; + } // Explicit unassignment sentinel + return Number.isInteger(parseInt(value)) && parseInt(value) > 0; + }) + .withMessage('Invalid user ID'), +]; + +const validateQuickAssign = [ + param('id').isInt({ min: 1 }).withMessage('Invalid ticket ID'), + body('assigned_to') + .optional({ checkFalsy: true }) + .isInt({ min: 1 }) + .withMessage('Invalid user ID'), +]; + module.exports = { validateTicketCreation, validateTicketUpdate, @@ -101,4 +133,6 @@ module.exports = { validateTicketAssignment, validateTicketStatusUpdate, validateTicketPriorityUpdate, + validateBulkUpdate, + validateQuickAssign, }; diff --git a/views/admin/dashboard.ejs b/views/admin/dashboard.ejs index 8e9ffc6..247dbda 100644 --- a/views/admin/dashboard.ejs +++ b/views/admin/dashboard.ejs @@ -33,11 +33,11 @@ @@ -45,11 +45,11 @@ @@ -76,68 +76,166 @@

<%= t('tickets:emptyState.noTicketsFound') %>. <%= filters.status || filters.priority || filters.search ? t('tickets:emptyState.adjustFilters') : t('tickets:emptyState.ticketsWillAppear') %>

<% } else { %> -
-
-

- - <%= t('tickets:table.allTickets') %> -

+
+ + + + + + + + +
+
+
+ + + + + +
+
-
- - - - - - - - - - - - - - - <% tickets.forEach(ticket => { %> + +
+
+
<%= t('tickets:table.id') %><%= t('tickets:table.title') %><%= t('tickets:table.status') %><%= t('tickets:table.priority') %><%= t('tickets:table.reporter') %><%= t('tickets:table.lastComment') %><%= t('tickets:table.created') %><%= t('tickets:table.actions') %>
+ - - - - - - - - + + + + + + + + + + - <% }); %> - -
#<%= ticket.id %> - <%= ticket.title %> - <% if (ticket.reporter_department === 'Internal') { %> - <%- include('../partials/badges/badge', { color: 'warning', text: t('tickets:badges.internal'), size: 'sm', cssClass: 'ml-2' }) %> - <% } %> - - <%- include('../partials/badges/status-badge', { status: ticket.status, withIcon: true }) %> - - <%- include('../partials/badges/priority-badge', { priority: ticket.priority, withIcon: true }) %> - <%= ticket.reporter_name || t('tickets:table.anonymous') %> - <% if (ticket.last_comment) { %> - <%= ticket.last_comment.length > 100 ? ticket.last_comment.substring(0, 100) + '...' : ticket.last_comment %> - <% } else { %> - <%= t('common:labels.noComments') %> - <% } %> - <%= new Date(ticket.created_at).toLocaleDateString(language === 'el' ? 'el-GR' : 'en-US') %> - <%= t('common:buttons.view') %> - + + <%= t('tickets:table.id') %><%= t('tickets:table.title') %><%= t('tickets:table.status') %><%= t('tickets:table.priority') %><%= t('tickets:table.age') %><%= t('tickets:table.lastComment') %><%= t('tickets:table.reporter') %><%= t('tickets:table.quickAssign') %><%= t('tickets:table.actions') %>
+ + + <% tickets.forEach(ticket => { + const age = calculateTicketAge(ticket.created_at); + const lastComment = lastComments[ticket.id]; + const ageText = age.unit === 'new' + ? t('tickets:table.ageNew') + : t('tickets:table.age' + (age.unit === 'days' ? 'Days' : 'Weeks'), { count: age.count }); + %> + + + + + #<%= ticket.id %> + + <%= ticket.title %> + <% if (ticket.reporter_department === 'Internal') { %> + <%- include('../partials/badges/badge', { color: 'warning', text: t('tickets:badges.internal'), size: 'sm', cssClass: 'ml-2' }) %> + <% } %> + + + <%- include('../partials/badges/status-badge', { status: ticket.status, withIcon: true }) %> + + + <%- include('../partials/badges/priority-badge', { priority: ticket.priority, withIcon: true }) %> + + + <%- include('../partials/badges/badge', { color: age.color, text: ageText, size: 'sm' }) %> + + + <% if (lastComment) { %> +
+ <%= lastComment.username %>: + <%= lastComment.content.length > 50 ? lastComment.content.substring(0, 50) + '...' : lastComment.content %> +
+ <% } else { %> + <%= t('common:labels.noComments') %> + <% } %> + + <%= ticket.reporter_name || t('tickets:table.anonymous') %> + + + + + + + + + + + + <%= t('common:buttons.view') %> + + + <% }); %> + + +
-
-
- <%= t('tickets:stats.showing', { count: tickets.length }) %> -
+ <% if (pagination && pagination.totalPages > 1) { %> + <% const qs = (extra) => { + const params = new URLSearchParams(); + if (filters.status) params.append('status', filters.status); + if (filters.priority) params.append('priority', filters.priority); + if (filters.search) params.append('search', filters.search); + params.append('page', extra); + return '?' + params.toString(); + }; %> + +

+ <%= t('tickets:pagination.showingOfTotal', { count: tickets.length, total: pagination.totalCount }) %> +

+ <% } else { %> +
+ <%= t('tickets:stats.showing', { count: tickets.length }) %> +
+ <% } %> + <% } %> <%- include('../partials/footer') %> +