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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user