diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index ff64a46..0000000 --- a/.dockerignore +++ /dev/null @@ -1,61 +0,0 @@ -# Dependencies -node_modules -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Production build -dist -build - -# Environment files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# IDE files -.vscode -.idea -*.swp -*.swo - -# OS files -.DS_Store -Thumbs.db - -# Git -.git -.gitignore - -# Docker files (to avoid infinite recursion) -Dockerfile* -docker-compose* - -# Logs -logs -*.log - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# ESLint cache -.eslintcache - -# Optional npm cache directory -.npm - -# Optional REPL history -.node_repl_history - -# Temporary folders -tmp/ -temp/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..341deec --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 3ef0451..dea7c1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,88 +1,6 @@ -# Dependencies node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Production build dist/ - -# Environment variables .env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# IDE and editor files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Logs -logs *.log - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Coverage directory used by tools like istanbul -coverage/ - -# nyc test coverage -.nyc_output - -# Dependency directories -jspm_packages/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless - -# Temporary files -*.tmp -*.temp - -# Production zip file (optional - you might want to include this) -coastal-timesheet-production.zip \ No newline at end of file +.DS_Store +.prisma/ diff --git a/DOCKER.md b/DOCKER.md deleted file mode 100644 index 56e2733..0000000 --- a/DOCKER.md +++ /dev/null @@ -1,84 +0,0 @@ -# Docker Deployment Guide - -## Production Deployment - -### Quick Start -```bash -# Build and run the production container -docker-compose up -d - -# Access the app at http://localhost:8080 -``` - -### Manual Build -```bash -# Build the Docker image -docker build -t coastal-timesheet . - -# Run the container -docker run -d -p 8080:80 --name coastal-timesheet coastal-timesheet -``` - -## Development with Docker - -### Development Server -```bash -# Run development environment -docker-compose -f docker-compose.dev.yml up - -# Access the app at http://localhost:5173 -``` - -## Docker Commands - -### Production -```bash -# Start services -docker-compose up -d - -# Stop services -docker-compose down - -# View logs -docker-compose logs -f - -# Rebuild after changes -docker-compose up -d --build -``` - -### Development -```bash -# Start dev environment -docker-compose -f docker-compose.dev.yml up - -# Stop dev environment -docker-compose -f docker-compose.dev.yml down - -# Rebuild dev container -docker-compose -f docker-compose.dev.yml up --build -``` - -## Configuration - -### Environment Variables -- `NODE_ENV`: Set to "production" for production builds -- Port mapping can be changed in docker-compose.yml - -### Nginx Configuration -The production container uses nginx to serve static files with: -- Gzip compression enabled -- Client-side routing support (SPA) -- Static asset caching -- Security headers - -### Ports -- **Production**: http://localhost:8080 -- **Development**: http://localhost:5173 - -## Architecture - -The production setup uses a multi-stage build: -1. **Build stage**: Compiles the React app using Node.js -2. **Production stage**: Serves static files using nginx - -This results in a lightweight production image (~25MB) that only contains the compiled app and nginx. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index ef17b97..0000000 --- a/Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -# Use Node.js 18 alpine as base image -FROM node:18-alpine as build - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci - -# Copy source code -COPY . . - -# Build the application -RUN npm run build - -# Production stage - use nginx to serve static files -FROM nginx:alpine - -# Copy built files from build stage -COPY --from=build /app/dist /usr/share/nginx/html - -# Copy custom nginx configuration -COPY nginx.conf /etc/nginx/nginx.conf - -# Expose port 80 -EXPOSE 80 - -# Start nginx -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index 667ad14..0000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,20 +0,0 @@ -# Development Dockerfile -FROM node:18-alpine - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install all dependencies (including dev dependencies) -RUN npm install - -# Copy source code -COPY . . - -# Expose development port -EXPOSE 5173 - -# Start development server -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/README.md b/README.md index fa028bb..43ee03b 100644 --- a/README.md +++ b/README.md @@ -1,189 +1,287 @@ -# Coastal Contracting Timesheet App +

+ Coastal Timesheet +

-A modern, responsive timesheet application built with React for tracking work hours and generating professional PDF timesheets. Features comprehensive validation, dynamic field layouts, and seamless data management. +

Coastal Timesheet v2

-![Coastal Timesheet App Screenshot](./screenshot.png) -*Screenshot showing the timesheet interface with validation, dynamic fields, and professional layout* +

+ A modern, mobile-first time tracking application for Coastal Contracting of FL +

