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
+22
View File
@@ -0,0 +1,22 @@
# ─── Database ───────────────────────────────────────────
DATABASE_URL=postgresql://coastal:coastal_secret@localhost:5432/coastal_timesheet
# ─── JWT Secrets ────────────────────────────────────────
# Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=change-me-to-a-random-64-byte-hex-string
JWT_REFRESH_SECRET=change-me-to-a-different-random-64-byte-hex-string
# ─── Server ────────────────────────────────────────────
PORT=3001
NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# ─── SMTP (Email) ──────────────────────────────────────
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=your-email@gmail.com
# ─── Admin ──────────────────────────────────────────────
ADMIN_EMAIL=bizzle@coastalcontracting.com
+35
View File
@@ -0,0 +1,35 @@
{
"name": "coastal-timesheet-backend",
"version": "2.0.0",
"description": "Coastal Contracting Timesheet API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:migrate": "npx prisma migrate deploy",
"db:push": "npx prisma db push",
"db:seed": "node prisma/seed.js",
"db:generate": "npx prisma generate",
"db:reset": "npx prisma migrate reset --force",
"setup": "npx prisma generate && npx prisma db push && node prisma/seed.js"
},
"dependencies": {
"@prisma/client": "^6.9.0",
"@react-pdf/renderer": "^4.3.0",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.10.1",
"react": "^18.3.1",
"zod": "^3.24.4"
},
"devDependencies": {
"prisma": "^6.9.0"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,98 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'warehouse',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Client" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"address" TEXT,
"phone" TEXT,
"email" TEXT,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Package" (
"id" TEXT NOT NULL,
"trackingCode" TEXT NOT NULL,
"description" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"createdById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'received',
"priority" TEXT NOT NULL DEFAULT 'normal',
"notes" TEXT,
"itemNotes" TEXT,
"deliveryNotes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Package_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CustodyEvent" (
"id" TEXT NOT NULL,
"packageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"fromStatus" TEXT,
"toStatus" TEXT NOT NULL,
"photoUrl" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"syncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustodyEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Package_trackingCode_key" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "Package_clientId_idx" ON "Package"("clientId");
-- CreateIndex
CREATE INDEX "Package_status_idx" ON "Package"("status");
-- CreateIndex
CREATE INDEX "Package_trackingCode_idx" ON "Package"("trackingCode");
-- CreateIndex
CREATE INDEX "CustodyEvent_packageId_idx" ON "CustodyEvent"("packageId");
-- CreateIndex
CREATE INDEX "CustodyEvent_userId_idx" ON "CustodyEvent"("userId");
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_clientId_fkey" FOREIGN KEY ("clientId") REFERENCES "Client"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Package" ADD CONSTRAINT "Package_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_packageId_fkey" FOREIGN KEY ("packageId") REFERENCES "Package"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CustodyEvent" ADD CONSTRAINT "CustodyEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1 @@
{"provider":"postgresql"}
+106
View File
@@ -0,0 +1,106 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
employee
admin
super_admin
}
enum TimesheetStatus {
draft
submitted
approved
rejected
}
model User {
id String @id @default(uuid())
email String @unique
name String
role Role @default(employee)
passwordHash String @map("password_hash")
isActive Boolean @default(true) @map("is_active")
refreshToken String? @map("refresh_token")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
timeEntries TimeEntry[]
timesheets Timesheet[] @relation("UserTimesheets")
approvals Timesheet[] @relation("ApprovedTimesheets")
@@map("users")
}
model Homeowner {
id String @id @default(uuid())
name String @unique
address String?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
timeEntries TimeEntry[]
@@map("homeowners")
}
model TimeEntry {
id String @id @default(uuid())
userId String @map("user_id")
date DateTime @db.Date
homeownerId String @map("homeowner_id")
hoursWorked Decimal @map("hours_worked") @db.Decimal(4, 2)
workDescription String @map("work_description")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
homeowner Homeowner @relation(fields: [homeownerId], references: [id])
timesheetLinks TimesheetEntry[]
@@index([userId, date])
@@index([homeownerId])
@@map("time_entries")
}
model Timesheet {
id String @id @default(uuid())
userId String @map("user_id")
weekStart DateTime @map("week_start") @db.Date
weekEnd DateTime @map("week_end") @db.Date
status TimesheetStatus @default(draft)
submittedAt DateTime? @map("submitted_at")
approvedBy String? @map("approved_by")
approvedAt DateTime? @map("approved_at")
notes String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation("UserTimesheets", fields: [userId], references: [id], onDelete: Cascade)
approver User? @relation("ApprovedTimesheets", fields: [approvedBy], references: [id])
entries TimesheetEntry[]
@@unique([userId, weekStart])
@@index([status])
@@index([userId, weekStart])
@@map("timesheets")
}
model TimesheetEntry {
id String @id @default(uuid())
timesheetId String @map("timesheet_id")
timeEntryId String @map("time_entry_id")
timesheet Timesheet @relation(fields: [timesheetId], references: [id], onDelete: Cascade)
timeEntry TimeEntry @relation(fields: [timeEntryId], references: [id], onDelete: Cascade)
@@unique([timesheetId, timeEntryId])
@@map("timesheet_entries")
}
+100
View File
@@ -0,0 +1,100 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const DEFAULT_HOMEOWNERS = [
'Anderson, 217',
'Bakos',
'Beckstead, 111',
'Bentley, 310',
'Best, 103',
'Caraway, 132',
'Carmichael, M, 216',
'Casa Blanca',
'Chapin, 106',
'Conner, 309',
'Cook, 118',
'Coyle, 109',
'Davis, 114a',
'Dimmitt, 213',
'Dockery, 502',
'Fassett, 303C',
'Gypsy Wind',
'Hager, 108',
'Hanford, 308',
'Hitchcox – Clarry, 218',
'Hughes, 215',
'Kaufman 129 (Blue View)',
'Kuchman, 104',
'Lockhart, 301A',
'Lokey, 136',
'McColgan, 312',
'Mercurio, 523',
'Moff – Dean Elect',
'Rogers, 501',
'Rusten, 204A',
'Ryan, 301B',
'Salas, 144',
'Sear 128 (Twin Shores)',
'Shimp, 517',
'Sipprelle, 202',
'Trino, 131',
'Useppa Fire',
'Vogt',
'Weinsz, 141',
'Wendorf, 306',
'White (Rogan)',
'Williams, Bob, 140',
'Williams, Dan, 137B',
'Williamson-Whetstone, 102',
'Wilson, George, 516',
'Wright, 137A',
];
async function main() {
console.log('🌱 Seeding database...');
// Create admin user
const passwordHash = await bcrypt.hash('CoastalAdmin2026!', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@coastal.com' },
update: {},
create: {
email: 'admin@coastal.com',
name: 'Admin',
role: 'super_admin',
passwordHash,
},
});
console.log(`✅ Admin user created: ${admin.email}`);
// Create homeowners
let created = 0;
let skipped = 0;
for (const name of DEFAULT_HOMEOWNERS) {
try {
await prisma.homeowner.upsert({
where: { name },
update: {},
create: { name },
});
created++;
} catch (err) {
console.warn(`⚠️ Skipped homeowner "${name}": ${err.message}`);
skipped++;
}
}
console.log(`✅ Homeowners: ${created} created, ${skipped} skipped`);
console.log('🌱 Seed complete!');
}
main()
.catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+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,
};