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:
@@ -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
|
||||
@@ -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,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")
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,684 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── 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' });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,210 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const {
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
verifyRefreshToken,
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
} = require('../middleware/auth');
|
||||
const {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
refreshSchema,
|
||||
validateBody,
|
||||
} = require('../utils/validation');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Rate limit: 5 attempts per 15 minutes on auth endpoints
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 50,
|
||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => req.ip,
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/login ───────────────
|
||||
router.post('/login', authLimiter, validateBody(loginSchema), async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.validated;
|
||||
|
||||
const user = await req.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.status(403).json({ error: 'Account is deactivated. Contact your admin.' });
|
||||
}
|
||||
|
||||
const passwordValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const accessToken = generateAccessToken(user);
|
||||
const refreshToken = generateRefreshToken(user);
|
||||
|
||||
// Store refresh token hash in DB
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/register (admin only) ───────────────
|
||||
router.post(
|
||||
'/register',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
validateBody(registerSchema),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { email, password, name, role } = req.validated;
|
||||
|
||||
// Only super_admin can create admin/super_admin accounts
|
||||
if (
|
||||
(role === 'admin' || role === 'super_admin') &&
|
||||
req.user.role !== 'super_admin'
|
||||
) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'Only super admins can create admin accounts' });
|
||||
}
|
||||
|
||||
const existing = await req.prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await req.prisma.user.create({
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
role,
|
||||
passwordHash,
|
||||
},
|
||||
select: { id: true, email: true, name: true, role: true, createdAt: true },
|
||||
});
|
||||
|
||||
res.status(201).json({ user });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Registration failed' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ─────────────── POST /api/auth/refresh ───────────────
|
||||
router.post('/refresh', validateBody(refreshSchema), async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = req.validated;
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = verifyRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'User not found or deactivated' });
|
||||
}
|
||||
|
||||
// Verify the refresh token matches the stored one (token rotation)
|
||||
if (user.refreshToken !== refreshToken) {
|
||||
// Possible token theft — invalidate all tokens for this user
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
return res.status(401).json({ error: 'Refresh token reuse detected. Please login again.' });
|
||||
}
|
||||
|
||||
const newAccessToken = generateAccessToken(user);
|
||||
const newRefreshToken = generateRefreshToken(user);
|
||||
|
||||
// Rotate refresh token
|
||||
await req.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { refreshToken: newRefreshToken },
|
||||
});
|
||||
|
||||
res.json({
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Refresh error:', err);
|
||||
res.status(500).json({ error: 'Token refresh failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── GET /api/auth/me ───────────────
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
const user = await req.prisma.user.findUnique({
|
||||
where: { id: req.user.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch (err) {
|
||||
console.error('Me error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch user profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────── POST /api/auth/logout ───────────────
|
||||
router.post('/logout', authenticate, async (req, res) => {
|
||||
try {
|
||||
await req.prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { refreshToken: null },
|
||||
});
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (err) {
|
||||
console.error('Logout error:', err);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require('express');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// GET /api/homeowners — list active homeowners (for all authenticated users)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const homeowners = await req.prisma.homeowner.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
res.json({ homeowners });
|
||||
} catch (err) {
|
||||
console.error('Get homeowners error:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch homeowners' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,309 @@
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendTimesheetEmail,
|
||||
verifySmtp,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user