(Feat): Initial Commit
This commit is contained in:
24
backend-deno/.env
Normal file
24
backend-deno/.env
Normal file
@@ -0,0 +1,24 @@
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=admin123
|
||||
DB_NAME=work_allocation
|
||||
DB_PORT=3306
|
||||
|
||||
# JWT Configuration - CHANGE IN PRODUCTION!
|
||||
JWT_SECRET=work_alloc_jwt_secret_key_change_in_production_2024
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
|
||||
# Security Configuration
|
||||
BCRYPT_ROUNDS=12
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# CORS Configuration (comma-separated for multiple origins)
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
210
backend-deno/README.md
Normal file
210
backend-deno/README.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Work Allocation Backend - Deno TypeScript
|
||||
|
||||
A secure, type-safe backend for the Work Allocation System built with Deno and TypeScript.
|
||||
|
||||
## Features
|
||||
|
||||
### Security Improvements
|
||||
|
||||
- **Strong Password Hashing**: bcrypt with configurable rounds (default: 12)
|
||||
- **JWT Authentication**: Secure token-based authentication with HMAC-SHA256
|
||||
- **Rate Limiting**: Configurable request rate limiting to prevent abuse
|
||||
- **CORS Protection**: Configurable cross-origin resource sharing
|
||||
- **Security Headers**: X-Frame-Options, X-Content-Type-Options, XSS protection
|
||||
- **Input Sanitization**: Protection against XSS and injection attacks
|
||||
- **Strict TypeScript**: Full type safety with strict compiler options
|
||||
|
||||
### Technical Stack
|
||||
|
||||
- **Runtime**: Deno 2.x
|
||||
- **Framework**: Oak (Deno's Express-like framework)
|
||||
- **Database**: MySQL 8.0 with mysql2 driver
|
||||
- **Authentication**: JWT with djwt library
|
||||
- **Password Hashing**: bcrypt
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Deno](https://deno.land/) 2.0 or higher
|
||||
- MySQL 8.0 (via Docker or local installation)
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install Deno** (if not already installed):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deno.land/install.sh | sh
|
||||
```
|
||||
|
||||
2. **Configure environment**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials
|
||||
```
|
||||
|
||||
3. **Start the database** (if using Docker):
|
||||
|
||||
```bash
|
||||
cd .. && docker-compose up -d
|
||||
```
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Development Mode (with auto-reload)
|
||||
|
||||
```bash
|
||||
deno task dev
|
||||
```
|
||||
|
||||
### Production Mode
|
||||
|
||||
```bash
|
||||
deno task start
|
||||
```
|
||||
|
||||
### Seed the Database
|
||||
|
||||
```bash
|
||||
deno task seed
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/login` - User login
|
||||
- `GET /api/auth/me` - Get current user
|
||||
- `POST /api/auth/change-password` - Change password
|
||||
|
||||
### Users
|
||||
|
||||
- `GET /api/users` - List users (filtered by role)
|
||||
- `GET /api/users/:id` - Get user by ID
|
||||
- `POST /api/users` - Create user (Admin/Supervisor)
|
||||
- `PUT /api/users/:id` - Update user (Admin/Supervisor)
|
||||
- `DELETE /api/users/:id` - Delete user (Admin/Supervisor)
|
||||
|
||||
### Departments
|
||||
|
||||
- `GET /api/departments` - List departments
|
||||
- `GET /api/departments/:id` - Get department
|
||||
- `GET /api/departments/:id/sub-departments` - Get sub-departments
|
||||
- `POST /api/departments` - Create department (SuperAdmin)
|
||||
- `POST /api/departments/:id/sub-departments` - Create sub-department (SuperAdmin)
|
||||
|
||||
### Work Allocations
|
||||
|
||||
- `GET /api/work-allocations` - List allocations (role-filtered)
|
||||
- `GET /api/work-allocations/:id` - Get allocation
|
||||
- `POST /api/work-allocations` - Create allocation (Supervisor/Admin)
|
||||
- `PUT /api/work-allocations/:id/status` - Update status (Supervisor/Admin)
|
||||
- `DELETE /api/work-allocations/:id` - Delete allocation (Supervisor/Admin)
|
||||
|
||||
### Attendance
|
||||
|
||||
- `GET /api/attendance` - List attendance records
|
||||
- `GET /api/attendance/:id` - Get attendance record
|
||||
- `POST /api/attendance/check-in` - Check in employee (Supervisor/Admin)
|
||||
- `POST /api/attendance/check-out` - Check out employee (Supervisor/Admin)
|
||||
- `GET /api/attendance/summary/stats` - Get attendance summary
|
||||
|
||||
### Contractor Rates
|
||||
|
||||
- `GET /api/contractor-rates` - List rates
|
||||
- `GET /api/contractor-rates/contractor/:id/current` - Get current rate
|
||||
- `POST /api/contractor-rates` - Set rate (Supervisor/Admin)
|
||||
- `PUT /api/contractor-rates/:id` - Update rate (Supervisor/Admin)
|
||||
- `DELETE /api/contractor-rates/:id` - Delete rate (Supervisor/Admin)
|
||||
|
||||
### Health Check
|
||||
|
||||
- `GET /health` - Server health status
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `PORT` | Server port | 3000 |
|
||||
| `DB_HOST` | Database host | localhost |
|
||||
| `DB_USER` | Database user | root |
|
||||
| `DB_PASSWORD` | Database password | admin123 |
|
||||
| `DB_NAME` | Database name | work_allocation |
|
||||
| `DB_PORT` | Database port | 3306 |
|
||||
| `JWT_SECRET` | JWT signing secret | (change in production!) |
|
||||
| `JWT_EXPIRES_IN` | Token expiration | 7d |
|
||||
| `BCRYPT_ROUNDS` | Password hash rounds | 12 |
|
||||
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | 900000 (15 min) |
|
||||
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | 100 |
|
||||
| `CORS_ORIGIN` | Allowed CORS origins | <http://localhost:5173> |
|
||||
| `NODE_ENV` | Environment | development |
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### For Production
|
||||
|
||||
1. **Change JWT Secret**: Use a strong, random secret
|
||||
|
||||
```bash
|
||||
JWT_SECRET=$(openssl rand -base64 64)
|
||||
```
|
||||
|
||||
2. **Enable HTTPS**: Use a reverse proxy (nginx) with SSL
|
||||
|
||||
3. **Set Production Environment**:
|
||||
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
4. **Increase bcrypt rounds** (if performance allows):
|
||||
|
||||
```bash
|
||||
BCRYPT_ROUNDS=14
|
||||
```
|
||||
|
||||
5. **Configure CORS** for your domain:
|
||||
|
||||
```bash
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```bash
|
||||
backend-deno/
|
||||
├── config/
|
||||
│ ├── database.ts # Database connection pool
|
||||
│ └── env.ts # Environment configuration
|
||||
├── middleware/
|
||||
│ ├── auth.ts # JWT authentication & authorization
|
||||
│ └── security.ts # Security middleware (CORS, rate limit, etc.)
|
||||
├── routes/
|
||||
│ ├── auth.ts # Authentication routes
|
||||
│ ├── users.ts # User management routes
|
||||
│ ├── departments.ts # Department routes
|
||||
│ ├── work-allocations.ts
|
||||
│ ├── attendance.ts
|
||||
│ └── contractor-rates.ts
|
||||
├── scripts/
|
||||
│ └── seed.ts # Database seeding script
|
||||
├── types/
|
||||
│ └── index.ts # TypeScript type definitions
|
||||
├── main.ts # Application entry point
|
||||
├── deno.json # Deno configuration
|
||||
└── .env # Environment variables
|
||||
```
|
||||
|
||||
## Differences from Node.js Backend
|
||||
|
||||
| Feature | Node.js | Deno |
|
||||
|---------|---------|------|
|
||||
| Runtime | Node.js | Deno |
|
||||
| Package Manager | npm | Built-in (JSR/npm) |
|
||||
| TypeScript | Requires compilation | Native support |
|
||||
| Security | Manual setup | Secure by default |
|
||||
| Permissions | Full access | Explicit permissions |
|
||||
| Framework | Express | Oak |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
79
backend-deno/config/database.ts
Normal file
79
backend-deno/config/database.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { createPool, Pool } from "mysql2/promise";
|
||||
import { load } from "@std/dotenv";
|
||||
|
||||
// Load environment variables
|
||||
await load({ export: true });
|
||||
|
||||
const config = {
|
||||
host: Deno.env.get("DB_HOST") || "localhost",
|
||||
user: Deno.env.get("DB_USER") || "root",
|
||||
password: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||
database: Deno.env.get("DB_NAME") || "work_allocation",
|
||||
port: parseInt(Deno.env.get("DB_PORT") || "3306"),
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
enableKeepAlive: true,
|
||||
keepAliveInitialDelay: 0,
|
||||
};
|
||||
|
||||
class Database {
|
||||
private pool: Pool | null = null;
|
||||
private static instance: Database;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): Database {
|
||||
if (!Database.instance) {
|
||||
Database.instance = new Database();
|
||||
}
|
||||
return Database.instance;
|
||||
}
|
||||
|
||||
async connect(): Promise<Pool> {
|
||||
if (!this.pool) {
|
||||
this.pool = createPool(config);
|
||||
|
||||
// Test connection
|
||||
try {
|
||||
const connection = await this.pool.getConnection();
|
||||
console.log("✅ Database connected successfully");
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
console.error("❌ Database connection failed:", (error as Error).message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
async getPool(): Promise<Pool> {
|
||||
if (!this.pool) {
|
||||
return await this.connect();
|
||||
}
|
||||
return this.pool;
|
||||
}
|
||||
|
||||
async query<T>(sql: string, params?: unknown[]): Promise<T> {
|
||||
const pool = await this.getPool();
|
||||
const [rows] = await pool.query(sql, params);
|
||||
return rows as T;
|
||||
}
|
||||
|
||||
async execute(sql: string, params?: unknown[]): Promise<{ insertId: number; affectedRows: number }> {
|
||||
const pool = await this.getPool();
|
||||
const [result] = await pool.execute(sql, params);
|
||||
return result as { insertId: number; affectedRows: number };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.pool) {
|
||||
await this.pool.end();
|
||||
this.pool = null;
|
||||
console.log("Database connection closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = Database.getInstance();
|
||||
export default db;
|
||||
40
backend-deno/config/env.ts
Normal file
40
backend-deno/config/env.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { load } from "@std/dotenv";
|
||||
|
||||
await load({ export: true });
|
||||
|
||||
export const config = {
|
||||
// Server
|
||||
PORT: parseInt(Deno.env.get("PORT") || "3000"),
|
||||
|
||||
// Database
|
||||
DB_HOST: Deno.env.get("DB_HOST") || "localhost",
|
||||
DB_USER: Deno.env.get("DB_USER") || "root",
|
||||
DB_PASSWORD: Deno.env.get("DB_PASSWORD") || "admin123",
|
||||
DB_NAME: Deno.env.get("DB_NAME") || "work_allocation",
|
||||
DB_PORT: parseInt(Deno.env.get("DB_PORT") || "3306"),
|
||||
|
||||
// JWT - Security: Use strong secret in production
|
||||
JWT_SECRET: Deno.env.get("JWT_SECRET") || "work_alloc_jwt_secret_key_change_in_production_2024",
|
||||
JWT_EXPIRES_IN: Deno.env.get("JWT_EXPIRES_IN") || "7d",
|
||||
|
||||
// Security settings
|
||||
BCRYPT_ROUNDS: parseInt(Deno.env.get("BCRYPT_ROUNDS") || "12"),
|
||||
RATE_LIMIT_WINDOW_MS: parseInt(Deno.env.get("RATE_LIMIT_WINDOW_MS") || "900000"), // 15 minutes
|
||||
RATE_LIMIT_MAX_REQUESTS: parseInt(Deno.env.get("RATE_LIMIT_MAX_REQUESTS") || "100"),
|
||||
|
||||
// CORS
|
||||
CORS_ORIGIN: Deno.env.get("CORS_ORIGIN") || "http://localhost:5173",
|
||||
|
||||
// Environment
|
||||
NODE_ENV: Deno.env.get("NODE_ENV") || "development",
|
||||
|
||||
isDevelopment(): boolean {
|
||||
return this.NODE_ENV === "development";
|
||||
},
|
||||
|
||||
isProduction(): boolean {
|
||||
return this.NODE_ENV === "production";
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
backend-deno/deno.json
Normal file
19
backend-deno/deno.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --watch --allow-net --allow-env --allow-read main.ts",
|
||||
"start": "deno run --allow-net --allow-env --allow-read main.ts",
|
||||
"seed": "deno run --allow-net --allow-env --allow-read scripts/seed.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@oak/oak": "jsr:@oak/oak@^17.1.4",
|
||||
"@std/dotenv": "jsr:@std/dotenv@^0.225.3",
|
||||
"mysql2": "npm:mysql2@^3.11.0",
|
||||
"bcrypt": "https://deno.land/x/bcrypt@v0.4.1/mod.ts",
|
||||
"djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts"
|
||||
},
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
163
backend-deno/deno.lock
generated
Normal file
163
backend-deno/deno.lock
generated
Normal file
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@oak/commons@1": "1.0.1",
|
||||
"jsr:@oak/oak@^17.1.4": "17.2.0",
|
||||
"jsr:@std/assert@1": "1.0.16",
|
||||
"jsr:@std/bytes@1": "1.0.6",
|
||||
"jsr:@std/crypto@1": "1.0.5",
|
||||
"jsr:@std/dotenv@~0.225.3": "0.225.5",
|
||||
"jsr:@std/encoding@1": "1.0.10",
|
||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||
"jsr:@std/http@1": "1.0.22",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||
"jsr:@std/media-types@1": "1.1.0",
|
||||
"jsr:@std/path@1": "1.1.3",
|
||||
"npm:mysql2@^3.11.0": "3.15.3",
|
||||
"npm:path-to-regexp@^6.3.0": "6.3.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@oak/commons@1.0.1": {
|
||||
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/bytes",
|
||||
"jsr:@std/crypto",
|
||||
"jsr:@std/encoding@1",
|
||||
"jsr:@std/http",
|
||||
"jsr:@std/media-types"
|
||||
]
|
||||
},
|
||||
"@oak/oak@17.2.0": {
|
||||
"integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
|
||||
"dependencies": [
|
||||
"jsr:@oak/commons",
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/bytes",
|
||||
"jsr:@std/http",
|
||||
"jsr:@std/media-types",
|
||||
"jsr:@std/path",
|
||||
"npm:path-to-regexp"
|
||||
]
|
||||
},
|
||||
"@std/assert@1.0.16": {
|
||||
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532"
|
||||
},
|
||||
"@std/bytes@1.0.6": {
|
||||
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
|
||||
},
|
||||
"@std/crypto@1.0.5": {
|
||||
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
|
||||
},
|
||||
"@std/dotenv@0.225.5": {
|
||||
"integrity": "9ce6f9d0ec3311f74a32535aa1b8c62ed88b1ab91b7f0815797d77a6f60c922f"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
},
|
||||
"@std/http@1.0.22": {
|
||||
"integrity": "53f0bb70e23a2eec3e17c4240a85bb23d185b2e20635adb37ce0f03cc4ca012a",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding@^1.0.10"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.12": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/media-types@1.1.0": {
|
||||
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||
},
|
||||
"@std/path@1.1.3": {
|
||||
"integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"aws-ssl-profiles@1.1.2": {
|
||||
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="
|
||||
},
|
||||
"denque@2.1.0": {
|
||||
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="
|
||||
},
|
||||
"generate-function@2.3.1": {
|
||||
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||
"dependencies": [
|
||||
"is-property"
|
||||
]
|
||||
},
|
||||
"iconv-lite@0.7.0": {
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"dependencies": [
|
||||
"safer-buffer"
|
||||
]
|
||||
},
|
||||
"is-property@1.0.2": {
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="
|
||||
},
|
||||
"long@5.3.2": {
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="
|
||||
},
|
||||
"lru-cache@7.18.3": {
|
||||
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="
|
||||
},
|
||||
"lru.min@1.1.3": {
|
||||
"integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="
|
||||
},
|
||||
"mysql2@3.15.3": {
|
||||
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
|
||||
"dependencies": [
|
||||
"aws-ssl-profiles",
|
||||
"denque",
|
||||
"generate-function",
|
||||
"iconv-lite",
|
||||
"long",
|
||||
"lru.min",
|
||||
"named-placeholders",
|
||||
"seq-queue",
|
||||
"sqlstring"
|
||||
]
|
||||
},
|
||||
"named-placeholders@1.1.3": {
|
||||
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
|
||||
"dependencies": [
|
||||
"lru-cache"
|
||||
]
|
||||
},
|
||||
"path-to-regexp@6.3.0": {
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="
|
||||
},
|
||||
"safer-buffer@2.1.2": {
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"seq-queue@0.0.5": {
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"sqlstring@2.3.3": {
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="
|
||||
}
|
||||
},
|
||||
"remote": {
|
||||
"https://deno.land/std@0.221.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
|
||||
"https://deno.land/std@0.221.0/encoding/base64.ts": "8ccae67a1227b875340a8582ff707f37b131df435b07080d3bb58e07f5f97807",
|
||||
"https://deno.land/std@0.221.0/encoding/base64url.ts": "9cc46cf510436be63ac00ebf97a7de1993e603ca58e1853b344bf90d80ea9945",
|
||||
"https://deno.land/x/bcrypt@v0.4.1/mod.ts": "ff09bdae282583cf5f7d87efe37ddcecef7f14f6d12e8b8066a3058db8c6c2f7",
|
||||
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/base64.ts": "b8266450a4f1eb6960f60f2f7986afc4dde6b45bd2d7ee7ba10789e67e17b9f7",
|
||||
"https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/bcrypt.ts": "ec221648cc6453ea5e3803bc817c01157dada06aa6f7a0ba6b9f87aae32b21e2",
|
||||
"https://deno.land/x/bcrypt@v0.4.1/src/main.ts": "08d201b289c8d9c46f8839c69cd6625b213863db29775c7a200afc3b540e64f8",
|
||||
"https://deno.land/x/bcrypt@v0.4.1/src/worker.ts": "5a73bdfee9c9e622f47c9733d374b627dce52fb3ec1e74c8226698b3fc57ffac",
|
||||
"https://deno.land/x/djwt@v3.0.2/algorithm.ts": "b1c6645f9dbd6e6c47c123a3b18c28b956f91c65ed17f5b6d5d968fc3750542b",
|
||||
"https://deno.land/x/djwt@v3.0.2/deps.ts": "a7954fe567f2097b4f6aca11d091b6df658e485a817ac4dee47257ed5c28fd6e",
|
||||
"https://deno.land/x/djwt@v3.0.2/mod.ts": "962d8f2c4d6a4db111f45d777b152356aec31ba7db0ca664601175a422629857",
|
||||
"https://deno.land/x/djwt@v3.0.2/signature.ts": "16238fbf558267c85dd6c0178045f006c8b914a7301db87149f3318326569272",
|
||||
"https://deno.land/x/djwt@v3.0.2/util.ts": "5cb264d2125c553678e11446bcfa0494025d120e3f59d0a3ab38f6800def697d"
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@oak/oak@^17.1.4",
|
||||
"jsr:@std/dotenv@~0.225.3",
|
||||
"npm:mysql2@^3.11.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
107
backend-deno/main.ts
Normal file
107
backend-deno/main.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Application, Router } from "@oak/oak";
|
||||
import { config } from "./config/env.ts";
|
||||
import { db } from "./config/database.ts";
|
||||
import { cors, securityHeaders, requestLogger, rateLimit } from "./middleware/security.ts";
|
||||
|
||||
// Import routes
|
||||
import authRoutes from "./routes/auth.ts";
|
||||
import userRoutes from "./routes/users.ts";
|
||||
import departmentRoutes from "./routes/departments.ts";
|
||||
import workAllocationRoutes from "./routes/work-allocations.ts";
|
||||
import attendanceRoutes from "./routes/attendance.ts";
|
||||
import contractorRateRoutes from "./routes/contractor-rates.ts";
|
||||
|
||||
// Initialize database connection
|
||||
await db.connect();
|
||||
|
||||
// Create Oak application
|
||||
const app = new Application();
|
||||
|
||||
// Global error handler
|
||||
app.use(async (ctx, next) => {
|
||||
try {
|
||||
await next();
|
||||
} catch (err) {
|
||||
const error = err as Error & { status?: number };
|
||||
console.error("Error:", error.message);
|
||||
ctx.response.status = error.status || 500;
|
||||
ctx.response.body = {
|
||||
error: config.isDevelopment() ? error.message : "Internal server error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Apply security middleware
|
||||
app.use(cors);
|
||||
app.use(securityHeaders);
|
||||
app.use(requestLogger);
|
||||
|
||||
// Rate limiting (only in production or if enabled)
|
||||
if (config.isProduction()) {
|
||||
app.use(rateLimit);
|
||||
}
|
||||
|
||||
// Create main router
|
||||
const router = new Router();
|
||||
|
||||
// Health check endpoint
|
||||
router.get("/health", (ctx) => {
|
||||
ctx.response.body = {
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "2.0.0-deno",
|
||||
runtime: "Deno",
|
||||
};
|
||||
});
|
||||
|
||||
// Mount API routes
|
||||
router.use("/api/auth", authRoutes.routes(), authRoutes.allowedMethods());
|
||||
router.use("/api/users", userRoutes.routes(), userRoutes.allowedMethods());
|
||||
router.use("/api/departments", departmentRoutes.routes(), departmentRoutes.allowedMethods());
|
||||
router.use("/api/work-allocations", workAllocationRoutes.routes(), workAllocationRoutes.allowedMethods());
|
||||
router.use("/api/attendance", attendanceRoutes.routes(), attendanceRoutes.allowedMethods());
|
||||
router.use("/api/contractor-rates", contractorRateRoutes.routes(), contractorRateRoutes.allowedMethods());
|
||||
|
||||
// Apply routes
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
// 404 handler
|
||||
app.use((ctx) => {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Route not found" };
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const controller = new AbortController();
|
||||
|
||||
Deno.addSignalListener("SIGINT", () => {
|
||||
console.log("\n🛑 Shutting down gracefully...");
|
||||
controller.abort();
|
||||
db.close();
|
||||
Deno.exit(0);
|
||||
});
|
||||
|
||||
Deno.addSignalListener("SIGTERM", () => {
|
||||
console.log("\n🛑 Shutting down gracefully...");
|
||||
controller.abort();
|
||||
db.close();
|
||||
Deno.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Work Allocation System - Deno Backend v2.0.0 ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ 🦕 Runtime: Deno ${Deno.version.deno} ║
|
||||
║ 🔒 TypeScript with strict type checking ║
|
||||
║ 🛡️ Security: Rate limiting, CORS, XSS protection ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
|
||||
console.log(`🚀 Server running on http://localhost:${config.PORT}`);
|
||||
console.log(`📊 Health check: http://localhost:${config.PORT}/health`);
|
||||
console.log(`🔧 Environment: ${config.NODE_ENV}`);
|
||||
|
||||
await app.listen({ port: config.PORT, signal: controller.signal });
|
||||
102
backend-deno/middleware/auth.ts
Normal file
102
backend-deno/middleware/auth.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { verify, create, getNumericDate } from "djwt";
|
||||
import { config } from "../config/env.ts";
|
||||
import type { JWTPayload, UserRole } from "../types/index.ts";
|
||||
|
||||
// Create crypto key from secret
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(config.JWT_SECRET);
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
keyData,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
export async function generateToken(payload: Omit<JWTPayload, "exp" | "iat">): Promise<string> {
|
||||
const expiresIn = config.JWT_EXPIRES_IN;
|
||||
let expSeconds = 7 * 24 * 60 * 60; // Default 7 days
|
||||
|
||||
if (expiresIn.endsWith("d")) {
|
||||
expSeconds = parseInt(expiresIn) * 24 * 60 * 60;
|
||||
} else if (expiresIn.endsWith("h")) {
|
||||
expSeconds = parseInt(expiresIn) * 60 * 60;
|
||||
} else if (expiresIn.endsWith("m")) {
|
||||
expSeconds = parseInt(expiresIn) * 60;
|
||||
}
|
||||
|
||||
const token = await create(
|
||||
{ alg: "HS256", typ: "JWT" },
|
||||
{
|
||||
...payload,
|
||||
exp: getNumericDate(expSeconds),
|
||||
iat: getNumericDate(0),
|
||||
},
|
||||
cryptoKey
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// Verify JWT token
|
||||
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
const payload = await verify(token, cryptoKey);
|
||||
return payload as unknown as JWTPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
export async function authenticateToken(ctx: Context, next: Next): Promise<void> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
||||
|
||||
if (!token) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Access token required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Invalid or expired token" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach user to context state
|
||||
ctx.state.user = payload;
|
||||
await next();
|
||||
}
|
||||
|
||||
// Authorization middleware factory
|
||||
export function authorize(...roles: UserRole[]) {
|
||||
return async (ctx: Context, next: Next): Promise<void> => {
|
||||
const user = ctx.state.user as JWTPayload | undefined;
|
||||
|
||||
if (!user) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Unauthorized" };
|
||||
return;
|
||||
}
|
||||
|
||||
if (!roles.includes(user.role)) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Insufficient permissions" };
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
|
||||
// Get current user from context
|
||||
export function getCurrentUser(ctx: Context): JWTPayload {
|
||||
return ctx.state.user as JWTPayload;
|
||||
}
|
||||
152
backend-deno/middleware/security.ts
Normal file
152
backend-deno/middleware/security.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Context, Next } from "@oak/oak";
|
||||
import { config } from "../config/env.ts";
|
||||
|
||||
// Rate limiting store (in-memory, use Redis in production)
|
||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
|
||||
|
||||
// Rate limiting middleware
|
||||
export async function rateLimit(ctx: Context, next: Next): Promise<void> {
|
||||
const ip = ctx.request.ip || "unknown";
|
||||
const now = Date.now();
|
||||
const windowMs = config.RATE_LIMIT_WINDOW_MS;
|
||||
const maxRequests = config.RATE_LIMIT_MAX_REQUESTS;
|
||||
|
||||
const record = rateLimitStore.get(ip);
|
||||
|
||||
if (!record || now > record.resetTime) {
|
||||
rateLimitStore.set(ip, { count: 1, resetTime: now + windowMs });
|
||||
} else {
|
||||
record.count++;
|
||||
|
||||
if (record.count > maxRequests) {
|
||||
ctx.response.status = 429;
|
||||
ctx.response.body = {
|
||||
error: "Too many requests",
|
||||
retryAfter: Math.ceil((record.resetTime - now) / 1000)
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
// Security headers middleware
|
||||
export async function securityHeaders(ctx: Context, next: Next): Promise<void> {
|
||||
await next();
|
||||
|
||||
// Prevent clickjacking
|
||||
ctx.response.headers.set("X-Frame-Options", "DENY");
|
||||
|
||||
// Prevent MIME type sniffing
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
|
||||
// XSS protection
|
||||
ctx.response.headers.set("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
// Referrer policy
|
||||
ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
// Content Security Policy
|
||||
ctx.response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';"
|
||||
);
|
||||
|
||||
// Strict Transport Security (only in production with HTTPS)
|
||||
if (config.isProduction()) {
|
||||
ctx.response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CORS middleware
|
||||
export async function cors(ctx: Context, next: Next): Promise<void> {
|
||||
const origin = ctx.request.headers.get("Origin");
|
||||
const allowedOrigins = config.CORS_ORIGIN.split(",").map(o => o.trim());
|
||||
|
||||
// Check if origin is allowed
|
||||
if (origin && (allowedOrigins.includes(origin) || allowedOrigins.includes("*"))) {
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
} else if (config.isDevelopment()) {
|
||||
// Allow all origins in development
|
||||
ctx.response.headers.set("Access-Control-Allow-Origin", origin || "*");
|
||||
}
|
||||
|
||||
ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
||||
ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
ctx.response.headers.set("Access-Control-Allow-Credentials", "true");
|
||||
ctx.response.headers.set("Access-Control-Max-Age", "86400");
|
||||
|
||||
// Handle preflight requests
|
||||
if (ctx.request.method === "OPTIONS") {
|
||||
ctx.response.status = 204;
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
// Request logging middleware
|
||||
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
||||
const start = Date.now();
|
||||
const { method, url } = ctx.request;
|
||||
|
||||
await next();
|
||||
|
||||
const ms = Date.now() - start;
|
||||
const status = ctx.response.status;
|
||||
|
||||
// Color code based on status
|
||||
let statusColor = "\x1b[32m"; // Green for 2xx
|
||||
if (status >= 400 && status < 500) statusColor = "\x1b[33m"; // Yellow for 4xx
|
||||
if (status >= 500) statusColor = "\x1b[31m"; // Red for 5xx
|
||||
|
||||
console.log(
|
||||
`${new Date().toISOString()} - ${method} ${url.pathname} ${statusColor}${status}\x1b[0m ${ms}ms`
|
||||
);
|
||||
}
|
||||
|
||||
// Input sanitization helper
|
||||
export function sanitizeInput(input: string): string {
|
||||
return input
|
||||
.replace(/[<>]/g, "") // Remove angle brackets
|
||||
.replace(/javascript:/gi, "") // Remove javascript: protocol
|
||||
.replace(/on\w+=/gi, "") // Remove event handlers
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
export function isStrongPassword(password: string): { valid: boolean; message?: string } {
|
||||
if (password.length < 8) {
|
||||
return { valid: false, message: "Password must be at least 8 characters long" };
|
||||
}
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one uppercase letter" };
|
||||
}
|
||||
if (!/[a-z]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one lowercase letter" };
|
||||
}
|
||||
if (!/[0-9]/.test(password)) {
|
||||
return { valid: false, message: "Password must contain at least one number" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Clean up old rate limit entries periodically
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, record] of rateLimitStore.entries()) {
|
||||
if (now > record.resetTime) {
|
||||
rateLimitStore.delete(ip);
|
||||
}
|
||||
}
|
||||
}, 60000); // Clean up every minute
|
||||
293
backend-deno/routes/attendance.ts
Normal file
293
backend-deno/routes/attendance.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { db } from "../config/database.ts";
|
||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
||||
import type { Attendance, CheckInOutRequest, User } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Get all attendance records
|
||||
router.get("/", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const employeeId = params.get("employeeId");
|
||||
const startDate = params.get("startDate");
|
||||
const endDate = params.get("endDate");
|
||||
const status = params.get("status");
|
||||
|
||||
let query = `
|
||||
SELECT a.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
// Role-based filtering
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND a.supervisor_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
} else if (currentUser.role === "Employee") {
|
||||
query += " AND a.employee_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
}
|
||||
|
||||
if (employeeId) {
|
||||
query += " AND a.employee_id = ?";
|
||||
queryParams.push(employeeId);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query += " AND a.work_date >= ?";
|
||||
queryParams.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query += " AND a.work_date <= ?";
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += " AND a.status = ?";
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
query += " ORDER BY a.work_date DESC, a.check_in_time DESC";
|
||||
|
||||
const records = await db.query<Attendance[]>(query, queryParams);
|
||||
ctx.response.body = records;
|
||||
} catch (error) {
|
||||
console.error("Get attendance error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get attendance by ID
|
||||
router.get("/:id", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const attendanceId = ctx.params.id;
|
||||
|
||||
const records = await db.query<Attendance[]>(
|
||||
`SELECT a.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
WHERE a.id = ?`,
|
||||
[attendanceId]
|
||||
);
|
||||
|
||||
if (records.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Attendance record not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = records[0];
|
||||
} catch (error) {
|
||||
console.error("Get attendance error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Check in employee (Supervisor or SuperAdmin)
|
||||
router.post("/check-in", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as CheckInOutRequest;
|
||||
const { employeeId, workDate } = body;
|
||||
|
||||
if (!employeeId || !workDate) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Employee ID and work date required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify employee exists
|
||||
let employeeQuery = "SELECT * FROM users WHERE id = ? AND role = ?";
|
||||
const employeeParams: unknown[] = [employeeId, "Employee"];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
employeeQuery += " AND department_id = ?";
|
||||
employeeParams.push(currentUser.departmentId);
|
||||
}
|
||||
|
||||
const employees = await db.query<User[]>(employeeQuery, employeeParams);
|
||||
|
||||
if (employees.length === 0) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Employee not found or not in your department" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already checked in today
|
||||
const existing = await db.query<Attendance[]>(
|
||||
"SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?",
|
||||
[employeeId, workDate, "CheckedIn"]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Employee already checked in today" };
|
||||
return;
|
||||
}
|
||||
|
||||
const checkInTime = new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO attendance (employee_id, supervisor_id, check_in_time, work_date, status) VALUES (?, ?, ?, ?, ?)",
|
||||
[employeeId, currentUser.id, checkInTime, workDate, "CheckedIn"]
|
||||
);
|
||||
|
||||
const newRecord = await db.query<Attendance[]>(
|
||||
`SELECT a.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
WHERE a.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newRecord[0];
|
||||
} catch (error) {
|
||||
console.error("Check in error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Check out employee (Supervisor or SuperAdmin)
|
||||
router.post("/check-out", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as CheckInOutRequest;
|
||||
const { employeeId, workDate } = body;
|
||||
|
||||
if (!employeeId || !workDate) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Employee ID and work date required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the check-in record
|
||||
let query = "SELECT * FROM attendance WHERE employee_id = ? AND work_date = ? AND status = ?";
|
||||
const params: unknown[] = [employeeId, workDate, "CheckedIn"];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND supervisor_id = ?";
|
||||
params.push(currentUser.id);
|
||||
}
|
||||
|
||||
const records = await db.query<Attendance[]>(query, params);
|
||||
|
||||
if (records.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "No check-in record found for today" };
|
||||
return;
|
||||
}
|
||||
|
||||
const checkOutTime = new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
|
||||
await db.execute(
|
||||
"UPDATE attendance SET check_out_time = ?, status = ? WHERE id = ?",
|
||||
[checkOutTime, "CheckedOut", records[0].id]
|
||||
);
|
||||
|
||||
const updatedRecord = await db.query<Attendance[]>(
|
||||
`SELECT a.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
JOIN users s ON a.supervisor_id = s.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
LEFT JOIN users c ON e.contractor_id = c.id
|
||||
WHERE a.id = ?`,
|
||||
[records[0].id]
|
||||
);
|
||||
|
||||
ctx.response.body = updatedRecord[0];
|
||||
} catch (error) {
|
||||
console.error("Check out error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get attendance summary
|
||||
router.get("/summary/stats", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const startDate = params.get("startDate");
|
||||
const endDate = params.get("endDate");
|
||||
const departmentId = params.get("departmentId");
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
COUNT(DISTINCT a.employee_id) as total_employees,
|
||||
COUNT(DISTINCT CASE WHEN a.status = 'CheckedIn' THEN a.employee_id END) as checked_in,
|
||||
COUNT(DISTINCT CASE WHEN a.status = 'CheckedOut' THEN a.employee_id END) as checked_out,
|
||||
d.name as department_name
|
||||
FROM attendance a
|
||||
JOIN users e ON a.employee_id = e.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND a.supervisor_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
query += " AND a.work_date >= ?";
|
||||
queryParams.push(startDate);
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
query += " AND a.work_date <= ?";
|
||||
queryParams.push(endDate);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
query += " AND e.department_id = ?";
|
||||
queryParams.push(departmentId);
|
||||
}
|
||||
|
||||
query += " GROUP BY d.id, d.name";
|
||||
|
||||
const summary = await db.query(query, queryParams);
|
||||
ctx.response.body = summary;
|
||||
} catch (error) {
|
||||
console.error("Get attendance summary error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
163
backend-deno/routes/auth.ts
Normal file
163
backend-deno/routes/auth.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { hash, compare } from "bcrypt";
|
||||
import { db } from "../config/database.ts";
|
||||
import { config } from "../config/env.ts";
|
||||
import { authenticateToken, generateToken, getCurrentUser } from "../middleware/auth.ts";
|
||||
import { sanitizeInput, isValidEmail, isStrongPassword } from "../middleware/security.ts";
|
||||
import type { User, LoginRequest, ChangePasswordRequest } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json() as LoginRequest;
|
||||
const { username, password } = body;
|
||||
|
||||
// Input validation
|
||||
if (!username || !password) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Username and password required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize input
|
||||
const sanitizedUsername = sanitizeInput(username);
|
||||
|
||||
// Query user
|
||||
const users = await db.query<User[]>(
|
||||
"SELECT * FROM users WHERE username = ? AND is_active = TRUE",
|
||||
[sanitizedUsername]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
// Use generic message to prevent user enumeration
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
|
||||
// Verify password
|
||||
const validPassword = await compare(password, user.password!);
|
||||
|
||||
if (!validPassword) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Invalid credentials" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = await generateToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
departmentId: user.department_id,
|
||||
});
|
||||
|
||||
// Return user data without password
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
ctx.response.body = {
|
||||
token,
|
||||
user: userWithoutPassword,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get("/me", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
|
||||
const users = await db.query<User[]>(
|
||||
"SELECT id, username, name, email, role, department_id, contractor_id, is_active FROM users WHERE id = ?",
|
||||
[currentUser.id]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = users[0];
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post("/change-password", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as ChangePasswordRequest;
|
||||
const { currentPassword, newPassword } = body;
|
||||
|
||||
// Input validation
|
||||
if (!currentPassword || !newPassword) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Current and new password required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate new password strength (only enforce in production or if explicitly enabled)
|
||||
if (config.isProduction()) {
|
||||
const passwordCheck = isStrongPassword(newPassword);
|
||||
if (!passwordCheck.valid) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: passwordCheck.message };
|
||||
return;
|
||||
}
|
||||
} else if (newPassword.length < 6) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Password must be at least 6 characters" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current password hash
|
||||
const users = await db.query<User[]>(
|
||||
"SELECT password FROM users WHERE id = ?",
|
||||
[currentUser.id]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const validPassword = await compare(currentPassword, users[0].password!);
|
||||
|
||||
if (!validPassword) {
|
||||
ctx.response.status = 401;
|
||||
ctx.response.body = { error: "Current password is incorrect" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash new password with configured rounds
|
||||
const hashedPassword = await hash(newPassword, config.BCRYPT_ROUNDS);
|
||||
|
||||
// Update password
|
||||
await db.execute(
|
||||
"UPDATE users SET password = ? WHERE id = ?",
|
||||
[hashedPassword, currentUser.id]
|
||||
);
|
||||
|
||||
ctx.response.body = { message: "Password changed successfully" };
|
||||
} catch (error) {
|
||||
console.error("Change password error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
241
backend-deno/routes/contractor-rates.ts
Normal file
241
backend-deno/routes/contractor-rates.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { db } from "../config/database.ts";
|
||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
||||
import { sanitizeInput } from "../middleware/security.ts";
|
||||
import type { ContractorRate, CreateContractorRateRequest, User } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Get contractor rates
|
||||
router.get("/", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const contractorId = params.get("contractorId");
|
||||
const subDepartmentId = params.get("subDepartmentId");
|
||||
|
||||
let query = `
|
||||
SELECT cr.*,
|
||||
u.name as contractor_name, u.username as contractor_username,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM contractor_rates cr
|
||||
JOIN users u ON cr.contractor_id = u.id
|
||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON sd.department_id = d.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
if (contractorId) {
|
||||
query += " AND cr.contractor_id = ?";
|
||||
queryParams.push(contractorId);
|
||||
}
|
||||
|
||||
if (subDepartmentId) {
|
||||
query += " AND cr.sub_department_id = ?";
|
||||
queryParams.push(subDepartmentId);
|
||||
}
|
||||
|
||||
query += " ORDER BY cr.effective_date DESC, cr.created_at DESC";
|
||||
|
||||
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
||||
ctx.response.body = rates;
|
||||
} catch (error) {
|
||||
console.error("Get contractor rates error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get current rate for a contractor + sub-department combination
|
||||
router.get("/contractor/:contractorId/current", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const contractorId = ctx.params.contractorId;
|
||||
const params = ctx.request.url.searchParams;
|
||||
const subDepartmentId = params.get("subDepartmentId");
|
||||
|
||||
let query = `
|
||||
SELECT cr.*,
|
||||
u.name as contractor_name, u.username as contractor_username,
|
||||
sd.name as sub_department_name
|
||||
FROM contractor_rates cr
|
||||
JOIN users u ON cr.contractor_id = u.id
|
||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||
WHERE cr.contractor_id = ?
|
||||
`;
|
||||
const queryParams: unknown[] = [contractorId];
|
||||
|
||||
if (subDepartmentId) {
|
||||
query += " AND cr.sub_department_id = ?";
|
||||
queryParams.push(subDepartmentId);
|
||||
}
|
||||
|
||||
query += " ORDER BY cr.effective_date DESC LIMIT 1";
|
||||
|
||||
const rates = await db.query<ContractorRate[]>(query, queryParams);
|
||||
|
||||
if (rates.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "No rate found for contractor" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = rates[0];
|
||||
} catch (error) {
|
||||
console.error("Get current rate error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Set contractor rate (Supervisor or SuperAdmin)
|
||||
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as CreateContractorRateRequest;
|
||||
const { contractorId, subDepartmentId, activity, rate, effectiveDate } = body;
|
||||
|
||||
if (!contractorId || !rate || !effectiveDate) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Missing required fields (contractorId, rate, effectiveDate)" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify contractor exists
|
||||
const contractors = await db.query<User[]>(
|
||||
"SELECT * FROM users WHERE id = ? AND role = ?",
|
||||
[contractorId, "Contractor"]
|
||||
);
|
||||
|
||||
if (contractors.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Contractor not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisors can only set rates for contractors in their department
|
||||
if (currentUser.role === "Supervisor" && contractors[0].department_id !== currentUser.departmentId) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Contractor not in your department" };
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO contractor_rates (contractor_id, sub_department_id, activity, rate, effective_date) VALUES (?, ?, ?, ?, ?)",
|
||||
[contractorId, subDepartmentId || null, sanitizedActivity, rate, effectiveDate]
|
||||
);
|
||||
|
||||
const newRate = await db.query<ContractorRate[]>(
|
||||
`SELECT cr.*,
|
||||
u.name as contractor_name, u.username as contractor_username,
|
||||
sd.name as sub_department_name
|
||||
FROM contractor_rates cr
|
||||
JOIN users u ON cr.contractor_id = u.id
|
||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||
WHERE cr.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newRate[0];
|
||||
} catch (error) {
|
||||
console.error("Set contractor rate error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Update contractor rate
|
||||
router.put("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const rateId = ctx.params.id;
|
||||
const body = await ctx.request.body.json() as { rate?: number; activity?: string; effectiveDate?: string };
|
||||
const { rate, activity, effectiveDate } = body;
|
||||
|
||||
const existing = await db.query<ContractorRate[]>(
|
||||
"SELECT * FROM contractor_rates WHERE id = ?",
|
||||
[rateId]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Rate not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (rate !== undefined) {
|
||||
updates.push("rate = ?");
|
||||
params.push(rate);
|
||||
}
|
||||
if (activity !== undefined) {
|
||||
updates.push("activity = ?");
|
||||
params.push(sanitizeInput(activity));
|
||||
}
|
||||
if (effectiveDate !== undefined) {
|
||||
updates.push("effective_date = ?");
|
||||
params.push(effectiveDate);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "No fields to update" };
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(rateId);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE contractor_rates SET ${updates.join(", ")} WHERE id = ?`,
|
||||
params
|
||||
);
|
||||
|
||||
const updatedRate = await db.query<ContractorRate[]>(
|
||||
`SELECT cr.*,
|
||||
u.name as contractor_name, u.username as contractor_username,
|
||||
sd.name as sub_department_name
|
||||
FROM contractor_rates cr
|
||||
JOIN users u ON cr.contractor_id = u.id
|
||||
LEFT JOIN sub_departments sd ON cr.sub_department_id = sd.id
|
||||
WHERE cr.id = ?`,
|
||||
[rateId]
|
||||
);
|
||||
|
||||
ctx.response.body = updatedRate[0];
|
||||
} catch (error) {
|
||||
console.error("Update contractor rate error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete contractor rate
|
||||
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const rateId = ctx.params.id;
|
||||
|
||||
const existing = await db.query<ContractorRate[]>(
|
||||
"SELECT * FROM contractor_rates WHERE id = ?",
|
||||
[rateId]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Rate not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute("DELETE FROM contractor_rates WHERE id = ?", [rateId]);
|
||||
ctx.response.body = { message: "Rate deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Delete contractor rate error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
139
backend-deno/routes/departments.ts
Normal file
139
backend-deno/routes/departments.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { db } from "../config/database.ts";
|
||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
||||
import { sanitizeInput } from "../middleware/security.ts";
|
||||
import type { Department, SubDepartment } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Get all departments
|
||||
router.get("/", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const departments = await db.query<Department[]>(
|
||||
"SELECT * FROM departments ORDER BY name"
|
||||
);
|
||||
ctx.response.body = departments;
|
||||
} catch (error) {
|
||||
console.error("Get departments error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get department by ID
|
||||
router.get("/:id", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const deptId = ctx.params.id;
|
||||
|
||||
const departments = await db.query<Department[]>(
|
||||
"SELECT * FROM departments WHERE id = ?",
|
||||
[deptId]
|
||||
);
|
||||
|
||||
if (departments.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Department not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = departments[0];
|
||||
} catch (error) {
|
||||
console.error("Get department error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get sub-departments by department ID
|
||||
router.get("/:id/sub-departments", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const deptId = ctx.params.id;
|
||||
|
||||
const subDepartments = await db.query<SubDepartment[]>(
|
||||
"SELECT * FROM sub_departments WHERE department_id = ? ORDER BY name",
|
||||
[deptId]
|
||||
);
|
||||
|
||||
ctx.response.body = subDepartments;
|
||||
} catch (error) {
|
||||
console.error("Get sub-departments error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Create department (SuperAdmin only)
|
||||
router.post("/", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const body = await ctx.request.body.json() as { name: string };
|
||||
const { name } = body;
|
||||
|
||||
if (!name) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Department name required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedName = sanitizeInput(name);
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO departments (name) VALUES (?)",
|
||||
[sanitizedName]
|
||||
);
|
||||
|
||||
const newDepartment = await db.query<Department[]>(
|
||||
"SELECT * FROM departments WHERE id = ?",
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newDepartment[0];
|
||||
} catch (error) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === "ER_DUP_ENTRY") {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Department already exists" };
|
||||
return;
|
||||
}
|
||||
console.error("Create department error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Create sub-department (SuperAdmin only)
|
||||
router.post("/:id/sub-departments", authenticateToken, authorize("SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const deptId = ctx.params.id;
|
||||
const body = await ctx.request.body.json() as { name: string; primaryActivity: string };
|
||||
const { name, primaryActivity } = body;
|
||||
|
||||
if (!name || !primaryActivity) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Name and primary activity required" };
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedName = sanitizeInput(name);
|
||||
const sanitizedActivity = sanitizeInput(primaryActivity);
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)",
|
||||
[deptId, sanitizedName, sanitizedActivity]
|
||||
);
|
||||
|
||||
const newSubDepartment = await db.query<SubDepartment[]>(
|
||||
"SELECT * FROM sub_departments WHERE id = ?",
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newSubDepartment[0];
|
||||
} catch (error) {
|
||||
console.error("Create sub-department error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
312
backend-deno/routes/users.ts
Normal file
312
backend-deno/routes/users.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { hash } from "bcrypt";
|
||||
import { db } from "../config/database.ts";
|
||||
import { config } from "../config/env.ts";
|
||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
||||
import { sanitizeInput, isValidEmail } from "../middleware/security.ts";
|
||||
import type { User, CreateUserRequest, UpdateUserRequest } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Get all users (with filters)
|
||||
router.get("/", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const role = params.get("role");
|
||||
const departmentId = params.get("departmentId");
|
||||
|
||||
let query = `
|
||||
SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||
u.contractor_id, u.is_active, u.created_at,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
LEFT JOIN users c ON u.contractor_id = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
// Supervisors can only see users in their department
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND u.department_id = ?";
|
||||
queryParams.push(currentUser.departmentId);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
query += " AND u.role = ?";
|
||||
queryParams.push(role);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
query += " AND u.department_id = ?";
|
||||
queryParams.push(departmentId);
|
||||
}
|
||||
|
||||
query += " ORDER BY u.created_at DESC";
|
||||
|
||||
const users = await db.query<User[]>(query, queryParams);
|
||||
ctx.response.body = users;
|
||||
} catch (error) {
|
||||
console.error("Get users error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get user by ID
|
||||
router.get("/:id", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const userId = ctx.params.id;
|
||||
|
||||
const users = await db.query<User[]>(
|
||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||
u.contractor_id, u.is_active, u.created_at,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
LEFT JOIN users c ON u.contractor_id = c.id
|
||||
WHERE u.id = ?`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisors can only view users in their department
|
||||
if (currentUser.role === "Supervisor" && users[0].department_id !== currentUser.departmentId) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Access denied" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = users[0];
|
||||
} catch (error) {
|
||||
console.error("Get user error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post("/", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as CreateUserRequest;
|
||||
const { username, name, email, password, role, departmentId, contractorId } = body;
|
||||
|
||||
// Input validation
|
||||
if (!username || !name || !email || !password || !role) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Missing required fields" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize inputs
|
||||
const sanitizedUsername = sanitizeInput(username);
|
||||
const sanitizedName = sanitizeInput(name);
|
||||
const sanitizedEmail = sanitizeInput(email);
|
||||
|
||||
// Validate email
|
||||
if (!isValidEmail(sanitizedEmail)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid email format" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisors can only create users in their department
|
||||
if (currentUser.role === "Supervisor") {
|
||||
if (departmentId !== currentUser.departmentId) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Can only create users in your department" };
|
||||
return;
|
||||
}
|
||||
if (role === "SuperAdmin" || role === "Supervisor") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Cannot create admin or supervisor users" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hash(password, config.BCRYPT_ROUNDS);
|
||||
|
||||
const result = await db.execute(
|
||||
"INSERT INTO users (username, name, email, password, role, department_id, contractor_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[sanitizedUsername, sanitizedName, sanitizedEmail, hashedPassword, role, departmentId || null, contractorId || null]
|
||||
);
|
||||
|
||||
const newUser = await db.query<User[]>(
|
||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||
u.contractor_id, u.is_active, u.created_at,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
LEFT JOIN users c ON u.contractor_id = c.id
|
||||
WHERE u.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newUser[0];
|
||||
} catch (error) {
|
||||
const err = error as { code?: string };
|
||||
if (err.code === "ER_DUP_ENTRY") {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Username or email already exists" };
|
||||
return;
|
||||
}
|
||||
console.error("Create user error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Update user
|
||||
router.put("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const userId = ctx.params.id;
|
||||
const body = await ctx.request.body.json() as UpdateUserRequest;
|
||||
const { name, email, role, departmentId, contractorId, isActive } = body;
|
||||
|
||||
// Check if user exists
|
||||
const existingUsers = await db.query<User[]>(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (existingUsers.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisors can only update users in their department
|
||||
if (currentUser.role === "Supervisor") {
|
||||
if (existingUsers[0].department_id !== currentUser.departmentId) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Can only update users in your department" };
|
||||
return;
|
||||
}
|
||||
if (role === "SuperAdmin" || role === "Supervisor") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Cannot modify admin or supervisor roles" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.push("name = ?");
|
||||
params.push(sanitizeInput(name));
|
||||
}
|
||||
if (email !== undefined) {
|
||||
if (!isValidEmail(email)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Invalid email format" };
|
||||
return;
|
||||
}
|
||||
updates.push("email = ?");
|
||||
params.push(sanitizeInput(email));
|
||||
}
|
||||
if (role !== undefined) {
|
||||
updates.push("role = ?");
|
||||
params.push(role);
|
||||
}
|
||||
if (departmentId !== undefined) {
|
||||
updates.push("department_id = ?");
|
||||
params.push(departmentId);
|
||||
}
|
||||
if (contractorId !== undefined) {
|
||||
updates.push("contractor_id = ?");
|
||||
params.push(contractorId);
|
||||
}
|
||||
if (isActive !== undefined) {
|
||||
updates.push("is_active = ?");
|
||||
params.push(isActive);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "No fields to update" };
|
||||
return;
|
||||
}
|
||||
|
||||
params.push(userId);
|
||||
|
||||
await db.execute(
|
||||
`UPDATE users SET ${updates.join(", ")} WHERE id = ?`,
|
||||
params
|
||||
);
|
||||
|
||||
const updatedUser = await db.query<User[]>(
|
||||
`SELECT u.id, u.username, u.name, u.email, u.role, u.department_id,
|
||||
u.contractor_id, u.is_active, u.created_at,
|
||||
d.name as department_name,
|
||||
c.name as contractor_name
|
||||
FROM users u
|
||||
LEFT JOIN departments d ON u.department_id = d.id
|
||||
LEFT JOIN users c ON u.contractor_id = c.id
|
||||
WHERE u.id = ?`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
ctx.response.body = updatedUser[0];
|
||||
} catch (error) {
|
||||
console.error("Update user error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete("/:id", authenticateToken, authorize("SuperAdmin", "Supervisor"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const userId = ctx.params.id;
|
||||
|
||||
const users = await db.query<User[]>(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "User not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisors can only delete users in their department
|
||||
if (currentUser.role === "Supervisor") {
|
||||
if (users[0].department_id !== currentUser.departmentId) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Can only delete users in your department" };
|
||||
return;
|
||||
}
|
||||
if (users[0].role === "SuperAdmin" || users[0].role === "Supervisor") {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Cannot delete admin or supervisor users" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await db.execute("DELETE FROM users WHERE id = ?", [userId]);
|
||||
ctx.response.body = { message: "User deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Delete user error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
278
backend-deno/routes/work-allocations.ts
Normal file
278
backend-deno/routes/work-allocations.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { db } from "../config/database.ts";
|
||||
import { authenticateToken, authorize, getCurrentUser } from "../middleware/auth.ts";
|
||||
import { sanitizeInput } from "../middleware/security.ts";
|
||||
import type { WorkAllocation, CreateWorkAllocationRequest, ContractorRate } from "../types/index.ts";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
// Get all work allocations
|
||||
router.get("/", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const params = ctx.request.url.searchParams;
|
||||
const employeeId = params.get("employeeId");
|
||||
const status = params.get("status");
|
||||
const departmentId = params.get("departmentId");
|
||||
|
||||
let query = `
|
||||
SELECT wa.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
JOIN users c ON wa.contractor_id = c.id
|
||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const queryParams: unknown[] = [];
|
||||
|
||||
// Role-based filtering
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND wa.supervisor_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
} else if (currentUser.role === "Employee") {
|
||||
query += " AND wa.employee_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
} else if (currentUser.role === "Contractor") {
|
||||
query += " AND wa.contractor_id = ?";
|
||||
queryParams.push(currentUser.id);
|
||||
}
|
||||
|
||||
if (employeeId) {
|
||||
query += " AND wa.employee_id = ?";
|
||||
queryParams.push(employeeId);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += " AND wa.status = ?";
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
query += " AND e.department_id = ?";
|
||||
queryParams.push(departmentId);
|
||||
}
|
||||
|
||||
query += " ORDER BY wa.assigned_date DESC, wa.created_at DESC";
|
||||
|
||||
const allocations = await db.query<WorkAllocation[]>(query, queryParams);
|
||||
ctx.response.body = allocations;
|
||||
} catch (error) {
|
||||
console.error("Get work allocations error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Get work allocation by ID
|
||||
router.get("/:id", authenticateToken, async (ctx) => {
|
||||
try {
|
||||
const allocationId = ctx.params.id;
|
||||
|
||||
const allocations = await db.query<WorkAllocation[]>(
|
||||
`SELECT wa.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
JOIN users c ON wa.contractor_id = c.id
|
||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE wa.id = ?`,
|
||||
[allocationId]
|
||||
);
|
||||
|
||||
if (allocations.length === 0) {
|
||||
ctx.response.status = 404;
|
||||
ctx.response.body = { error: "Work allocation not found" };
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.response.body = allocations[0];
|
||||
} catch (error) {
|
||||
console.error("Get work allocation error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Create work allocation (Supervisor or SuperAdmin)
|
||||
router.post("/", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const body = await ctx.request.body.json() as CreateWorkAllocationRequest;
|
||||
const { employeeId, contractorId, subDepartmentId, activity, description, assignedDate, rate, units, totalAmount, departmentId } = body;
|
||||
|
||||
if (!employeeId || !contractorId || !assignedDate) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Missing required fields" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify employee exists
|
||||
let employeeQuery = "SELECT * FROM users WHERE id = ?";
|
||||
const employeeParams: unknown[] = [employeeId];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
employeeQuery += " AND department_id = ?";
|
||||
employeeParams.push(currentUser.departmentId);
|
||||
}
|
||||
|
||||
const employees = await db.query<{ id: number }[]>(employeeQuery, employeeParams);
|
||||
|
||||
if (employees.length === 0) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Employee not found or not in your department" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided rate or get contractor's current rate
|
||||
let finalRate = rate;
|
||||
if (!finalRate) {
|
||||
const rates = await db.query<ContractorRate[]>(
|
||||
"SELECT rate FROM contractor_rates WHERE contractor_id = ? ORDER BY effective_date DESC LIMIT 1",
|
||||
[contractorId]
|
||||
);
|
||||
finalRate = rates.length > 0 ? rates[0].rate : null;
|
||||
}
|
||||
|
||||
const sanitizedActivity = activity ? sanitizeInput(activity) : null;
|
||||
const sanitizedDescription = description ? sanitizeInput(description) : null;
|
||||
|
||||
const result = await db.execute(
|
||||
`INSERT INTO work_allocations
|
||||
(employee_id, supervisor_id, contractor_id, sub_department_id, activity, description, assigned_date, rate, units, total_amount)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[employeeId, currentUser.id, contractorId, subDepartmentId || null, sanitizedActivity, sanitizedDescription, assignedDate, finalRate, units || null, totalAmount || null]
|
||||
);
|
||||
|
||||
const newAllocation = await db.query<WorkAllocation[]>(
|
||||
`SELECT wa.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
JOIN users c ON wa.contractor_id = c.id
|
||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE wa.id = ?`,
|
||||
[result.insertId]
|
||||
);
|
||||
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = newAllocation[0];
|
||||
} catch (error) {
|
||||
console.error("Create work allocation error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Update work allocation status (Supervisor or SuperAdmin)
|
||||
router.put("/:id/status", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const allocationId = ctx.params.id;
|
||||
const body = await ctx.request.body.json() as { status: string; completionDate?: string };
|
||||
const { status, completionDate } = body;
|
||||
|
||||
if (!status) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = { error: "Status required" };
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify allocation exists and user has access
|
||||
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
||||
const params: unknown[] = [allocationId];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND supervisor_id = ?";
|
||||
params.push(currentUser.id);
|
||||
}
|
||||
|
||||
const allocations = await db.query<WorkAllocation[]>(query, params);
|
||||
|
||||
if (allocations.length === 0) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Work allocation not found or access denied" };
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute(
|
||||
"UPDATE work_allocations SET status = ?, completion_date = ? WHERE id = ?",
|
||||
[status, completionDate || null, allocationId]
|
||||
);
|
||||
|
||||
const updatedAllocation = await db.query<WorkAllocation[]>(
|
||||
`SELECT wa.*,
|
||||
e.name as employee_name, e.username as employee_username,
|
||||
s.name as supervisor_name,
|
||||
c.name as contractor_name,
|
||||
sd.name as sub_department_name,
|
||||
d.name as department_name
|
||||
FROM work_allocations wa
|
||||
JOIN users e ON wa.employee_id = e.id
|
||||
JOIN users s ON wa.supervisor_id = s.id
|
||||
JOIN users c ON wa.contractor_id = c.id
|
||||
LEFT JOIN sub_departments sd ON wa.sub_department_id = sd.id
|
||||
LEFT JOIN departments d ON e.department_id = d.id
|
||||
WHERE wa.id = ?`,
|
||||
[allocationId]
|
||||
);
|
||||
|
||||
ctx.response.body = updatedAllocation[0];
|
||||
} catch (error) {
|
||||
console.error("Update work allocation error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
// Delete work allocation (Supervisor or SuperAdmin)
|
||||
router.delete("/:id", authenticateToken, authorize("Supervisor", "SuperAdmin"), async (ctx) => {
|
||||
try {
|
||||
const currentUser = getCurrentUser(ctx);
|
||||
const allocationId = ctx.params.id;
|
||||
|
||||
// Verify allocation exists and user has access
|
||||
let query = "SELECT * FROM work_allocations WHERE id = ?";
|
||||
const params: unknown[] = [allocationId];
|
||||
|
||||
if (currentUser.role === "Supervisor") {
|
||||
query += " AND supervisor_id = ?";
|
||||
params.push(currentUser.id);
|
||||
}
|
||||
|
||||
const allocations = await db.query<WorkAllocation[]>(query, params);
|
||||
|
||||
if (allocations.length === 0) {
|
||||
ctx.response.status = 403;
|
||||
ctx.response.body = { error: "Work allocation not found or access denied" };
|
||||
return;
|
||||
}
|
||||
|
||||
await db.execute("DELETE FROM work_allocations WHERE id = ?", [allocationId]);
|
||||
ctx.response.body = { message: "Work allocation deleted successfully" };
|
||||
} catch (error) {
|
||||
console.error("Delete work allocation error:", error);
|
||||
ctx.response.status = 500;
|
||||
ctx.response.body = { error: "Internal server error" };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
247
backend-deno/scripts/seed.ts
Normal file
247
backend-deno/scripts/seed.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { hash } from "bcrypt";
|
||||
import { db } from "../config/database.ts";
|
||||
import { config } from "../config/env.ts";
|
||||
|
||||
async function seedDatabase() {
|
||||
try {
|
||||
console.log("🔌 Connecting to database...");
|
||||
await db.connect();
|
||||
console.log("✅ Connected to database\n");
|
||||
|
||||
// 1. Seed Departments
|
||||
console.log("📁 Seeding departments...");
|
||||
const existingDepts = await db.query<{ count: number }[]>(
|
||||
"SELECT COUNT(*) as count FROM departments"
|
||||
);
|
||||
|
||||
if (existingDepts[0].count === 0) {
|
||||
await db.execute(`
|
||||
INSERT INTO departments (name) VALUES
|
||||
('Tudki'),
|
||||
('Dana'),
|
||||
('Groundnut')
|
||||
`);
|
||||
console.log(" ✅ Departments created");
|
||||
} else {
|
||||
console.log(" ℹ️ Departments already exist");
|
||||
}
|
||||
|
||||
// 2. Seed Sub-departments for Groundnut
|
||||
console.log("📂 Seeding sub-departments...");
|
||||
const groundnutDept = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM departments WHERE name = ?",
|
||||
["Groundnut"]
|
||||
);
|
||||
|
||||
let groundnutId: number | null = null;
|
||||
|
||||
if (groundnutDept.length > 0) {
|
||||
groundnutId = groundnutDept[0].id;
|
||||
const existingSubDepts = await db.query<{ count: number }[]>(
|
||||
"SELECT COUNT(*) as count FROM sub_departments WHERE department_id = ?",
|
||||
[groundnutId]
|
||||
);
|
||||
|
||||
if (existingSubDepts[0].count === 0) {
|
||||
const subDepts = [
|
||||
"Mufali Aavak Katai",
|
||||
"Mufali Aavak Dhang",
|
||||
"Dhang Se Katai",
|
||||
"Guthli Bori Silai Dhang",
|
||||
"Guthali dada Pala Tulai Silai Dhang",
|
||||
"Mufali Patthar Bori silai dhang",
|
||||
"Mufali Patthar Bori Utrai",
|
||||
"Bardana Bandal Loading Unloading",
|
||||
"Bardana Gatthi Loading",
|
||||
"Black Dana Loading/Unloading",
|
||||
"Pre Cleaning",
|
||||
"Destoner",
|
||||
"Water",
|
||||
"Decordicater",
|
||||
"Round Chalna",
|
||||
"Cleaning",
|
||||
"Round Chalna No.1"
|
||||
];
|
||||
|
||||
for (const name of subDepts) {
|
||||
await db.execute(
|
||||
"INSERT INTO sub_departments (department_id, name, primary_activity) VALUES (?, ?, ?)",
|
||||
[groundnutId, name, "Loading/Unloading"]
|
||||
);
|
||||
}
|
||||
console.log(" ✅ Sub-departments created");
|
||||
} else {
|
||||
console.log(" ℹ️ Sub-departments already exist");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Seed SuperAdmin
|
||||
console.log("👤 Seeding SuperAdmin user...");
|
||||
const existingAdmin = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
["admin"]
|
||||
);
|
||||
|
||||
const adminPassword = await hash("admin123", config.BCRYPT_ROUNDS);
|
||||
|
||||
if (existingAdmin.length > 0) {
|
||||
await db.execute(
|
||||
"UPDATE users SET password = ?, is_active = TRUE WHERE username = ?",
|
||||
[adminPassword, "admin"]
|
||||
);
|
||||
console.log(" ✅ SuperAdmin password updated");
|
||||
} else {
|
||||
await db.execute(
|
||||
"INSERT INTO users (username, name, email, password, role, is_active) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
["admin", "Super Admin", "admin@workallocate.com", adminPassword, "SuperAdmin", true]
|
||||
);
|
||||
console.log(" ✅ SuperAdmin created");
|
||||
}
|
||||
|
||||
// 4. Seed Sample Supervisors
|
||||
console.log("👥 Seeding sample supervisors...");
|
||||
const tudkiDept = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM departments WHERE name = ?",
|
||||
["Tudki"]
|
||||
);
|
||||
const danaDept = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM departments WHERE name = ?",
|
||||
["Dana"]
|
||||
);
|
||||
|
||||
const supervisorPassword = await hash("supervisor123", config.BCRYPT_ROUNDS);
|
||||
|
||||
const supervisors = [
|
||||
{ username: "supervisor_tudki", name: "Tudki Supervisor", email: "supervisor.tudki@workallocate.com", deptId: tudkiDept[0]?.id },
|
||||
{ username: "supervisor_dana", name: "Dana Supervisor", email: "supervisor.dana@workallocate.com", deptId: danaDept[0]?.id },
|
||||
{ username: "supervisor_groundnut", name: "Groundnut Supervisor", email: "supervisor.groundnut@workallocate.com", deptId: groundnutId }
|
||||
];
|
||||
|
||||
for (const sup of supervisors) {
|
||||
if (sup.deptId) {
|
||||
const existing = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
[sup.username]
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await db.execute(
|
||||
"INSERT INTO users (username, name, email, password, role, department_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[sup.username, sup.name, sup.email, supervisorPassword, "Supervisor", sup.deptId, true]
|
||||
);
|
||||
console.log(` ✅ ${sup.name} created`);
|
||||
} else {
|
||||
console.log(` ℹ️ ${sup.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Seed Sample Contractors
|
||||
console.log("🏗️ Seeding sample contractors...");
|
||||
const contractorPassword = await hash("contractor123", config.BCRYPT_ROUNDS);
|
||||
|
||||
const contractors = [
|
||||
{ username: "contractor1", name: "Contractor One", email: "contractor1@workallocate.com", deptId: groundnutId },
|
||||
{ username: "contractor2", name: "Contractor Two", email: "contractor2@workallocate.com", deptId: groundnutId }
|
||||
];
|
||||
|
||||
for (const con of contractors) {
|
||||
const existing = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
[con.username]
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await db.execute(
|
||||
"INSERT INTO users (username, name, email, password, role, department_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
[con.username, con.name, con.email, contractorPassword, "Contractor", con.deptId, true]
|
||||
);
|
||||
console.log(` ✅ ${con.name} created`);
|
||||
} else {
|
||||
console.log(` ℹ️ ${con.name} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Seed Sample Employees
|
||||
console.log("👷 Seeding sample employees...");
|
||||
const contractor1 = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
["contractor1"]
|
||||
);
|
||||
const employeePassword = await hash("employee123", config.BCRYPT_ROUNDS);
|
||||
|
||||
if (contractor1.length > 0) {
|
||||
const employees = [
|
||||
{ username: "employee1", name: "Employee One", email: "employee1@workallocate.com" },
|
||||
{ username: "employee2", name: "Employee Two", email: "employee2@workallocate.com" },
|
||||
{ username: "employee3", name: "Employee Three", email: "employee3@workallocate.com" }
|
||||
];
|
||||
|
||||
for (const emp of employees) {
|
||||
const existing = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
[emp.username]
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await db.execute(
|
||||
"INSERT INTO users (username, name, email, password, role, department_id, contractor_id, is_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[emp.username, emp.name, emp.email, employeePassword, "Employee", groundnutId, contractor1[0].id, true]
|
||||
);
|
||||
console.log(` ✅ ${emp.name} created`);
|
||||
} else {
|
||||
console.log(` ℹ️ ${emp.name} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Seed Contractor Rates
|
||||
console.log("💰 Seeding contractor rates...");
|
||||
if (contractor1.length > 0) {
|
||||
const existingRate = await db.query<{ id: number }[]>(
|
||||
"SELECT id FROM contractor_rates WHERE contractor_id = ?",
|
||||
[contractor1[0].id]
|
||||
);
|
||||
|
||||
if (existingRate.length === 0) {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
await db.execute(
|
||||
"INSERT INTO contractor_rates (contractor_id, rate, effective_date) VALUES (?, ?, ?)",
|
||||
[contractor1[0].id, 500.00, today]
|
||||
);
|
||||
console.log(" ✅ Contractor rates created");
|
||||
} else {
|
||||
console.log(" ℹ️ Contractor rates already exist");
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
✅ Database seeding completed successfully!
|
||||
|
||||
🔑 Default Login Credentials:
|
||||
|
||||
SuperAdmin:
|
||||
Username: admin
|
||||
Password: admin123
|
||||
|
||||
Supervisor (Groundnut):
|
||||
Username: supervisor_groundnut
|
||||
Password: supervisor123
|
||||
|
||||
Contractor:
|
||||
Username: contractor1
|
||||
Password: contractor123
|
||||
|
||||
Employee:
|
||||
Username: employee1
|
||||
Password: employee123
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error seeding database:", (error as Error).message);
|
||||
Deno.exit(1);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
await seedDatabase();
|
||||
170
backend-deno/types/index.ts
Normal file
170
backend-deno/types/index.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
// User types
|
||||
export type UserRole = "SuperAdmin" | "Supervisor" | "Contractor" | "Employee";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
role: UserRole;
|
||||
department_id: number | null;
|
||||
contractor_id: number | null;
|
||||
is_active: boolean;
|
||||
created_at: Date;
|
||||
department_name?: string;
|
||||
contractor_name?: string;
|
||||
}
|
||||
|
||||
export interface JWTPayload {
|
||||
id: number;
|
||||
username: string;
|
||||
role: UserRole;
|
||||
departmentId: number | null;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
// Department types
|
||||
export interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface SubDepartment {
|
||||
id: number;
|
||||
department_id: number;
|
||||
name: string;
|
||||
primary_activity: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// Work allocation types
|
||||
export type AllocationStatus = "Pending" | "InProgress" | "Completed" | "Cancelled";
|
||||
|
||||
export interface WorkAllocation {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
supervisor_id: number;
|
||||
contractor_id: number;
|
||||
sub_department_id: number | null;
|
||||
activity: string | null;
|
||||
description: string | null;
|
||||
assigned_date: Date;
|
||||
completion_date: Date | null;
|
||||
status: AllocationStatus;
|
||||
rate: number | null;
|
||||
units: number | null;
|
||||
total_amount: number | null;
|
||||
created_at: Date;
|
||||
employee_name?: string;
|
||||
supervisor_name?: string;
|
||||
contractor_name?: string;
|
||||
sub_department_name?: string;
|
||||
department_name?: string;
|
||||
}
|
||||
|
||||
// Attendance types
|
||||
export type AttendanceStatus = "CheckedIn" | "CheckedOut" | "Absent";
|
||||
|
||||
export interface Attendance {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
supervisor_id: number;
|
||||
check_in_time: Date | null;
|
||||
check_out_time: Date | null;
|
||||
work_date: Date;
|
||||
status: AttendanceStatus;
|
||||
created_at: Date;
|
||||
employee_name?: string;
|
||||
supervisor_name?: string;
|
||||
department_name?: string;
|
||||
contractor_name?: string;
|
||||
}
|
||||
|
||||
// Contractor rate types
|
||||
export interface ContractorRate {
|
||||
id: number;
|
||||
contractor_id: number;
|
||||
sub_department_id: number | null;
|
||||
activity: string | null;
|
||||
rate: number;
|
||||
effective_date: Date;
|
||||
created_at: Date;
|
||||
contractor_name?: string;
|
||||
sub_department_name?: string;
|
||||
department_name?: string;
|
||||
}
|
||||
|
||||
// API response types
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface ApiSuccess<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: Omit<User, "password">;
|
||||
}
|
||||
|
||||
// Request body types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role: UserRole;
|
||||
departmentId?: number | null;
|
||||
contractorId?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
name?: string;
|
||||
email?: string;
|
||||
role?: UserRole;
|
||||
departmentId?: number | null;
|
||||
contractorId?: number | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface CreateWorkAllocationRequest {
|
||||
employeeId: number;
|
||||
contractorId: number;
|
||||
subDepartmentId?: number | null;
|
||||
activity?: string | null;
|
||||
description?: string | null;
|
||||
assignedDate: string;
|
||||
rate?: number | null;
|
||||
units?: number | null;
|
||||
totalAmount?: number | null;
|
||||
departmentId?: number | null;
|
||||
}
|
||||
|
||||
export interface CheckInOutRequest {
|
||||
employeeId: number;
|
||||
workDate: string;
|
||||
}
|
||||
|
||||
export interface CreateContractorRateRequest {
|
||||
contractorId: number;
|
||||
subDepartmentId?: number | null;
|
||||
activity?: string | null;
|
||||
rate: number;
|
||||
effectiveDate: string;
|
||||
}
|
||||
Reference in New Issue
Block a user