v2.0.0: Complete rewrite — React + Express + PostgreSQL + Docker

BREAKING: Full rewrite from static HTML to production-grade stack.

Features:
- React 18 + Vite + Tailwind CSS (mobile-first)
- Express + Prisma + PostgreSQL backend
- JWT authentication with role-based access
- Weekly Mon-Sun timesheets with auto-save
- Multiple homeowner entries per day
- Submit → Approve/Reject workflow
- Server-side PDF generation
- SMTP email integration
- Admin panel with reporting & filters
- Dark mode (system-aware)
- Docker Compose one-command deploy
- Non-root containers, Helmet, bcrypt, Zod validation
This commit is contained in:
BizzleBot
2026-02-15 20:16:46 +00:00
parent 68832bd958
commit a7c138add1
88 changed files with 5522 additions and 9159 deletions
+271
View File
@@ -0,0 +1,271 @@
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' });
}
});
module.exports = router;