Files
web-hosts/domains/coppertone.tech/backend/functions/work-management-service/main.go
2025-12-26 13:38:04 +01:00

1648 lines
48 KiB
Go

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 <token>", 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)
}