+ +

+ React 18 + Express + PostgreSQL + Prisma + Docker + Tailwind +

+ +--- ## ✨ Features -### 📊 **Time Tracking & Management** -- **Weekly Time Tracking**: Track work hours for each day of the week with multiple entries per day -- **Dynamic Date Navigation**: Navigate between weeks with intuitive date picker controls -- **Flexible Entry Management**: Add, remove, and modify time entries as needed -- **Auto-saving**: Automatically saves all data to browser localStorage +- **📱 Mobile-first design** — Built for field workers, optimized for phones +- **🔐 JWT authentication** — Secure login with access/refresh tokens + rate limiting +- **📅 Weekly timesheets** — Monday–Sunday pay period with auto-save +- **🏠 Multiple homeowners per day** — Track work at different job sites +- **✅ Submit → Approve workflow** — Employees submit, admins approve or reject +- **📄 PDF generation** — Professional server-side PDF export +- **📧 Email integration** — Send timesheets via email with SMTP +- **👥 Admin panel** — Manage employees, homeowners, review timesheets +- **📊 Reporting** — Filter by employee, date range, status with summary stats +- **🌙 Dark mode** — System-aware with manual toggle +- **🐳 One-command deploy** — Single `docker compose up` for the entire stack -### 🏠 **Homeowner Management** -- **Pre-loaded Homeowners**: Comprehensive list of default homeowner names and addresses -- **Custom Homeowners**: Add and save custom homeowner names that persist across sessions -- **Smart Dropdown**: Searchable dropdown with autocomplete functionality +--- -### ✅ **Smart Validation System** -- **Required Employee Name**: Employee name must be entered before exporting timesheets -- **Complete Entry Validation**: When any field is filled, all three fields (homeowner, hours, work description) become required -- **Visual Feedback**: Red borders, asterisks, and warning messages guide users to complete entries -- **Export Protection**: PDF generation disabled until all validation requirements are met +## 📸 Screenshots -### 📝 **Enhanced User Interface** -- **Dynamic Work Description Fields**: Auto-resizing text areas that expand to show all content without scrolling -- **Optimized Field Layout**: Homeowner → Hours → Work Description order with maximum space for descriptions -- **Today Highlighting**: Current day is visually highlighted for easy identification -- **Dark/Light Mode**: Toggle between themes with persistent user preference + + + + + + + + + + + +
Timesheet EntryEntry FormDark Mode
-### 📄 **Professional PDF Export** -- **Download PDF**: Generate and download professional timesheet PDFs -- **Email Integration**: Share PDFs via device share sheet or download with pre-filled email subject -- **Optimized Layout**: PDF layout matches UI with proper field sizing and professional formatting -- **Validation Integration**: Only complete, valid timesheets can be exported + + + + + + + + + + + +
Admin PanelReports & FiltersHistory
-### 💾 **Data Management** -- **Import/Export**: Backup and restore timesheet data with JSON export/import -- **Week-based Storage**: Each week's data stored separately for better organization -- **Custom Homeowner Persistence**: Added homeowners saved across all sessions -- **Cross-device Compatibility**: Works on desktop, tablet, and mobile devices +### Desktop -### 📱 **Responsive Design** -- **Mobile-first**: Optimized for touch interfaces and small screens -- **Adaptive Layouts**: Fields reorganize appropriately for different screen sizes -- **Touch-friendly**: Large buttons and touch targets for mobile users + + -## 🚀 Getting Started +--- + +## 🚀 Quick Start ### Prerequisites -- Node.js (version 16 or higher) -- npm or yarn +- [Docker](https://docs.docker.com/get-docker/) and Docker Compose +- That's it. Everything else runs in containers. -### Installation - -1. Clone or download the project files -2. Install dependencies: - ```bash - npm install - ``` - -3. Start the development server: - ```bash - npm run dev - ``` - -4. Open [http://localhost:5173](http://localhost:5173) in your browser - -### Building for Production +### Deploy ```bash -npm run build +# Clone the repo +git clone https://git.bizzle.lol/bizzle/coastal_timesheet.git +cd coastal_timesheet +git checkout v2 + +# Configure environment +cp .env.example .env +# Edit .env with your secrets (see Configuration below) + +# Launch +cd docker +docker compose up -d ``` -The built files will be in the `dist/` directory, ready for web hosting. +The app will be available at `http://localhost` (port 80). -### Preview Production Build +### Default Admin Account -```bash -npm run preview +| Field | Value | +|----------|-------------------------| +| Email | `admin@coastal.com` | +| Password | `CoastalAdmin2026!` | + +> ⚠️ **Change the admin password after first login.** + +--- + +## ⚙️ Configuration + +Copy `.env.example` to `.env` and configure: + +```env +# Database (auto-configured in Docker) +DB_PASSWORD=your-secure-db-password + +# JWT Secrets (CHANGE THESE!) +JWT_SECRET=your-jwt-secret-min-32-chars +JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars + +# CORS (add your domain) +CORS_ORIGINS=https://timesheets.yourdomain.com + +# Email (optional — for sending timesheets) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password +SMTP_FROM=timesheets@yourdomain.com +ADMIN_EMAIL=admin@yourdomain.com ``` -## 📖 Usage Guide +### Email Setup (Gmail) -### 1. **Setup** -- Enter your **Employee Name** (required for all exports) -- Select the week you want to track using the date picker +1. Enable 2FA on your Google account +2. Go to [App Passwords](https://myaccount.google.com/apppasswords) +3. Generate a new app password for "Mail" +4. Use that as `SMTP_PASS` -### 2. **Adding Time Entries** -- Click "Add Entry" for any day to create multiple entries -- Fill in all three fields for each entry: - - **Homeowner**: Select from dropdown or add custom names - - **Hours Worked**: Enter decimal hours (e.g., 2.5 for 2 hours 30 minutes) - - **Work Description**: Detailed description of work performed (auto-expanding field) +--- -### 3. **Validation & Completion** -- **Red asterisks (*)** indicate required fields -- **Warning messages** appear for incomplete entries -- **Export buttons** are disabled until all requirements are met +## 🏗️ Architecture -### 4. **Export Options** -- **Download PDF**: Save a printable PDF to your device -- **Share/Email PDF**: Use device share functionality or download with email setup -- **Import/Export Data**: Backup/restore your timesheet data +``` +┌─────────────────────────────────────────────┐ +│ Nginx │ +│ (reverse proxy) │ +│ /api/* → backend:3001 │ +│ /* → static frontend │ +├──────────────────┬──────────────────────────┤ +│ Frontend │ Backend │ +│ React + Vite │ Express + Prisma │ +│ Tailwind CSS │ JWT Auth │ +│ Lucide Icons │ @react-pdf/renderer │ +│ │ Nodemailer │ +│ ├──────────────────────────┤ +│ │ PostgreSQL 16 │ +│ │ (persistent volume) │ +└──────────────────┴──────────────────────────┘ +``` -### 5. **Additional Features** -- **Theme Toggle**: Switch between light and dark modes -- **Office Contact**: Download contact card for easy email setup +### Tech Stack -## 🗂️ Data Storage +| Layer | Technology | +|-----------|-----------------------------------------------| +| Frontend | React 18, Vite 6, Tailwind CSS 3, Lucide | +| Backend | Express 4, Prisma ORM, bcryptjs, jsonwebtoken | +| Database | PostgreSQL 16 (Alpine) | +| PDF | @react-pdf/renderer (server-side) | +| Email | Nodemailer + SMTP | +| Proxy | Nginx 1.27 (Alpine) | +| Container | Docker Compose v3.9 | -- **Local Browser Storage**: All data stored locally, no external servers -- **Weekly Organization**: Each week stored as separate dataset -- **Persistent Settings**: Theme preferences and custom homeowners preserved -- **Privacy-focused**: Your data never leaves your device - -## 🛠️ Tech Stack - -- **Frontend Framework**: React 18 with Vite -- **Styling**: Tailwind CSS with responsive design -- **PDF Generation**: @react-pdf/renderer for professional documents -- **Icons**: Lucide React icon library -- **Storage**: Browser localStorage API -- **Build Tool**: Vite for fast development and optimized builds +--- ## 📁 Project Structure ``` -src/ -├── components/ # React components -│ ├── TimeSheet.jsx # Main timesheet interface -│ ├── DayEntries.jsx # Day-specific entry management -│ ├── TimeEntryRow.jsx # Individual entry row (legacy) -│ ├── DatePicker.jsx # Week navigation -│ ├── HomeownerDropdown.jsx # Homeowner selection -│ ├── ThemeToggle.jsx # Dark/light mode toggle -│ ├── PDFExport.jsx # PDF download functionality -│ ├── EmailPDF.jsx # PDF sharing functionality -│ ├── TimesheetPDF.jsx # PDF document structure -│ ├── ImportExport.jsx # Data backup/restore -│ └── MiniCalendar.jsx # Calendar widget -├── hooks/ # Custom React hooks -│ ├── useTimeSheet.js # Timesheet state & validation -│ └── useTheme.js # Theme management -├── utils/ # Utility functions -│ └── dateUtils.js # Date manipulation helpers -├── App.jsx # Main application component -├── main.jsx # Application entry point -└── index.css # Global styles +. +├── frontend/ # React SPA +│ ├── src/ +│ │ ├── api/ # Axios client with token refresh +│ │ ├── components/ # Reusable UI components +│ │ ├── contexts/ # Auth context (JWT) +│ │ ├── hooks/ # Custom hooks (theme, swipe, auto-save) +│ │ └── pages/ # Route pages +│ └── vite.config.js +├── backend/ # Express API +│ ├── prisma/ +│ │ ├── schema.prisma # Database schema +│ │ └── seed.js # Seed admin + homeowners +│ └── src/ +│ ├── middleware/ # Auth middleware +│ ├── routes/ # API routes +│ └── utils/ # PDF, email, validation +├── docker/ # Deployment +│ ├── docker-compose.yml +│ ├── backend/Dockerfile +│ ├── frontend/Dockerfile +│ └── nginx/default.conf +└── docs/screenshots/ # App screenshots ``` -## 🔧 Configuration Files - -- `vite.config.js` - Vite build configuration -- `tailwind.config.js` - Tailwind CSS customization -- `postcss.config.js` - PostCSS processing -- `package.json` - Dependencies and scripts - -## 🌐 Deployment - -The built application is a static site that can be deployed to any web hosting service: - -1. Run `npm run build` -2. Upload the contents of the `dist/` directory to your web server -3. Ensure your server serves `index.html` for all routes (SPA configuration) - -Compatible with: Netlify, Vercel, GitHub Pages, traditional web hosting, and more. - -## 📋 Features Summary - -✅ **Employee name validation** -✅ **Complete entry validation (all fields required when any field has input)** -✅ **Dynamic auto-resizing work description fields** -✅ **Optimized field order (Homeowner → Hours → Work Description)** -✅ **Professional PDF generation with validation** -✅ **Email/share functionality** -✅ **Data import/export** -✅ **Dark/light theme support** -✅ **Mobile-responsive design** -✅ **Local data persistence** -✅ **Multiple entries per day** -✅ **Custom homeowner management** - --- -*Built for Coastal Contracting of FL - Making timesheet management simple and professional.* \ No newline at end of file +## 🔒 Security + +- **bcrypt** password hashing (12 rounds) +- **JWT** access tokens (15min) + refresh tokens (7 days) +- **Helmet** security headers +- **Rate limiting** on auth endpoints (50 req / 15 min) +- **Zod** input validation on all endpoints +- **Prisma ORM** — parameterized queries (no SQL injection) +- **Non-root Docker** containers +- **CORS** origin whitelist + +--- + +## 📡 API Endpoints + +### Auth +| Method | Endpoint | Description | +|--------|----------------------|----------------------| +| POST | `/api/auth/login` | Login, get tokens | +| POST | `/api/auth/refresh` | Refresh access token | +| GET | `/api/auth/me` | Current user info | + +### Entries +| Method | Endpoint | Description | +|--------|---------------------|------------------------| +| GET | `/api/entries` | List entries (by week) | +| POST | `/api/entries` | Create entry | +| PUT | `/api/entries/:id` | Update entry | +| DELETE | `/api/entries/:id` | Delete entry | + +### Timesheets +| Method | Endpoint | Description | +|--------|------------------------------|----------------------| +| GET | `/api/timesheets` | Get current week | +| GET | `/api/timesheets/history` | All user timesheets | +| POST | `/api/timesheets/submit` | Submit for approval | +| GET | `/api/timesheets/:id/pdf` | Download PDF | + +### Admin +| Method | Endpoint | Description | +|--------|-------------------------------------|-------------------------| +| GET | `/api/admin/timesheets` | All timesheets (filter) | +| GET | `/api/admin/timesheets/:id` | Timesheet detail | +| PUT | `/api/admin/timesheets/:id/approve` | Approve timesheet | +| PUT | `/api/admin/timesheets/:id/reject` | Reject timesheet | +| PUT | `/api/admin/timesheets/:id/reopen` | Reopen for editing | +| GET | `/api/admin/users` | List employees | +| POST | `/api/admin/users` | Create employee | +| GET | `/api/admin/homeowners` | List homeowners | +| POST | `/api/admin/homeowners` | Add homeowner | +| GET | `/api/admin/reports` | Reporting with filters | + +--- + +## 🔄 Upgrading from v1 + +v2 is a complete rewrite. Key differences: + +| Feature | v1 | v2 | +|-----------------|-----------------------------|----------------------------------| +| Storage | Browser localStorage | PostgreSQL database | +| Auth | None | JWT with roles | +| Multi-user | No | Yes — unlimited employees | +| Approval flow | No | Submit → Approve/Reject | +| PDF | Client-side (jsPDF) | Server-side (@react-pdf) | +| Email | mailto: link | SMTP with PDF attachment | +| Deploy | Static HTML | Docker Compose (one command) | +| Admin panel | No | Full admin with reporting | +| Dark mode | No | System-aware + manual toggle | + +--- + +## 📝 License + +Private — Coastal Contracting of FL. All rights reserved. + +--- + +

