700 lines
29 KiB
JavaScript
700 lines
29 KiB
JavaScript
require('dotenv').config();
|
|
console.log('--- DIAGNOSTIC TEST ---');
|
|
console.log('PUBLIC_VAPID_KEY loaded as:', process.env.PUBLIC_VAPID_KEY);
|
|
console.log('--- END DIAGNOSTIC TEST ---');
|
|
const express = require('express');
|
|
const sqlite3 = require('sqlite3');
|
|
const { open } = require('sqlite');
|
|
const bcrypt = require('bcryptjs');
|
|
const jwt = require('jsonwebtoken');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const axios = require('axios');
|
|
const ics = require('ics');
|
|
const webpush = require('web-push');
|
|
const bodyParser = require('body-parser');
|
|
const PORT = process.env.PORT || 3000;
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'default_secret_key';
|
|
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'adminpassword';
|
|
const publicVapidKey = 'BI1mWxe0yAsMw_iDjmb4Te2ByWwKuHhWsLYFilk7prozsnCEbtNHEJfNh_zIiNumLgWFKSvD6pMhnRbjhXVY_pU';
|
|
const privateVapidKey = process.env.PRIVATE_VAPID_KEY;
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
|
|
let db;
|
|
|
|
async function startServer() {
|
|
try {
|
|
const dbPath = path.resolve(__dirname, 'data', 'timetracker.db');
|
|
db = await open({ filename: dbPath, driver: sqlite3.Database });
|
|
console.log("Connected to the SQLite database.");
|
|
await initializeDatabase();
|
|
setupRoutes();
|
|
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
|
|
} catch (err) {
|
|
console.error("FATAL: Could not start server.", err);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
webpush.setVapidDetails(
|
|
'mailto:utility@beachpartyballoons.com', // A contact email
|
|
publicVapidKey,
|
|
privateVapidKey
|
|
);
|
|
|
|
async function initializeDatabase() {
|
|
console.log("Initializing database schema...");
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'employee'
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS time_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT,
|
|
punch_in_time DATETIME NOT NULL,
|
|
punch_out_time DATETIME,
|
|
status TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS archived_time_entries (
|
|
id INTEGER PRIMARY KEY,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT,
|
|
punch_in_time DATETIME NOT NULL,
|
|
punch_out_time DATETIME,
|
|
status TEXT NOT NULL,
|
|
archived_at DATETIME NOT NULL
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS time_off_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
username TEXT,
|
|
start_date TEXT NOT NULL,
|
|
end_date TEXT NOT NULL,
|
|
reason TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS subscriptions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL,
|
|
subscription_object TEXT NOT NULL,
|
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
)`);
|
|
await db.exec(`CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
admin_username TEXT NOT NULL,
|
|
employee_user_id INTEGER NOT NULL,
|
|
note_text TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (employee_user_id) REFERENCES users (id) ON DELETE CASCADE
|
|
)`);
|
|
const adminUser = await db.get('SELECT * FROM users WHERE username = ?', [ADMIN_USERNAME]);
|
|
if (!adminUser) {
|
|
const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
|
await db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [ADMIN_USERNAME, hashedPassword, 'admin']);
|
|
console.log("Primary admin user created.");
|
|
}
|
|
console.log("Database initialization complete.");
|
|
}
|
|
|
|
// In server.js
|
|
|
|
async function addEventToNextcloud(request) {
|
|
// Ensure we have the required config from the .env file
|
|
if (!process.env.NEXTCLOUD_URL) {
|
|
console.log("Nextcloud integration is not configured. Skipping calendar event creation.");
|
|
return;
|
|
}
|
|
|
|
// The ics library expects date parts in an array: [YYYY, MM, DD]
|
|
const start = request.start_date.split('-').map(Number);
|
|
|
|
// For an all-day event, the end date is the day *after* the last day.
|
|
const endDate = new Date(request.end_date);
|
|
endDate.setDate(endDate.getDate() + 1);
|
|
const end = [endDate.getFullYear(), endDate.getMonth() + 1, endDate.getDate()];
|
|
|
|
// Create the event object for the ics library
|
|
const event = {
|
|
start: start,
|
|
end: end,
|
|
title: `${request.username} - Time Off`,
|
|
description: `Reason: ${request.reason || 'Not specified'}`,
|
|
status: 'CONFIRMED',
|
|
busyStatus: 'BUSY',
|
|
};
|
|
|
|
// Generate the raw .ics file content
|
|
const { error, value: icsFileContent } = ics.createEvent(event);
|
|
if (error) {
|
|
console.error("Could not create .ics file:", error);
|
|
return;
|
|
}
|
|
|
|
// --- THIS IS THE FIX ---
|
|
// Remove the METHOD:PUBLISH line that Nextcloud/SabreDAV rejects.
|
|
const finalIcsContent = icsFileContent.replace('METHOD:PUBLISH\r\n', '');
|
|
|
|
// Each calendar event is a unique .ics file on the WebDAV server.
|
|
// We use the request ID to ensure a unique filename.
|
|
const eventUrl = `${process.env.NEXTCLOUD_URL}${process.env.NEXTCLOUD_CALENDAR_URL}time-off-${request.id}.ics`;
|
|
|
|
try {
|
|
// Send the corrected .ics content to the Nextcloud server
|
|
await axios.put(eventUrl, finalIcsContent, {
|
|
auth: {
|
|
username: process.env.NEXTCLOUD_USER,
|
|
password: process.env.NEXTCLOUD_APP_PASSWORD
|
|
},
|
|
headers: {
|
|
'Content-Type': 'text/calendar; charset=utf-8'
|
|
}
|
|
});
|
|
console.log(`Successfully added time-off request ${request.id} to Nextcloud calendar.`);
|
|
} catch (err) {
|
|
console.error("Error sending event to Nextcloud:", err.response ? err.response.data : err.message);
|
|
}
|
|
}
|
|
|
|
// In server.js
|
|
|
|
async function removeEventFromNextcloud(requestId) {
|
|
if (!process.env.NEXTCLOUD_URL) {
|
|
console.log("Nextcloud integration is not configured. Skipping calendar event removal.");
|
|
return;
|
|
}
|
|
|
|
// Construct the exact URL for the event's .ics file
|
|
const eventUrl = `${process.env.NEXTCLOUD_URL}${process.env.NEXTCLOUD_CALENDAR_URL}time-off-${requestId}.ics`;
|
|
|
|
try {
|
|
await axios.delete(eventUrl, {
|
|
auth: {
|
|
username: process.env.NEXTCLOUD_USER,
|
|
password: process.env.NEXTCLOUD_APP_PASSWORD
|
|
}
|
|
});
|
|
console.log(`Successfully removed time-off request ${requestId} from Nextcloud calendar.`);
|
|
} catch (err) {
|
|
// A 404 error is okay here; it just means the event wasn't on the calendar to begin with.
|
|
if (err.response && err.response.status === 404) {
|
|
console.log(`Event for request ${requestId} not found on calendar. Nothing to remove.`);
|
|
} else {
|
|
console.error("Error removing event from Nextcloud:", err.response ? err.response.data : err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function setupRoutes() {
|
|
const authenticateToken = (req, res, next) => {
|
|
const token = req.headers['authorization']?.split(' ')[1];
|
|
if (!token) return res.status(401).json({ message: "Authentication required. No token provided." });
|
|
jwt.verify(token, JWT_SECRET, (err, user) => {
|
|
if (err) return res.status(403).json({ message: "Access denied. Invalid or expired token." });
|
|
req.user = user;
|
|
next();
|
|
});
|
|
};
|
|
app.post('/subscribe', authenticateToken, async (req, res) => {
|
|
try {
|
|
const subscription = req.body;
|
|
const userId = req.user.id;
|
|
|
|
// Save the subscription to the database, linked to the user
|
|
await db.run(
|
|
'INSERT INTO subscriptions (user_id, subscription_object) VALUES (?, ?)',
|
|
[userId, JSON.stringify(subscription)]
|
|
);
|
|
|
|
res.status(201).json({ message: 'Subscribed successfully.' });
|
|
|
|
// Optional: Send a welcome notification
|
|
const payload = JSON.stringify({ title: 'Time Pulse', body: 'You are now subscribed to notifications!' });
|
|
webpush.sendNotification(subscription, payload).catch(err => console.error("Error sending welcome notification:", err));
|
|
|
|
} catch (error) {
|
|
console.error('Failed to save subscription:', error);
|
|
res.status(500).json({ message: 'Failed to subscribe.' });
|
|
}
|
|
});
|
|
const requireRole = (role) => (req, res, next) => {
|
|
if (req.user && req.user.role === role) next();
|
|
else res.status(403).json({ message: "Access denied." });
|
|
};
|
|
|
|
// --- Auth Routes ---
|
|
app.post('/api/login', async (req, res) => {
|
|
try {
|
|
const { username, password } = req.body;
|
|
const user = await db.get('SELECT * FROM users WHERE username = ?', [username]);
|
|
if (!user) return res.status(404).json({ message: "User not found." });
|
|
const isMatch = await bcrypt.compare(password, user.password);
|
|
if (!isMatch) return res.status(401).json({ message: "Invalid credentials." });
|
|
const tokenPayload = { id: user.id, username: user.username, role: user.role };
|
|
const token = jwt.sign(tokenPayload, JWT_SECRET, { expiresIn: '30d' });
|
|
res.json({ token, user: tokenPayload });
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Server error during login." });
|
|
}
|
|
});
|
|
|
|
// --- Employee-Facing Routes ---
|
|
app.post('/api/punch', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id, username } = req.user;
|
|
const openPunch = await db.get(`SELECT * FROM time_entries WHERE user_id = ? AND status = 'in' ORDER BY punch_in_time DESC LIMIT 1`, [id]);
|
|
const now = new Date().toISOString();
|
|
if (openPunch) {
|
|
await db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [now, openPunch.id]);
|
|
res.json({ message: "Punched out." });
|
|
} else {
|
|
await db.run(`INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, 'in')`, [id, username, now]);
|
|
res.json({ message: "Punched in." });
|
|
}
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Server error during punch." });
|
|
}
|
|
});
|
|
|
|
app.get('/api/status', authenticateToken, async (req, res) => {
|
|
try {
|
|
const rows = await db.all(`SELECT * FROM time_entries WHERE user_id = ? ORDER BY punch_in_time DESC`, [req.user.id]);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Server error fetching status." });
|
|
}
|
|
});
|
|
|
|
app.post('/api/user/change-password', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { currentPassword, newPassword } = req.body;
|
|
const user = await db.get('SELECT * FROM users WHERE id = ?', [req.user.id]);
|
|
if (!user) return res.status(404).json({ message: "User not found." });
|
|
const isMatch = await bcrypt.compare(currentPassword, user.password);
|
|
if (!isMatch) return res.status(401).json({ message: "Incorrect current password." });
|
|
const hashed = await bcrypt.hash(newPassword, 10);
|
|
await db.run('UPDATE users SET password = ? WHERE id = ?', [hashed, req.user.id]);
|
|
res.json({ message: "Password updated successfully." });
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Error changing password." });
|
|
}
|
|
});
|
|
|
|
app.post('/api/user/request-time-off', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { startDate, endDate, reason } = req.body;
|
|
const { id, username } = req.user;
|
|
await db.run(`INSERT INTO time_off_requests (user_id, username, start_date, end_date, reason) VALUES (?, ?, ?, ?, ?)`, [id, username, startDate, endDate, reason]);
|
|
|
|
try {
|
|
const adminSubs = await db.all(`
|
|
SELECT s.subscription_object
|
|
FROM subscriptions s
|
|
JOIN users u ON s.user_id = u.id
|
|
WHERE u.role = 'admin'
|
|
`);
|
|
|
|
const payload = JSON.stringify({
|
|
title: 'New Time-Off Request',
|
|
body: `${username} has requested time off.`
|
|
});
|
|
|
|
const promises = adminSubs.map(s => {
|
|
const subscription = JSON.parse(s.subscription_object);
|
|
return webpush.sendNotification(subscription, payload).catch(err => {
|
|
if (err.statusCode === 410) db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]);
|
|
else console.error('Error sending admin notification:', err);
|
|
});
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
} catch (notifyError) {
|
|
console.error('Failed to send admin notifications:', notifyError);
|
|
}
|
|
|
|
res.status(201).json({ message: "Time off request submitted." });
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to submit request." });
|
|
}
|
|
});
|
|
|
|
app.get('/api/user/time-off-requests', authenticateToken, async (req, res) => {
|
|
try {
|
|
const rows = await db.all(`SELECT * FROM time_off_requests WHERE user_id = ? AND (status = 'pending' OR end_date >= date('now')) ORDER BY start_date ASC`, [req.user.id]);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch requests." });
|
|
}
|
|
});
|
|
|
|
app.get('/api/user/time-off-requests/history', authenticateToken, async (req, res) => {
|
|
try {
|
|
const rows = await db.all("SELECT * FROM time_off_requests WHERE user_id = ? ORDER BY start_date DESC", [req.user.id]);
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch request history." });
|
|
}
|
|
});
|
|
|
|
|
|
app.get('/api/user/notes', authenticateToken, async (req, res) => {
|
|
try {
|
|
const notes = await db.all("SELECT admin_username, note_text, created_at FROM notes WHERE employee_user_id = ? ORDER BY created_at DESC", [req.user.id]);
|
|
// The notification block has been removed from here.
|
|
res.json(notes);
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to fetch notes.' });
|
|
}
|
|
});
|
|
|
|
// --- Admin User Management ---
|
|
app.get('/api/admin/users', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const users = await db.all("SELECT id, username, role FROM users");
|
|
const usersWithPrimaryFlag = users.map(u => ({...u, isPrimary: u.username === ADMIN_USERNAME}));
|
|
res.json(usersWithPrimaryFlag);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch users." });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/create-user', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { username, password, role } = req.body;
|
|
if (!username || !password || !role) return res.status(400).json({ message: "Username, password, and role are required." });
|
|
|
|
const existingUser = await db.get("SELECT id FROM users WHERE username = ?", [username]);
|
|
if (existingUser) return res.status(409).json({ message: "Username already exists." });
|
|
|
|
const hashedPassword = await bcrypt.hash(password, 10);
|
|
await db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [username, hashedPassword, role]);
|
|
res.status(201).json({ message: 'User created successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to create user.' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/update-role', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { username, newRole } = req.body;
|
|
if (username === ADMIN_USERNAME) return res.status(403).json({ message: "Cannot change the role of the primary admin." });
|
|
await db.run('UPDATE users SET role = ? WHERE username = ?', [newRole, username]);
|
|
res.json({ message: 'User role updated.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to update role.' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/reset-password', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { username, newPassword } = req.body;
|
|
if (!username || !newPassword) return res.status(400).json({ message: "Username and new password are required." });
|
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
|
const result = await db.run('UPDATE users SET password = ? WHERE username = ?', [hashedPassword, username]);
|
|
if (result.changes === 0) return res.status(404).json({ message: "User not found." });
|
|
res.json({ message: `Password for ${username} has been reset.` });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to reset password.' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/delete-user/:username', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { username } = req.params;
|
|
if (username === req.user.username) return res.status(403).json({ message: "Cannot delete yourself." });
|
|
if (username === ADMIN_USERNAME) return res.status(403).json({ message: "Cannot delete the primary admin." });
|
|
|
|
const result = await db.run('DELETE FROM users WHERE username = ?', [username]);
|
|
if (result.changes === 0) return res.status(404).json({ message: "User not found." });
|
|
res.json({ message: 'User and all associated data deleted.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to delete user.' });
|
|
}
|
|
});
|
|
|
|
// --- Admin Time & Log Management ---
|
|
app.get('/api/admin/logs', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const rows = await db.all("SELECT * FROM time_entries ORDER BY punch_in_time DESC");
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Server error fetching logs." });
|
|
}
|
|
});
|
|
|
|
app.put('/api/admin/logs/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { punch_in_time, punch_out_time } = req.body;
|
|
const status = punch_out_time ? 'out' : 'in';
|
|
await db.run(`UPDATE time_entries SET punch_in_time = ?, punch_out_time = ?, status = ? WHERE id = ?`, [punch_in_time, punch_out_time, status, id]);
|
|
res.json({ message: 'Entry updated successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to update entry.' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/logs/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await db.run('DELETE FROM time_entries WHERE id = ?', [id]);
|
|
if (result.changes === 0) return res.status(404).json({ message: "Entry not found." });
|
|
res.json({ message: 'Time entry deleted successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to delete time entry.' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/force-clock-out', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { userId } = req.body;
|
|
const punch = await db.get(`SELECT * FROM time_entries WHERE user_id = ? AND status = 'in' ORDER BY punch_in_time DESC LIMIT 1`, [userId]);
|
|
if (!punch) return res.status(404).json({ message: "No active punch-in found." });
|
|
await db.run(`UPDATE time_entries SET punch_out_time = ?, status = 'out' WHERE id = ?`, [new Date().toISOString(), punch.id]);
|
|
res.json({ message: "User has been clocked out." });
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Server error forcing clock out." });
|
|
}
|
|
});
|
|
// server.js - Inside setupRoutes(), with other admin routes
|
|
app.post('/api/admin/notify', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { userId, title, message } = req.body;
|
|
|
|
// Get all subscriptions for the target user
|
|
const subs = await db.all('SELECT subscription_object FROM subscriptions WHERE user_id = ?', [userId]);
|
|
if (subs.length === 0) {
|
|
return res.status(404).json({ message: 'User has no active notification subscriptions.' });
|
|
}
|
|
|
|
const payload = JSON.stringify({ title: title, body: message });
|
|
|
|
// Send a notification to each subscription
|
|
const promises = subs.map(s => {
|
|
const subscription = JSON.parse(s.subscription_object);
|
|
return webpush.sendNotification(subscription, payload).catch(err => {
|
|
// If a subscription is expired (410 Gone), delete it from the DB
|
|
if (err.statusCode === 410) {
|
|
db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]);
|
|
}
|
|
console.error('Error sending notification, user ID:', userId);
|
|
});
|
|
});
|
|
|
|
await Promise.all(promises);
|
|
res.json({ message: 'Notification sent successfully.' });
|
|
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to send notification.' });
|
|
}
|
|
});
|
|
app.post('/api/admin/add-punch', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { userId, username, punchInTime, punchOutTime } = req.body;
|
|
if (!userId || !punchInTime) return res.status(400).json({ message: "User and Punch In time are required." });
|
|
if (!punchOutTime) {
|
|
const existingPunch = await db.get("SELECT id FROM time_entries WHERE user_id = ? AND status = 'in'", [userId]);
|
|
if (existingPunch) return res.status(409).json({ message: `${username} is already punched in.` });
|
|
await db.run('INSERT INTO time_entries (user_id, username, punch_in_time, status) VALUES (?, ?, ?, ?)', [userId, username, punchInTime, 'in']);
|
|
return res.status(201).json({ message: `Active punch started for ${username}.` });
|
|
}
|
|
if (new Date(punchOutTime) < new Date(punchInTime)) return res.status(400).json({ message: "Punch out time cannot be before punch in time." });
|
|
await db.run('INSERT INTO time_entries (user_id, username, punch_in_time, punch_out_time, status) VALUES (?, ?, ?, ?, ?)', [userId, username, punchInTime, punchOutTime, 'out']);
|
|
res.status(201).json({ message: `Completed entry added for ${username}.` });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to add manual punch.' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/admin/archives', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const rows = await db.all("SELECT * FROM archived_time_entries ORDER BY archived_at DESC");
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch archives." });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/archive', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const entriesToArchive = await db.all("SELECT * FROM time_entries WHERE status = 'out'");
|
|
if (entriesToArchive.length === 0) return res.json({ message: "No completed entries to archive." });
|
|
|
|
const archivedAt = new Date().toISOString();
|
|
await db.exec('BEGIN TRANSACTION');
|
|
for (const entry of entriesToArchive) {
|
|
await db.run('INSERT INTO archived_time_entries VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
[entry.id, entry.user_id, entry.username, entry.punch_in_time, entry.punch_out_time, entry.status, archivedAt]
|
|
);
|
|
await db.run('DELETE FROM time_entries WHERE id = ?', [entry.id]);
|
|
}
|
|
await db.exec('COMMIT');
|
|
res.json({ message: `${entriesToArchive.length} entries archived successfully.` });
|
|
} catch (err) {
|
|
await db.exec('ROLLBACK');
|
|
res.status(500).json({ message: 'Failed to archive entries.' });
|
|
}
|
|
});
|
|
|
|
// --- Admin Time Off & Note Management ---
|
|
app.get('/api/admin/time-off-requests/pending', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const rows = await db.all("SELECT * FROM time_off_requests WHERE status = 'pending' ORDER BY start_date ASC");
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch pending requests." });
|
|
}
|
|
});
|
|
|
|
app.get('/api/admin/time-off-requests/history', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const rows = await db.all("SELECT * FROM time_off_requests WHERE status != 'pending' ORDER BY start_date DESC");
|
|
res.json(rows);
|
|
} catch (err) {
|
|
res.status(500).json({ message: "Failed to fetch request history." });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/update-time-off-status', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { requestId, status } = req.body;
|
|
await db.run('UPDATE time_off_requests SET status = ? WHERE id = ?', [status, requestId]);
|
|
|
|
// Get the details of the request we're updating
|
|
const requestDetails = await db.get("SELECT * FROM time_off_requests WHERE id = ?", [requestId]);
|
|
|
|
if (requestDetails) {
|
|
if (status === 'approved') {
|
|
// If approved, add it to the calendar
|
|
await addEventToNextcloud(requestDetails);
|
|
} else if (status === 'pending' || status === 'denied') {
|
|
// If pushed back to pending or denied, remove it from the calendar
|
|
await removeEventFromNextcloud(requestId);
|
|
}
|
|
}
|
|
|
|
res.json({ message: `Request status updated to ${status}.` });
|
|
} catch (err) {
|
|
console.error("Error in update-time-off-status route:", err); // Added better logging
|
|
res.status(500).json({ message: 'Failed to update request status.' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/time-off-requests/:id', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await db.run('DELETE FROM time_off_requests WHERE id = ?', [id]);
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ message: "Request not found." });
|
|
}
|
|
res.json({ message: 'Time off request permanently deleted.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Error deleting time off request.' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/admin/notes', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { userId, noteText } = req.body;
|
|
const adminUsername = req.user.username;
|
|
if (!userId || !noteText) return res.status(400).json({ message: "Employee and note text are required." });
|
|
|
|
await db.run('INSERT INTO notes (admin_username, employee_user_id, note_text) VALUES (?, ?, ?)', [adminUsername, userId, noteText]);
|
|
|
|
// --- START: NOTIFICATION CODE (Correct Placement) ---
|
|
try {
|
|
const userSubs = await db.all('SELECT subscription_object FROM subscriptions WHERE user_id = ?', [userId]);
|
|
const payload = JSON.stringify({
|
|
title: 'You Have a New Note',
|
|
body: `A new note has been added by ${adminUsername}.`
|
|
});
|
|
|
|
const promises = userSubs.map(s => {
|
|
const subscription = JSON.parse(s.subscription_object);
|
|
return webpush.sendNotification(subscription, payload).catch(err => {
|
|
if (err.statusCode === 410) db.run('DELETE FROM subscriptions WHERE subscription_object = ?', [s.subscription_object]);
|
|
else console.error('Error sending employee notification:', err);
|
|
});
|
|
});
|
|
await Promise.all(promises);
|
|
} catch (notifyError) {
|
|
console.error('Failed to send employee notification:', notifyError);
|
|
}
|
|
// --- END: NOTIFICATION CODE ---
|
|
|
|
res.status(201).json({ message: "Note successfully posted." });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to post note.' });
|
|
}
|
|
});
|
|
|
|
// NEW: Endpoint to UPDATE a specific time-off request
|
|
app.put('/api/user/time-off-requests/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { startDate, endDate, reason } = req.body;
|
|
// Ensure users can only edit their own pending requests
|
|
const result = await db.run(
|
|
`UPDATE time_off_requests
|
|
SET start_date = ?, end_date = ?, reason = ?
|
|
WHERE id = ? AND user_id = ? AND status = 'pending'`,
|
|
[startDate, endDate, reason, id, req.user.id]
|
|
);
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ message: "Pending request not found or you don't have permission to edit it." });
|
|
}
|
|
res.json({ message: 'Time off request updated successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Error updating time off request.' });
|
|
}
|
|
});
|
|
|
|
// NEW: Endpoint to DELETE a specific time-off request
|
|
app.delete('/api/user/time-off-requests/:id', authenticateToken, async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
// Ensure users can only delete their own pending requests
|
|
const result = await db.run(
|
|
`DELETE FROM time_off_requests
|
|
WHERE id = ? AND user_id = ? AND status = 'pending'`,
|
|
[id, req.user.id]
|
|
);
|
|
if (result.changes === 0) {
|
|
return res.status(404).json({ message: "Pending request not found or you don't have permission to delete it." });
|
|
}
|
|
res.json({ message: 'Time off request deleted successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Error deleting time off request.' });
|
|
}
|
|
});
|
|
app.get('/api/admin/notes/:userId', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { userId } = req.params;
|
|
const notes = await db.all("SELECT id, admin_username, note_text, created_at FROM notes WHERE employee_user_id = ? ORDER BY created_at DESC", [userId]);
|
|
res.json(notes);
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to fetch notes.' });
|
|
}
|
|
});
|
|
|
|
app.delete('/api/admin/notes/:noteId', authenticateToken, requireRole('admin'), async (req, res) => {
|
|
try {
|
|
const { noteId } = req.params;
|
|
const result = await db.run('DELETE FROM notes WHERE id = ?', [noteId]);
|
|
if (result.changes === 0) return res.status(404).json({ message: "Note not found." });
|
|
res.json({ message: 'Note deleted successfully.' });
|
|
} catch (err) {
|
|
res.status(500).json({ message: 'Failed to delete note.' });
|
|
}
|
|
});
|
|
}
|
|
|
|
startServer(); |