const express = require('express'); const { authenticate } = require('../middleware/auth'); const { createEntrySchema, updateEntrySchema, weekQuerySchema, copyWeekSchema, validateBody, validateQuery, validateIdParam, } = 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' }); } // Check per-day hour cap (max 24h total across all entries) const existingHours = await req.prisma.timeEntry.aggregate({ where: { userId: req.user.id, date: new Date(date + 'T00:00:00Z') }, _sum: { hoursWorked: true }, }); const totalForDay = parseFloat(existingHours._sum.hoursWorked || 0) + hoursWorked; if (totalForDay > 24) { return res.status(400).json({ error: `Total hours for this day would be ${totalForDay.toFixed(1)}. Maximum is 24.`, }); } 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', validateIdParam, 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', validateIdParam, 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', validateBody(copyWeekSchema), async (req, res) => { try { const { fromWeek, toWeek } = req.validated; 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;