Files
Ticketsystem/server.js
T

529 lines
18 KiB
JavaScript

import express from 'express';
import cors from 'cors';
import sqlite3 from 'sqlite3';
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();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = 3000;
// 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;
// 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());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/assets', express.static(path.join(__dirname, 'assets')));
// --- 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 NOT NULL,
requester TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'Open',
priority TEXT DEFAULT 'Medium',
category TEXT,
total_time INTEGER DEFAULT 0
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
minutes INTEGER NOT NULL,
comment TEXT,
author TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
action TEXT NOT NULL,
message TEXT,
author TEXT,
role TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
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('PRAGMA foreign_keys = ON');
});
// --- Helpers ---
function utcNow() {
return moment().utc().format('YYYY-MM-DD HH:mm:ss');
}
function logHistory(ticketId, action, message, author = 'system', role = 'user') {
db.run(
'INSERT INTO history (ticket_id, action, message, author, role, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
[ticketId, action, message, author, role, utcNow()]
);
}
// --- 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({
configured: {
admin: !!ADMIN_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', 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', 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);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST new ticket
app.post('/api/tickets', validateTicketBody, async (req, res) => {
const { title, requester, description, status, priority, category } = req.body;
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', 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' });
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]
);
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', 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
app.post('/api/tickets/:id/comments', async (req, res) => {
const { author, role, content } = req.body;
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()]
);
// Server-side webhook for comments
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
const ticketTitle = ticket?.title || '';
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
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', async (req, res) => {
const { minutes, comment, author } = req.body;
if (typeof minutes !== 'number' || minutes <= 0) {
return res.status(400).json({ error: 'Minutes must be a positive number.' });
}
if (!author?.trim()) {
return res.status(400).json({ error: 'Author is required.' });
}
try {
const ticket = await dbGet('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
await dbRun(
'INSERT INTO time_logs (ticket_id, minutes, comment, author) VALUES (?, ?, ?, ?)',
[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', 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 running on http://localhost:${port}`);
console.log('Webhooks:', {
admin: ADMIN_WEBHOOK_URL ? 'Configured' : 'Not configured',
user: USER_WEBHOOK_URL ? 'Configured' : 'Not configured',
});
});