V.1.2 - Improved Secrurity, Better Ticket Handling

This commit is contained in:
Honey committed 2026-02-26 23:15:05 +01:00
1 parent cb5d091be2
commit 3136c81533
16 files changed
+879 -738

No files matched your search

+1 -3
View File
@@ -2,17 +2,15 @@ FROM node:20-alpine
WORKDIR /app
# Copy package files first for better layer caching
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
# Copy the rest of the application
COPY server.js ./
COPY categories.json ./
COPY public/ ./public/
COPY assets/ ./assets/
# Create data directory for the database
RUN mkdir -p /app/data
EXPOSE 3000
+10
View File
@@ -0,0 +1,10 @@
{
"categories": [
"HELP",
"HELP MEE NOOOW",
"IT-Support",
"Suggestion...",
"Add Book pls",
"Honeyycloud!"
]
}
+3 -2
View File
@@ -6,7 +6,8 @@ services:
ports:
- "3000:3000"
volumes:
- ./data:/app/data # Bind mount for the SQLite database
- ./assets:/app/assets # Bind mount so you can update avatars etc.
- ./data:/app/data
- ./assets:/app/assets
- ./categories.json:/app/categories.json
env_file:
- .env
+2 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ticketsystem",
"version": "1.2",
"version": "1.2.0",
"description": "",
"main": "server.js",
"scripts": {
@@ -14,12 +14,10 @@
"dependencies": {
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"date-fns-tz": "^3.2.0",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"luxon": "^3.7.1",
"moment-timezone": "^0.6.0",
"multer": "^2.0.2",
"sqlite3": "^5.1.7"
}
}
}
+143 -41
View File
@@ -9,21 +9,130 @@ document.addEventListener('DOMContentLoaded', () => {
const statOpen = document.getElementById('open-tickets');
const statInProgress = document.getElementById('in-progress-tickets');
const statClosed = document.getElementById('closed-tickets');
const statTotalTime = document.getElementById('time-tickets'); // Neu: Gesamtzeit Anzeige
const statTotalTime = document.getElementById('time-tickets');
// Accordion elements
const closedToggle = document.getElementById('closed-toggle');
const closedAccordion = document.getElementById('closed-accordion');
const closedCount = document.getElementById('closed-count');
// PFP elements
const updatePfpBtn = document.getElementById('update-pfp-btn');
const pfpModal = document.getElementById('update-pfp-modal');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('pfp-file-input');
const uploadPfpBtn = document.getElementById('upload-pfp-btn');
let selectedPfpFile = null;
let accordionOpen = false;
searchInput.addEventListener('input', refreshTickets);
statusFilter.addEventListener('change', refreshTickets);
priorityFilter.addEventListener('change', refreshTickets);
categoryFilter.addEventListener('change', refreshTickets);
// Helper: Status/Priority zu "Title-Case-With-Dash"
function toTitleCaseWithDash(str) {
return str
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('-');
// --- XSS Protection ---
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function toTitleCaseWithDash(str) {
return str.split(' ').map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join('-');
}
// --- Dynamic Categories ---
async function loadCategories() {
try {
const res = await fetch('/api/categories');
const categories = await res.json();
if (categoryFilter) {
categoryFilter.innerHTML = '<option value="all">All Categories</option>' +
categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
}
} catch (err) {
console.error('Failed to load categories:', err);
}
}
// --- Accordion ---
if (closedToggle) {
closedToggle.addEventListener('click', () => {
accordionOpen = !accordionOpen;
closedAccordion.style.display = accordionOpen ? 'block' : 'none';
closedToggle.classList.toggle('open', accordionOpen);
});
}
// --- PFP Modal ---
function openPfpModal() {
if (pfpModal) {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
pfpModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
}
}
function closePfpModal() {
if (pfpModal) {
pfpModal.style.display = 'none';
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
document.body.style.paddingRight = '';
}
}
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
if (pfpModal) {
window.addEventListener('click', (e) => { if (e.target === pfpModal) closePfpModal(); });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); });
}
// --- Drag & Drop ---
if (dropZone) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#10b981'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#4b5563'; });
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#4b5563';
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
selectedPfpFile = file;
dropZone.querySelector('p').textContent = file.name;
}
});
}
if (fileInput) {
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
selectedPfpFile = file;
dropZone.querySelector('p').textContent = file.name;
}
});
}
if (uploadPfpBtn) {
uploadPfpBtn.addEventListener('click', async () => {
if (!selectedPfpFile) return alert('Please select an image first.');
const formData = new FormData();
formData.append('pfp', selectedPfpFile, 'admin.jpg');
try {
const res = await fetch('/api/upload-admin-pfp', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
alert('Admin profile picture updated!');
closePfpModal();
window.location.reload();
} catch (err) {
console.error(err);
alert('Error uploading profile picture');
}
});
}
// --- API ---
async function fetchTickets() {
try {
const res = await fetch('/api/tickets');
@@ -39,7 +148,7 @@ document.addEventListener('DOMContentLoaded', () => {
const res = await fetch(`/api/tickets/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ actor: 'Admin' })
body: JSON.stringify({ actor: 'Admin' }),
});
return await res.json();
} catch (err) {
@@ -59,33 +168,33 @@ document.addEventListener('DOMContentLoaded', () => {
const category = categoryFilter.value;
const searchTerm = searchInput.value.toLowerCase();
const filtered = tickets.filter(t =>
(status === 'all' || t.status === status) &&
(priority === 'all' || t.priority === priority) &&
(category === 'all' || t.category === category) &&
(t.title.toLowerCase().includes(searchTerm) ||
t.description.toLowerCase().includes(searchTerm) ||
t.requester.toLowerCase().includes(searchTerm))
).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const filtered = tickets
.filter(t =>
(status === 'all' || t.status === status) &&
(priority === 'all' || t.priority === priority) &&
(category === 'all' || t.category === category) &&
(t.title.toLowerCase().includes(searchTerm) ||
t.description.toLowerCase().includes(searchTerm) ||
t.requester.toLowerCase().includes(searchTerm))
)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const openRows = [];
const closedRows = [];
filtered.forEach(ticket => {
const shortTitle = ticket.title.length > 50 ? ticket.title.slice(0, 50) + '...' : ticket.title;
// Wandle Status und Priority korrekt in Klassen um
const statusClass = toTitleCaseWithDash(ticket.status || 'Unknown');
const priorityClass = toTitleCaseWithDash(ticket.priority || 'None');
const rowHTML = `
<tr>
<td>${ticket.id}</td>
<td class="ticket-title">${shortTitle}</td>
<td>${ticket.requester}</td>
<td>${ticket.category || '-'}</td>
<td><span class="status-badge status-${statusClass}">${ticket.status || 'Unknown'}</span></td>
<td><span class="priority-badge priority-${priorityClass}">${ticket.priority || 'None'}</span></td>
<td class="ticket-title">${escapeHtml(shortTitle)}</td>
<td>${escapeHtml(ticket.requester)}</td>
<td>${escapeHtml(ticket.category) || '-'}</td>
<td><span class="status-badge status-${statusClass}">${escapeHtml(ticket.status) || 'Unknown'}</span></td>
<td><span class="priority-badge priority-${priorityClass}">${escapeHtml(ticket.priority) || 'None'}</span></td>
<td>${formatDate(ticket.created_at)}</td>
<td>${ticket.total_time} min</td>
<td class="table-actions">
@@ -94,31 +203,28 @@ document.addEventListener('DOMContentLoaded', () => {
</td>
</tr>`;
if (ticket.status === 'Closed') {
closedRows.push(rowHTML);
} else {
openRows.push(rowHTML);
}
if (ticket.status === 'Closed') closedRows.push(rowHTML);
else openRows.push(rowHTML);
});
ticketsBody.innerHTML = openRows.length
? openRows.join('')
: `<tr><td colspan="8" class="empty-table">No open tickets found</td></tr>`;
: `<tr><td colspan="9" class="empty-table">No open tickets found</td></tr>`;
if (closedTicketsBody) {
closedTicketsBody.innerHTML = closedRows.length
? closedRows.join('')
: `<tr><td colspan="8" class="empty-table">No closed tickets</td></tr>`;
: `<tr><td colspan="9" class="empty-table">No closed tickets</td></tr>`;
}
// Event listeners an Buttons hängen
[...document.querySelectorAll('.view-btn')].forEach(btn =>
if (closedCount) closedCount.textContent = closedRows.length;
document.querySelectorAll('.view-btn').forEach(btn =>
btn.addEventListener('click', () => {
window.location.href = `/ticketview/ticket-view-admin.html?id=${btn.dataset.id}`;
})
);
[...document.querySelectorAll('.delete-btn')].forEach(btn =>
document.querySelectorAll('.delete-btn').forEach(btn =>
btn.addEventListener('click', () => {
if (confirm('Are you sure you want to delete this ticket?')) {
deleteTicket(btn.dataset.id).then(refreshTickets);
@@ -132,19 +238,15 @@ document.addEventListener('DOMContentLoaded', () => {
statOpen.textContent = tickets.filter(t => t.status === 'Open').length;
statInProgress.textContent = tickets.filter(t => t.status === 'In Progress').length;
statClosed.textContent = tickets.filter(t => t.status === 'Closed').length;
// Gesamtzeit aller Tickets summieren (total_time in Minuten)
const totalTime = tickets.reduce((sum, ticket) => sum + (ticket.total_time || 0), 0);
const totalTime = tickets.reduce((sum, t) => sum + (t.total_time || 0), 0);
statTotalTime.textContent = `${totalTime} min`;
}
function formatDate(date) {
return new Date(date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
year: 'numeric', month: 'short', day: 'numeric',
});
}
refreshTickets();
loadCategories().then(() => refreshTickets());
});
+50 -28
View File
@@ -13,12 +13,30 @@
<div class="container">
<header class="header">
<h1><i class="fas fa-ticket-alt"></i> Admin Dashboard</h1>
<div>
<div class="user-actions">
<span>Welcome, Admin!</span>
<img class="avatar" src="/assets/admin.jpg" alt="Admin Avatar">
<button id="update-pfp-btn" class="btn-update-pfp small-btn">
<i class="fas fa-camera"></i> Update Picture
</button>
<a href="/">User View</a>
</div>
</header>
<!-- Admin Profile Picture Modal -->
<div id="update-pfp-modal" class="modal" style="display:none;">
<div class="modal-content small-modal">
<h2><i class="fas fa-camera"></i> Update Admin Picture</h2>
<div id="drop-zone">
<p>Drag & Drop your picture here<br>or click to select</p>
<input type="file" id="pfp-file-input" accept="image/*" hidden>
</div>
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
<i class="fas fa-upload"></i> Upload
</button>
</div>
</div>
<main class="main-content">
<section class="admin-stats">
<div class="stat-card">
@@ -64,10 +82,6 @@
</select>
<select id="category-filter">
<option value="all">All Categories</option>
<option value="Bug">Bug</option>
<option value="Feature">Feature</option>
<option value="Support">Support</option>
<option value="Other">Other</option>
</select>
</div>
</div>
@@ -92,36 +106,44 @@
</section>
</main>
<!-- Closed Tickets Accordion -->
<section class="closed-tickets-section">
<div class="section-header">
<h2><i class="fas fa-archive"></i> Closed Tickets</h2>
<div class="accordion-section">
<button class="accordion-toggle" id="closed-toggle">
<i class="fas fa-archive"></i>
Closed Tickets
<span class="accordion-count" id="closed-count">0</span>
<i class="fas fa-chevron-down accordion-arrow"></i>
</button>
<div class="accordion-body" id="closed-accordion" style="display: none;">
<div class="admin-tickets-list">
<table id="closed-tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Requester</th>
<th>Category</th>
<th>Status</th>
<th>Priority</th>
<th>Created</th>
<th>Total Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="closed-tickets-body"></tbody>
</table>
</div>
</div>
</div>
<div class="admin-tickets-list">
<table id="closed-tickets-table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Requester</th>
<th>Category</th>
<th>Status</th>
<th>Priority</th>
<th>Created</th>
<th>Total Time</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="closed-tickets-body"></tbody>
</table>
</div>
</section>
</section>
<footer class="footer">
<p>Ticketsystem by Honey v1.1 &copy; 2025</p>
<p class="app-footer-text"></p>
</footer>
</div>
<script src="admin-script.js"></script>
<script src="version.js"></script>
</body>
</html>
+21 -39
View File
@@ -6,10 +6,7 @@
<title>Laila Dashboard - Ticket System!</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
<link rel="stylesheet" href="user.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div class="container">
@@ -30,9 +27,7 @@
<!-- Modal Window for Creating Ticket -->
<div id="create-ticket-modal" class="modal" style="display:none;">
<div class="modal-content">
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">
&times;
</button>
<button id="close-modal-btn" class="modal-close-btn" aria-label="Close modal">&times;</button>
<section class="ticket-form">
<h2><i class="fas fa-plus-circle"></i> Create New Ticket</h2>
<form id="create-form">
@@ -40,12 +35,7 @@
<input type="text" id="title" placeholder="Ticket Title" required />
</div>
<div class="form-group">
<textarea
id="description"
rows="3"
placeholder="Describe your issue..."
required
></textarea>
<textarea id="description" rows="3" placeholder="Describe your issue..." required></textarea>
</div>
<div class="form-row">
<div class="form-group">
@@ -57,20 +47,13 @@
</select>
</div>
<div class="form-group">
<select id="category">
<option value="HELP">HELP</option>
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
<option value="IT-Support">IT-Support</option>
<option value="Suggestion...">Suggestion...</option>
<option value="Add Book pls">Add Book pls</option>
<option value="Honeyycloud!">Honeyycloud!</option>
</select>
<select id="category"></select>
</div>
</div>
<div class="form-group button-wrapper">
<button type="submit" class="btn-primary">
<i class="fas fa-paper-plane"></i> Create Ticket
</button>
<button type="submit" class="btn-primary">
<i class="fas fa-paper-plane"></i> Create Ticket
</button>
</div>
</form>
</section>
@@ -81,12 +64,10 @@
<div id="update-pfp-modal" class="modal" style="display:none;">
<div class="modal-content small-modal">
<h2><i class="fas fa-camera"></i> Update Profile Picture</h2>
<div id="drop-zone">
<p>Drag & Drop your picture here<br>or click to select</p>
<input type="file" id="pfp-file-input" accept="image/*" hidden>
</div>
<button id="upload-pfp-btn" class="btn-update-pfp" style="margin-top: 12px;">
<i class="fas fa-upload"></i> Upload
</button>
@@ -107,35 +88,36 @@
</select>
<select id="category-filter">
<option value="all">All Categories</option>
<option value="HELP">HELP</option>
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
<option value="IT-Support">IT-Support</option>
<option value="Suggestion...">Suggestion...</option>
<option value="Add Book pls">Add Book pls</option>
<option value="Honeyycloud!">Honeyycloud!</option>
</select>
</div>
</div>
<!-- 🟢 Open/Active Tickets -->
<div>
<h3>Active Tickets</h3>
<div id="tickets-container" class="tickets-grid"></div>
</div>
<!-- 🔴 Closed Tickets -->
<div style="margin-top: 2rem;">
<h3>Closed Tickets</h3>
<div id="closed-tickets-container" class="tickets-grid"></div>
<!-- Closed Tickets Accordion -->
<div class="accordion-section" style="margin-top: 2rem;">
<button class="accordion-toggle" id="closed-toggle">
<i class="fas fa-archive"></i>
Closed Tickets
<span class="accordion-count" id="closed-count">0</span>
<i class="fas fa-chevron-down accordion-arrow"></i>
</button>
<div class="accordion-body" id="closed-accordion" style="display: none;">
<div id="closed-tickets-container" class="tickets-grid"></div>
</div>
</div>
</section>
</main>
<footer class="footer">
<p>Ticket System by Honey v1.1 &copy; 2025</p>
<p class="app-footer-text"></p>
</footer>
</div>
<script src="user-script.js"></script>
<script src="version.js"></script>
</body>
</html>
</html>
+68 -142
View File
@@ -1,11 +1,11 @@
import {
fetchTicket, fetchComments, fetchHistory, createComment, logTime,
formatDateTime, formatMinutes, getHistoryIcon
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
} from './ticket-shared.js';
document.addEventListener('DOMContentLoaded', async () => {
const ticketId = new URLSearchParams(window.location.search).get('id');
if (!ticketId) return window.location.href = '../admin.html';
if (!ticketId) return (window.location.href = '../admin.html');
const statusSelect = document.getElementById('status-select');
const priorityInput = document.getElementById('priority-text');
@@ -19,55 +19,26 @@ document.addEventListener('DOMContentLoaded', async () => {
const totalTimeDisplay = document.getElementById('total-time');
const timeForm = document.getElementById('time-booking-form');
let webhookConfig = null;
// Fetch Webhook Configuration
async function loadWebhookConfig() {
try {
const response = await fetch('/api/webhooks');
if (response.ok) {
webhookConfig = await response.json();
console.log('Webhook configuration loaded:', webhookConfig.configured);
} else {
console.warn('Failed to load webhook configuration');
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
}
} catch (error) {
console.error('Error loading webhook configuration:', error);
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
}
}
// Send Webhook Notification
async function sendWebhook(message) {
if (!webhookConfig?.user) {
console.log('User webhook not configured, skipping notification');
return;
}
try {
const res = await fetch(webhookConfig.user, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message })
});
if (!res.ok) {
console.error('User webhook failed:', res.statusText);
}
} catch (err) {
console.error('Error sending user webhook:', err);
}
}
let originalStatus = '', originalPriority = '', originalTitle = '', originalDesc = '', originalCategory = '';
// Load categories dynamically
async function loadCategories() {
try {
const res = await fetch('/api/categories');
const categories = await res.json();
if (categorySelect) {
categorySelect.innerHTML = categories.map(c =>
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
).join('');
}
} catch (err) {
console.error('Failed to load categories:', err);
}
}
async function loadPage() {
loadWebhookConfig().then(() => {
loadTicketDetails();
loadComments();
loadHistory();
});
await loadCategories();
await Promise.all([loadTicketDetails(), loadComments(), loadHistory()]);
}
async function loadTicketDetails() {
@@ -84,9 +55,7 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('ticket-requester').textContent = ticket.requester;
document.getElementById('ticket-created').textContent = formatDateTime(ticket.created_at);
document.getElementById('ticket-updated').textContent = formatDateTime(ticket.updated_at);
const totalMins = typeof ticket.total_time === 'number' ? ticket.total_time : 0;
totalTimeDisplay.textContent = formatMinutes(totalMins);
totalTimeDisplay.textContent = formatMinutes(typeof ticket.total_time === 'number' ? ticket.total_time : 0);
statusSelect.className = 'form-control status-' + (ticket.status || '').toLowerCase().replace(/\s+/g, '-');
@@ -98,54 +67,28 @@ document.addEventListener('DOMContentLoaded', async () => {
}
async function updateTicket({ status, priority, title, description, category, actor = 'Admin' }) {
try {
const ticket = await fetchTicket(ticketId); // Fetch ticket to get the requester
const requester = ticket.requester || ''; // Ensure requester is included in the update request
try {
const ticket = await fetchTicket(ticketId);
const requester = ticket.requester || '';
const res = await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, priority, title, description, category, actor, requester }) // Add requester
});
const res = await fetch(`/api/tickets/${ticketId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, priority, title, description, category, author: actor, requester }),
});
if (!res.ok) throw new Error('Failed to update');
if (!res.ok) throw new Error('Failed to update');
alert('Ticket updated successfully');
// Webhooks are handled server-side now — no need to send from here
alert('Ticket updated successfully');
if (status !== originalStatus) {
await sendWebhook(`Ticket #${ticketId} status changed from "${originalStatus}" to "${status}"`);
originalStatus = status;
titleText.textContent = title;
descText.textContent = description;
await loadPage();
} catch (err) {
console.error('Update failed:', err);
alert('Error updating ticket');
}
if (priority !== originalPriority) {
await sendWebhook(`Ticket #${ticketId} priority changed from "${originalPriority}" to "${priority}"`);
originalPriority = priority;
}
if (title !== originalTitle) {
await sendWebhook(`Ticket #${ticketId} title changed by ${actor}`);
originalTitle = title;
}
if (description !== originalDesc) {
await sendWebhook(`Ticket #${ticketId} description changed by ${actor}`);
originalDesc = description;
}
if (category !== originalCategory) {
await sendWebhook(`Ticket #${ticketId} category changed from "${originalCategory}" to "${category}"`);
originalCategory = category;
}
titleText.textContent = title;
descText.textContent = description;
await loadPage();
} catch (err) {
console.error('Update failed:', err);
alert('Error updating ticket');
}
}
document.getElementById('update-ticket-btn').addEventListener('click', async () => {
const isEditingTitle = titleInput.style.display === 'block';
@@ -153,14 +96,16 @@ document.addEventListener('DOMContentLoaded', async () => {
const newTitle = isEditingTitle ? titleInput.value.trim() : titleText.textContent;
const newDesc = isEditingDesc ? descInput.value.trim() : descText.textContent;
const newStatus = statusSelect.value;
const newPriority = priorityInput.value;
const newCategory = categorySelect.value;
const confirmed = confirm('Möchten Sie die Änderungen wirklich speichern?');
if (!confirmed) return;
if (!confirm('Save changes?')) return;
await updateTicket({ status: newStatus, priority: newPriority, title: newTitle, description: newDesc, category: newCategory });
await updateTicket({
status: statusSelect.value,
priority: priorityInput.value,
title: newTitle,
description: newDesc,
category: categorySelect.value,
});
titleInput.style.display = 'none';
titleText.style.display = 'block';
@@ -169,16 +114,14 @@ document.addEventListener('DOMContentLoaded', async () => {
});
document.getElementById('close-ticket-btn').addEventListener('click', async () => {
const confirmed = confirm('Möchtest du das Ticket wirklich schließen?');
if (!confirmed) return;
const newStatus = 'Closed';
const newPriority = priorityInput.value;
const newTitle = titleText.textContent;
const newDesc = descText.textContent;
const newCategory = categorySelect.value;
await updateTicket({ status: newStatus, priority: newPriority, title: newTitle, description: newDesc, category: newCategory });
if (!confirm('Close this ticket?')) return;
await updateTicket({
status: 'Closed',
priority: priorityInput.value,
title: titleText.textContent,
description: descText.textContent,
category: categorySelect.value,
});
});
titleText.addEventListener('dblclick', () => {
@@ -195,6 +138,7 @@ document.addEventListener('DOMContentLoaded', async () => {
descInput.focus();
});
// Restore saved author name
const savedAuthor = localStorage.getItem('commentAuthor');
if (savedAuthor) commentAuthorInput.value = savedAuthor;
@@ -205,17 +149,16 @@ document.addEventListener('DOMContentLoaded', async () => {
const role = comment.role || 'user';
const isAdmin = role === 'admin';
const avatarUrl = isAdmin ? '/assets/admin.jpg' : '/assets/user.jpg';
return `
<div class="comment ${isAdmin ? 'comment-admin' : ''}">
<img class="comment-avatar" src="${avatarUrl}" alt="${isAdmin ? 'Admin' : 'User'} Avatar">
<div class="comment-content">
<div class="comment-header">
<span class="comment-author">${comment.author}</span>
<span class="comment-author">${escapeHtml(comment.author)}</span>
${isAdmin ? '<span class="comment-role-badge">Admin</span>' : ''}
<span class="comment-date">${formatDateTime(comment.date)}</span>
</div>
<div class="comment-text">${comment.content}</div>
<div class="comment-text">${linkify(comment.content)}</div>
</div>
</div>`;
}).join('');
@@ -228,10 +171,10 @@ document.addEventListener('DOMContentLoaded', async () => {
<div class="history-item">
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
<div class="history-content">
<div class="history-action">${h.action}</div>
<div class="history-details">${h.message || ''}</div>
<div class="history-action">${escapeHtml(h.action)}</div>
<div class="history-details">${escapeHtml(h.message || '')}</div>
<div class="history-meta">
<span>By ${h.author || h.actor || 'unknown'}</span>
<span>By ${escapeHtml(h.author || h.actor || 'unknown')}</span>
<span>${formatDateTime(h.date || h.timestamp)}</span>
</div>
</div>
@@ -240,20 +183,14 @@ document.addEventListener('DOMContentLoaded', async () => {
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const author = commentAuthorInput.value.trim();
if (!author) return alert('Bitte gib deinen Namen ein');
if (!author) return alert('Please enter your name');
const content = document.getElementById('comment-content').value.trim();
if (!content) return alert('Comment required');
const role = 'admin';
const timestamp = new Date().toISOString();
try {
await createComment(ticketId, { author, role, content, timestamp });
await sendWebhook(`New comment added by ${author} (${role}): "${content}" on ticket #${ticketId}`);
await createComment(ticketId, { author, role: 'admin', content, timestamp: new Date().toISOString() });
localStorage.setItem('commentAuthor', author);
document.getElementById('comment-content').value = '';
await loadComments();
await loadHistory();
@@ -269,41 +206,30 @@ document.addEventListener('DOMContentLoaded', async () => {
const comment = document.getElementById('time-comment').value.trim();
const actor = commentAuthorInput.value || 'Admin';
if (isNaN(minutes) || minutes <= 0) return alert('Gültige Zeit in Minuten eingeben');
if (isNaN(minutes) || minutes <= 0) return alert('Enter valid minutes');
try {
// Save the time log
await logTime(ticketId, { minutes, comment, author: actor });
// Also add it as a comment
const timeComment = `⏱ Zeit gebucht: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
await createComment(ticketId, {
author: actor,
role: 'admin',
content: timeComment,
timestamp: new Date().toISOString()
});
const timeComment = `⏱ Time booked: ${formatMinutes(minutes)}${comment ? ` – ${comment}` : ''}`;
await createComment(ticketId, { author: actor, role: 'admin', content: timeComment, timestamp: new Date().toISOString() });
// Webhook notification
await sendWebhook(`Time booked: ${formatMinutes(minutes)} by ${actor} on ticket #${ticketId}${comment ? ` – "${comment}"` : ''}`);
// Clear inputs and reload
document.getElementById('time-minutes').value = '';
document.getElementById('time-comment').value = '';
await loadPage();
} catch (err) {
console.error('Fehler beim Speichern der Zeit:', err);
alert('Fehler beim Speichern');
console.error('Error saving time:', err);
alert('Error saving time');
}
});
// Tabs
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.getElementById(`${tab}-tab`).classList.add('active');
document.getElementById(`${btn.dataset.tab}-tab`).classList.add('active');
});
});
+16 -7
View File
@@ -17,7 +17,6 @@ export async function fetchHistory(id) {
}
export async function logTime(id, timeLog) {
// timeLog = { minutes: Number, comment: String, author: String }
const res = await fetch(`/api/tickets/${id}/time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -26,7 +25,6 @@ export async function logTime(id, timeLog) {
return res.json();
}
// Flexible Update-Funktion für beliebige Felder, z.B. {status, priority, category, actor, ...}
export async function updateTicket(id, data) {
const res = await fetch(`/api/tickets/${id}`, {
method: 'PUT',
@@ -45,15 +43,26 @@ export async function createComment(id, comment) {
return res.json();
}
// XSS protection — escape HTML entities before inserting into innerHTML
export function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Convert URLs in text to clickable links (applied AFTER escaping)
export function linkify(text) {
const escaped = escapeHtml(text);
const urlPattern = /(\bhttps?:\/\/[^\s<]+)/g;
return escaped.replace(urlPattern, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
}
export function formatDateTime(date) {
if (!date) return 'n/a';
// Convert SQLite DATETIME if needed
const safeDate = date.includes('T') ? date : date.replace(' ', 'T');
const parsed = new Date(safeDate + 'Z'); // Assume UTC
const parsed = new Date(safeDate + 'Z');
if (isNaN(parsed.getTime())) return 'n/a';
return parsed.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
+16 -82
View File
@@ -1,16 +1,14 @@
import {
fetchTicket, fetchComments, fetchHistory,
createComment,
formatDateTime, formatMinutes, getHistoryIcon
fetchTicket, fetchComments, fetchHistory, createComment,
formatDateTime, formatMinutes, getHistoryIcon, escapeHtml, linkify
} from './ticket-shared.js';
document.addEventListener('DOMContentLoaded', () => {
document.body.classList.add('user-view');
const ticketId = new URLSearchParams(window.location.search).get('id');
if (!ticketId) return window.location.href = '/';
if (!ticketId) return (window.location.href = '/');
// DOM elements
const ticketTitle = document.getElementById('ticket-title-text');
const ticketIdElem = document.getElementById('ticket-id-val');
const ticketStatus = document.getElementById('ticket-status-badge');
@@ -24,54 +22,6 @@ document.addEventListener('DOMContentLoaded', () => {
const historyContainer = document.getElementById('history-container');
const commentForm = document.getElementById('comment-form');
const tabBtns = document.querySelectorAll('.tab-btn');
const totalTimeDisplay = document.getElementById('total-time');
let webhookConfig = null;
// Fetch Webhook Configuration
async function loadWebhookConfig() {
try {
const response = await fetch('/api/webhooks');
if (response.ok) {
webhookConfig = await response.json();
console.log('Webhook configuration loaded:', webhookConfig.configured);
} else {
console.warn('Failed to load webhook configuration');
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
}
} catch (error) {
console.error('Error loading webhook configuration:', error);
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
}
}
// Send Webhook Notification
async function sendAdminWebhook(message) {
if (!webhookConfig?.admin) {
console.log('Admin webhook not configured, skipping notification');
return;
}
try {
const res = await fetch(webhookConfig.admin, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: message })
});
if (!res.ok) {
console.error('Admin webhook failed:', res.statusText);
}
} catch (err) {
console.error('Error sending admin webhook:', err);
}
}
// Links automatically convert to clickable
function linkify(text) {
const urlPattern = /(\bhttps?:\/\/[^\s<]+)/g;
return text.replace(urlPattern, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
}
let originalStatus = '';
let originalPriority = '';
@@ -89,16 +39,12 @@ document.addEventListener('DOMContentLoaded', () => {
ticketUpdated.textContent = formatDateTime(ticket.updated_at);
ticketPriority.textContent = ticket.priority;
if (ticketCategory) {
ticketCategory.textContent = ticket.category || '—';
}
if (ticketCategory) ticketCategory.textContent = ticket.category || '—';
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
const totalTimeDisplay = document.getElementById('total-time');
if (totalTimeDisplay) {
totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
}
if (totalTimeDisplay) totalTimeDisplay.textContent = formatMinutes(ticket.total_time || 0);
originalStatus = ticket.status;
originalPriority = ticket.priority;
@@ -110,20 +56,16 @@ document.addEventListener('DOMContentLoaded', () => {
const role = comment.role ? comment.role.toLowerCase() : 'user';
let avatarUrl = '/assets/user.jpg';
if (role === 'admin') avatarUrl = '/assets/admin.jpg';
else if (role === 'moderator') avatarUrl = '/assets/moderator.jpg';
// Comment content is linkified
const linkedContent = linkify(comment.content || '');
return `
<div class="comment">
<img class="comment-avatar" src="${avatarUrl}" alt="${role.charAt(0).toUpperCase() + role.slice(1)} Avatar">
<div class="comment-content">
<div class="comment-header">
<span class="comment-author">${comment.author}</span>
<span class="comment-author">${escapeHtml(comment.author)}</span>
<span class="comment-date">${formatDateTime(comment.date)}</span>
</div>
<div class="comment-text">${linkedContent}</div>
<div class="comment-text">${linkify(comment.content)}</div>
</div>
</div>
`;
@@ -136,10 +78,10 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="history-item">
<div class="history-icon"><i class="fas fa-${getHistoryIcon(h.action)}"></i></div>
<div class="history-content">
<div class="history-action">${h.action}</div>
<div class="history-details">${h.message || h.details || ''}</div>
<div class="history-action">${escapeHtml(h.action)}</div>
<div class="history-details">${escapeHtml(h.message || h.details || '')}</div>
<div class="history-meta">
<span class="history-actor">By ${h.author || h.actor || 'unknown'}</span>
<span class="history-actor">By ${escapeHtml(h.author || h.actor || 'unknown')}</span>
<span class="history-date">${formatDateTime(h.date)}</span>
</div>
</div>
@@ -154,14 +96,12 @@ document.addEventListener('DOMContentLoaded', () => {
const content = document.getElementById('comment-content')?.value;
if (!content?.trim()) return alert('Please enter a comment');
const timestamp = new Date().toISOString();
try {
await createComment(ticketId, { author, content, timestamp });
await createComment(ticketId, { author, content, timestamp: new Date().toISOString() });
// Webhook notification is handled server-side
document.getElementById('comment-content').value = '';
await loadComments();
await loadHistory();
await sendAdminWebhook(`New comment added by ${author}: "${content}" on ticket #${ticketId}`);
} catch (err) {
console.error('Failed to create comment:', err);
alert('Failed to submit comment. Please try again.');
@@ -173,19 +113,15 @@ document.addEventListener('DOMContentLoaded', () => {
const ticket = await fetchTicket(ticketId);
if (!ticket) return;
// Update UI if status or priority changes server-side
if (ticket.status !== originalStatus) {
originalStatus = ticket.status;
ticketStatus.textContent = ticket.status;
ticketStatus.className = `ticket-status status-${ticket.status.replace(/ /g, '-')}`;
}
if (ticket.priority !== originalPriority) {
originalPriority = ticket.priority;
ticketPriority.textContent = ticket.priority;
}
// Check and update category if necessary
if (ticketCategory && ticket.category !== ticketCategory.textContent) {
ticketCategory.textContent = ticket.category || '—';
}
@@ -199,12 +135,10 @@ document.addEventListener('DOMContentLoaded', () => {
}));
// Initial load
loadWebhookConfig().then(() => {
loadTicketDetails();
loadComments();
loadHistory();
});
loadTicketDetails();
loadComments();
loadHistory();
// Periodic updates
// Poll for changes every 30s
setInterval(checkForChanges, 30000);
});
+4 -25
View File
@@ -7,10 +7,7 @@
<link rel="icon" type="image/x-icon" href="/assets/favicon_ticket.ico">
<link rel="stylesheet" href="ticket-view.css" />
<link rel="stylesheet" href="admin-view.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body>
<div class="container">
@@ -25,7 +22,6 @@
<main class="main-content">
<div class="ticket-details-layout">
<!-- Ticket Info Section -->
<section class="ticket-details">
<div class="ticket-header">
<div id="ticket-title-view">
@@ -42,28 +38,23 @@
<h4>Requester</h4>
<p id="ticket-requester">-</p>
</div>
<div class="info-item description">
<h3>Description</h3>
<p id="ticket-description-text">Loading...</p>
<textarea id="ticket-description-input" class="form-control" style="display: none"></textarea>
</div>
<div class="info-item created">
<h4>Created</h4>
<p id="ticket-created">-</p>
</div>
<div class="info-item updated">
<h4>Last Updated</h4>
<p id="ticket-updated">-</p>
</div>
<div class="info-item">
<h4>Time</h4>
<p><span id="total-time">0</span></p>
</div>
<div class="info-item status">
<h4><label for="status-select">Status</label></h4>
<select id="status-select" class="form-control">
@@ -73,7 +64,6 @@
<option value="Closed">Closed</option>
</select>
</div>
<div class="info-item priority">
<h4><label for="priority-text">Priority</label></h4>
<select id="priority-text" class="form-control">
@@ -83,16 +73,10 @@
<option value="Urgent">Urgent</option>
</select>
</div>
<div class="info-item category">
<h4><label for="category-select">Category</label></h4>
<select id="category-select" class="form-control">
<option value="HELP">HELP</option>
<option value="HELP MEE NOOOW">HELP MEE NOOOW</option>
<option value="IT-Support">IT-Support</option>
<option value="Suggestion...">Suggestion...</option>
<option value="Add Book pls">Add Book pls</option>
<option value="Honeyycloud!">Honeyycloud!</option>
<!-- Populated dynamically -->
</select>
</div>
</div>
@@ -107,7 +91,6 @@
</div>
</section>
<!-- Ticket Activity Section -->
<section class="ticket-activity">
<div class="tabs">
<button class="tab-btn active" data-tab="comments">Comments</button>
@@ -121,18 +104,13 @@
<textarea id="comment-content" rows="2" placeholder="Add a comment..." required></textarea>
<button class="btn-primary" type="submit">Post Comment</button>
</form>
<form id="time-booking-form" class="comment-form compact-form">
<input type="number" id="time-minutes" name="time-minutes" min="0" placeholder="Minutes" required />
<textarea id="time-comment" name="time-comment" rows="2" placeholder="Optional comment"></textarea>
<button class="btn-primary" type="submit">Add Time</button>
</form>
</div>
<!-- Comment Output -->
<div id="comments-container"></div>
<!-- Time Logs -->
<section id="time-logs" class="time-logs"></section>
</div>
@@ -144,10 +122,11 @@
</main>
<footer class="footer">
<p>Ticket System by Honey v1.1 © 2025</p>
<p class="app-footer-text"></p>
</footer>
</div>
<script type="module" src="ticket-admin.js"></script>
<script src="../version.js"></script>
</body>
</html>
+2 -1
View File
@@ -110,10 +110,11 @@
</main>
<footer class="footer">
<p>Ticket System by Honey v1.1 © 2025</p>
<p class="app-footer-text"></p>
</footer>
</div>
<script type="module" src="ticket-user.js"></script>
<script src="../version.js"></script>
</body>
</html>
+83 -189
View File
@@ -1,121 +1,82 @@
document.addEventListener('DOMContentLoaded', () => {
const isAdminView = document.body.classList.contains('admin-view');
const createForm = document.getElementById('create-form');
const ticketsContainer = document.getElementById('tickets-container');
const statusFilter = document.getElementById('status-filter');
const categoryFilter = document.getElementById('category-filter');
const searchInput = document.getElementById('search-input');
// Webhook configuration - will be loaded dynamically
let webhookConfig = null;
// --- Ticket Modal Elements ---
const openModalBtn = document.getElementById('open-modal-btn');
const modal = document.getElementById('create-ticket-modal');
const closeModalBtn = document.getElementById('close-modal-btn');
// --- PFP Modal Elements ---
const updatePfpBtn = document.getElementById('update-pfp-btn');
const pfpModal = document.getElementById('update-pfp-modal');
const closePfpModalBtn = document.getElementById('close-pfp-modal-btn');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('pfp-file-input');
const uploadPfpBtn = document.getElementById('upload-pfp-btn');
// Accordion elements
const closedToggle = document.getElementById('closed-toggle');
const closedAccordion = document.getElementById('closed-accordion');
const closedCount = document.getElementById('closed-count');
let selectedPfpFile = null;
let allTickets = [];
let accordionOpen = false;
// --- XSS Protection ---
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// --- Helper ---
function formatStatusClass(status) {
if (!status) return 'Unknown';
return status
.split(' ')
.map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase())
.join('-');
return status.split(' ').map(s => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()).join('-');
}
// --- Webhook Functions ---
async function loadWebhookConfig() {
// --- Dynamic Categories ---
async function loadCategories() {
try {
const response = await fetch('/api/webhooks');
if (response.ok) {
webhookConfig = await response.json();
console.log('Webhook configuration loaded:', webhookConfig.configured);
} else {
console.warn('Failed to load webhook configuration');
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
}
} catch (error) {
console.error('Error loading webhook configuration:', error);
webhookConfig = { admin: null, user: null, configured: { admin: false, user: false } };
const res = await fetch('/api/categories');
const categories = await res.json();
populateCategorySelects(categories);
} catch (err) {
console.error('Failed to load categories:', err);
}
}
async function sendWebhookNotification(webhookUrl, message) {
if (!webhookUrl) {
console.log('No webhook URL provided, skipping notification');
return;
function populateCategorySelects(categories) {
// Modal create form select
const createSelect = document.getElementById('category');
if (createSelect) {
createSelect.innerHTML = categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
}
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
});
if (!response.ok) {
console.error(`Webhook notification failed: ${response.status} ${response.statusText}`);
} else {
console.log('Webhook notification sent successfully');
}
} catch (error) {
console.error('Failed to send webhook notification:', error);
// Filter select (keep "All Categories" as first option)
if (categoryFilter) {
categoryFilter.innerHTML = '<option value="all">All Categories</option>' +
categories.map(c => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
}
}
async function sendAdminWebhook(ticket) {
if (!webhookConfig?.admin) {
console.log('Admin webhook not configured, skipping notification');
return;
}
const message = {
embeds: [{
title: '🎫 New Ticket Created',
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
fields: [
{ name: '👤 Requester', value: 'Laila' },
{ name: '📊 Status', value: ticket.status || 'Open', inline: true },
{ name: '⚡ Priority', value: ticket.priority || 'None', inline: true },
{ name: '📂 Category', value: ticket.category || 'None', inline: true },
{ name: '📝 Description', value: ticket.description.length > 100 ?
ticket.description.substring(0, 100) + '...' :
ticket.description, inline: false }
],
timestamp: new Date().toISOString(),
color: 0x00ff00,
footer: {
text: 'Ticket System',
icon_url: 'https://cdn-icons-png.flaticon.com/512/2666/2666505.png'
}
}]
};
await sendWebhookNotification(webhookConfig.admin, message);
// --- Accordion ---
if (closedToggle) {
closedToggle.addEventListener('click', () => {
accordionOpen = !accordionOpen;
closedAccordion.style.display = accordionOpen ? 'block' : 'none';
closedToggle.classList.toggle('open', accordionOpen);
});
}
// --- Ticket Modal Open/Close ---
// --- Ticket Modal ---
function openModal() {
if (modal) {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
if (scrollBarWidth > 0) {
document.body.style.paddingRight = scrollBarWidth + 'px';
}
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
}
}
function closeModal() {
@@ -126,31 +87,21 @@ document.addEventListener('DOMContentLoaded', () => {
document.body.style.paddingRight = '';
}
}
function outsideClickListener(event) {
if (event.target === modal) closeModal();
}
function escKeyListener(event) {
if (event.key === 'Escape' && modal.style.display === 'flex') {
closeModal();
}
}
if (openModalBtn) openModalBtn.addEventListener('click', openModal);
if (closeModalBtn) closeModalBtn.addEventListener('click', closeModal);
if (modal) {
window.addEventListener('click', outsideClickListener);
window.addEventListener('keydown', escKeyListener);
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modal.style.display === 'flex') closeModal(); });
}
// --- PFP Modal Open/Close ---
// --- PFP Modal ---
function openPfpModal() {
if (pfpModal) {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
pfpModal.style.display = 'flex';
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
if (scrollBarWidth > 0) {
document.body.style.paddingRight = scrollBarWidth + 'px';
}
if (scrollBarWidth > 0) document.body.style.paddingRight = scrollBarWidth + 'px';
}
}
function closePfpModal() {
@@ -162,26 +113,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
if (updatePfpBtn) updatePfpBtn.addEventListener('click', openPfpModal);
if (closePfpModalBtn) closePfpModalBtn.addEventListener('click', closePfpModal);
if (pfpModal) {
window.addEventListener('click', (e) => {
if (e.target === pfpModal) closePfpModal();
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal();
});
window.addEventListener('click', (e) => { if (e.target === pfpModal) closePfpModal(); });
window.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pfpModal.style.display === 'flex') closePfpModal(); });
}
// --- Drag & Drop Logic ---
// --- Drag & Drop ---
if (dropZone) {
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#10b981';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = '#4b5563';
});
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#10b981'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#4b5563'; });
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#4b5563';
@@ -192,7 +133,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
if (fileInput) {
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
@@ -202,21 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
// --- Upload PFP ---
if (uploadPfpBtn) {
uploadPfpBtn.addEventListener('click', async () => {
if (!selectedPfpFile) {
alert('Please select an image first.');
return;
}
if (!selectedPfpFile) return alert('Please select an image first.');
const formData = new FormData();
formData.append('pfp', selectedPfpFile, 'user.jpg');
try {
const res = await fetch('/api/upload-pfp', {
method: 'POST',
body: formData
});
const res = await fetch('/api/upload-pfp', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload failed');
alert('Profile picture updated!');
closePfpModal();
@@ -239,23 +171,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
async function createTicket(ticket) {
try {
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ticket)
});
return await res.json();
} catch (err) {
console.error('Error creating ticket:', err);
throw err;
}
const res = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(ticket),
});
if (!res.ok) throw new Error('Failed to create ticket');
return await res.json();
}
// --- Date Formatting ---
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric'
year: 'numeric', month: 'short', day: 'numeric',
});
}
@@ -265,38 +192,19 @@ document.addEventListener('DOMContentLoaded', () => {
const category = categoryFilter?.value || 'all';
const search = searchInput?.value.trim().toLowerCase() || '';
const openTickets = allTickets.filter(ticket =>
(ticket.status || '').toLowerCase() !== 'closed'
);
const closedTickets = allTickets.filter(ticket =>
(ticket.status || '').toLowerCase() === 'closed'
);
const filteredOpen = openTickets.filter(ticket => {
const filtered = allTickets.filter(ticket => {
const ticketStatus = (ticket.status || '').toLowerCase();
const ticketCategory = (ticket.category || '').toLowerCase();
const ticketTitle = (ticket.title || '').toLowerCase();
const ticketDescription = (ticket.description || '').toLowerCase();
const isClosed = ticketStatus === 'closed';
const matchesStatus = status === 'all' || ticketStatus === status.toLowerCase();
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
const matchesSearch =
ticketTitle.includes(search) ||
ticketDescription.includes(search);
return matchesStatus && matchesCategory && matchesSearch;
const matchesSearch = ticketTitle.includes(search) || ticketDescription.includes(search);
return (isClosed || matchesStatus) && matchesCategory && matchesSearch;
});
const filteredClosed = closedTickets.filter(ticket => {
const ticketCategory = (ticket.category || '').toLowerCase();
const ticketTitle = (ticket.title || '').toLowerCase();
const ticketDescription = (ticket.description || '').toLowerCase();
const matchesCategory = category === 'all' || ticketCategory === category.toLowerCase();
const matchesSearch =
ticketTitle.includes(search) ||
ticketDescription.includes(search);
return matchesCategory && matchesSearch;
});
renderTickets([...filteredOpen, ...filteredClosed]);
renderTickets(filtered);
}
function renderTickets(tickets) {
@@ -304,6 +212,9 @@ document.addEventListener('DOMContentLoaded', () => {
const closedContainer = document.getElementById('closed-tickets-container');
openContainer.innerHTML = '';
closedContainer.innerHTML = '';
let closedTicketCount = 0;
if (!tickets.length) {
openContainer.innerHTML = `
<div class="empty-state">
@@ -311,29 +222,31 @@ document.addEventListener('DOMContentLoaded', () => {
<h3>No tickets found</h3>
<p>Try changing your filters or create a new ticket</p>
</div>`;
closedContainer.innerHTML = '';
if (closedCount) closedCount.textContent = '0';
return;
}
tickets.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
tickets.forEach(ticket => {
const statusSafe = formatStatusClass(ticket.status);
const prioritySafe = (ticket.priority || 'None');
const prioritySafe = ticket.priority || 'None';
const el = document.createElement('div');
el.className = `ticket status-${statusSafe.replace(/\s+/g, '-')}`;
el.innerHTML = `
<h3>${ticket.title} <span class="ticket-id">#${ticket.id}</span> <small class="total-time"> Time Cin: (${ticket.total_time} min)</small> </h3>
<p class="description">${ticket.description}</p>
<h3>${escapeHtml(ticket.title)} <span class="ticket-id">#${ticket.id}</span> <small class="total-time">Time: (${ticket.total_time} min)</small></h3>
<p class="description">${escapeHtml(ticket.description)}</p>
<div class="meta">
<span class="requester"><i class="fas fa-user"></i> ${ticket.requester}</span>
<span class="requester"><i class="fas fa-user"></i> ${escapeHtml(ticket.requester)}</span>
<span class="date"><i class="fas fa-calendar"></i> ${formatDate(ticket.created_at)}</span>
${ticket.category ? `<span class="category"><i class="fas fa-tag"></i> ${ticket.category}</span>` : ''}
${ticket.category ? `<span class="category"><i class="fas fa-tag"></i> ${escapeHtml(ticket.category)}</span>` : ''}
</div>
<div class="status-row">
<div class="status status-${statusSafe.replace(/\s+/g, '-')}">
<i class="fas fa-circle"></i> ${ticket.status}
<i class="fas fa-circle"></i> ${escapeHtml(ticket.status)}
</div>
<div class="priority priority-${prioritySafe.replace(/\s+/g, '-')}">
<i class="fas fa-flag"></i> ${prioritySafe}
<i class="fas fa-flag"></i> ${escapeHtml(prioritySafe)}
</div>
</div>
<div class="ticket-actions">
@@ -344,14 +257,17 @@ document.addEventListener('DOMContentLoaded', () => {
`;
if ((ticket.status || '').toLowerCase() === 'closed') {
closedContainer.appendChild(el);
closedTicketCount++;
} else {
openContainer.appendChild(el);
}
});
if (closedCount) closedCount.textContent = closedTicketCount;
}
// --- Handlers ---
let isSubmittingTicket = false; // prevent double submit
let isSubmittingTicket = false;
async function handleCreateTicket(e) {
e.preventDefault();
@@ -372,28 +288,11 @@ document.addEventListener('DOMContentLoaded', () => {
const submitBtn = createForm.querySelector('.btn-primary');
submitBtn.disabled = true;
const ticket = {
title,
description,
priority,
category,
requester: 'Laila',
status: 'Open'
};
try {
const createdTicket = await createTicket(ticket);
await createTicket({ title, description, priority, category, requester: 'Laila', status: 'Open' });
createForm.reset();
closeModal();
await refreshTickets();
await sendAdminWebhook({
id: createdTicket.id,
title,
description,
priority,
category,
status: 'Open'
});
submitBtn.innerHTML = '<i class="fas fa-check"></i> Created!';
submitBtn.style.backgroundColor = '#28a745';
@@ -416,15 +315,10 @@ document.addEventListener('DOMContentLoaded', () => {
}
// --- Init ---
async function initialize() {
await loadWebhookConfig();
await refreshTickets();
}
if (createForm) createForm.addEventListener('submit', handleCreateTicket);
if (statusFilter) statusFilter.addEventListener('change', renderFilteredTickets);
if (categoryFilter) categoryFilter.addEventListener('change', renderFilteredTickets);
if (searchInput) searchInput.addEventListener('input', renderFilteredTickets);
initialize();
loadCategories().then(() => refreshTickets());
});
+50
View File
@@ -654,4 +654,54 @@ a:focus {
.header h1 {
font-size: 1.2rem;
}
}
/* ===== ACCORDION ===== */
.accordion-section {
margin-top: 2rem;
}
.accordion-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: var(--card-bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
color: var(--neutral-medium);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
user-select: none;
}
.accordion-toggle:hover {
background: var(--border-color);
color: var(--text-color);
}
.accordion-toggle .accordion-arrow {
margin-left: auto;
transition: transform 0.3s ease;
}
.accordion-toggle.open .accordion-arrow {
transform: rotate(180deg);
}
.accordion-count {
background: var(--primary-color);
color: white;
font-size: 0.8rem;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
min-width: 24px;
text-align: center;
}
.accordion-body {
padding-top: 15px;
}
+9
View File
@@ -0,0 +1,9 @@
// Fetches app version and updates all footer texts
fetch('/api/version')
.then(res => res.json())
.then(({ version }) => {
document.querySelectorAll('.app-footer-text').forEach(el => {
el.textContent = `Ticket System by Honey v${version} © 2026`;
});
})
.catch(() => {});
+401 -175
View File
@@ -5,6 +5,8 @@ import path from 'path';
import { fileURLToPath } from 'url';
import moment from 'moment-timezone';
import dotenv from 'dotenv';
import multer from 'multer';
import fs from 'fs';
// Load environment variables
dotenv.config();
@@ -15,11 +17,13 @@ const __dirname = path.dirname(__filename);
const app = express();
const port = 3000;
// Load webhook URLs from environment
// Load webhook URLs from environment (kept server-side only!)
const ADMIN_WEBHOOK_URL = process.env.ADMIN_WEBHOOK_URL;
const USER_WEBHOOK_URL = process.env.USER_WEBHOOK_URL;
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss');
// Read version from package.json
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
const APP_VERSION = pkg.version || '0.0.0';
app.use(cors());
app.use(express.json());
@@ -27,27 +31,75 @@ app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/assets', express.static(path.join(__dirname, 'assets')));
const dbPath = path.resolve(__dirname, 'tickets.db');
const db = new sqlite3.Database(dbPath, err => {
if (err) {
console.error('Failed to connect to DB:', err.message);
} else {
console.log('Connected to SQLite DB.');
// --- Profile Picture Upload (supports user + admin) ---
function createPfpUpload(filename) {
const storage = multer.diskStorage({
destination: path.join(__dirname, 'assets'),
filename: (_req, _file, cb) => cb(null, filename),
});
return multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true);
else cb(new Error('Only image files are allowed'));
},
});
}
// --- Categories Config ---
const categoriesPath = path.resolve(__dirname, 'categories.json');
function loadCategories() {
try {
const data = fs.readFileSync(categoriesPath, 'utf-8');
return JSON.parse(data).categories || [];
} catch {
return ['HELP', 'IT-Support', 'Suggestion...', 'Other'];
}
}
// --- Database ---
const dbPath = path.resolve(__dirname, 'data', 'tickets.db');
const db = new sqlite3.Database(dbPath, err => {
if (err) console.error('Failed to connect to DB:', err.message);
else console.log('Connected to SQLite DB.');
});
// Enable WAL mode for better concurrent reads
db.run('PRAGMA journal_mode=WAL');
// Promisified DB helpers
function dbAll(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)));
});
}
function dbGet(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => (err ? reject(err) : resolve(row)));
});
}
function dbRun(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) reject(err);
else resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
// Create tables
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
requester TEXT,
title TEXT NOT NULL,
requester TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT,
priority TEXT,
status TEXT DEFAULT 'Open',
priority TEXT DEFAULT 'Medium',
category TEXT,
total_time INTEGER DEFAULT 0
)
@@ -61,7 +113,7 @@ db.serialize(() => {
comment TEXT,
author TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(ticket_id) REFERENCES tickets(id)
FOREIGN KEY(ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
)
`);
@@ -74,230 +126,404 @@ db.serialize(() => {
author TEXT,
role TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(ticket_id) REFERENCES tickets(id)
FOREIGN KEY(ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
)
`);
db.run(`CREATE INDEX IF NOT EXISTS idx_time_logs_ticket ON time_logs(ticket_id)`);
db.run(`CREATE INDEX IF NOT EXISTS idx_history_ticket ON history(ticket_id)`);
db.run('CREATE INDEX IF NOT EXISTS idx_time_logs_ticket ON time_logs(ticket_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_history_ticket ON history(ticket_id)');
db.run('PRAGMA foreign_keys = ON');
});
// Log history
// --- Helpers ---
function utcNow() {
return moment().utc().format('YYYY-MM-DD HH:mm:ss');
}
function logHistory(ticketId, action, message, author = 'system', role = 'user') {
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss');
console.log(`Logging history for ticket: ${ticketId}, timestamp: ${timestamp}`); // Debugging line
db.run(
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
[ticketId, action, message, author, role, timestamp]
[ticketId, action, message, author, role, utcNow()]
);
}
// GET webhook configuration for frontend
app.get('/api/webhooks', (req, res) => {
// --- Server-side webhook sending (URLs never exposed to frontend) ---
async function sendWebhook(url, payload) {
if (!url) return;
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
console.error('Webhook send failed:', err.message);
}
}
// Discord embed colors
const COLORS = {
green: 0x00ff00,
orange: 0xffa500,
blue: 0x4a90e2,
red: 0xd0021b,
purple: 0x9b59b6,
yellow: 0xf5a623,
};
// --- Admin Notifications ---
function notifyAdmin(ticket, event = 'created') {
if (!ADMIN_WEBHOOK_URL) return;
const embeds = {
created: {
title: '🎫 New Ticket Created',
color: COLORS.green,
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
fields: [
{ name: '👤 Requester', value: ticket.requester || 'Unknown' },
{ name: '📊 Status', value: ticket.status || 'Open', inline: true },
{ name: '⚡ Priority', value: ticket.priority || 'None', inline: true },
{ name: '📂 Category', value: ticket.category || 'None', inline: true },
...(ticket.description ? [{
name: '📝 Description',
value: ticket.description.length > 200 ? ticket.description.substring(0, 200) + '...' : ticket.description,
}] : []),
],
},
comment: {
title: '💬 New User Comment',
color: COLORS.blue,
description: `**Ticket #${ticket.id}**: ${ticket.title}`,
fields: [
{ name: '👤 Author', value: ticket.commentAuthor || 'Unknown', inline: true },
{ name: '📊 Status', value: ticket.status || '-', inline: true },
{ name: '💬 Comment', value: ticket.commentContent?.length > 200 ? ticket.commentContent.substring(0, 200) + '...' : (ticket.commentContent || '-') },
],
},
};
const embed = embeds[event] || embeds.created;
embed.timestamp = new Date().toISOString();
embed.footer = { text: 'Ticket System' };
sendWebhook(ADMIN_WEBHOOK_URL, { embeds: [embed] });
}
// --- User Notifications ---
function notifyUser(type, data = {}) {
if (!USER_WEBHOOK_URL) return;
const embeds = {
statusChanged: {
title: '📊 Status Changed',
color: data.newStatus === 'Closed' ? COLORS.red : COLORS.orange,
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
fields: [
{ name: 'Previous', value: data.oldStatus || '-', inline: true },
{ name: 'New', value: data.newStatus || '-', inline: true },
{ name: '👤 Changed by', value: data.actor || 'Admin', inline: true },
],
},
priorityChanged: {
title: '⚡ Priority Changed',
color: COLORS.yellow,
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
fields: [
{ name: 'Previous', value: data.oldPriority || '-', inline: true },
{ name: 'New', value: data.newPriority || '-', inline: true },
{ name: '👤 Changed by', value: data.actor || 'Admin', inline: true },
],
},
comment: {
title: '💬 New Admin Comment',
color: COLORS.purple,
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
fields: [
{ name: '👤 Author', value: data.author || 'Admin', inline: true },
{ name: '💬 Comment', value: data.content?.length > 200 ? data.content.substring(0, 200) + '...' : (data.content || '-') },
],
},
timeLogged: {
title: '⏱ Time Booked',
color: COLORS.blue,
description: `**Ticket #${data.ticketId}**: ${data.title || ''}`,
fields: [
{ name: '⏱ Duration', value: `${data.minutes || 0} min`, inline: true },
{ name: '👤 By', value: data.author || 'Admin', inline: true },
...(data.comment ? [{ name: '📝 Note', value: data.comment }] : []),
],
},
};
const embed = embeds[type];
if (!embed) return;
embed.timestamp = new Date().toISOString();
embed.footer = { text: 'Ticket System' };
sendWebhook(USER_WEBHOOK_URL, { embeds: [embed] });
}
// --- Input validation middleware ---
function validateTicketBody(req, res, next) {
const { title, requester } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
if (!requester?.trim()) return res.status(400).json({ error: 'Requester is required' });
next();
}
// ==================== API ROUTES ====================
// GET webhook status (only tells frontend if configured, NOT the URLs)
app.get('/api/webhooks', (_req, res) => {
res.json({
admin: ADMIN_WEBHOOK_URL || null,
user: USER_WEBHOOK_URL || null,
configured: {
admin: !!ADMIN_WEBHOOK_URL,
user: !!USER_WEBHOOK_URL
}
user: !!USER_WEBHOOK_URL,
},
});
});
// Upload profile picture (user)
app.post('/api/upload-pfp', createPfpUpload('user.jpg').single('pfp'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ message: 'Profile picture updated', filename: req.file.filename });
});
// Upload profile picture (admin)
app.post('/api/upload-admin-pfp', createPfpUpload('admin.jpg').single('pfp'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
res.json({ message: 'Admin profile picture updated', filename: req.file.filename });
});
// GET categories
app.get('/api/categories', (_req, res) => {
res.json(loadCategories());
});
// GET app version
app.get('/api/version', (_req, res) => {
res.json({ version: APP_VERSION });
});
// GET all tickets
app.get('/api/tickets', (req, res) => {
db.all(
`SELECT id, title, requester, description, status, priority, category, total_time,
strftime('%Y-%m-%dT%H:%M:%S', created_at) AS created_at,
strftime('%Y-%m-%dT%H:%M:%S', updated_at) AS updated_at
FROM tickets
ORDER BY created_at DESC`,
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
}
);
app.get('/api/tickets', async (_req, res) => {
try {
const rows = await dbAll(`
SELECT id, title, requester, description, status, priority, category, total_time,
strftime('%Y-%m-%dT%H:%M:%S', created_at) AS created_at,
strftime('%Y-%m-%dT%H:%M:%S', updated_at) AS updated_at
FROM tickets ORDER BY created_at DESC
`);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET single ticket
app.get('/api/tickets/:id', (req, res) => {
db.get('SELECT * FROM tickets WHERE id = ?', [req.params.id], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
app.get('/api/tickets/:id', async (req, res) => {
try {
const row = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Ticket not found' });
res.json(row);
});
});
// GET ticket history (ISO formatted date)
app.get('/api/tickets/:id/history', (req, res) => {
db.all(
`SELECT id, ticket_id, action, message, author, role,
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
FROM history
WHERE ticket_id = ?
ORDER BY timestamp DESC`,
[req.params.id],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
}
);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST new ticket
app.post('/api/tickets', (req, res) => {
app.post('/api/tickets', validateTicketBody, async (req, res) => {
const { title, requester, description, status, priority, category } = req.body;
const statusValue = status && status.trim() ? status : 'open';
// Get current Berlin time and convert it to UTC
const berlinTime = moment.tz('Europe/Berlin');
const utcTime = berlinTime.clone().utc().format('YYYY-MM-DD HH:mm:ss'); // Convert Berlin time to UTC
db.run(
'INSERT INTO tickets (title, requester, description, status, priority, category, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[title, requester, description, statusValue, priority, category, utcTime, utcTime],
function (err) {
if (err) return res.status(500).json({ error: err.message });
logHistory(this.lastID, 'Ticket Created', `Ticket "${title}" created`, requester);
res.status(201).json({ id: this.lastID });
}
);
const ts = utcNow();
try {
const result = await dbRun(
`INSERT INTO tickets (title, requester, description, status, priority, category, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[title.trim(), requester.trim(), description?.trim() || '', status || 'Open', priority || 'Medium', category || '', ts, ts]
);
const ticket = { id: result.lastID, title, requester, description, status: status || 'Open', priority: priority || 'Medium', category };
logHistory(result.lastID, 'Ticket Created', `Ticket "${title}" created`, requester);
notifyAdmin(ticket, 'created');
res.status(201).json({ id: result.lastID });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT update ticket
app.put('/api/tickets/:id', (req, res) => {
app.put('/api/tickets/:id', async (req, res) => {
const { title, requester, description, status, priority, category, author } = req.body;
const ts = utcNow();
try {
// Fetch old ticket for change detection
const old = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!old) return res.status(404).json({ error: 'Ticket not found' });
// Get current Berlin time and convert it to UTC
const berlinTime = moment.tz('Europe/Berlin');
const utcTime = berlinTime.clone().utc().format('YYYY-MM-DD HH:mm:ss'); // Convert Berlin time to UTC
await dbRun(
`UPDATE tickets SET title=?, requester=?, description=?, status=?, priority=?, category=?, updated_at=? WHERE id=?`,
[title, requester || old.requester, description, status, priority, category, ts, req.params.id]
);
db.run(
`UPDATE tickets SET
title = ?,
requester = ?,
description = ?,
status = ?,
priority = ?,
category = ?,
updated_at = ?
WHERE id = ?`,
[title, requester, description, status, priority, category, utcTime, req.params.id],
function (err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
logHistory(req.params.id, 'Ticket Updated', 'Ticket details updated', author || 'system');
res.json({ message: 'Ticket updated' });
const actor = author || 'system';
// Log specific changes to history
if (status !== old.status) {
logHistory(req.params.id, 'Status Changed', `Status changed from "${old.status}" to "${status}"`, actor);
notifyUser('statusChanged', { ticketId: req.params.id, title: old.title, oldStatus: old.status, newStatus: status, actor });
}
);
if (priority !== old.priority) {
logHistory(req.params.id, 'Priority Changed', `Priority changed from "${old.priority}" to "${priority}"`, actor);
notifyUser('priorityChanged', { ticketId: req.params.id, title: old.title, oldPriority: old.priority, newPriority: priority, actor });
}
if (category !== (old.category || '')) {
logHistory(req.params.id, 'Category Changed', `Category changed from "${old.category || 'none'}" to "${category}"`, actor);
}
if (title !== old.title || description !== old.description) {
logHistory(req.params.id, 'Ticket Updated', 'Ticket details updated', actor);
}
res.json({ message: 'Ticket updated' });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE ticket
app.delete('/api/tickets/:id', (req, res) => {
db.run('DELETE FROM tickets WHERE id = ?', [req.params.id], function (err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
logHistory(req.params.id, 'Ticket Deleted', 'Ticket deleted');
app.delete('/api/tickets/:id', async (req, res) => {
try {
const result = await dbRun('DELETE FROM tickets WHERE id = ?', [req.params.id]);
if (result.changes === 0) return res.status(404).json({ error: 'Ticket not found' });
res.json({ message: 'Ticket deleted' });
});
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST comment with role
app.post('/api/tickets/:id/comments', (req, res) => {
// POST comment
app.post('/api/tickets/:id/comments', async (req, res) => {
const { author, role, content } = req.body;
if (!author || !content) return res.status(400).json({ error: 'Author and content required' });
if (!author?.trim() || !content?.trim()) {
return res.status(400).json({ error: 'Author and content required' });
}
try {
const result = await dbRun(
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, "Comment", ?, ?, ?, ?)',
[req.params.id, content.trim(), author.trim(), role || 'user', utcNow()]
);
const timestamp = moment().utc().format('YYYY-MM-DD HH:mm:ss'); // Explicit UTC timestamp
// Server-side webhook for comments
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
const ticketTitle = ticket?.title || '';
db.run(
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, "Comment", ?, ?, ?, ?)',
[req.params.id, content, author, role || 'user', timestamp],
function (err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({ id: this.lastID });
if (role === 'admin') {
notifyUser('comment', { ticketId: req.params.id, title: ticketTitle, author, content: content.trim() });
} else {
notifyAdmin({
id: req.params.id,
title: ticketTitle,
requester: ticket?.requester || author,
status: ticket?.status || '-',
commentAuthor: author,
commentContent: content.trim(),
}, 'comment');
}
);
res.status(201).json({ id: result.lastID });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET comments (ISO formatted date)
app.get('/api/tickets/:id/comments', (req, res) => {
db.all(
`SELECT id, ticket_id, message AS content, author, role,
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
FROM history
WHERE ticket_id = ? AND action = "Comment"
ORDER BY timestamp DESC`,
[req.params.id],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
}
);
// GET comments
app.get('/api/tickets/:id/comments', async (req, res) => {
try {
const rows = await dbAll(
`SELECT id, ticket_id, message AS content, author, role,
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
FROM history WHERE ticket_id = ? AND action = "Comment"
ORDER BY timestamp DESC`,
[req.params.id]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET ticket history
app.get('/api/tickets/:id/history', async (req, res) => {
try {
const rows = await dbAll(
`SELECT id, ticket_id, action, message, author, role,
strftime('%Y-%m-%dT%H:%M:%S', timestamp) AS date
FROM history WHERE ticket_id = ?
ORDER BY timestamp DESC`,
[req.params.id]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST time log
app.post('/api/tickets/:id/time', (req, res) => {
app.post('/api/tickets/:id/time', async (req, res) => {
const { minutes, comment, author } = req.body;
if (typeof minutes !== 'number' || minutes <= 0)
if (typeof minutes !== 'number' || minutes <= 0) {
return res.status(400).json({ error: 'Minutes must be a positive number.' });
if (!author)
}
if (!author?.trim()) {
return res.status(400).json({ error: 'Author is required.' });
db.get('SELECT * FROM tickets WHERE id = ?', [req.params.id], (err, ticket) => {
if (err) return res.status(500).json({ error: err.message });
}
try {
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
db.run(
await dbRun(
'INSERT INTO time_logs (ticket_id, minutes, comment, author) VALUES (?, ?, ?, ?)',
[req.params.id, minutes, comment || '', author],
function (err) {
if (err) return res.status(500).json({ error: err.message });
db.get(
'SELECT SUM(minutes) as total FROM time_logs WHERE ticket_id = ?',
[req.params.id],
(err, row) => {
if (err) return res.status(500).json({ error: err.message });
const totalTime = row.total || 0;
db.run(
'UPDATE tickets SET total_time = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[totalTime, req.params.id],
err => {
if (err) return res.status(500).json({ error: err.message });
logHistory(
req.params.id,
'Time Logged',
`Added ${minutes} minutes by ${author}. Comment: ${comment || 'none'}`,
author
);
// Send back the updated total_time to the frontend
res.status(201).json({ message: 'Time logged', total_time: totalTime });
}
);
}
);
}
[req.params.id, minutes, comment || '', author]
);
});
const { total } = await dbGet(
'SELECT SUM(minutes) as total FROM time_logs WHERE ticket_id = ?',
[req.params.id]
);
const totalTime = total || 0;
await dbRun(
'UPDATE tickets SET total_time = ?, updated_at = ? WHERE id = ?',
[totalTime, utcNow(), req.params.id]
);
logHistory(req.params.id, 'Time Logged', `Added ${minutes} min by ${author}. Comment: ${comment || 'none'}`, author);
notifyUser('timeLogged', { ticketId: req.params.id, title: ticket.title, minutes, author, comment });
res.status(201).json({ message: 'Time logged', total_time: totalTime });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET time logs
app.get('/api/tickets/:id/time', (req, res) => {
db.all(
'SELECT * FROM time_logs WHERE ticket_id = ? ORDER BY created_at DESC',
[req.params.id],
(err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
}
);
app.get('/api/tickets/:id/time', async (req, res) => {
try {
const rows = await dbAll(
'SELECT * FROM time_logs WHERE ticket_id = ? ORDER BY created_at DESC',
[req.params.id]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// --- Start ---
app.listen(port, () => {
console.log(`Server läuft auf http://localhost:${port}`);
console.log('Webhook URLs loaded:', {
console.log(`Server running on http://localhost:${port}`);
console.log('Webhooks:', {
admin: ADMIN_WEBHOOK_URL ? 'Configured' : 'Not configured',
user: USER_WEBHOOK_URL ? 'Configured' : 'Not configured'
user: USER_WEBHOOK_URL ? 'Configured' : 'Not configured',
});
});