package main import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log" "net" "net/http" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" "github.com/golang-jwt/jwt/v5" _ "github.com/lib/pq" ) // Rate limiting configuration const ( rateLimitWindow = 1 * time.Minute maxWriteRequests = 30 // Max write requests per minute per IP maxReadRequests = 100 // Max read requests per minute per IP maxRequestBody = 1 << 20 // 1MB max request body size ) type rateLimiter struct { mu sync.RWMutex requests map[string]*requestInfo } type requestInfo struct { count int firstReq time.Time } var writeLimiter = &rateLimiter{requests: make(map[string]*requestInfo)} var readLimiter = &rateLimiter{requests: make(map[string]*requestInfo)} func (rl *rateLimiter) checkRateLimit(key string, maxRequests int, window time.Duration) bool { rl.mu.Lock() defer rl.mu.Unlock() now := time.Now() info, exists := rl.requests[key] if !exists { rl.requests[key] = &requestInfo{count: 1, firstReq: now} return false } if now.Sub(info.firstReq) > window { info.count = 1 info.firstReq = now return false } info.count++ return info.count > maxRequests } func getClientIP(r *http.Request) string { xff := r.Header.Get("X-Forwarded-For") if xff != "" { if idx := strings.Index(xff, ","); idx != -1 { return strings.TrimSpace(xff[:idx]) } return strings.TrimSpace(xff) } xri := r.Header.Get("X-Real-IP") if xri != "" { return strings.TrimSpace(xri) } ip, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return ip } // Project represents a project in the system. type Project struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` ClientID *int `json:"clientId"` IPFSMetadataCID *string `json:"ipfsMetadataCid"` StartDate *string `json:"startDate"` EndDate *string `json:"endDate"` RequestedBy *int `json:"requestedBy"` ApprovedBy *int `json:"approvedBy"` ApprovalStatus string `json:"approvalStatus"` ApprovalDate *string `json:"approvalDate"` RejectionReason *string `json:"rejectionReason"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // ProjectRequest represents a client's request for a new project type ProjectRequest struct { Name string `json:"name"` Description string `json:"description"` } // ApprovalAction represents an approval or rejection action type ApprovalAction struct { Action string `json:"action"` // "approve" or "reject" Reason string `json:"reason"` // Required for rejection } // Task represents a task in the system. type Task struct { ID int `json:"id"` ProjectID int `json:"projectId"` Title string `json:"title"` Description string `json:"description"` Status string `json:"status"` AssigneeID *int `json:"assigneeId"` DueDate *string `json:"dueDate"` CompletedAt *time.Time `json:"completedAt"` Priority int `json:"priority"` EstimatedHours *float64 `json:"estimatedHours"` ActualHours *float64 `json:"actualHours"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } // WorkOrder represents a work order in the system. type WorkOrder struct { ID int `json:"id"` ProjectID int `json:"projectId"` Title string `json:"title"` Description string `json:"description"` OrderNumber string `json:"orderNumber"` IPFSDocumentCID *string `json:"ipfsDocumentCid"` CreatedBy *int `json:"createdBy"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } var db *sql.DB func main() { db = initDB() defer db.Close() ensureJWTSecret() // Project routes (protected - requires authentication) http.HandleFunc("/projects", authMiddleware(handleProjects)) http.HandleFunc("/projects/", authMiddleware(handleProjectByID)) // Project request routes (for CLIENTs to request projects) http.HandleFunc("/project-requests", authMiddleware(handleProjectRequests)) http.HandleFunc("/project-requests/", authMiddleware(handleProjectRequestByID)) // Approval routes (STAFF/ADMIN only) http.HandleFunc("/projects/pending", requireRole(handlePendingProjects, "STAFF", "ADMIN")) http.HandleFunc("/projects/approve/", requireRole(handleProjectApproval, "STAFF", "ADMIN")) // Task routes (protected - requires authentication) http.HandleFunc("/tasks", authMiddleware(handleTasks)) http.HandleFunc("/tasks/", authMiddleware(handleTaskByID)) // Work order routes (protected - staff/admin only) http.HandleFunc("/workorders", requireRole(handleWorkOrders, "STAFF", "ADMIN")) http.HandleFunc("/workorders/", requireRole(handleWorkOrderByID, "STAFF", "ADMIN")) // Health check (public) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) fmt.Fprintln(w, "ok") }) server := &http.Server{ Addr: ":8080", Handler: corsMiddleware(http.DefaultServeMux), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } // Graceful shutdown done := make(chan bool, 1) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) go func() { <-quit log.Println("Work Management Service shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() server.SetKeepAlivesEnabled(false) if err := server.Shutdown(ctx); err != nil { log.Printf("Could not gracefully shutdown: %v", err) } close(done) }() log.Println("Work Management Service listening on :8080") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server error: %v", err) } <-done log.Println("Work Management Service stopped") } func initDB() *sql.DB { user := strings.TrimSpace(os.Getenv("DB_USER")) password := strings.TrimSpace(os.Getenv("DB_PASSWORD")) name := strings.TrimSpace(os.Getenv("DB_NAME")) host := strings.TrimSpace(os.Getenv("DB_HOST")) sslMode := strings.TrimSpace(os.Getenv("DB_SSL_MODE")) schema := strings.TrimSpace(os.Getenv("DB_SCHEMA")) if user == "" || password == "" || name == "" || host == "" { log.Fatal("Database configuration missing: DB_USER, DB_PASSWORD, DB_NAME, DB_HOST required") } // Secure default: require TLS for production if sslMode == "" { sslMode = "require" log.Println("WARNING: DB_SSL_MODE not set, defaulting to 'require' for security") } // Validate sslMode value validSSLModes := map[string]bool{ "disable": true, "require": true, "verify-ca": true, "verify-full": true, } if !validSSLModes[sslMode] { log.Fatalf("Invalid DB_SSL_MODE '%s'. Must be: disable, require, verify-ca, or verify-full", sslMode) } // Warn if using insecure mode if sslMode == "disable" { log.Println("WARNING: Database SSL is DISABLED. This should only be used for local development!") } // Validate schema value if provided validSchemas := map[string]bool{ "": true, "public": true, "dev": true, "testing": true, "prod": true, } if !validSchemas[schema] { log.Fatalf("Invalid DB_SCHEMA '%s'. Must be: dev, testing, prod, or empty for public", schema) } // Build connection string with optional search_path connStr := fmt.Sprintf("user=%s password=%s dbname=%s host=%s sslmode=%s", user, password, name, host, sslMode) // Add search_path if schema is specified if schema != "" && schema != "public" { connStr += fmt.Sprintf(" search_path=%s,public", schema) } database, err := sql.Open("postgres", connStr) if err != nil { log.Fatalf("Error opening database: %v", err) } // Configure connection pool limits database.SetMaxOpenConns(25) database.SetMaxIdleConns(5) database.SetConnMaxLifetime(5 * time.Minute) database.SetConnMaxIdleTime(1 * time.Minute) if err := database.Ping(); err != nil { log.Fatalf("Error connecting to database: %v", err) } schemaInfo := "public" if schema != "" && schema != "public" { schemaInfo = schema } log.Printf("Successfully connected to database (SSL mode: %s, schema: %s, max_conns: 25)", sslMode, schemaInfo) return database } // corsMiddleware adds CORS headers to allow frontend to communicate with backend func corsMiddleware(next http.Handler) http.Handler { var corsConfigLogged bool return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Limit request body size to prevent DoS if r.Body != nil { r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) } // Rate limiting clientIP := getClientIP(r) if r.Method == http.MethodGet || r.Method == http.MethodHead { if readLimiter.checkRateLimit(clientIP, maxReadRequests, rateLimitWindow) { log.Printf("SECURITY: Read rate limit exceeded for IP %s on %s", clientIP, r.URL.Path) http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests) return } } else if r.Method != http.MethodOptions { if writeLimiter.checkRateLimit(clientIP, maxWriteRequests, rateLimitWindow) { log.Printf("SECURITY: Write rate limit exceeded for IP %s on %s", clientIP, r.URL.Path) http.Error(w, "Too many requests. Please slow down.", http.StatusTooManyRequests) return } } allowedOrigin := strings.TrimSpace(os.Getenv("CORS_ALLOW_ORIGIN")) if allowedOrigin == "" { allowedOrigin = "http://localhost:8090" if !corsConfigLogged { log.Println("WARNING: CORS_ALLOW_ORIGIN not set, defaulting to http://localhost:8090") corsConfigLogged = true } } else if allowedOrigin == "*" && !corsConfigLogged { log.Println("WARNING: CORS configured to allow ALL origins (*). This is insecure for production!") corsConfigLogged = true } // CORS headers w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") // Security headers w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } func ensureJWTSecret() { secret := strings.TrimSpace(os.Getenv("JWT_SECRET")) if len(secret) < 32 { log.Fatal("JWT_SECRET must be set and at least 32 characters") } } // authMiddleware validates JWT token and extracts user info func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { http.Error(w, "Authorization header required", http.StatusUnauthorized) return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") if tokenString == authHeader { http.Error(w, "Invalid authorization format. Expected: Bearer ", http.StatusUnauthorized) return } // Parse and validate JWT token token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // Verify signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } // Get JWT secret from environment secret := os.Getenv("JWT_SECRET") if secret == "" { return nil, fmt.Errorf("JWT_SECRET not configured") } return []byte(secret), nil }) if err != nil { http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized) return } if !token.Valid { http.Error(w, "Invalid token", http.StatusUnauthorized) return } // Extract claims and add to request context claims, ok := token.Claims.(jwt.MapClaims) if !ok { http.Error(w, "Invalid token claims", http.StatusUnauthorized) return } userID, err := extractUserID(claims) if err != nil { http.Error(w, "Invalid token: "+err.Error(), http.StatusUnauthorized) return } roles, err := extractRoles(claims) if err != nil { http.Error(w, "Invalid token roles", http.StatusUnauthorized) return } ctx := r.Context() ctx = context.WithValue(ctx, "user_id", userID) ctx = context.WithValue(ctx, "roles", roles) r = r.WithContext(ctx) next.ServeHTTP(w, r) } } // requireRole middleware checks if user has required role func requireRole(next http.HandlerFunc, allowedRoles ...string) http.HandlerFunc { return authMiddleware(func(w http.ResponseWriter, r *http.Request) { userRoles, ok := r.Context().Value("roles").([]string) if !ok { http.Error(w, "No roles found in token", http.StatusForbidden) return } // Check if user has any of the allowed roles hasRole := false for _, userRole := range userRoles { for _, allowedRole := range allowedRoles { if userRole == allowedRole { hasRole = true break } } if hasRole { break } } if !hasRole { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } func extractRoles(claims jwt.MapClaims) ([]string, error) { rawRoles, ok := claims["roles"] if !ok { return nil, errors.New("roles missing") } switch v := rawRoles.(type) { case []interface{}: out := make([]string, 0, len(v)) for _, r := range v { roleStr, ok := r.(string) if !ok { return nil, errors.New("role value not string") } out = append(out, roleStr) } return out, nil case []string: return v, nil default: return nil, errors.New("roles claim type invalid") } } func extractUserID(claims jwt.MapClaims) (int, error) { if id, ok := claims["user_id"]; ok { if idNum, err := numericToInt(id); err == nil { return idNum, nil } } if id, ok := claims["userId"]; ok { if idNum, err := numericToInt(id); err == nil { return idNum, nil } } return 0, errors.New("user_id claim missing or invalid") } func numericToInt(v interface{}) (int, error) { switch val := v.(type) { case float64: return int(val), nil case int: return val, nil case json.Number: i, err := val.Int64() return int(i), err default: return 0, errors.New("not a number") } } func hasAnyRole(ctx context.Context, allowedRoles ...string) bool { raw, ok := ctx.Value("roles").([]string) if !ok { return false } for _, role := range raw { // SUPERUSER has god-like permissions - always returns true if role == "SUPERUSER" { return true } for _, allowed := range allowedRoles { if role == allowed { return true } } } return false } // ===== PROJECT HANDLERS ===== func handleProjects(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listProjects(w, r) case http.MethodPost: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } createProject(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handleProjectByID(w http.ResponseWriter, r *http.Request) { // Extract ID from path parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid project ID", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: getProject(w, r, id) case http.MethodPut: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } updateProject(w, r, id) case http.MethodDelete: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } deleteProject(w, r, id) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listProjects(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) var rows *sql.Rows var err error // Authorization: STAFF/ADMIN can see all approved projects, CLIENTs only see their own approved projects if hasAnyRole(r.Context(), "STAFF", "ADMIN") { rows, err = db.Query(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, COALESCE(approval_status, 'APPROVED') as approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE COALESCE(approval_status, 'APPROVED') = 'APPROVED' ORDER BY created_at DESC `) } else { // CLIENT users only see their own approved projects rows, err = db.Query(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, COALESCE(approval_status, 'APPROVED') as approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE client_id = $1 AND COALESCE(approval_status, 'APPROVED') = 'APPROVED' ORDER BY created_at DESC `, userID) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var projects []Project for rows.Next() { var p Project err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } projects = append(projects, p) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(projects) } func createProject(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) var p Project if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Validate required fields if p.Name == "" { http.Error(w, "Project name is required", http.StatusBadRequest) return } // Set default status if not provided if p.Status == "" { p.Status = "PLANNING" } // STAFF/ADMIN creating projects are auto-approved p.ApprovalStatus = "APPROVED" p.RequestedBy = &userID p.ApprovedBy = &userID err := db.QueryRow(` INSERT INTO projects (name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, approval_status, approval_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CURRENT_TIMESTAMP) RETURNING id, created_at, updated_at `, p.Name, p.Description, p.Status, p.ClientID, p.IPFSMetadataCID, p.StartDate, p.EndDate, p.RequestedBy, p.ApprovedBy, p.ApprovalStatus). Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(p) } func getProject(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) var p Project err := db.QueryRow(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, COALESCE(approval_status, 'APPROVED') as approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE id = $1 `, id).Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Project not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Authorization: Check if user is owner (CLIENT), requester, or has elevated role (STAFF/ADMIN) isOwner := (p.ClientID != nil && *p.ClientID == userID) isRequester := (p.RequestedBy != nil && *p.RequestedBy == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isRequester && !isStaffOrAdmin { http.Error(w, "Forbidden: you do not have access to this project", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(p) } func updateProject(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) // Check ownership first var existingClientID *int err := db.QueryRow("SELECT client_id FROM projects WHERE id = $1", id).Scan(&existingClientID) if err == sql.ErrNoRows { http.Error(w, "Project not found", http.StatusNotFound) return } else if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } // Authorization check isOwner := (existingClientID != nil && *existingClientID == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isOwner && !isStaffOrAdmin { http.Error(w, "Forbidden: you do not have permission to update this project", http.StatusForbidden) return } var p Project if err := json.NewDecoder(r.Body).Decode(&p); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } result, err := db.Exec(` UPDATE projects SET name = $1, description = $2, status = $3, client_id = $4, ipfs_metadata_cid = $5, start_date = $6, end_date = $7 WHERE id = $8 `, p.Name, p.Description, p.Status, p.ClientID, p.IPFSMetadataCID, p.StartDate, p.EndDate, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Project not found", http.StatusNotFound) return } p.ID = id w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(p) } func deleteProject(w http.ResponseWriter, r *http.Request, id int) { // Check ownership first var existingClientID *int err := db.QueryRow("SELECT client_id FROM projects WHERE id = $1", id).Scan(&existingClientID) if err == sql.ErrNoRows { http.Error(w, "Project not found", http.StatusNotFound) return } else if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } // Authorization check - only STAFF/ADMIN can delete (protect clients from accidental deletion) if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Forbidden: only STAFF or ADMIN can delete projects", http.StatusForbidden) return } result, err := db.Exec("DELETE FROM projects WHERE id = $1", id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Project not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== TASK HANDLERS ===== func handleTasks(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listTasks(w, r) case http.MethodPost: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } createTask(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handleTaskByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid task ID", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: getTask(w, r, id) case http.MethodPut: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } updateTask(w, r, id) case http.MethodDelete: if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { http.Error(w, "Insufficient permissions", http.StatusForbidden) return } deleteTask(w, r, id) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listTasks(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) projectID := r.URL.Query().Get("project_id") var rows *sql.Rows var err error // Authorization: STAFF/ADMIN can see all tasks (optionally filtered), CLIENTs only see tasks for their projects if hasAnyRole(r.Context(), "STAFF", "ADMIN") { if projectID != "" { rows, err = db.Query(` SELECT id, project_id, title, description, status, assignee_id, due_date, completed_at, priority, estimated_hours, actual_hours, created_at, updated_at FROM tasks WHERE project_id = $1 ORDER BY priority DESC, created_at DESC `, projectID) } else { rows, err = db.Query(` SELECT id, project_id, title, description, status, assignee_id, due_date, completed_at, priority, estimated_hours, actual_hours, created_at, updated_at FROM tasks ORDER BY priority DESC, created_at DESC `) } } else { // CLIENT users only see tasks for projects they own if projectID != "" { // Verify client owns this project var ownerID int err = db.QueryRow(`SELECT client_id FROM projects WHERE id = $1`, projectID).Scan(&ownerID) if err == sql.ErrNoRows { http.Error(w, "Project not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if ownerID != userID { http.Error(w, "Forbidden: you do not have access to this project's tasks", http.StatusForbidden) return } rows, err = db.Query(` SELECT id, project_id, title, description, status, assignee_id, due_date, completed_at, priority, estimated_hours, actual_hours, created_at, updated_at FROM tasks WHERE project_id = $1 ORDER BY priority DESC, created_at DESC `, projectID) } else { // Get all tasks for projects owned by this client rows, err = db.Query(` SELECT t.id, t.project_id, t.title, t.description, t.status, t.assignee_id, t.due_date, t.completed_at, t.priority, t.estimated_hours, t.actual_hours, t.created_at, t.updated_at FROM tasks t INNER JOIN projects p ON t.project_id = p.id WHERE p.client_id = $1 ORDER BY t.priority DESC, t.created_at DESC `, userID) } } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var tasks []Task for rows.Next() { var t Task err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.DueDate, &t.CompletedAt, &t.Priority, &t.EstimatedHours, &t.ActualHours, &t.CreatedAt, &t.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } tasks = append(tasks, t) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) } func createTask(w http.ResponseWriter, r *http.Request) { var t Task if err := json.NewDecoder(r.Body).Decode(&t); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Validate required fields if t.Title == "" || t.ProjectID == 0 { http.Error(w, "Task title and project_id are required", http.StatusBadRequest) return } // Set default status if t.Status == "" { t.Status = "TODO" } err := db.QueryRow(` INSERT INTO tasks (project_id, title, description, status, assignee_id, due_date, priority, estimated_hours, actual_hours) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at `, t.ProjectID, t.Title, t.Description, t.Status, t.AssigneeID, t.DueDate, t.Priority, t.EstimatedHours, t.ActualHours). Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(t) } func getTask(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) var t Task err := db.QueryRow(` SELECT id, project_id, title, description, status, assignee_id, due_date, completed_at, priority, estimated_hours, actual_hours, created_at, updated_at FROM tasks WHERE id = $1 `, id).Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.AssigneeID, &t.DueDate, &t.CompletedAt, &t.Priority, &t.EstimatedHours, &t.ActualHours, &t.CreatedAt, &t.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Task not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Authorization: Check if user has access to the parent project if !hasAnyRole(r.Context(), "STAFF", "ADMIN") { var projectOwnerID int err = db.QueryRow(`SELECT client_id FROM projects WHERE id = $1`, t.ProjectID).Scan(&projectOwnerID) if err != nil { http.Error(w, "Failed to verify project access", http.StatusInternalServerError) return } if projectOwnerID != userID { http.Error(w, "Forbidden: you do not have access to this task", http.StatusForbidden) return } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(t) } func updateTask(w http.ResponseWriter, r *http.Request, id int) { // First verify task exists and get project_id for authorization check var existingProjectID int err := db.QueryRow(`SELECT project_id FROM tasks WHERE id = $1`, id).Scan(&existingProjectID) if err == sql.ErrNoRows { http.Error(w, "Task not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Authorization: Verify user has access to the project // Note: Only STAFF/ADMIN can reach here due to handler check, but verify project access var projectOwnerID int err = db.QueryRow(`SELECT client_id FROM projects WHERE id = $1`, existingProjectID).Scan(&projectOwnerID) if err != nil { http.Error(w, "Failed to verify project access", http.StatusInternalServerError) return } var t Task if err := json.NewDecoder(r.Body).Decode(&t); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // If status is being set to COMPLETED, set completed_at timestamp var completedAt interface{} if t.Status == "COMPLETED" { completedAt = time.Now() } else { completedAt = nil } result, err := db.Exec(` UPDATE tasks SET title = $1, description = $2, status = $3, assignee_id = $4, due_date = $5, completed_at = $6, priority = $7, estimated_hours = $8, actual_hours = $9 WHERE id = $10 `, t.Title, t.Description, t.Status, t.AssigneeID, t.DueDate, completedAt, t.Priority, t.EstimatedHours, t.ActualHours, id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Task not found", http.StatusNotFound) return } t.ID = id w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(t) } func deleteTask(w http.ResponseWriter, r *http.Request, id int) { // First verify task exists and get project_id for authorization check var existingProjectID int err := db.QueryRow(`SELECT project_id FROM tasks WHERE id = $1`, id).Scan(&existingProjectID) if err == sql.ErrNoRows { http.Error(w, "Task not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Authorization: Verify user has access to the project // Note: Only STAFF/ADMIN can reach here due to handler check, but verify project access var projectOwnerID int err = db.QueryRow(`SELECT client_id FROM projects WHERE id = $1`, existingProjectID).Scan(&projectOwnerID) if err != nil { http.Error(w, "Failed to verify project access", http.StatusInternalServerError) return } result, err := db.Exec("DELETE FROM tasks WHERE id = $1", id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Task not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== WORK ORDER HANDLERS ===== func handleWorkOrders(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listWorkOrders(w, r) case http.MethodPost: createWorkOrder(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handleWorkOrderByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid work order ID", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: getWorkOrder(w, r, id) case http.MethodPut: updateWorkOrder(w, r, id) case http.MethodDelete: deleteWorkOrder(w, r, id) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func listWorkOrders(w http.ResponseWriter, r *http.Request) { projectID := r.URL.Query().Get("project_id") var rows *sql.Rows var err error if projectID != "" { rows, err = db.Query(` SELECT id, project_id, title, description, order_number, ipfs_document_cid, created_by, created_at, updated_at FROM work_orders WHERE project_id = $1 ORDER BY created_at DESC `, projectID) } else { rows, err = db.Query(` SELECT id, project_id, title, description, order_number, ipfs_document_cid, created_by, created_at, updated_at FROM work_orders ORDER BY created_at DESC `) } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var workOrders []WorkOrder for rows.Next() { var wo WorkOrder err := rows.Scan(&wo.ID, &wo.ProjectID, &wo.Title, &wo.Description, &wo.OrderNumber, &wo.IPFSDocumentCID, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } workOrders = append(workOrders, wo) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(workOrders) } func createWorkOrder(w http.ResponseWriter, r *http.Request) { var wo WorkOrder if err := json.NewDecoder(r.Body).Decode(&wo); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Validate required fields if wo.Title == "" || wo.ProjectID == 0 || wo.OrderNumber == "" { http.Error(w, "Work order title, project_id, and order_number are required", http.StatusBadRequest) return } err := db.QueryRow(` INSERT INTO work_orders (project_id, title, description, order_number, ipfs_document_cid, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at `, wo.ProjectID, wo.Title, wo.Description, wo.OrderNumber, wo.IPFSDocumentCID, wo.CreatedBy). Scan(&wo.ID, &wo.CreatedAt, &wo.UpdatedAt) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Work order number already exists", http.StatusConflict) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(wo) } func getWorkOrder(w http.ResponseWriter, r *http.Request, id int) { var wo WorkOrder err := db.QueryRow(` SELECT id, project_id, title, description, order_number, ipfs_document_cid, created_by, created_at, updated_at FROM work_orders WHERE id = $1 `, id).Scan(&wo.ID, &wo.ProjectID, &wo.Title, &wo.Description, &wo.OrderNumber, &wo.IPFSDocumentCID, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Work order not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(wo) } func updateWorkOrder(w http.ResponseWriter, r *http.Request, id int) { var wo WorkOrder if err := json.NewDecoder(r.Body).Decode(&wo); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } result, err := db.Exec(` UPDATE work_orders SET title = $1, description = $2, order_number = $3, ipfs_document_cid = $4 WHERE id = $5 `, wo.Title, wo.Description, wo.OrderNumber, wo.IPFSDocumentCID, id) if err != nil { if strings.Contains(err.Error(), "duplicate key") { http.Error(w, "Work order number already exists", http.StatusConflict) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Work order not found", http.StatusNotFound) return } wo.ID = id w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(wo) } func deleteWorkOrder(w http.ResponseWriter, r *http.Request, id int) { result, err := db.Exec("DELETE FROM work_orders WHERE id = $1", id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { http.Error(w, "Work order not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } // ===== PROJECT REQUEST HANDLERS (for CLIENTs) ===== func handleProjectRequests(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: listMyProjectRequests(w, r) case http.MethodPost: createProjectRequest(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func handleProjectRequestByID(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 3 { http.Error(w, "Invalid path", http.StatusBadRequest) return } id, err := strconv.Atoi(parts[2]) if err != nil { http.Error(w, "Invalid request ID", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: getProjectRequest(w, r, id) case http.MethodDelete: cancelProjectRequest(w, r, id) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } // listMyProjectRequests returns all project requests made by the current user func listMyProjectRequests(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) rows, err := db.Query(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, COALESCE(approval_status, 'APPROVED') as approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE requested_by = $1 ORDER BY created_at DESC `, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var projects []Project for rows.Next() { var p Project err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } projects = append(projects, p) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(projects) } // createProjectRequest allows CLIENTs to request a new project (requires approval) func createProjectRequest(w http.ResponseWriter, r *http.Request) { userID := r.Context().Value("user_id").(int) var req ProjectRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Validate required fields if req.Name == "" { http.Error(w, "Project name is required", http.StatusBadRequest) return } if req.Description == "" { http.Error(w, "Project description is required to help us understand your needs", http.StatusBadRequest) return } var p Project err := db.QueryRow(` INSERT INTO projects (name, description, status, client_id, requested_by, approval_status) VALUES ($1, $2, 'PENDING_APPROVAL', $3, $3, 'PENDING') RETURNING id, name, description, status, client_id, requested_by, approval_status, created_at, updated_at `, req.Name, req.Description, userID). Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.RequestedBy, &p.ApprovalStatus, &p.CreatedAt, &p.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(p) } // getProjectRequest returns a single project request (must be owned by user or STAFF/ADMIN) func getProjectRequest(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) var p Project err := db.QueryRow(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, COALESCE(approval_status, 'APPROVED') as approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE id = $1 `, id).Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt) if err == sql.ErrNoRows { http.Error(w, "Project request not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Authorization: Only requester or STAFF/ADMIN can view isRequester := (p.RequestedBy != nil && *p.RequestedBy == userID) isStaffOrAdmin := hasAnyRole(r.Context(), "STAFF", "ADMIN") if !isRequester && !isStaffOrAdmin { http.Error(w, "Forbidden: you do not have access to this request", http.StatusForbidden) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(p) } // cancelProjectRequest allows a CLIENT to cancel their own pending request func cancelProjectRequest(w http.ResponseWriter, r *http.Request, id int) { userID := r.Context().Value("user_id").(int) // First check ownership and status var requestedBy *int var approvalStatus string err := db.QueryRow(` SELECT requested_by, COALESCE(approval_status, 'APPROVED') FROM projects WHERE id = $1 `, id).Scan(&requestedBy, &approvalStatus) if err == sql.ErrNoRows { http.Error(w, "Project request not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Only requester can cancel if requestedBy == nil || *requestedBy != userID { http.Error(w, "Forbidden: you can only cancel your own requests", http.StatusForbidden) return } // Can only cancel PENDING requests if approvalStatus != "PENDING" { http.Error(w, "Cannot cancel a request that has already been processed", http.StatusBadRequest) return } // Delete the request _, err = db.Exec("DELETE FROM projects WHERE id = $1", id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ===== APPROVAL HANDLERS (STAFF/ADMIN only) ===== // handlePendingProjects returns all projects pending approval func handlePendingProjects(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } rows, err := db.Query(` SELECT p.id, p.name, p.description, p.status, p.client_id, p.ipfs_metadata_cid, p.start_date, p.end_date, p.requested_by, p.approved_by, COALESCE(p.approval_status, 'APPROVED') as approval_status, p.approval_date, p.rejection_reason, p.created_at, p.updated_at, u.email as requester_email, u.name as requester_name FROM projects p LEFT JOIN users u ON p.requested_by = u.id WHERE COALESCE(p.approval_status, 'APPROVED') = 'PENDING' ORDER BY p.created_at ASC `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() type PendingProject struct { Project RequesterEmail *string `json:"requesterEmail"` RequesterName *string `json:"requesterName"` } var projects []PendingProject for rows.Next() { var p PendingProject err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt, &p.RequesterEmail, &p.RequesterName) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } projects = append(projects, p) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(projects) } // handleProjectApproval approves or rejects a project request func handleProjectApproval(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Extract project ID from path: /projects/approve/{id} parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { http.Error(w, "Invalid path", http.StatusBadRequest) return } projectID, err := strconv.Atoi(parts[3]) if err != nil { http.Error(w, "Invalid project ID", http.StatusBadRequest) return } userID := r.Context().Value("user_id").(int) var action ApprovalAction if err := json.NewDecoder(r.Body).Decode(&action); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Validate action if action.Action != "approve" && action.Action != "reject" { http.Error(w, "Action must be 'approve' or 'reject'", http.StatusBadRequest) return } if action.Action == "reject" && action.Reason == "" { http.Error(w, "Rejection reason is required", http.StatusBadRequest) return } // Check that project exists and is pending var approvalStatus string err = db.QueryRow(` SELECT COALESCE(approval_status, 'APPROVED') FROM projects WHERE id = $1 `, projectID).Scan(&approvalStatus) if err == sql.ErrNoRows { http.Error(w, "Project not found", http.StatusNotFound) return } else if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if approvalStatus != "PENDING" { http.Error(w, "Project is not pending approval", http.StatusBadRequest) return } var newStatus, newApprovalStatus string var rejectionReason *string if action.Action == "approve" { newStatus = "PLANNING" newApprovalStatus = "APPROVED" } else { newStatus = "CANCELLED" newApprovalStatus = "REJECTED" rejectionReason = &action.Reason } _, err = db.Exec(` UPDATE projects SET status = $1, approval_status = $2, approved_by = $3, approval_date = CURRENT_TIMESTAMP, rejection_reason = $4 WHERE id = $5 `, newStatus, newApprovalStatus, userID, rejectionReason, projectID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Return the updated project var p Project err = db.QueryRow(` SELECT id, name, description, status, client_id, ipfs_metadata_cid, start_date, end_date, requested_by, approved_by, approval_status, approval_date, rejection_reason, created_at, updated_at FROM projects WHERE id = $1 `, projectID).Scan(&p.ID, &p.Name, &p.Description, &p.Status, &p.ClientID, &p.IPFSMetadataCID, &p.StartDate, &p.EndDate, &p.RequestedBy, &p.ApprovedBy, &p.ApprovalStatus, &p.ApprovalDate, &p.RejectionReason, &p.CreatedAt, &p.UpdatedAt) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(p) }