+ Built with ☀️ in Florida +

diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..341deec --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..d144ecf --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..a63b79f --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js new file mode 100644 index 0000000..5dc930b --- /dev/null +++ b/backend/prisma/seed.js @@ -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(); + }); diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..f9d5106 --- /dev/null +++ b/backend/src/index.js @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..68e87c9 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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, +}; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..3387151 --- /dev/null +++ b/backend/src/routes/admin.js @@ -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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..2082489 --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/entries.js b/backend/src/routes/entries.js new file mode 100644 index 0000000..53c7b5f --- /dev/null +++ b/backend/src/routes/entries.js @@ -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; diff --git a/backend/src/routes/homeowners.js b/backend/src/routes/homeowners.js new file mode 100644 index 0000000..d908422 --- /dev/null +++ b/backend/src/routes/homeowners.js @@ -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; diff --git a/backend/src/routes/timesheets.js b/backend/src/routes/timesheets.js new file mode 100644 index 0000000..ab3fc37 --- /dev/null +++ b/backend/src/routes/timesheets.js @@ -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; diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js new file mode 100644 index 0000000..8aa6f30 --- /dev/null +++ b/backend/src/utils/email.js @@ -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} 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 = ` +
+
+

+ COASTAL CONTRACTING OF FL +

+
+
+

Timesheet Attached

+ ${message ? `

