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
+104
View File
@@ -0,0 +1,104 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const { PrismaClient } = require('@prisma/client');
const authRoutes = require('./routes/auth');
const entriesRoutes = require('./routes/entries');
const timesheetsRoutes = require('./routes/timesheets');
const adminRoutes = require('./routes/admin');
const homeownersRoutes = require('./routes/homeowners');
const app = express();
const prisma = new PrismaClient();
const PORT = process.env.PORT || 3001;
// Trust proxy (behind nginx)
app.set('trust proxy', 1);
// Security headers
app.use(helmet());
// CORS
const allowedOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
: ['http://localhost:5173', 'http://localhost:3000'];
app.use(
cors({
origin(origin, callback) {
// Allow requests with no origin (mobile apps, curl, etc.)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
})
);
// Body parsing
app.use(express.json({ limit: '1mb' }));
// Handle malformed JSON errors
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'Invalid JSON body' });
}
next(err);
});
// Attach prisma to request
app.use((req, _res, next) => {
req.prisma = prisma;
next();
});
// Health check
app.get('/api/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', timestamp: new Date().toISOString() });
} catch (err) {
res.status(503).json({ status: 'error', message: 'Database unavailable' });
}
});
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/entries', entriesRoutes);
app.use('/api/timesheets', timesheetsRoutes);
app.use('/api/homeowners', homeownersRoutes);
app.use('/api/admin', adminRoutes);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Global error handler
app.use((err, _req, res, _next) => {
console.error('Unhandled error:', err);
if (err.message === 'Not allowed by CORS') {
return res.status(403).json({ error: 'CORS policy violation' });
}
res.status(500).json({ error: 'Internal server error' });
});
// Graceful shutdown
async function shutdown(signal) {
console.log(`\n${signal} received. Shutting down gracefully...`);
await prisma.$disconnect();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Start server
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Coastal Timesheet API running on port ${PORT}`);
console.log(`📋 Health check: http://localhost:${PORT}/api/health`);
});
module.exports = app;
+103
View File
@@ -0,0 +1,103 @@
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-jwt-secret-change-me';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-me';
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';
/**
* Generate an access token (short-lived)
*/
function generateAccessToken(user) {
return jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
/**
* Generate a refresh token (long-lived)
*/
function generateRefreshToken(user) {
return jwt.sign(
{ userId: user.id, tokenType: 'refresh' },
JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRY }
);
}
/**
* Verify an access token
*/
function verifyAccessToken(token) {
return jwt.verify(token, JWT_SECRET);
}
/**
* Verify a refresh token
*/
function verifyRefreshToken(token) {
return jwt.verify(token, JWT_REFRESH_SECRET);
}
/**
* Authentication middleware — requires valid access token
*/
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.slice(7);
try {
const decoded = verifyAccessToken(token);
req.user = {
id: decoded.userId,
email: decoded.email,
role: decoded.role,
};
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Access token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid access token' });
}
}
/**
* Admin-only middleware — must be called after authenticate
*/
function requireAdmin(req, res, next) {
if (!req.user || (req.user.role !== 'admin' && req.user.role !== 'super_admin')) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
/**
* Super admin middleware — must be called after authenticate
*/
function requireSuperAdmin(req, res, next) {
if (!req.user || req.user.role !== 'super_admin') {
return res.status(403).json({ error: 'Super admin access required' });
}
next();
}
module.exports = {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
authenticate,
requireAdmin,
requireSuperAdmin,
JWT_SECRET,
JWT_REFRESH_SECRET,
ACCESS_TOKEN_EXPIRY,
REFRESH_TOKEN_EXPIRY,
};
+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;
+122
View File
@@ -0,0 +1,122 @@
const nodemailer = require('nodemailer');
let _transporter = null;
/**
* Get or create the nodemailer transporter (lazy singleton)
*/
function getTransporter() {
if (_transporter) return _transporter;
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587', 10);
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
if (!host || !user || !pass) {
throw new Error(
'SMTP not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.'
);
}
_transporter = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
tls: {
// Allow self-signed certs in dev
rejectUnauthorized: process.env.NODE_ENV === 'production',
},
});
return _transporter;
}
/**
* Verify SMTP connection is working
*/
async function verifySmtp() {
const transporter = getTransporter();
await transporter.verify();
return true;
}
/**
* Send a timesheet PDF via email
*
* @param {Object} options
* @param {string} options.to - Recipient email
* @param {string} options.subject - Email subject
* @param {string} options.message - Plain-text body (optional)
* @param {Buffer} options.pdfBuffer - PDF file buffer
* @param {string} options.pdfFilename - Filename for attachment
* @param {string} options.fromName - Sender display name
* @returns {Promise<Object>} nodemailer send result
*/
async function sendTimesheetEmail({
to,
subject,
message,
pdfBuffer,
pdfFilename,
fromName,
}) {
const transporter = getTransporter();
const fromAddress = process.env.SMTP_FROM || process.env.SMTP_USER;
const htmlBody = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #3b82f6; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: white; margin: 0; font-size: 20px; letter-spacing: 1px;">
COASTAL CONTRACTING OF FL
</h1>
</div>
<div style="padding: 24px; background: #f9fafb; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<h2 style="color: #1f2937; margin-top: 0;">Timesheet Attached</h2>
${message ? `<p style="color: #374151; line-height: 1.6;">${escapeHtml(message)}</p>` : ''}
<p style="color: #6b7280; font-size: 14px;">
The timesheet PDF is attached to this email.
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 20px 0;">
<p style="color: #9ca3af; font-size: 12px; text-align: center;">
Sent from Coastal Timesheet • ${new Date().toLocaleDateString()}
</p>
</div>
</div>
`;
const result = await transporter.sendMail({
from: fromName ? `"${fromName}" <${fromAddress}>` : fromAddress,
to,
subject: subject || 'Timesheet – Coastal Contracting of FL',
text: message || 'Your timesheet is attached.',
html: htmlBody,
attachments: [
{
filename: pdfFilename || 'timesheet.pdf',
content: pdfBuffer,
contentType: 'application/pdf',
},
],
});
return result;
}
/**
* Escape HTML special characters
*/
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
module.exports = {
sendTimesheetEmail,
verifySmtp,
};
+356
View File
@@ -0,0 +1,356 @@
const React = require('react');
const {
Document,
Page,
Text,
View,
StyleSheet,
renderToBuffer,
} = require('@react-pdf/renderer');
const styles = StyleSheet.create({
page: {
fontFamily: 'Helvetica',
fontSize: 10,
paddingTop: 25,
paddingBottom: 40,
paddingHorizontal: 30,
backgroundColor: '#ffffff',
},
headerSection: {
marginBottom: 20,
paddingBottom: 10,
borderBottomWidth: 2,
borderBottomColor: '#e5e7eb',
},
header: {
fontSize: 20,
marginBottom: 6,
textAlign: 'center',
fontWeight: 'bold',
color: '#1f2937',
letterSpacing: 1.0,
},
brandLine: {
width: 60,
height: 3,
backgroundColor: '#3b82f6',
alignSelf: 'center',
marginBottom: 5,
},
weekInfo: {
fontSize: 14,
marginBottom: 18,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f8fafc',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
employeeInfo: {
fontSize: 14,
marginBottom: 12,
textAlign: 'center',
fontWeight: 'bold',
color: '#374151',
backgroundColor: '#f0f9ff',
paddingVertical: 6,
paddingHorizontal: 16,
borderRadius: 4,
},
statusBadge: {
fontSize: 10,
textAlign: 'center',
marginBottom: 12,
paddingVertical: 4,
paddingHorizontal: 12,
borderRadius: 4,
alignSelf: 'center',
},
daySection: {
marginBottom: 8,
borderRadius: 4,
overflow: 'hidden',
border: '1px solid #e5e7eb',
},
dayHeader: {
backgroundColor: '#3b82f6',
paddingVertical: 6,
paddingHorizontal: 10,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
dayName: {
fontSize: 11,
fontWeight: 'bold',
color: '#ffffff',
},
dayDate: {
fontSize: 9,
color: '#dbeafe',
},
dayTotal: {
fontSize: 9,
color: '#dbeafe',
fontWeight: 'bold',
},
entryRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: '#f3f4f6',
minHeight: 28,
},
entryRowLast: {
borderBottomWidth: 0,
},
entryRowAlternate: {
backgroundColor: '#f9fafb',
},
entryCell: {
paddingVertical: 6,
paddingHorizontal: 8,
fontSize: 9,
color: '#374151',
justifyContent: 'center',
},
homeownerCell: {
width: '25%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
fontWeight: 'bold',
color: '#1f2937',
},
hoursCell: {
width: '15%',
borderRightWidth: 1,
borderRightColor: '#e5e7eb',
alignItems: 'center',
fontWeight: 'bold',
color: '#059669',
},
workDescCell: {
width: '60%',
},
summarySection: {
marginTop: 18,
paddingTop: 12,
borderTopWidth: 2,
borderTopColor: '#3b82f6',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
summaryText: {
fontSize: 12,
fontWeight: 'bold',
color: '#1f2937',
},
totalHours: {
fontSize: 16,
fontWeight: 'bold',
color: '#059669',
},
footer: {
position: 'absolute',
fontSize: 8,
bottom: 25,
left: 0,
right: 0,
textAlign: 'center',
color: '#9ca3af',
},
});
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
/**
* Get the 7 days of the week (Monday → Sunday) from a Monday date string
*/
function getWeekDays(mondayStr) {
const days = [];
const start = new Date(mondayStr + 'T00:00:00Z');
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setUTCDate(d.getUTCDate() + i);
days.push(d);
}
return days;
}
function formatDate(date) {
const m = date.getUTCMonth() + 1;
const d = date.getUTCDate();
const y = date.getUTCFullYear();
return `${m}/${d}/${y}`;
}
function formatWeekRange(weekDays) {
if (!weekDays.length) return '';
const first = weekDays[0];
const last = weekDays[weekDays.length - 1];
const opts = { month: 'short', day: 'numeric' };
const startStr = first.toLocaleDateString('en-US', { ...opts, timeZone: 'UTC' });
const endStr = last.toLocaleDateString('en-US', { ...opts, year: 'numeric', timeZone: 'UTC' });
return `${startStr} – ${endStr}`;
}
function getStatusColor(status) {
switch (status) {
case 'approved': return { bg: '#dcfce7', text: '#166534' };
case 'submitted': return { bg: '#dbeafe', text: '#1e40af' };
case 'rejected': return { bg: '#fef2f2', text: '#991b1b' };
default: return { bg: '#f3f4f6', text: '#374151' };
}
}
/**
* Build the React-PDF document element
*/
function buildTimesheetDocument({ userName, weekStart, entries, status }) {
const weekDays = getWeekDays(weekStart);
// Group entries by date
const entriesByDate = {};
for (const entry of entries) {
const dateKey = typeof entry.date === 'string'
? entry.date
: entry.date.toISOString().split('T')[0];
if (!entriesByDate[dateKey]) entriesByDate[dateKey] = [];
entriesByDate[dateKey].push(entry);
}
// Total hours
const totalHours = entries.reduce(
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
0
);
const statusColors = getStatusColor(status);
const el = React.createElement;
return el(Document, null,
el(Page, { size: 'A4', style: styles.page },
// Header
el(View, { style: styles.headerSection },
el(View, { style: styles.brandLine }),
el(Text, { style: styles.header }, 'COASTAL CONTRACTING OF FL')
),
// Week range
el(View, { style: styles.weekInfo },
el(Text, null, `Week of: ${formatWeekRange(weekDays)}`)
),
// Employee name
userName
? el(View, { style: styles.employeeInfo },
el(Text, null, `Employee: ${userName}`)
)
: null,
// Status badge
status && status !== 'draft'
? el(View, {
style: {
...styles.statusBadge,
backgroundColor: statusColors.bg,
color: statusColors.text,
},
},
el(Text, {
style: { color: statusColors.text },
}, `Status: ${status.charAt(0).toUpperCase() + status.slice(1)}`)
)
: null,
// Days
...weekDays.map((day, dayIndex) => {
const dayKey = day.toISOString().split('T')[0];
const dayEntries = entriesByDate[dayKey] || [];
if (dayEntries.length === 0) return null;
const dayTotal = dayEntries.reduce(
(sum, e) => sum + (parseFloat(e.hoursWorked) || 0),
0
);
return el(View, { key: dayIndex, style: styles.daySection },
// Day header
el(View, { style: styles.dayHeader },
el(View, null,
el(Text, { style: styles.dayName }, DAY_NAMES[day.getUTCDay()]),
el(Text, { style: styles.dayDate }, formatDate(day))
),
el(Text, { style: styles.dayTotal }, `${dayTotal.toFixed(1)} hours`)
),
// Entries
...dayEntries.map((entry, entryIndex) => {
const isLast = entryIndex === dayEntries.length - 1;
const isAlt = entryIndex % 2 === 1;
const rowStyle = [
styles.entryRow,
isLast ? styles.entryRowLast : null,
isAlt ? styles.entryRowAlternate : null,
].filter(Boolean);
return el(View, { key: entryIndex, style: rowStyle },
el(View, { style: [styles.entryCell, styles.homeownerCell] },
el(Text, null, entry.homeownerName || entry.homeowner || '-')
),
el(View, { style: [styles.entryCell, styles.hoursCell] },
el(Text, null, String(entry.hoursWorked || '0'))
),
el(View, { style: [styles.entryCell, styles.workDescCell] },
el(Text, null, entry.workDescription || '-')
)
);
})
);
}).filter(Boolean),
// Summary
el(View, { style: styles.summarySection },
el(Text, { style: styles.summaryText }, 'Weekly Total'),
el(Text, { style: styles.totalHours }, `${totalHours.toFixed(1)} Hours`)
),
// Footer
el(Text, { style: styles.footer },
`Generated on ${new Date().toLocaleDateString()} • Coastal Contracting of FL`
)
)
);
}
/**
* Generate a timesheet PDF buffer
* @param {Object} data - { userName, weekStart, entries, status }
* @returns {Promise<Buffer>}
*/
async function generateTimesheetPDF(data) {
const doc = buildTimesheetDocument(data);
const buffer = await renderToBuffer(doc);
return buffer;
}
/**
* Build a filename for the PDF
*/
function buildPdfFilename(userName, weekStart) {
const safeName = (userName || 'timesheet')
.replace(/[^a-zA-Z0-9]/g, '_')
.replace(/_+/g, '_')
.toLowerCase();
return `timesheet_${safeName}_${weekStart}.pdf`;
}
module.exports = {
generateTimesheetPDF,
buildPdfFilename,
};
+147
View File
@@ -0,0 +1,147 @@
const { z } = require('zod');
// ──────────────────────────── Auth ────────────────────────────
const loginSchema = z.object({
email: z.string().email('Invalid email address').max(255),
password: z.string().min(1, 'Password is required').max(128),
});
const registerSchema = z.object({
email: z.string().email('Invalid email address').max(255),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(128),
name: z.string().min(1, 'Name is required').max(100).trim(),
role: z.enum(['employee', 'admin', 'super_admin']).default('employee'),
});
const refreshSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
// ──────────────────────────── Entries ────────────────────────────
const createEntrySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
homeownerId: z.string().uuid('Invalid homeowner ID'),
hoursWorked: z
.number()
.positive('Hours must be positive')
.max(24, 'Hours cannot exceed 24'),
workDescription: z.string().min(1, 'Description is required').max(1000).trim(),
});
const updateEntrySchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD').optional(),
homeownerId: z.string().uuid('Invalid homeowner ID').optional(),
hoursWorked: z
.number()
.positive('Hours must be positive')
.max(24, 'Hours cannot exceed 24')
.optional(),
workDescription: z.string().min(1).max(1000).trim().optional(),
});
const weekQuerySchema = z.object({
week: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Week must be YYYY-MM-DD (Monday)')
.optional(),
});
// ──────────────────────────── Timesheets ────────────────────────────
const submitTimesheetSchema = z.object({
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'weekStart must be YYYY-MM-DD'),
});
const emailTimesheetSchema = z.object({
to: z.string().email('Invalid recipient email').max(255),
subject: z.string().max(200).optional(),
message: z.string().max(2000).optional(),
});
// ──────────────────────────── Admin ────────────────────────────
const approveRejectSchema = z.object({
notes: z.string().max(1000).trim().optional(),
});
const createUserSchema = registerSchema;
const createHomeownerSchema = z.object({
name: z.string().min(1, 'Name is required').max(200).trim(),
address: z.string().max(500).trim().optional().nullable(),
});
const updateHomeownerSchema = z.object({
name: z.string().min(1).max(200).trim().optional(),
address: z.string().max(500).trim().optional().nullable(),
isActive: z.boolean().optional(),
});
const reportQuerySchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'from must be YYYY-MM-DD').optional(),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'to must be YYYY-MM-DD').optional(),
userId: z.string().uuid().optional(),
homeownerId: z.string().uuid().optional(),
status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(),
});
// ──────────────────────────── Helpers ────────────────────────────
/**
* Express middleware factory for validating request body with a zod schema
*/
function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const errors = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}));
return res.status(400).json({ error: 'Validation failed', details: errors });
}
req.validated = result.data;
next();
};
}
/**
* Express middleware factory for validating query parameters
*/
function validateQuery(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.query);
if (!result.success) {
const errors = result.error.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
}));
return res.status(400).json({ error: 'Invalid query parameters', details: errors });
}
req.validatedQuery = result.data;
next();
};
}
module.exports = {
loginSchema,
registerSchema,
refreshSchema,
createEntrySchema,
updateEntrySchema,
weekQuerySchema,
submitTimesheetSchema,
emailTimesheetSchema,
approveRejectSchema,
createUserSchema,
createHomeownerSchema,
updateHomeownerSchema,
reportQuerySchema,
validateBody,
validateQuery,
};