From a97dcfa33e8df228e572115b602cd34a0eb1ebf6 Mon Sep 17 00:00:00 2001 From: frakarr Date: Tue, 9 Dec 2025 15:58:52 +0100 Subject: [PATCH] feat: Implement ticket commenting functionality Adds the ability for users to comment on tickets, view comments, and distinguish between user and admin responses. Also introduces a new 'SUSPENDED' status for tickets and refactors database schema and API endpoints to support comments. --- .dockerignore | Bin 89 -> 136 bytes Dockerfile | 15 --- nginx.conf | 38 ------- pages/Tickets.tsx | 271 ++++++++++++++++++++++++++++++++------------- server/Dockerfile | 13 --- server/db.js | 13 +++ server/server.js | 98 ++++++++++++++-- services/mockDb.ts | 13 ++- types.ts | 14 ++- 9 files changed, 324 insertions(+), 151 deletions(-) diff --git a/.dockerignore b/.dockerignore index 21edd644e3803ddbc480cea5c66a3986704aed4a..276bbec6497998dcc0f46046911191732494e1c2 100644 GIT binary patch literal 136 zcmaFAfA9PKd*gsOOBP6k196$QE)xfkW&~m&VlV*`UO=o}2_$5I7=nu6EC?gh8A8j! Y#jB35hO;3I6nmJ|<8rux;vj?K0B1~FyZ`_I literal 89 zcmW;Eu?>JQ3`EiXcEM9*0|r1SK2SswBO4;IJ&5KPulU`ROEbMI16tyO?BxslfTVeu hFLOdIAM`0(J1rr4m6ObVyOIkP-QN;sugEA5H)O diff --git a/Dockerfile b/Dockerfile index bb87fa8..e69de29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +0,0 @@ -# Stage 1: Build Frontend -FROM node:18-alpine as build -WORKDIR /app -COPY package*.json ./ -RUN npm install -COPY . . -RUN npm run build - -# Stage 2: Serve with Nginx -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -# Copy the nginx configuration file (using the .txt extension as provided in source) -COPY nginx.txt /etc/nginx/nginx.conf -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/nginx.conf b/nginx.conf index f8625d9..e69de29 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,38 +0,0 @@ -worker_processes 1; - -events { worker_connections 1024; } - -http { - include mime.types; - default_type application/octet-stream; - sendfile on; - keepalive_timeout 65; - - server { - listen 80; - root /usr/share/nginx/html; - index index.html; - - # Limite upload per allegati (es. foto/video ticket) - Allineato con il backend - client_max_body_size 50M; - - # Compressione Gzip - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - # Gestione SPA (React Router) - location / { - try_files $uri $uri/ /index.html; - } - - # Proxy API verso il backend - location /api/ { - proxy_pass http://backend:3001; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - } -} diff --git a/pages/Tickets.tsx b/pages/Tickets.tsx index 09e867a..3648607 100644 --- a/pages/Tickets.tsx +++ b/pages/Tickets.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState } from 'react'; import { CondoService } from '../services/mockDb'; -import { Ticket, TicketStatus, TicketPriority, TicketCategory, TicketAttachment } from '../types'; -import { MessageSquareWarning, Plus, Search, Filter, Paperclip, X, CheckCircle2, Clock, XCircle, FileIcon, Image as ImageIcon, Film } from 'lucide-react'; +import { Ticket, TicketStatus, TicketPriority, TicketCategory, TicketAttachment, TicketComment } from '../types'; +import { MessageSquareWarning, Plus, Search, Filter, Paperclip, X, CheckCircle2, Clock, XCircle, FileIcon, Image as ImageIcon, Film, Send, PauseCircle, Archive, Trash2, User } from 'lucide-react'; export const TicketsPage: React.FC = () => { const user = CondoService.getCurrentUser(); @@ -10,11 +10,19 @@ export const TicketsPage: React.FC = () => { const [tickets, setTickets] = useState([]); const [loading, setLoading] = useState(true); - const [filterStatus, setFilterStatus] = useState('ALL'); + + // View Filters + const [viewMode, setViewMode] = useState<'active' | 'archived'>('active'); + const [showModal, setShowModal] = useState(false); const [viewTicket, setViewTicket] = useState(null); const [submitting, setSubmitting] = useState(false); + // Comments State + const [comments, setComments] = useState([]); + const [newComment, setNewComment] = useState(''); + const [loadingComments, setLoadingComments] = useState(false); + // Form State const [formTitle, setFormTitle] = useState(''); const [formDesc, setFormDesc] = useState(''); @@ -69,10 +77,27 @@ export const TicketsPage: React.FC = () => { } }; + const loadComments = async (ticketId: string) => { + setLoadingComments(true); + try { + const data = await CondoService.getTicketComments(ticketId); + setComments(data); + } catch(e) { console.error(e); } + finally { setLoadingComments(false); } + }; + useEffect(() => { loadTickets(); }, []); + useEffect(() => { + if (viewTicket) { + loadComments(viewTicket.id); + } else { + setComments([]); + } + }, [viewTicket]); + const handleCreateSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); @@ -118,16 +143,33 @@ export const TicketsPage: React.FC = () => { await CondoService.deleteTicket(id); setTickets(tickets.filter(t => t.id !== id)); setViewTicket(null); - } catch (e) { alert("Impossibile eliminare."); } + } catch (e: any) { alert(e.message || "Impossibile eliminare."); } }; - const filteredTickets = tickets.filter(t => filterStatus === 'ALL' || t.status === filterStatus); + const handleSendComment = async (e: React.FormEvent) => { + e.preventDefault(); + if (!viewTicket || !newComment.trim()) return; + + try { + await CondoService.addTicketComment(viewTicket.id, newComment); + setNewComment(''); + loadComments(viewTicket.id); + } catch(e) { console.error(e); } + }; + + // Filter Logic + const filteredTickets = tickets.filter(t => { + const isArchived = t.status === TicketStatus.RESOLVED || t.status === TicketStatus.CLOSED; + if (viewMode === 'active') return !isArchived; + return isArchived; + }); // Helpers for Badge Colors const getStatusColor = (s: TicketStatus) => { switch(s) { case TicketStatus.OPEN: return 'bg-blue-100 text-blue-700'; case TicketStatus.IN_PROGRESS: return 'bg-yellow-100 text-yellow-700'; + case TicketStatus.SUSPENDED: return 'bg-orange-100 text-orange-700'; case TicketStatus.RESOLVED: return 'bg-green-100 text-green-700'; case TicketStatus.CLOSED: return 'bg-slate-200 text-slate-600'; } @@ -155,16 +197,13 @@ export const TicketsPage: React.FC = () => { const openAttachment = async (ticketId: string, attId: string) => { try { const file = await CondoService.getTicketAttachment(ticketId, attId); - // Open base64 in new tab const win = window.open(); if (win) { - // Determine display method if (file.fileType.startsWith('image/')) { win.document.write(``); } else if (file.fileType === 'application/pdf') { win.document.write(``); } else { - // Download link fallback win.document.write(`Clicca qui per scaricare ${file.fileName}`); } } @@ -186,17 +225,24 @@ export const TicketsPage: React.FC = () => { - {/* Filters */} -
- {['ALL', 'OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'].map(status => ( + {/* Main Tabs */} +
+
- ))} + +
{/* List */} @@ -205,7 +251,7 @@ export const TicketsPage: React.FC = () => { ) : filteredTickets.length === 0 ? (
-

Nessuna segnalazione trovata.

+

Nessuna segnalazione in questa vista.

) : (
@@ -320,73 +366,146 @@ export const TicketsPage: React.FC = () => { {/* VIEW DETAILS MODAL */} {viewTicket && (
-
-
-
-

{viewTicket.title}

-

{new Date(viewTicket.createdAt).toLocaleString()} • {getCategoryLabel(viewTicket.category)}

+
+ + {/* LEFT COLUMN: Info */} +
+
+
+

{viewTicket.title}

+

{new Date(viewTicket.createdAt).toLocaleString()} • {getCategoryLabel(viewTicket.category)}

+
+
- + +
+
+ {viewTicket.status.replace('_', ' ')} +
+
+ {viewTicket.priority} +
+
+ +
+ {viewTicket.description} +
+ + {/* Attachments */} + {viewTicket.attachments && viewTicket.attachments.length > 0 && ( +
+

Allegati

+
+ {viewTicket.attachments.map(att => ( + + ))} +
+
+ )} + + {/* Admin Actions */} + {isAdmin && ( +
+

Gestione Admin

+
+ {(viewTicket.status === TicketStatus.OPEN || viewTicket.status === TicketStatus.SUSPENDED) && ( + + )} + {viewTicket.status === TicketStatus.IN_PROGRESS && ( + + )} + {(viewTicket.status !== TicketStatus.RESOLVED && viewTicket.status !== TicketStatus.CLOSED) && ( + + )} + {(viewTicket.status !== TicketStatus.RESOLVED && viewTicket.status !== TicketStatus.CLOSED) && ( + + )} + + {/* DELETE ONLY ALLOWED IF NOT ARCHIVED OR IF ADMIN WANTS TO FORCE CLEANUP (But prompt requested archive logic) */} + {/* Based on requirement: Do not allow deletion when closed. So hide delete button if archived */} + {(viewTicket.status !== TicketStatus.RESOLVED && viewTicket.status !== TicketStatus.CLOSED) && ( + + )} +
+
+ )} + {!isAdmin && viewTicket.status === TicketStatus.OPEN && ( +
+ +
+ )}
-
-
- {viewTicket.status.replace('_', ' ')} -
-
- {viewTicket.priority} -
-
+ {/* RIGHT COLUMN: Chat / History */} +
+
+

Discussione

+ +
+ +
+ {loadingComments ? ( +

Caricamento...

+ ) : comments.length === 0 ? ( +

Nessun commento.

+ ) : ( + comments.map(comment => { + const isMe = comment.userId === user?.id; + return ( +
+
+

{comment.text}

+
+ + {isMe ? 'Tu' : comment.userName} • {new Date(comment.createdAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + +
+ ); + }) + )} +
-
- {viewTicket.description} -
- - {/* Attachments */} - {viewTicket.attachments && viewTicket.attachments.length > 0 && ( -
-

Allegati

-
- {viewTicket.attachments.map(att => ( - - ))} +
+ + ) : ( +
+ Ticket archiviato. Non è possibile rispondere.
-
- )} - - {/* Admin Actions */} - {isAdmin && ( -
-

Gestione Admin

-
- {viewTicket.status !== TicketStatus.IN_PROGRESS && ( - - )} - {viewTicket.status !== TicketStatus.RESOLVED && ( - - )} - {viewTicket.status !== TicketStatus.CLOSED && ( - - )} - -
-
- )} - {!isAdmin && viewTicket.status === TicketStatus.OPEN && ( -
- -
- )} + )} +
)}
); -}; \ No newline at end of file +}; diff --git a/server/Dockerfile b/server/Dockerfile index 9a6d3cb..e69de29 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,13 +0,0 @@ -FROM node:18-alpine -WORKDIR /app - -# Set production environment -ENV NODE_ENV=production - -COPY package*.json ./ -RUN npm install --production - -COPY . . - -EXPOSE 3001 -CMD ["node", "server.js"] diff --git a/server/db.js b/server/db.js index ea08b21..1550d2b 100644 --- a/server/db.js +++ b/server/db.js @@ -334,6 +334,19 @@ const initDb = async () => { ) `); + // 10. Ticket Comments Table + await connection.query(` + CREATE TABLE IF NOT EXISTS ticket_comments ( + id VARCHAR(36) PRIMARY KEY, + ticket_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + text TEXT NOT NULL, + created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + // --- SEEDING --- const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1'); const defaultFeatures = { diff --git a/server/server.js b/server/server.js index 2e6d818..4e5a9f3 100644 --- a/server/server.js +++ b/server/server.js @@ -617,6 +617,81 @@ app.get('/api/tickets/:id/attachments/:attachmentId', authenticateToken, async ( } catch (e) { res.status(500).json({ error: e.message }); } }); +app.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => { + try { + const [rows] = await pool.query(` + SELECT c.*, u.name as user_name, u.role as user_role + FROM ticket_comments c + JOIN users u ON c.user_id = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at ASC + `, [req.params.id]); + + res.json(rows.map(r => ({ + id: r.id, + ticketId: r.ticket_id, + userId: r.user_id, + userName: r.user_name, + text: r.text, + createdAt: r.created_at, + isAdminResponse: r.user_role === 'admin' || r.user_role === 'poweruser' + }))); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => { + const { text } = req.body; + const userId = req.user.id; + const ticketId = req.params.id; + const commentId = uuidv4(); + const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser'; + + try { + await pool.query( + 'INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)', + [commentId, ticketId, userId, text] + ); + + // --- EMAIL NOTIFICATION LOGIC --- + // 1. Get ticket info to know who to notify + const [ticketRows] = await pool.query(` + SELECT t.title, t.user_id, u.email as creator_email, u.receive_alerts as creator_alerts + FROM tickets t + JOIN users u ON t.user_id = u.id + WHERE t.id = ? + `, [ticketId]); + + if (ticketRows.length > 0) { + const ticket = ticketRows[0]; + const subject = `Nuovo commento sul ticket: ${ticket.title}`; + + // If ADMIN replied -> Notify Creator + if (isAdmin && ticket.creator_email && ticket.creator_alerts) { + const body = `Salve,\n\nÈ stato aggiunto un nuovo commento al tuo ticket "${ticket.title}".\n\nCommento:\n${text}\n\nAccedi alla piattaforma per rispondere.`; + sendDirectEmail(ticket.creator_email, subject, body); + } + // If CREATOR replied -> Notify Admins (logic similar to new ticket) + else if (!isAdmin) { + const [admins] = await pool.query(` + SELECT u.email FROM users u + LEFT JOIN families f ON u.family_id = f.id + JOIN tickets t ON t.id = ? + WHERE (u.role = 'admin' OR u.role = 'poweruser') + AND (f.condo_id = t.condo_id OR u.family_id IS NULL) + AND u.receive_alerts = TRUE + `, [ticketId]); + + const body = `Salve,\n\nNuova risposta dall'utente sul ticket "${ticket.title}".\n\nCommento:\n${text}\n\nAccedi alla piattaforma per gestire.`; + for(const admin of admins) { + if (admin.email) sendDirectEmail(admin.email, subject, body); + } + } + } + + res.json({ success: true, id: commentId }); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + app.post('/api/tickets', authenticateToken, async (req, res) => { const { condoId, title, description, category, priority, attachments } = req.body; const userId = req.user.id; @@ -709,23 +784,32 @@ app.put('/api/tickets/:id', authenticateToken, async (req, res) => { app.delete('/api/tickets/:id', authenticateToken, async (req, res) => { // Only delete own ticket if open, or admin can delete any + // MODIFIED: Prevent deletion if status is CLOSED or RESOLVED (Archived) const isAdmin = req.user.role === 'admin' || req.user.role === 'poweruser'; const userId = req.user.id; try { + // Check status first + const [rows] = await pool.query('SELECT status, user_id FROM tickets WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ message: 'Ticket not found' }); + + const ticket = rows[0]; + + // Block deletion of Archived tickets + if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') { + return res.status(403).json({ message: 'Cannot delete archived tickets. They are kept for history.' }); + } + let query = 'DELETE FROM tickets WHERE id = ?'; let params = [req.params.id]; if (!isAdmin) { - query += ' AND user_id = ? AND status = "OPEN"'; // Users can only delete their own OPEN tickets - params.push(userId); - } - - const [result] = await pool.query(query, params); - if (result.affectedRows === 0) { - return res.status(403).json({ message: 'Cannot delete ticket (Permission denied or not found)' }); + // Additional check for user ownership + if (ticket.user_id !== userId) return res.status(403).json({ message: 'Forbidden' }); + if (ticket.status !== 'OPEN') return res.status(403).json({ message: 'Can only delete OPEN tickets' }); } + await pool.query(query, params); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/services/mockDb.ts b/services/mockDb.ts index f8213a0..a349726 100644 --- a/services/mockDb.ts +++ b/services/mockDb.ts @@ -1,5 +1,5 @@ -import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead, Ticket, TicketAttachment } from '../types'; +import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead, Ticket, TicketAttachment, TicketComment } from '../types'; // --- CONFIGURATION TOGGLE --- const FORCE_LOCAL_DB = false; @@ -325,6 +325,17 @@ export const CondoService = { return request(`/tickets/${ticketId}/attachments/${attachmentId}`); }, + getTicketComments: async (ticketId: string): Promise => { + return request(`/tickets/${ticketId}/comments`); + }, + + addTicketComment: async (ticketId: string, text: string): Promise => { + await request(`/tickets/${ticketId}/comments`, { + method: 'POST', + body: JSON.stringify({ text }) + }); + }, + // --- SEEDING --- seedPayments: () => { // No-op in remote mode diff --git a/types.ts b/types.ts index 6239747..435b06c 100644 --- a/types.ts +++ b/types.ts @@ -125,8 +125,9 @@ export interface AuthResponse { export enum TicketStatus { OPEN = 'OPEN', IN_PROGRESS = 'IN_PROGRESS', + SUSPENDED = 'SUSPENDED', // New State RESOLVED = 'RESOLVED', - CLOSED = 'CLOSED' + CLOSED = 'CLOSED' // Closed without resolution (Archived/WontFix) } export enum TicketPriority { @@ -152,6 +153,16 @@ export interface TicketAttachment { data: string; // Base64 Data URI } +export interface TicketComment { + id: string; + ticketId: string; + userId: string; + userName: string; + text: string; + createdAt: string; + isAdminResponse: boolean; +} + export interface Ticket { id: string; condoId: string; @@ -164,6 +175,7 @@ export interface Ticket { createdAt: string; updatedAt: string; attachments?: TicketAttachment[]; + comments?: TicketComment[]; userName?: string; // Joined field userEmail?: string; // Joined field }