Coastal Timesheet v2 — full feature buildout

Features:
- Copy Previous Week (entries duplication)
- Bulk Approve/Reject (admin workflow)
- Overtime tracking (employee + admin views)
- DayCard component with auto-save
- PDF generation, email notifications
- React + Tailwind frontend, Prisma + PostgreSQL backend
- Docker deployment (3 containers)
This commit is contained in:
BizzleBot
2026-02-16 10:01:39 +00:00
commit f718a76153
65 changed files with 6756 additions and 0 deletions
+863
View File
@@ -0,0 +1,863 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { authenticate, requireAdmin } = require('../middleware/auth');
const {
approveRejectSchema,
createUserSchema,
createHomeownerSchema,
updateHomeownerSchema,
reportQuerySchema,
validateBody,
validateQuery,
} = require('../utils/validation');
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
const router = express.Router();
// All admin routes require authentication + admin role
router.use(authenticate, requireAdmin);
// ═══════════════════════════════════════════════════════════════
// TIMESHEETS
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/timesheets?status=submitted ───────────────
router.get('/timesheets', async (req, res) => {
try {
const { status, userId, page = '1', limit = '50' } = req.query;
const where = {};
if (status) where.status = status;
if (userId) where.userId = userId;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(limit, 10) || 50));
const [timesheets, total] = await Promise.all([
req.prisma.timesheet.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
approver: { select: { id: true, name: true } },
entries: {
include: {
timeEntry: {
include: { homeowner: { select: { name: true } } },
},
},
},
},
orderBy: { submittedAt: 'desc' },
skip: (pageNum - 1) * pageSize,
take: pageSize,
}),
req.prisma.timesheet.count({ where }),
]);
res.json({
timesheets: timesheets.map((ts) => {
const totalHours = ts.entries.reduce(
(sum, link) => sum + parseFloat(link.timeEntry.hoursWorked || 0),
0
);
return {
id: ts.id,
user: ts.user,
weekStart: ts.weekStart.toISOString().split('T')[0],
weekEnd: ts.weekEnd.toISOString().split('T')[0],
status: ts.status,
submittedAt: ts.submittedAt,
approvedAt: ts.approvedAt,
approvedBy: ts.approver?.name || null,
notes: ts.notes,
totalHours,
entryCount: ts.entries.length,
};
}),
pagination: {
page: pageNum,
limit: pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
});
} catch (err) {
console.error('Admin get timesheets error:', err);
res.status(500).json({ error: 'Failed to fetch timesheets' });
}
});
// ─────────────── GET /api/admin/timesheets/:id ───────────────
router.get('/timesheets/:id', async (req, res) => {
try {
const { id } = req.params;
const timesheet = await req.prisma.timesheet.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true, email: true } },
approver: { select: { id: true, name: true } },
entries: {
include: {
timeEntry: {
include: { homeowner: { select: { id: true, name: true } } },
},
},
},
},
});
if (!timesheet) {
return res.status(404).json({ error: 'Timesheet not found' });
}
const entries = timesheet.entries.map((link) => ({
id: link.timeEntry.id,
date: link.timeEntry.date.toISOString().split('T')[0],
homeownerId: link.timeEntry.homeownerId,
homeownerName: link.timeEntry.homeowner.name,
hoursWorked: parseFloat(link.timeEntry.hoursWorked),
workDescription: link.timeEntry.workDescription,
}));
const totalHours = entries.reduce((sum, e) => sum + e.hoursWorked, 0);
res.json({
id: timesheet.id,
user: timesheet.user,
weekStart: timesheet.weekStart.toISOString().split('T')[0],
weekEnd: timesheet.weekEnd.toISOString().split('T')[0],
status: timesheet.status,
submittedAt: timesheet.submittedAt,
approvedAt: timesheet.approvedAt,
approvedBy: timesheet.approver?.name || null,
notes: timesheet.notes,
totalHours,
entries,
});
} catch (err) {
console.error('Admin get timesheet error:', err);
res.status(500).json({ error: 'Failed to fetch timesheet' });
}
});
// ─────────────── POST /api/admin/timesheets/bulk-approve ───────────────
router.post('/timesheets/bulk-approve', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
if (timesheetIds.length > 100) {
return res.status(400).json({ error: 'Maximum 100 timesheets per batch' });
}
const results = { approved: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}", expected "submitted"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'approved', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || null },
});
results.approved++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk approve error:', err);
res.status(500).json({ error: 'Failed to bulk approve' });
}
});
// ─────────────── POST /api/admin/timesheets/bulk-reject ───────────────
router.post('/timesheets/bulk-reject', async (req, res) => {
try {
const { timesheetIds, notes } = req.body;
if (!Array.isArray(timesheetIds) || timesheetIds.length === 0) {
return res.status(400).json({ error: 'timesheetIds array is required' });
}
const results = { rejected: 0, failed: [] };
for (const id of timesheetIds) {
try {
const ts = await req.prisma.timesheet.findUnique({ where: { id } });
if (!ts) { results.failed.push({ id, reason: 'Not found' }); continue; }
if (ts.status !== 'submitted') { results.failed.push({ id, reason: `Status is "${ts.status}"` }); continue; }
await req.prisma.timesheet.update({
where: { id },
data: { status: 'rejected', approvedBy: req.user.id, approvedAt: new Date(), notes: notes || 'Rejected' },
});
results.rejected++;
} catch (e) { results.failed.push({ id, reason: e.message }); }
}
res.json(results);
} catch (err) {
console.error('Bulk reject error:', err);
res.status(500).json({ error: 'Failed to bulk reject' });
}
});
// ─────────────── PUT /api/admin/timesheets/:id/approve ───────────────
router.put(
'/timesheets/:id/approve',
validateBody(approveRejectSchema),
async (req, res) => {
try {
const { id } = req.params;
const { notes } = req.validated;
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
if (!timesheet) {
return res.status(404).json({ error: 'Timesheet not found' });
}
if (timesheet.status !== 'submitted') {
return res.status(400).json({
error: `Cannot approve a timesheet with status "${timesheet.status}"`,
});
}
const updated = await req.prisma.timesheet.update({
where: { id },
data: {
status: 'approved',
approvedBy: req.user.id,
approvedAt: new Date(),
notes: notes || null,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
res.json({
id: updated.id,
status: updated.status,
approvedAt: updated.approvedAt,
notes: updated.notes,
user: updated.user,
});
} catch (err) {
console.error('Approve timesheet error:', err);
res.status(500).json({ error: 'Failed to approve timesheet' });
}
}
);
// ─────────────── PUT /api/admin/timesheets/:id/reject ───────────────
router.put(
'/timesheets/:id/reject',
validateBody(approveRejectSchema),
async (req, res) => {
try {
const { id } = req.params;
const { notes } = req.validated;
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
if (!timesheet) {
return res.status(404).json({ error: 'Timesheet not found' });
}
if (timesheet.status !== 'submitted') {
return res.status(400).json({
error: `Cannot reject a timesheet with status "${timesheet.status}"`,
});
}
const updated = await req.prisma.timesheet.update({
where: { id },
data: {
status: 'rejected',
approvedBy: req.user.id,
approvedAt: new Date(),
notes: notes || 'Rejected — please review and resubmit.',
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
res.json({
id: updated.id,
status: updated.status,
notes: updated.notes,
user: updated.user,
});
} catch (err) {
console.error('Reject timesheet error:', err);
res.status(500).json({ error: 'Failed to reject timesheet' });
}
}
);
// ─────────────── PUT /api/admin/timesheets/:id/reopen ───────────────
router.put('/timesheets/:id/reopen', async (req, res) => {
try {
const { id } = req.params;
const timesheet = await req.prisma.timesheet.findUnique({ where: { id } });
if (!timesheet) return res.status(404).json({ error: 'Timesheet not found' });
if (timesheet.status === 'draft') {
return res.status(400).json({ error: 'Timesheet is already a draft' });
}
const updated = await req.prisma.timesheet.update({
where: { id },
data: { status: 'draft', approvedBy: null, approvedAt: null, notes: null },
});
res.json({ id: updated.id, status: updated.status, message: 'Timesheet reopened' });
} catch (err) {
console.error('Reopen timesheet error:', err);
res.status(500).json({ error: 'Failed to reopen timesheet' });
}
});
// ═══════════════════════════════════════════════════════════════
// USERS
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/users ───────────────
router.get('/users', async (req, res) => {
try {
const users = await req.prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
_count: { select: { timeEntries: true, timesheets: true } },
},
orderBy: { name: 'asc' },
});
res.json({
users: users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
role: u.role,
isActive: u.isActive,
createdAt: u.createdAt,
entryCount: u._count.timeEntries,
timesheetCount: u._count.timesheets,
})),
});
} catch (err) {
console.error('Admin get users error:', err);
res.status(500).json({ error: 'Failed to fetch users' });
}
});
// ─────────────── POST /api/admin/users ───────────────
router.post('/users', validateBody(createUserSchema), async (req, res) => {
try {
const { email, password, name, role } = req.validated;
// Only super_admin can create admin/super_admin
if (
(role === 'admin' || role === 'super_admin') &&
req.user.role !== 'super_admin'
) {
return res
.status(403)
.json({ error: 'Only super admins can create admin accounts' });
}
const existing = await req.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await req.prisma.user.create({
data: {
email: email.toLowerCase(),
name,
role,
passwordHash,
},
select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
});
res.status(201).json({ user });
} catch (err) {
console.error('Admin create user error:', err);
res.status(500).json({ error: 'Failed to create user' });
}
});
// ─────────────── PUT /api/admin/users/:id ───────────────
router.put('/users/:id', async (req, res) => {
try {
const { id } = req.params;
const { name, role, isActive, password } = req.body;
const user = await req.prisma.user.findUnique({ where: { id } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Only super_admin can change roles to admin/super_admin
if (
role &&
(role === 'admin' || role === 'super_admin') &&
req.user.role !== 'super_admin'
) {
return res
.status(403)
.json({ error: 'Only super admins can assign admin roles' });
}
const updateData = {};
if (name !== undefined) updateData.name = name;
if (role !== undefined) updateData.role = role;
if (isActive !== undefined) updateData.isActive = isActive;
if (password) {
updateData.passwordHash = await bcrypt.hash(password, 12);
}
const updated = await req.prisma.user.update({
where: { id },
data: updateData,
select: { id: true, email: true, name: true, role: true, isActive: true, createdAt: true },
});
res.json({ user: updated });
} catch (err) {
console.error('Admin update user error:', err);
res.status(500).json({ error: 'Failed to update user' });
}
});
// ═══════════════════════════════════════════════════════════════
// ─────────────── DELETE /api/admin/users/:id (deactivate) ───────────────
router.delete('/users/:id', async (req, res) => {
try {
const { id } = req.params;
if (id === req.user.id) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
const user = await req.prisma.user.findUnique({ where: { id } });
if (!user) return res.status(404).json({ error: 'User not found' });
// Soft-delete: deactivate instead of hard delete (preserves timesheet history)
await req.prisma.user.update({ where: { id }, data: { isActive: false } });
res.json({ message: 'User deactivated', id });
} catch (err) {
console.error('Delete user error:', err);
res.status(500).json({ error: 'Failed to delete user' });
}
});
// HOMEOWNERS
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/homeowners ───────────────
router.get('/homeowners', async (req, res) => {
try {
const { includeInactive } = req.query;
const where = includeInactive === 'true' ? {} : { isActive: true };
const homeowners = await req.prisma.homeowner.findMany({
where,
select: {
id: true,
name: true,
address: true,
isActive: true,
createdAt: true,
_count: { select: { timeEntries: true } },
},
orderBy: { name: 'asc' },
});
res.json({
homeowners: homeowners.map((h) => ({
id: h.id,
name: h.name,
address: h.address,
isActive: h.isActive,
createdAt: h.createdAt,
entryCount: h._count.timeEntries,
})),
});
} catch (err) {
console.error('Admin get homeowners error:', err);
res.status(500).json({ error: 'Failed to fetch homeowners' });
}
});
// ─────────────── POST /api/admin/homeowners ───────────────
router.post(
'/homeowners',
validateBody(createHomeownerSchema),
async (req, res) => {
try {
const { name, address } = req.validated;
const existing = await req.prisma.homeowner.findUnique({ where: { name } });
if (existing) {
return res.status(409).json({ error: 'Homeowner with this name already exists' });
}
const homeowner = await req.prisma.homeowner.create({
data: { name, address: address || null },
select: { id: true, name: true, address: true, isActive: true, createdAt: true },
});
res.status(201).json({ homeowner });
} catch (err) {
console.error('Admin create homeowner error:', err);
res.status(500).json({ error: 'Failed to create homeowner' });
}
}
);
// ─────────────── PUT /api/admin/homeowners/:id ───────────────
router.put(
'/homeowners/:id',
validateBody(updateHomeownerSchema),
async (req, res) => {
try {
const { id } = req.params;
const { name, address, isActive } = req.validated;
const existing = await req.prisma.homeowner.findUnique({ where: { id } });
if (!existing) {
return res.status(404).json({ error: 'Homeowner not found' });
}
// Check name uniqueness if changing name
if (name && name !== existing.name) {
const nameConflict = await req.prisma.homeowner.findUnique({ where: { name } });
if (nameConflict) {
return res.status(409).json({ error: 'A homeowner with this name already exists' });
}
}
const updateData = {};
if (name !== undefined) updateData.name = name;
if (address !== undefined) updateData.address = address;
if (isActive !== undefined) updateData.isActive = isActive;
const updated = await req.prisma.homeowner.update({
where: { id },
data: updateData,
select: { id: true, name: true, address: true, isActive: true, createdAt: true },
});
res.json({ homeowner: updated });
} catch (err) {
console.error('Admin update homeowner error:', err);
res.status(500).json({ error: 'Failed to update homeowner' });
}
}
);
// ═══════════════════════════════════════════════════════════════
// REPORTS
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/reports ───────────────
router.get('/reports', validateQuery(reportQuerySchema), async (req, res) => {
try {
const { from, to, userId, homeownerId, status } = req.validatedQuery;
// Build time entry filter
const entryWhere = {};
if (from || to) {
entryWhere.date = {};
if (from) entryWhere.date.gte = new Date(from + 'T00:00:00Z');
if (to) entryWhere.date.lte = new Date(to + 'T00:00:00Z');
}
if (userId) entryWhere.userId = userId;
if (homeownerId) entryWhere.homeownerId = homeownerId;
// Build timesheet filter
const timesheetWhere = {};
if (status) timesheetWhere.status = status;
if (userId) timesheetWhere.userId = userId;
if (from || to) {
timesheetWhere.weekStart = {};
if (from) timesheetWhere.weekStart.gte = new Date(from + 'T00:00:00Z');
if (to) timesheetWhere.weekStart.lte = new Date(to + 'T00:00:00Z');
}
const [entries, timesheets, userSummary, homeownerSummary] = await Promise.all([
// Raw entries
req.prisma.timeEntry.findMany({
where: entryWhere,
include: {
user: { select: { id: true, name: true, email: true } },
homeowner: { select: { id: true, name: true } },
},
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
take: 1000,
}),
// Timesheets
req.prisma.timesheet.findMany({
where: timesheetWhere,
include: {
user: { select: { id: true, name: true } },
approver: { select: { id: true, name: true } },
},
orderBy: { weekStart: 'desc' },
take: 200,
}),
// Hours by user
req.prisma.timeEntry.groupBy({
by: ['userId'],
where: entryWhere,
_sum: { hoursWorked: true },
_count: { id: true },
}),
// Hours by homeowner
req.prisma.timeEntry.groupBy({
by: ['homeownerId'],
where: entryWhere,
_sum: { hoursWorked: true },
_count: { id: true },
}),
]);
// Enrich user summary with names
const userIds = userSummary.map((u) => u.userId);
const users = await req.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true },
});
const userMap = Object.fromEntries(users.map((u) => [u.id, u.name]));
// Enrich homeowner summary with names
const hoIds = homeownerSummary.map((h) => h.homeownerId);
const homeowners = await req.prisma.homeowner.findMany({
where: { id: { in: hoIds } },
select: { id: true, name: true },
});
const hoMap = Object.fromEntries(homeowners.map((h) => [h.id, h.name]));
const totalHours = entries.reduce(
(sum, e) => sum + parseFloat(e.hoursWorked || 0),
0
);
res.json({
summary: {
totalEntries: entries.length,
totalHours,
dateRange: {
from: from || null,
to: to || null,
},
},
byUser: userSummary.map((u) => ({
userId: u.userId,
userName: userMap[u.userId] || 'Unknown',
totalHours: parseFloat(u._sum.hoursWorked || 0),
entryCount: u._count.id,
})),
byHomeowner: homeownerSummary.map((h) => ({
homeownerId: h.homeownerId,
homeownerName: hoMap[h.homeownerId] || 'Unknown',
totalHours: parseFloat(h._sum.hoursWorked || 0),
entryCount: h._count.id,
})),
timesheets: timesheets.map((ts) => ({
id: ts.id,
userName: ts.user.name,
weekStart: ts.weekStart.toISOString().split('T')[0],
weekEnd: ts.weekEnd.toISOString().split('T')[0],
status: ts.status,
submittedAt: ts.submittedAt,
approvedBy: ts.approver?.name || null,
})),
entries: entries.map((e) => ({
id: e.id,
date: e.date.toISOString().split('T')[0],
userName: e.user.name,
homeownerName: e.homeowner.name,
hoursWorked: parseFloat(e.hoursWorked),
workDescription: e.workDescription,
})),
});
} catch (err) {
console.error('Admin reports error:', err);
res.status(500).json({ error: 'Failed to generate report' });
}
});
// ─────────────── GET /api/admin/reports/pdf ───────────────
router.get('/reports/pdf', async (req, res) => {
try {
const { userId, weekStart } = req.query;
if (!userId || !weekStart) {
return res.status(400).json({ error: 'userId and weekStart are required' });
}
const user = await req.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true },
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const monday = weekStart;
const sundayDate = new Date(monday + 'T00:00:00Z');
sundayDate.setUTCDate(sundayDate.getUTCDate() + 6);
const sunday = sundayDate.toISOString().split('T')[0];
const entries = await req.prisma.timeEntry.findMany({
where: {
userId,
date: {
gte: new Date(monday + 'T00:00:00Z'),
lte: new Date(sunday + 'T00:00:00Z'),
},
},
include: { homeowner: { select: { name: true } } },
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
});
const timesheet = await req.prisma.timesheet.findUnique({
where: { userId_weekStart: { userId, weekStart: new Date(monday + 'T00:00:00Z') } },
});
const pdfBuffer = await generateTimesheetPDF({
userName: user.name,
weekStart: monday,
entries: entries.map((e) => ({
date: e.date.toISOString().split('T')[0],
homeownerName: e.homeowner.name,
hoursWorked: parseFloat(e.hoursWorked),
workDescription: e.workDescription,
})),
status: timesheet?.status || 'draft',
});
const filename = buildPdfFilename(user.name, monday);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', pdfBuffer.length);
res.send(pdfBuffer);
} catch (err) {
console.error('Admin PDF error:', err);
res.status(500).json({ error: 'Failed to generate PDF' });
}
});
// ═══════════════════════════════════════════════════════════════
// OVERTIME
// ═══════════════════════════════════════════════════════════════
// ─────────────── GET /api/admin/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const { from, to, userId } = req.query;
const weeklyThreshold = parseFloat(req.query.threshold || '40');
const overtimeRate = parseFloat(req.query.rate || '1.5');
const where = {};
if (userId) where.userId = userId;
if (from || to) {
where.date = {};
if (from) where.date.gte = new Date(from + 'T00:00:00Z');
if (to) where.date.lte = new Date(to + 'T23:59:59Z');
}
const entries = await req.prisma.timeEntry.findMany({
where,
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: [{ userId: 'asc' }, { date: 'asc' }],
});
// Group by user → week
const userWeeks = {};
for (const e of entries) {
const uid = e.userId;
const d = new Date(e.date);
const day = d.getUTCDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setUTCDate(d.getUTCDate() + mondayOffset);
const weekKey = monday.toISOString().split('T')[0];
if (!userWeeks[uid]) userWeeks[uid] = { user: e.user, weeks: {} };
if (!userWeeks[uid].weeks[weekKey]) userWeeks[uid].weeks[weekKey] = { totalHours: 0, entries: 0 };
userWeeks[uid].weeks[weekKey].totalHours += parseFloat(e.hoursWorked) || 0;
userWeeks[uid].weeks[weekKey].entries++;
}
// Compute overtime per user
const results = Object.values(userWeeks).map((uw) => {
let totalRegular = 0;
let totalOvertime = 0;
let totalHours = 0;
const weekBreakdown = [];
for (const [week, data] of Object.entries(uw.weeks)) {
const regular = Math.min(data.totalHours, weeklyThreshold);
const overtime = Math.max(0, data.totalHours - weeklyThreshold);
totalRegular += regular;
totalOvertime += overtime;
totalHours += data.totalHours;
weekBreakdown.push({
week,
totalHours: parseFloat(data.totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
entries: data.entries,
});
}
return {
user: uw.user,
summary: {
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(totalRegular.toFixed(2)),
overtimeHours: parseFloat(totalOvertime.toFixed(2)),
overtimeCost: parseFloat((totalOvertime * overtimeRate).toFixed(2)),
weeksWithOvertime: weekBreakdown.filter((w) => w.overtimeHours > 0).length,
},
weeks: weekBreakdown,
};
});
// Sort by most overtime first
results.sort((a, b) => b.summary.overtimeHours - a.summary.overtimeHours);
res.json({
config: { weeklyThreshold, overtimeRate },
employees: results,
totals: {
totalHours: parseFloat(results.reduce((s, r) => s + r.summary.totalHours, 0).toFixed(2)),
regularHours: parseFloat(results.reduce((s, r) => s + r.summary.regularHours, 0).toFixed(2)),
overtimeHours: parseFloat(results.reduce((s, r) => s + r.summary.overtimeHours, 0).toFixed(2)),
},
});
} catch (err) {
console.error('Overtime report error:', err);
res.status(500).json({ error: 'Failed to generate overtime report' });
}
});
// Employee sees their own overtime
// ─────────────── GET /api/timesheets/overtime ───────────────
// (mounted at /api/timesheets/overtime in timesheets router)
module.exports = router;
+210
View File
@@ -0,0 +1,210 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const {
generateAccessToken,
generateRefreshToken,
verifyRefreshToken,
authenticate,
requireAdmin,
} = require('../middleware/auth');
const {
loginSchema,
registerSchema,
refreshSchema,
validateBody,
} = require('../utils/validation');
const router = express.Router();
// Rate limit: 5 attempts per 15 minutes on auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 50,
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// ─────────────── POST /api/auth/login ───────────────
router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) => {
try {
const { email, password } = req.validated;
const user = await req.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
if (!user) {
return res.status(401).json({ error: 'Invalid email or password' });
}
if (!user.isActive) {
return res.status(403).json({ error: 'Account is deactivated. Contact your admin.' });
}
const passwordValid = await bcrypt.compare(password, user.passwordHash);
if (!passwordValid) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const accessToken = generateAccessToken(user);
const refreshToken = generateRefreshToken(user);
// Store refresh token hash in DB
await req.prisma.user.update({
where: { id: user.id },
data: { refreshToken },
});
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
},
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
// ─────────────── POST /api/auth/register (admin only) ───────────────
router.post(
'/register',
authenticate,
requireAdmin,
validateBody(registerSchema),
async (req, res) => {
try {
const { email, password, name, role } = req.validated;
// Only super_admin can create admin/super_admin accounts
if (
(role === 'admin' || role === 'super_admin') &&
req.user.role !== 'super_admin'
) {
return res
.status(403)
.json({ error: 'Only super admins can create admin accounts' });
}
const existing = await req.prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (existing) {
return res.status(409).json({ error: 'Email already registered' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await req.prisma.user.create({
data: {
email: email.toLowerCase(),
name,
role,
passwordHash,
},
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
res.status(201).json({ user });
} catch (err) {
console.error('Register error:', err);
res.status(500).json({ error: 'Registration failed' });
}
}
);
// ─────────────── POST /api/auth/refresh ───────────────
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
try {
const { refreshToken } = req.validated;
let decoded;
try {
decoded = verifyRefreshToken(refreshToken);
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired refresh token' });
}
const user = await req.prisma.user.findUnique({
where: { id: decoded.userId },
});
if (!user || !user.isActive) {
return res.status(401).json({ error: 'User not found or deactivated' });
}
// Verify the refresh token matches the stored one (token rotation)
if (user.refreshToken !== refreshToken) {
// Possible token theft — invalidate all tokens for this user
await req.prisma.user.update({
where: { id: user.id },
data: { refreshToken: null },
});
return res.status(401).json({ error: 'Refresh token reuse detected. Please login again.' });
}
const newAccessToken = generateAccessToken(user);
const newRefreshToken = generateRefreshToken(user);
// Rotate refresh token
await req.prisma.user.update({
where: { id: user.id },
data: { refreshToken: newRefreshToken },
});
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken,
});
} catch (err) {
console.error('Refresh error:', err);
res.status(500).json({ error: 'Token refresh failed' });
}
});
// ─────────────── GET /api/auth/me ───────────────
router.get('/me', authenticate, async (req, res) => {
try {
const user = await req.prisma.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
email: true,
name: true,
role: true,
isActive: true,
createdAt: true,
},
});
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ user });
} catch (err) {
console.error('Me error:', err);
res.status(500).json({ error: 'Failed to fetch user profile' });
}
});
// ─────────────── POST /api/auth/logout ───────────────
router.post('/logout', authenticate, async (req, res) => {
try {
await req.prisma.user.update({
where: { id: req.user.id },
data: { refreshToken: null },
});
res.json({ message: 'Logged out successfully' });
} catch (err) {
console.error('Logout error:', err);
res.status(500).json({ error: 'Logout failed' });
}
});
module.exports = router;
+359
View File
@@ -0,0 +1,359 @@
const express = require('express');
const { authenticate } = require('../middleware/auth');
const {
createEntrySchema,
updateEntrySchema,
weekQuerySchema,
validateBody,
validateQuery,
} = require('../utils/validation');
const router = express.Router();
// All routes require authentication
router.use(authenticate);
/**
* Get the Monday of the week containing the given date
*/
function getWeekMonday(dateStr) {
const d = dateStr ? new Date(dateStr + 'T00:00:00Z') : new Date();
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day; // Monday = 1, Sunday = 0 → go back 6
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().split('T')[0];
}
function getWeekSunday(mondayStr) {
const d = new Date(mondayStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().split('T')[0];
}
// ─────────────── GET /api/entries?week=YYYY-MM-DD ───────────────
router.get('/', validateQuery(weekQuerySchema), async (req, res) => {
try {
const weekParam = req.validatedQuery.week;
const monday = getWeekMonday(weekParam);
const sunday = getWeekSunday(monday);
const entries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: {
gte: new Date(monday + 'T00:00:00Z'),
lte: new Date(sunday + 'T00:00:00Z'),
},
},
include: {
homeowner: { select: { id: true, name: true } },
},
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
});
// Check if this week's timesheet is locked (submitted/approved)
const timesheet = await req.prisma.timesheet.findUnique({
where: {
userId_weekStart: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
},
},
select: { id: true, status: true },
});
const isLocked = timesheet
? ['submitted', 'approved'].includes(timesheet.status)
: false;
res.json({
entries: entries.map((e) => ({
id: e.id,
date: e.date.toISOString().split('T')[0],
homeownerId: e.homeownerId,
homeownerName: e.homeowner.name,
hoursWorked: parseFloat(e.hoursWorked),
workDescription: e.workDescription,
createdAt: e.createdAt,
updatedAt: e.updatedAt,
})),
weekStart: monday,
weekEnd: sunday,
isLocked,
timesheetStatus: timesheet?.status || 'draft',
});
} catch (err) {
console.error('Get entries error:', err);
res.status(500).json({ error: 'Failed to fetch entries' });
}
});
// ─────────────── POST /api/entries ───────────────
router.post('/', validateBody(createEntrySchema), async (req, res) => {
try {
const { date, homeownerId, hoursWorked, workDescription } = req.validated;
// Check if week is locked
const monday = getWeekMonday(date);
const timesheet = await req.prisma.timesheet.findUnique({
where: {
userId_weekStart: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
},
},
});
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
return res.status(403).json({
error: 'Cannot modify entries for a submitted or approved timesheet',
});
}
// Verify homeowner exists and is active
const homeowner = await req.prisma.homeowner.findUnique({
where: { id: homeownerId },
});
if (!homeowner || !homeowner.isActive) {
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
}
const entry = await req.prisma.timeEntry.create({
data: {
userId: req.user.id,
date: new Date(date + 'T00:00:00Z'),
homeownerId,
hoursWorked,
workDescription,
},
include: {
homeowner: { select: { id: true, name: true } },
},
});
res.status(201).json({
entry: {
id: entry.id,
date: entry.date.toISOString().split('T')[0],
homeownerId: entry.homeownerId,
homeownerName: entry.homeowner.name,
hoursWorked: parseFloat(entry.hoursWorked),
workDescription: entry.workDescription,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
},
});
} catch (err) {
console.error('Create entry error:', err);
res.status(500).json({ error: 'Failed to create entry' });
}
});
// ─────────────── PUT /api/entries/:id ───────────────
router.put('/:id', validateBody(updateEntrySchema), async (req, res) => {
try {
const { id } = req.params;
// Verify ownership
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
if (!existing) {
return res.status(404).json({ error: 'Entry not found' });
}
if (existing.userId !== req.user.id) {
return res.status(403).json({ error: 'Not your entry' });
}
// Check if week is locked
const entryDate = existing.date.toISOString().split('T')[0];
const monday = getWeekMonday(req.validated.date || entryDate);
const timesheet = await req.prisma.timesheet.findUnique({
where: {
userId_weekStart: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
},
},
});
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
return res.status(403).json({
error: 'Cannot modify entries for a submitted or approved timesheet',
});
}
// Build update data
const updateData = {};
if (req.validated.date !== undefined) {
updateData.date = new Date(req.validated.date + 'T00:00:00Z');
}
if (req.validated.homeownerId !== undefined) {
const homeowner = await req.prisma.homeowner.findUnique({
where: { id: req.validated.homeownerId },
});
if (!homeowner || !homeowner.isActive) {
return res.status(400).json({ error: 'Invalid or inactive homeowner' });
}
updateData.homeownerId = req.validated.homeownerId;
}
if (req.validated.hoursWorked !== undefined) {
updateData.hoursWorked = req.validated.hoursWorked;
}
if (req.validated.workDescription !== undefined) {
updateData.workDescription = req.validated.workDescription;
}
const entry = await req.prisma.timeEntry.update({
where: { id },
data: updateData,
include: {
homeowner: { select: { id: true, name: true } },
},
});
res.json({
entry: {
id: entry.id,
date: entry.date.toISOString().split('T')[0],
homeownerId: entry.homeownerId,
homeownerName: entry.homeowner.name,
hoursWorked: parseFloat(entry.hoursWorked),
workDescription: entry.workDescription,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
},
});
} catch (err) {
console.error('Update entry error:', err);
res.status(500).json({ error: 'Failed to update entry' });
}
});
// ─────────────── DELETE /api/entries/:id ───────────────
router.delete('/:id', async (req, res) => {
try {
const { id } = req.params;
const existing = await req.prisma.timeEntry.findUnique({ where: { id } });
if (!existing) {
return res.status(404).json({ error: 'Entry not found' });
}
if (existing.userId !== req.user.id) {
return res.status(403).json({ error: 'Not your entry' });
}
// Check if week is locked
const entryDate = existing.date.toISOString().split('T')[0];
const monday = getWeekMonday(entryDate);
const timesheet = await req.prisma.timesheet.findUnique({
where: {
userId_weekStart: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
},
},
});
if (timesheet && ['submitted', 'approved'].includes(timesheet.status)) {
return res.status(403).json({
error: 'Cannot delete entries from a submitted or approved timesheet',
});
}
await req.prisma.timeEntry.delete({ where: { id } });
res.json({ message: 'Entry deleted' });
} catch (err) {
console.error('Delete entry error:', err);
res.status(500).json({ error: 'Failed to delete entry' });
}
});
// ─────────────── POST /api/entries/copy-week ───────────────
router.post('/copy-week', async (req, res) => {
try {
const { fromWeek, toWeek } = req.body;
if (!fromWeek || !toWeek) {
return res.status(400).json({ error: 'fromWeek and toWeek are required (YYYY-MM-DD Monday)' });
}
const fromMonday = getWeekMonday(fromWeek);
const toMonday = getWeekMonday(toWeek);
const fromSunday = getWeekSunday(fromMonday);
// Check target week isn't locked
const targetTs = await req.prisma.timesheet.findUnique({
where: { userId_weekStart: { userId: req.user.id, weekStart: new Date(toMonday + 'T00:00:00Z') } },
});
if (targetTs && ['submitted', 'approved'].includes(targetTs.status)) {
return res.status(403).json({ error: 'Target week is locked (submitted or approved)' });
}
// Get source week entries
const sourceEntries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: {
gte: new Date(fromMonday + 'T00:00:00Z'),
lte: new Date(fromSunday + 'T00:00:00Z'),
},
},
include: { homeowner: { select: { id: true, isActive: true } } },
});
if (sourceEntries.length === 0) {
return res.status(404).json({ error: 'No entries found in source week' });
}
// Calculate day offset (Mon=0 ... Sun=6)
const fromStart = new Date(fromMonday + 'T00:00:00Z');
const toStart = new Date(toMonday + 'T00:00:00Z');
const dayOffset = Math.round((toStart - fromStart) / (1000 * 60 * 60 * 24));
// Delete existing entries in target week (that are in draft)
const toSunday = getWeekSunday(toMonday);
await req.prisma.timeEntry.deleteMany({
where: {
userId: req.user.id,
date: {
gte: new Date(toMonday + 'T00:00:00Z'),
lte: new Date(toSunday + 'T00:00:00Z'),
},
},
});
// Copy entries with shifted dates, only for active homeowners
const created = [];
for (const entry of sourceEntries) {
if (!entry.homeowner.isActive) continue;
const oldDate = new Date(entry.date);
const newDate = new Date(oldDate);
newDate.setUTCDate(newDate.getUTCDate() + dayOffset);
const newEntry = await req.prisma.timeEntry.create({
data: {
userId: req.user.id,
date: newDate,
homeownerId: entry.homeownerId,
hoursWorked: entry.hoursWorked,
workDescription: entry.workDescription,
},
include: { homeowner: { select: { name: true } } },
});
created.push({
id: newEntry.id,
date: newEntry.date.toISOString().split('T')[0],
homeownerId: newEntry.homeownerId,
homeownerName: newEntry.homeowner.name,
hoursWorked: newEntry.hoursWorked,
workDescription: newEntry.workDescription,
});
}
res.status(201).json({ copied: created.length, entries: created });
} catch (err) {
console.error('Copy week error:', err);
res.status(500).json({ error: 'Failed to copy week' });
}
});
module.exports = router;
+23
View File
@@ -0,0 +1,23 @@
const express = require('express');
const { authenticate } = require('../middleware/auth');
const router = express.Router();
router.use(authenticate);
// GET /api/homeowners — list active homeowners (for all authenticated users)
router.get('/', async (req, res) => {
try {
const homeowners = await req.prisma.homeowner.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
});
res.json({ homeowners });
} catch (err) {
console.error('Get homeowners error:', err);
res.status(500).json({ error: 'Failed to fetch homeowners' });
}
});
module.exports = router;
+341
View File
@@ -0,0 +1,341 @@
const express = require('express');
const { authenticate } = require('../middleware/auth');
const {
submitTimesheetSchema,
emailTimesheetSchema,
weekQuerySchema,
validateBody,
validateQuery,
} = require('../utils/validation');
const { generateTimesheetPDF, buildPdfFilename } = require('../utils/pdf');
const { sendTimesheetEmail } = require('../utils/email');
const router = express.Router();
router.use(authenticate);
/**
* Compute Monday of the week for a given date
*/
function getWeekMonday(dateStr) {
const d = new Date(dateStr + 'T00:00:00Z');
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().split('T')[0];
}
function getWeekSunday(mondayStr) {
const d = new Date(mondayStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 6);
return d.toISOString().split('T')[0];
}
/**
* Load entries + user info for a timesheet's week
*/
async function loadTimesheetData(prisma, userId, weekStart) {
const monday = typeof weekStart === 'string' ? weekStart : weekStart.toISOString().split('T')[0];
const sunday = getWeekSunday(monday);
const [user, entries, timesheet] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true },
}),
prisma.timeEntry.findMany({
where: {
userId,
date: {
gte: new Date(monday + 'T00:00:00Z'),
lte: new Date(sunday + 'T00:00:00Z'),
},
},
include: { homeowner: { select: { id: true, name: true } } },
orderBy: [{ date: 'asc' }, { createdAt: 'asc' }],
}),
prisma.timesheet.findUnique({
where: { userId_weekStart: { userId, weekStart: new Date(monday + 'T00:00:00Z') } },
include: {
approver: { select: { id: true, name: true } },
},
}),
]);
return {
user,
entries: entries.map((e) => ({
id: e.id,
date: e.date.toISOString().split('T')[0],
homeownerId: e.homeownerId,
homeownerName: e.homeowner.name,
hoursWorked: parseFloat(e.hoursWorked),
workDescription: e.workDescription,
})),
timesheet,
weekStart: monday,
weekEnd: sunday,
};
}
// ─────────────── GET /api/timesheets?week=YYYY-MM-DD ───────────────
router.get('/', validateQuery(weekQuerySchema), async (req, res) => {
try {
const weekParam = req.validatedQuery.week;
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
const data = await loadTimesheetData(req.prisma, req.user.id, monday);
const totalHours = data.entries.reduce((sum, e) => sum + e.hoursWorked, 0);
res.json({
weekStart: data.weekStart,
weekEnd: data.weekEnd,
status: data.timesheet?.status || 'draft',
submittedAt: data.timesheet?.submittedAt || null,
approvedAt: data.timesheet?.approvedAt || null,
approvedBy: data.timesheet?.approver?.name || null,
notes: data.timesheet?.notes || null,
timesheetId: data.timesheet?.id || null,
totalHours,
entries: data.entries,
});
} catch (err) {
console.error('Get timesheet error:', err);
res.status(500).json({ error: 'Failed to fetch timesheet' });
}
});
// ─────────────── GET /api/timesheets/history ───────────────
router.get('/history', async (req, res) => {
try {
const timesheets = await req.prisma.timesheet.findMany({
where: { userId: req.user.id },
orderBy: { weekStart: 'desc' },
include: {
approver: { select: { name: true } },
_count: { select: { entries: true } },
},
});
res.json({
timesheets: timesheets.map((ts) => ({
id: ts.id,
weekStart: ts.weekStart.toISOString().split('T')[0],
weekEnd: ts.weekEnd.toISOString().split('T')[0],
status: ts.status,
submittedAt: ts.submittedAt,
approvedAt: ts.approvedAt,
approvedBy: ts.approver?.name || null,
notes: ts.notes,
totalHours: null, // Could aggregate if needed
entryCount: ts._count.entries,
})),
});
} catch (err) {
console.error('Get history error:', err);
res.status(500).json({ error: 'Failed to fetch history' });
}
});
// ─────────────── POST /api/timesheets/submit ───────────────
router.post('/submit', validateBody(submitTimesheetSchema), async (req, res) => {
try {
const { weekStart } = req.validated;
const monday = getWeekMonday(weekStart);
const sunday = getWeekSunday(monday);
// Get entries for this week
const entries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: {
gte: new Date(monday + 'T00:00:00Z'),
lte: new Date(sunday + 'T00:00:00Z'),
},
},
});
if (entries.length === 0) {
return res.status(400).json({ error: 'Cannot submit an empty timesheet' });
}
// Upsert the timesheet
const timesheet = await req.prisma.timesheet.upsert({
where: {
userId_weekStart: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
},
},
update: {
status: 'submitted',
submittedAt: new Date(),
notes: null,
approvedBy: null,
approvedAt: null,
},
create: {
userId: req.user.id,
weekStart: new Date(monday + 'T00:00:00Z'),
weekEnd: new Date(sunday + 'T00:00:00Z'),
status: 'submitted',
submittedAt: new Date(),
},
});
// Link entries to timesheet
// First, remove old links
await req.prisma.timesheetEntry.deleteMany({
where: { timesheetId: timesheet.id },
});
// Create new links
await req.prisma.timesheetEntry.createMany({
data: entries.map((e) => ({
timesheetId: timesheet.id,
timeEntryId: e.id,
})),
});
res.json({
timesheetId: timesheet.id,
status: timesheet.status,
submittedAt: timesheet.submittedAt,
weekStart: monday,
weekEnd: sunday,
entryCount: entries.length,
});
} catch (err) {
console.error('Submit timesheet error:', err);
res.status(500).json({ error: 'Failed to submit timesheet' });
}
});
// ─────────────── GET /api/timesheets/:id/pdf ───────────────
router.get('/:id/pdf', async (req, res) => {
try {
const { id } = req.params;
const timesheet = await req.prisma.timesheet.findUnique({
where: { id },
include: { user: { select: { id: true, name: true } } },
});
if (!timesheet) {
return res.status(404).json({ error: 'Timesheet not found' });
}
// Only owner or admin can access
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
if (timesheet.userId !== req.user.id && !isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
const data = await loadTimesheetData(req.prisma, timesheet.userId, weekStart);
const pdfBuffer = await generateTimesheetPDF({
userName: data.user.name,
weekStart,
entries: data.entries,
status: timesheet.status,
});
const filename = buildPdfFilename(data.user.name, weekStart);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Length', pdfBuffer.length);
res.send(pdfBuffer);
} catch (err) {
console.error('PDF generation error:', err);
res.status(500).json({ error: 'Failed to generate PDF' });
}
});
// ─────────────── POST /api/timesheets/:id/email ───────────────
router.post('/:id/email', validateBody(emailTimesheetSchema), async (req, res) => {
try {
const { id } = req.params;
const { to, subject, message } = req.validated;
const timesheet = await req.prisma.timesheet.findUnique({
where: { id },
include: { user: { select: { id: true, name: true, email: true } } },
});
if (!timesheet) {
return res.status(404).json({ error: 'Timesheet not found' });
}
// Only owner or admin
const isAdmin = req.user.role === 'admin' || req.user.role === 'super_admin';
if (timesheet.userId !== req.user.id && !isAdmin) {
return res.status(403).json({ error: 'Access denied' });
}
const weekStart = timesheet.weekStart.toISOString().split('T')[0];
const data = await loadTimesheetData(req.prisma, timesheet.userId, weekStart);
const pdfBuffer = await generateTimesheetPDF({
userName: data.user.name,
weekStart,
entries: data.entries,
status: timesheet.status,
});
const pdfFilename = buildPdfFilename(data.user.name, weekStart);
await sendTimesheetEmail({
to,
subject: subject || `Timesheet – ${data.user.name} – Week of ${weekStart}`,
message,
pdfBuffer,
pdfFilename,
fromName: data.user.name,
});
res.json({ message: `Timesheet emailed to ${to}` });
} catch (err) {
console.error('Email timesheet error:', err);
if (err.message && err.message.includes('SMTP')) {
return res.status(503).json({ error: 'Email service not configured' });
}
res.status(500).json({ error: 'Failed to email timesheet' });
}
});
// ─────────────── GET /api/timesheets/overtime ───────────────
router.get('/overtime', async (req, res) => {
try {
const weekParam = req.query.week;
const monday = weekParam ? getWeekMonday(weekParam) : getWeekMonday(new Date().toISOString().split('T')[0]);
const sunday = getWeekSunday(monday);
const entries = await req.prisma.timeEntry.findMany({
where: {
userId: req.user.id,
date: { gte: new Date(monday + 'T00:00:00Z'), lte: new Date(sunday + 'T00:00:00Z') },
},
});
const totalHours = entries.reduce((s, e) => s + (parseFloat(e.hoursWorked) || 0), 0);
const threshold = 40;
const regular = Math.min(totalHours, threshold);
const overtime = Math.max(0, totalHours - threshold);
res.json({
week: monday,
totalHours: parseFloat(totalHours.toFixed(2)),
regularHours: parseFloat(regular.toFixed(2)),
overtimeHours: parseFloat(overtime.toFixed(2)),
threshold,
});
} catch (err) {
console.error('Employee overtime error:', err);
res.status(500).json({ error: 'Failed to get overtime' });
}
});
module.exports = router;