1648 lines
48 KiB
Go
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)
|
|
}
|