${escapeHtml(message)}

` : ''} +

+ The timesheet PDF is attached to this email. +

+
+

+ Sent from Coastal Timesheet • ${new Date().toLocaleDateString()} +

+
+
+ `; + + 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, '''); +} + +module.exports = { + sendTimesheetEmail, + verifySmtp, +}; diff --git a/backend/src/utils/pdf.js b/backend/src/utils/pdf.js new file mode 100644 index 0000000..7a4b74b --- /dev/null +++ b/backend/src/utils/pdf.js @@ -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} + */ +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, +}; diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js new file mode 100644 index 0000000..d700311 --- /dev/null +++ b/backend/src/utils/validation.js @@ -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, +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml deleted file mode 100644 index b3350f0..0000000 --- a/docker-compose.dev.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: '3.8' - -services: - coastal-timesheet-dev: - build: - context: . - dockerfile: Dockerfile.dev - container_name: coastal-timesheet-dev - ports: - - "5173:5173" - volumes: - - .:/app - - /app/node_modules - environment: - - NODE_ENV=development - stdin_open: true - tty: true - networks: - - timesheet-dev-network - -networks: - timesheet-dev-network: - driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index dc9dd86..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,38 +0,0 @@ -version: '3.8' - -services: - coastal-timesheet: - build: - context: . - dockerfile: Dockerfile - container_name: coastal-timesheet-app - ports: - - "8080:80" - environment: - - NODE_ENV=production - restart: unless-stopped - networks: - - timesheet-network - - # Optional: Add a reverse proxy with SSL if needed - # nginx-proxy: - # image: nginx:alpine - # container_name: coastal-timesheet-proxy - # ports: - # - "80:80" - # - "443:443" - # volumes: - # - ./proxy.conf:/etc/nginx/nginx.conf - # - ./ssl:/etc/nginx/ssl - # depends_on: - # - coastal-timesheet - # networks: - # - timesheet-network - -networks: - timesheet-network: - driver: bridge - -# Optional: Add volumes for persistent storage if needed in future -# volumes: -# timesheet-data: \ No newline at end of file diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile new file mode 100644 index 0000000..68f35d1 --- /dev/null +++ b/docker/backend/Dockerfile @@ -0,0 +1,55 @@ +# ─────────────────────────────────────────────────── +# Coastal Timesheet — Backend Dockerfile +# Node 22 Alpine · Prisma · Express +# ─────────────────────────────────────────────────── + +# ── Stage 1: Install dependencies ────────────────── +FROM node:22-alpine AS deps + +WORKDIR /app + +# Copy package files +COPY backend/package.json backend/package-lock.json* ./ + +# Install production dependencies +RUN npm ci --omit=dev 2>/dev/null || npm install --omit=dev + +# ── Stage 2: Build (generate Prisma client) ──────── +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy deps from previous stage +COPY --from=deps /app/node_modules ./node_modules +COPY backend/ ./ + +# Generate Prisma client +RUN npx prisma generate + +# ── Stage 3: Production image ───────────────────── +FROM node:22-alpine AS runner + +WORKDIR /app + +# Add non-root user for security +RUN addgroup --system --gid 1001 coastal && \ + adduser --system --uid 1001 coastal + +# Copy application +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/src ./src +COPY --from=builder /app/package.json ./ + +# Switch to non-root user +USER coastal + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD node -e "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))" + +# Start with migration and seed on first run +CMD ["sh", "-c", "npx prisma db push --accept-data-loss 2>/dev/null; node prisma/seed.js 2>/dev/null; node src/index.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4743de5 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,89 @@ +version: '3.9' + +services: + # ─── PostgreSQL ───────────────────────────────────────── + db: + image: postgres:16-alpine + container_name: coastal-db + restart: unless-stopped + environment: + POSTGRES_USER: coastal + POSTGRES_PASSWORD: ${DB_PASSWORD:-coastal_secret} + POSTGRES_DB: coastal_timesheet + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - '127.0.0.1:5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U coastal -d coastal_timesheet'] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - coastal + + # ─── Backend API ──────────────────────────────────────── + backend: + build: + context: ../ + dockerfile: docker/backend/Dockerfile + container_name: coastal-backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://coastal:${DB_PASSWORD:-coastal_secret}@db:5432/coastal_timesheet + JWT_SECRET: ${JWT_SECRET:-change-me-in-production-jwt-secret-2026} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-in-production-refresh-secret-2026} + PORT: '3001' + NODE_ENV: ${NODE_ENV:-production} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://100.94.106.120:8080} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASS: ${SMTP_PASS:-} + SMTP_FROM: ${SMTP_FROM:-} + ADMIN_EMAIL: ${ADMIN_EMAIL:-bizzle@coastalcontracting.com} + ports: + - '127.0.0.1:3001:3001' + healthcheck: + test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/api/health').then(r=>{if(!r.ok)throw 1}).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s + networks: + - coastal + + # ─── Frontend (Nginx) ────────────────────────────────── + frontend: + build: + context: ../ + dockerfile: docker/frontend/Dockerfile + container_name: coastal-frontend + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + ports: + - '100.94.106.120:8080:80' + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + healthcheck: + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/'] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - coastal + +volumes: + pgdata: + driver: local + +networks: + coastal: + driver: bridge diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile new file mode 100644 index 0000000..05792c7 --- /dev/null +++ b/docker/frontend/Dockerfile @@ -0,0 +1,56 @@ +# ─────────────────────────────────────────────────── +# Coastal Timesheet — Frontend Dockerfile +# Build: Node 22 Alpine → Serve: Nginx Alpine +# ─────────────────────────────────────────────────── + +# ── Stage 1: Install dependencies ────────────────── +FROM node:22-alpine AS deps + +WORKDIR /app + +# Copy package files (frontend lives in the frontend/ directory) +COPY frontend/package.json frontend/package-lock.json* ./ + +# Install all dependencies (including devDependencies for build) +RUN npm ci 2>/dev/null || npm install + +# ── Stage 2: Build ──────────────────────────────── +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy dependencies +COPY --from=deps /app/node_modules ./node_modules + +# Copy frontend source +COPY frontend/ ./ + +# Set API URL for production build +ARG VITE_API_URL=/api +ENV VITE_API_URL=${VITE_API_URL} + +# Build the React app +RUN npm run build + +# ── Stage 3: Serve with Nginx ───────────────────── +FROM nginx:1.27-alpine AS runner + +# Remove default nginx config and static files +RUN rm -rf /etc/nginx/conf.d/default.conf /usr/share/nginx/html/* + +# Copy built frontend assets +COPY --from=builder /app/dist /usr/share/nginx/html + +# The nginx config is mounted via docker-compose volume +# but we include a fallback in case it's run standalone +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf + +# Expose ports +EXPOSE 80 443 + +# Nginx runs as non-root by default in alpine image +# Health check +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..f64b80d --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,81 @@ +# Coastal Timesheet — Nginx reverse proxy +# /api/* → backend:3001 +# /* → frontend static files + +upstream backend_api { + server backend:3001; + keepalive 16; +} + +server { + listen 80; + listen [::]:80; + server_name _; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_min_length 256; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml + application/rss+xml + image/svg+xml; + + # Client body size (for file uploads if ever needed) + client_max_body_size 10m; + + # ─── API proxy ─────────────────────────────────────── + location /api/ { + proxy_pass http://backend_api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Timeouts for PDF generation / email sending + proxy_read_timeout 60s; + proxy_connect_timeout 10s; + proxy_send_timeout 30s; + } + + # ─── Frontend static files ─────────────────────────── + location / { + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — serve index.html for client-side routes + try_files $uri $uri/ /index.html; + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + } + + # ─── Health check endpoint for load balancer ───────── + location = /nginx-health { + access_log off; + return 200 'ok'; + add_header Content-Type text/plain; + } +} diff --git a/docs/screenshots/01-login-desktop.png b/docs/screenshots/01-login-desktop.png new file mode 100644 index 0000000..81d78a1 Binary files /dev/null and b/docs/screenshots/01-login-desktop.png differ diff --git a/docs/screenshots/01-login-mobile.png b/docs/screenshots/01-login-mobile.png new file mode 100644 index 0000000..46b0fb5 Binary files /dev/null and b/docs/screenshots/01-login-mobile.png differ diff --git a/docs/screenshots/02-timesheet-desktop.png b/docs/screenshots/02-timesheet-desktop.png new file mode 100644 index 0000000..61b621f Binary files /dev/null and b/docs/screenshots/02-timesheet-desktop.png differ diff --git a/docs/screenshots/02-timesheet-mobile.png b/docs/screenshots/02-timesheet-mobile.png new file mode 100644 index 0000000..82152da Binary files /dev/null and b/docs/screenshots/02-timesheet-mobile.png differ diff --git a/docs/screenshots/03-entry-form-mobile.png b/docs/screenshots/03-entry-form-mobile.png new file mode 100644 index 0000000..a6322f5 Binary files /dev/null and b/docs/screenshots/03-entry-form-mobile.png differ diff --git a/docs/screenshots/04-history-mobile.png b/docs/screenshots/04-history-mobile.png new file mode 100644 index 0000000..9bc82af Binary files /dev/null and b/docs/screenshots/04-history-mobile.png differ diff --git a/docs/screenshots/05-admin-mobile.png b/docs/screenshots/05-admin-mobile.png new file mode 100644 index 0000000..59575ad Binary files /dev/null and b/docs/screenshots/05-admin-mobile.png differ diff --git a/docs/screenshots/06-admin-reports-desktop.png b/docs/screenshots/06-admin-reports-desktop.png new file mode 100644 index 0000000..54ed984 Binary files /dev/null and b/docs/screenshots/06-admin-reports-desktop.png differ diff --git a/docs/screenshots/06-admin-reports-mobile.png b/docs/screenshots/06-admin-reports-mobile.png new file mode 100644 index 0000000..0cd1652 Binary files /dev/null and b/docs/screenshots/06-admin-reports-mobile.png differ diff --git a/docs/screenshots/07-dark-mode-desktop.png b/docs/screenshots/07-dark-mode-desktop.png new file mode 100644 index 0000000..be669b9 Binary files /dev/null and b/docs/screenshots/07-dark-mode-desktop.png differ diff --git a/docs/screenshots/07-dark-mode-mobile.png b/docs/screenshots/07-dark-mode-mobile.png new file mode 100644 index 0000000..1f48adf Binary files /dev/null and b/docs/screenshots/07-dark-mode-mobile.png differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0909917 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Coastal Timesheet + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e63ff40 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "coastal-timesheet-v2", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.1" + }, + "devDependencies": { + "@tailwindcss/forms": "^0.5.9", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7" + } +} diff --git a/postcss.config.js b/frontend/postcss.config.js similarity index 96% rename from postcss.config.js rename to frontend/postcss.config.js index e99ebc2..2aa7205 100644 --- a/postcss.config.js +++ b/frontend/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..dba555e --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,129 @@ +import axios from 'axios'; + +const client = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, + timeout: 15000, +}); + +/* ─── Token helpers ─── */ + +function getAccessToken() { + return localStorage.getItem('accessToken'); +} + +function getRefreshToken() { + return localStorage.getItem('refreshToken'); +} + +function setTokens(accessToken, refreshToken) { + localStorage.setItem('accessToken', accessToken); + if (refreshToken) { + localStorage.setItem('refreshToken', refreshToken); + } +} + +function clearTokens() { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); +} + +/* ─── Request interceptor: attach access token ─── */ + +client.interceptors.request.use( + (config) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +/* ─── Response interceptor: auto-refresh on 401 ─── */ + +let isRefreshing = false; +let failedQueue = []; + +function processQueue(error, token) { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +} + +client.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + /* If 401 with TOKEN_EXPIRED and we haven't retried yet */ + if ( + error.response?.status === 401 && + error.response?.data?.code === 'TOKEN_EXPIRED' && + !originalRequest._retry + ) { + if (isRefreshing) { + /* Queue this request until the refresh completes */ + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return client(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token'); + } + + /* Call refresh endpoint directly (bypass interceptor) */ + const { data } = await axios.post('/api/auth/refresh', { + refreshToken, + }); + + setTokens(data.accessToken, data.refreshToken); + processQueue(null, data.accessToken); + + originalRequest.headers.Authorization = `Bearer ${data.accessToken}`; + return client(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + clearTokens(); + /* Redirect to login */ + window.location.href = '/login'; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + /* If 401 on non-refresh, clear and redirect */ + if ( + error.response?.status === 401 && + !originalRequest.url?.includes('/auth/refresh') + ) { + clearTokens(); + window.location.href = '/login'; + } + + return Promise.reject(error); + } +); + +export { client as default, setTokens, clearTokens, getAccessToken, getRefreshToken }; diff --git a/frontend/src/components/DayCard.jsx b/frontend/src/components/DayCard.jsx new file mode 100644 index 0000000..149f841 --- /dev/null +++ b/frontend/src/components/DayCard.jsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { ChevronDown, Plus } from 'lucide-react'; +import EntryForm from './EntryForm'; + +const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; +const SHORT_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export default function DayCard({ date, entries, homeowners, onEntryChange, onAddEntry, onDeleteEntry, disabled, defaultExpanded }) { + const [expanded, setExpanded] = useState(defaultExpanded); + // Parse YYYY-MM-DD as local date (avoid UTC midnight timezone shift) + const [y, m, dy] = date.split('-').map(Number); + const d = new Date(y, m - 1, dy); + const isToday = new Date().toLocaleDateString('en-CA') === date; + const dayName = DAY_NAMES[d.getDay()]; + const shortDay = SHORT_DAYS[d.getDay()]; + const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + + const totalHours = (entries || []).reduce((sum, e) => sum + (parseFloat(e.hoursWorked) || 0), 0); + const entryCount = (entries || []).filter((e) => e.homeownerId || e.hoursWorked || e.workDescription).length; + + return ( +
+ {/* Header */} + + + {/* Entries */} + {expanded && ( +
+ {(entries || []).map((entry) => ( + 1} + disabled={disabled} + /> + ))} + {!disabled && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/EntryForm.jsx b/frontend/src/components/EntryForm.jsx new file mode 100644 index 0000000..bbd89ad --- /dev/null +++ b/frontend/src/components/EntryForm.jsx @@ -0,0 +1,77 @@ +import { Trash2 } from 'lucide-react'; +import HomeownerSelect from './HomeownerSelect'; + +export default function EntryForm({ entry, homeowners, onChange, onDelete, canDelete, disabled }) { + function handleChange(field, value) { + onChange(entry.id, { ...entry, [field]: value }); + } + + const hasInput = entry.homeownerId || entry.hoursWorked || entry.workDescription; + const isComplete = entry.homeownerId && entry.hoursWorked && entry.workDescription; + const isPartial = hasInput && !isComplete; + + return ( +
+ {/* Homeowner */} +
+ + handleChange('homeownerId', v)} + disabled={disabled} + /> +
+ + {/* Hours + Delete */} +
+
+ + handleChange('hoursWorked', e.target.value)} + disabled={disabled} + placeholder="0.0" + className="w-full px-3 py-2.5 rounded-xl bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/40 focus:border-sky-500 transition-all" + /> +
+ {canDelete && !disabled && ( + + )} +
+ + {/* Work Description */} +
+ +