diff --git a/README.md b/README.md index b154216..c546102 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,4 @@ the official web interface of the Estate Office of NIT Hamirpur to manage compla ### Complaint status public dashboard -Complaint status public dashboard +Complaint status public dashboard \ No newline at end of file diff --git a/cms-web b/cms-web new file mode 100755 index 0000000..e75446d Binary files /dev/null and b/cms-web differ diff --git a/config/db.go b/config/db.go index 2d00774..2608198 100644 --- a/config/db.go +++ b/config/db.go @@ -33,7 +33,11 @@ func ConnectDB() { &models.Faculty{}, &models.Warden{}, &models.CentreHead{}, + &models.FacultyPost{}, + &models.WardenPost{}, + &models.CentreHeadPost{}, + &models.Comment{}, ) log.Println("Database connected") -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index 49400a0..4e4ecb7 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -33,11 +34,14 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect @@ -47,4 +51,6 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 4784c20..106910d 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -108,5 +110,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/handlers/post_handler.go b/handlers/post_handler.go new file mode 100644 index 0000000..4dcd04d --- /dev/null +++ b/handlers/post_handler.go @@ -0,0 +1,466 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/ayush00git/cms-web/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type PostHandler struct { + DB *gorm.DB +} + +// Let faculty members submit a new post +func (h *PostHandler) FacultyReportPost(c *gin.Context) { + var input struct { + FacultyID uint `json:"faculty_id" binding:"required"` + Place models.PostPlace `json:"place" binding:"required"` + TypeOfPost models.PostType `json:"type_of_post" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + post := models.FacultyPost{ + FacultyID: &input.FacultyID, + Place: input.Place, + TypeOfPost: input.TypeOfPost, + Title: input.Title, + Description: input.Description, + Status: models.StatusPendingXEN, + Stage: models.StageXEN, + } + + if h.DB != nil { + h.DB.Create(&post) + } + + xenEmail := "xen_civil@nit.edu" + if input.TypeOfPost == models.TypeElectrical { + xenEmail = "xen_electrical@nit.edu" + } + + fmt.Printf("Email to %s: New Faculty Post - A new %s post was reported: %s\n", xenEmail, input.TypeOfPost, input.Title) + + c.JSON(http.StatusOK, gin.H{"message": "Post submitted successfully", "post": post}) +} + +// Let wardens submit a post for their hostel +func (h *PostHandler) WardenReportPost(c *gin.Context) { + var input struct { + WardenID uint `json:"warden_id" binding:"required"` + TypeOfPost models.PostType `json:"type_of_post" binding:"required"` + RoomNumber string `json:"room_number" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + post := models.WardenPost{ + WardenID: &input.WardenID, + RoomNumber: input.RoomNumber, + TypeOfPost: input.TypeOfPost, + Title: input.Title, + Description: input.Description, + Status: models.StatusPendingXEN, + Stage: models.StageXEN, + } + + if h.DB != nil { + h.DB.Create(&post) + } + + xenEmail := "xen_civil@nit.edu" + if input.TypeOfPost == models.TypeElectrical { + xenEmail = "xen_electrical@nit.edu" + } + + fmt.Printf("Email to %s: New Warden Post - A new %s post was reported for room %s: %s\n", xenEmail, input.TypeOfPost, input.RoomNumber, input.Title) + + c.JSON(http.StatusOK, gin.H{"message": "Post submitted successfully", "post": post}) +} + +// Let Centre Heads submit posts for their department +func (h *PostHandler) CentreHeadReportPost(c *gin.Context) { + var input struct { + CentreHeadID uint `json:"centre_head_id" binding:"required"` + TypeOfPost models.PostType `json:"type_of_post" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + post := models.CentreHeadPost{ + CentreHeadID: &input.CentreHeadID, + TypeOfPost: input.TypeOfPost, + Title: input.Title, + Description: input.Description, + Status: models.StatusPendingXEN, + Stage: models.StageXEN, + } + + if h.DB != nil { + h.DB.Create(&post) + } + + xenEmail := "xen_civil@nit.edu" + if input.TypeOfPost == models.TypeElectrical { + xenEmail = "xen_electrical@nit.edu" + } + + fmt.Printf("Email to %s: New Centre Head Post - A new %s post was reported by Centre Head: %s\n", xenEmail, input.TypeOfPost, input.Title) + + c.JSON(http.StatusOK, gin.H{"message": "Post submitted successfully", "post": post}) +} + +// Allow XEN to review the post and either pass it forward or reject it +func (h *PostHandler) XENUpdateStatus(c *gin.Context) { + var input struct { + Source string `json:"source" binding:"required"` // "Faculty", "Warden", "CentreHead" + PostID uint `json:"post_id" binding:"required"` + Action string `json:"action" binding:"required"` // "pass" or "reject" + CommentText string `json:"comment" binding:"required"` + AdminID uint `json:"admin_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if h.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection missing"}) + return + } + + var typeOfPost models.PostType + var stage models.PostStage + commentableType := input.Source + "Post" + + // Fetch and update based on source + if input.Source == string(models.SourceFaculty) { + var post models.FacultyPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + typeOfPost = post.TypeOfPost + stage = post.Stage + if stage != models.StageXEN { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at XEN stage"}) + return + } + if input.Action == "pass" { + post.Status = models.StatusPendingAE + post.Stage = models.StageAE + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceWarden) { + var post models.WardenPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + typeOfPost = post.TypeOfPost + stage = post.Stage + if stage != models.StageXEN { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at XEN stage"}) + return + } + if input.Action == "pass" { + post.Status = models.StatusPendingAE + post.Stage = models.StageAE + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceCentreHead) { + var post models.CentreHeadPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + typeOfPost = post.TypeOfPost + stage = post.Stage + if stage != models.StageXEN { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at XEN stage"}) + return + } + if input.Action == "pass" { + post.Status = models.StatusPendingAE + post.Stage = models.StageAE + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source"}) + return + } + + // Create the comment + comment := models.Comment{ + CommentableID: input.PostID, + CommentableType: commentableType, + AdminID: input.AdminID, + CommentText: input.CommentText, + } + h.DB.Create(&comment) + + if input.Action == "pass" { + aeEmail := "ae_civil@nit.edu" + if typeOfPost == models.TypeElectrical { + aeEmail = "ae_electrical@nit.edu" + } + fmt.Printf("Email to %s: Post Forwarded to AE - A post has been forwarded to you for action.\n", aeEmail) + } else if input.Action == "reject" { + fmt.Println("Email to filer@nit.edu: Post Rejected - Your post has been rejected by XEN.") + } + + c.JSON(http.StatusOK, gin.H{"message": "Status updated by XEN"}) +} + +// Allow AE to review the post, and if passing, assign a specific JE +func (h *PostHandler) AEUpdateStatus(c *gin.Context) { + var input struct { + Source string `json:"source" binding:"required"` + PostID uint `json:"post_id" binding:"required"` + Action string `json:"action" binding:"required"` // "pass" or "reject" + CommentText string `json:"comment" binding:"required"` + SelectJE_ID *uint `json:"select_je_id"` // required if pass + AdminID uint `json:"admin_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if h.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection missing"}) + return + } + + var stage models.PostStage + commentableType := input.Source + "Post" + + // Fetch and update based on source + if input.Source == string(models.SourceFaculty) { + var post models.FacultyPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageAE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at AE stage"}) + return + } + if input.Action == "pass" && input.SelectJE_ID != nil { + post.Status = models.StatusPendingJE + post.Stage = models.StageJE + post.AssignedJE_ID = input.SelectJE_ID + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceWarden) { + var post models.WardenPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageAE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at AE stage"}) + return + } + if input.Action == "pass" && input.SelectJE_ID != nil { + post.Status = models.StatusPendingJE + post.Stage = models.StageJE + post.AssignedJE_ID = input.SelectJE_ID + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceCentreHead) { + var post models.CentreHeadPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageAE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at AE stage"}) + return + } + if input.Action == "pass" && input.SelectJE_ID != nil { + post.Status = models.StatusPendingJE + post.Stage = models.StageJE + post.AssignedJE_ID = input.SelectJE_ID + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source"}) + return + } + + // Create the comment + comment := models.Comment{ + CommentableID: input.PostID, + CommentableType: commentableType, + AdminID: input.AdminID, + CommentText: input.CommentText, + } + h.DB.Create(&comment) + + if input.Action == "pass" && input.SelectJE_ID != nil { + fmt.Println("Email to je@nit.edu: Post Assigned to JE - A new post has been assigned to you by AE.") + } else if input.Action == "reject" { + fmt.Println("Email to filer@nit.edu: Post Rejected - Your post has been rejected by AE.") + } + + c.JSON(http.StatusOK, gin.H{"message": "Status updated by AE"}) +} + +// Allow JE to mark the post as finally resolved or reject it +func (h *PostHandler) JEUpdateStatus(c *gin.Context) { + var input struct { + Source string `json:"source" binding:"required"` + PostID uint `json:"post_id" binding:"required"` + Action string `json:"action" binding:"required"` // "resolve" or "reject" + CommentText string `json:"comment" binding:"required"` + AdminID uint `json:"admin_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if h.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection missing"}) + return + } + + var stage models.PostStage + commentableType := input.Source + "Post" + + // Fetch and update based on source + if input.Source == string(models.SourceFaculty) { + var post models.FacultyPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageJE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at JE stage"}) + return + } + if input.Action == "resolve" { + post.Status = models.StatusResolved + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceWarden) { + var post models.WardenPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageJE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at JE stage"}) + return + } + if input.Action == "resolve" { + post.Status = models.StatusResolved + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + + } else if input.Source == string(models.SourceCentreHead) { + var post models.CentreHeadPost + if err := h.DB.First(&post, input.PostID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Post not found"}) + return + } + stage = post.Stage + if stage != models.StageJE { + c.JSON(http.StatusForbidden, gin.H{"error": "Post is not at JE stage"}) + return + } + if input.Action == "resolve" { + post.Status = models.StatusResolved + } else if input.Action == "reject" { + post.Status = models.StatusRejected + } + h.DB.Save(&post) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source"}) + return + } + + // Create the comment + comment := models.Comment{ + CommentableID: input.PostID, + CommentableType: commentableType, + AdminID: input.AdminID, + CommentText: input.CommentText, + } + h.DB.Create(&comment) + + if input.Action == "resolve" { + fmt.Println("Email to filer@nit.edu: Post Resolved - Your post has been successfully resolved.") + } else if input.Action == "reject" { + fmt.Println("Email to filer@nit.edu: Post Rejected - Your post has been rejected by JE.") + } + + c.JSON(http.StatusOK, gin.H{"message": "Status updated by JE"}) +} + +// Fetch all posts and their comments to show on the public dashboard +func (h *PostHandler) GetPublicDashboard(c *gin.Context) { + var facultyPosts []models.FacultyPost + var wardenPosts []models.WardenPost + var centreHeadPosts []models.CentreHeadPost + + if h.DB != nil { + h.DB.Preload("Comments").Find(&facultyPosts) + h.DB.Preload("Comments").Find(&wardenPosts) + h.DB.Preload("Comments").Find(¢reHeadPosts) + } + + c.JSON(http.StatusOK, gin.H{ + "faculty_posts": facultyPosts, + "warden_posts": wardenPosts, + "centre_head_posts": centreHeadPosts, + }) +} diff --git a/helpers/env.go b/helpers/env.go index 38c925f..2b82ec8 100644 --- a/helpers/env.go +++ b/helpers/env.go @@ -3,18 +3,12 @@ package helpers import ( "log" "os" - "github.com/joho/godotenv" ) -func GetEnv(target string) (string) { - err := godotenv.Load() - if err != nil { - log.Fatal("Error while loading the environment variables") - } - +func GetEnv(target string) string { value := os.Getenv(target) if value == "" { - log.Fatalf("No value for %s exists in environment variables", value) + log.Fatalf("No value for %s exists in environment variables", target) } return value } diff --git a/helpers/jwt.go b/helpers/jwt.go index 4efcad6..ac4b487 100644 --- a/helpers/jwt.go +++ b/helpers/jwt.go @@ -7,11 +7,8 @@ import ( "github.com/golang-jwt/jwt/v5" ) -var secret = GetEnv("JWT_SECRET") -// signing key is passed as slice of bytes -var secretKey = []byte(secret) - func GenerateToken(email string) (string, error) { + secretKey := []byte(GetEnv("JWT_SECRET")) // define the algorithm to sign the header and payload with token := jwt.NewWithClaims(jwt.SigningMethodHS256, @@ -30,6 +27,8 @@ func GenerateToken(email string) (string, error) { } func VerifyToken(tokenString string) (error) { + secretKey := []byte(GetEnv("JWT_SECRET")) + // parsing the jwt string token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error){ return secretKey, nil diff --git a/main.go b/main.go index e069657..6aab21a 100644 --- a/main.go +++ b/main.go @@ -6,13 +6,16 @@ import ( "github.com/ayush00git/cms-web/config" "github.com/ayush00git/cms-web/handlers" "github.com/ayush00git/cms-web/routes" - "github.com/gin-gonic/gin" + "github.com/joho/godotenv" ) func main() { - - config.ConnectDB() + err := godotenv.Load() + if err != nil { + log.Fatal("Error while loading the environment variables") + } + config.ConnectDB() r := gin.Default() @@ -26,6 +29,13 @@ func main() { routes.AuthRoute(r, authHandler) + postHandler := &handlers.PostHandler{ + DB: config.DB, + } + + // Setup application routes + routes.PostRoute(r, postHandler) + r.Run(":8080") fmt.Println("Sevrer running on port 8080") } diff --git a/models/admin_auth.go b/models/admin_auth.go index 7d4f183..958a738 100644 --- a/models/admin_auth.go +++ b/models/admin_auth.go @@ -4,21 +4,21 @@ import ( "time" ) -type PostType string +type PositionType string const ( - TypeXENCivil PostType = "XEN_Civil" - TypeAECivil PostType = "AE_Civil" - TypeJECivil PostType = "JE_Civil" - TypeXENElectrical PostType = "XEN_Electrical" - TypeAEElectrical PostType = "AE_Electrical" - TypeJEElectrical PostType = "JE_Electrical" + TypeXENCivil PositionType = "XEN_Civil" + TypeAECivil PositionType = "AE_Civil" + TypeJECivil PositionType = "JE_Civil" + TypeXENElectrical PositionType = "XEN_Electrical" + TypeAEElectrical PositionType = "AE_Electrical" + TypeJEElectrical PositionType = "JE_Electrical" ) type Admin struct { ID uint `gorm:"primaryKey;autoIncrement" json:"id"` Email string `gorm:"uniqueIndex;not null" json:"email"` Password string `gorm:"not null" json:"password"` - Post PostType `gorm:"type:varchar(15);unique;not null" json:"post"` + Post PositionType `gorm:"type:varchar(15);unique;not null" json:"post"` IsVerified bool `gorm:"default:false" json:"is_verified"` CreatedAt time.Time `json:"created_at"` } diff --git a/models/post.go b/models/post.go new file mode 100644 index 0000000..d8db113 --- /dev/null +++ b/models/post.go @@ -0,0 +1,101 @@ +package models + +import ( + "time" +) + +type PostSource string + +const ( + SourceFaculty PostSource = "Faculty" + SourceWarden PostSource = "Warden" + SourceCentreHead PostSource = "CentreHead" +) + +type PostPlace string + +const ( + PlaceResidential PostPlace = "Residential" + PlaceDepartmental PostPlace = "Departmental" +) + +type PostType string + +const ( + TypeCivil PostType = "Civil" + TypeElectrical PostType = "Electrical" +) + +type PostStatus string + +const ( + StatusPendingXEN PostStatus = "Pending_XEN" + StatusPendingAE PostStatus = "Pending_AE" + StatusPendingJE PostStatus = "Pending_JE" + StatusResolved PostStatus = "Resolved" + StatusRejected PostStatus = "Rejected" +) + +type PostStage string + +const ( + StageXEN PostStage = "XEN" + StageAE PostStage = "AE" + StageJE PostStage = "JE" +) + +type FacultyPost struct { + ID uint `gorm:"primaryKey;autoIncrement"` + FacultyID *uint // Link to Faculty + Place PostPlace `gorm:"type:varchar(20)"` + TypeOfPost PostType `gorm:"type:varchar(20);not null"` + Title string `gorm:"not null"` + Description string `gorm:"type:text;not null"` + Status PostStatus `gorm:"type:varchar(20);not null;default:'Pending_XEN'"` + Stage PostStage `gorm:"type:varchar(20);not null;default:'XEN'"` + AssignedJE_ID *uint // Populated when AE delegates to JE + CreatedAt time.Time + UpdatedAt time.Time + + Comments []Comment `gorm:"polymorphic:Commentable;"` +} + +type WardenPost struct { + ID uint `gorm:"primaryKey;autoIncrement"` + WardenID *uint // Link to Warden + RoomNumber string `gorm:"type:varchar(50)"` + TypeOfPost PostType `gorm:"type:varchar(20);not null"` + Title string `gorm:"not null"` + Description string `gorm:"type:text;not null"` + Status PostStatus `gorm:"type:varchar(20);not null;default:'Pending_XEN'"` + Stage PostStage `gorm:"type:varchar(20);not null;default:'XEN'"` + AssignedJE_ID *uint // Populated when AE delegates to JE + CreatedAt time.Time + UpdatedAt time.Time + + Comments []Comment `gorm:"polymorphic:Commentable;"` +} + +type CentreHeadPost struct { + ID uint `gorm:"primaryKey;autoIncrement"` + CentreHeadID *uint // Link to CentreHead + TypeOfPost PostType `gorm:"type:varchar(20);not null"` + Title string `gorm:"not null"` + Description string `gorm:"type:text;not null"` + Status PostStatus `gorm:"type:varchar(20);not null;default:'Pending_XEN'"` + Stage PostStage `gorm:"type:varchar(20);not null;default:'XEN'"` + AssignedJE_ID *uint // Populated when AE delegates to JE + CreatedAt time.Time + UpdatedAt time.Time + + Comments []Comment `gorm:"polymorphic:Commentable;"` +} + +type Comment struct { + ID uint `gorm:"primaryKey;autoIncrement"` + CommentableID uint `gorm:"not null"` + CommentableType string `gorm:"type:varchar(50);not null"` + AdminID uint `gorm:"not null"` + CommentText string `gorm:"type:text;not null"` + CreatedAt time.Time +} diff --git a/routes/post_routes.go b/routes/post_routes.go new file mode 100644 index 0000000..3301469 --- /dev/null +++ b/routes/post_routes.go @@ -0,0 +1,26 @@ +package routes + +import ( + "github.com/ayush00git/cms-web/handlers" + "github.com/gin-gonic/gin" +) + +// PostRoute initializes all post-related endpoints +func PostRoute(router *gin.Engine, h *handlers.PostHandler) { + + // Faculty, Warden & Centre Head reporting routes + router.POST("/faculty/post/report", h.FacultyReportPost) + router.POST("/warden/post/report", h.WardenReportPost) + router.POST("/centrehead/post/report", h.CentreHeadReportPost) + + // Admin update routes + admin := router.Group("/admin") + { + admin.POST("/xen/post/status", h.XENUpdateStatus) + admin.POST("/ae/post/status", h.AEUpdateStatus) + admin.POST("/je/post/status", h.JEUpdateStatus) + } + + // Public dashboard + router.GET("/posts/public", h.GetPublicDashboard) +} diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..dc531ae --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Ensure Go is in PATH +export PATH=$PATH:/usr/local/go/bin + +# Set environment variables for testing +export JWT_SECRET=testsecret +export DB_USER=test +export DB_NAME=test +export DB_PASS=test +echo "--------------------------------------" + +# Run tests +go test ./tests -v + +if [ $? -eq 0 ]; then + echo "--------------------------------------" + echo "SUCCESS: All features are working correctly." +else + echo "--------------------------------------" + echo "FAILURE: Some features failed the functionality check." + exit 1 +fi diff --git a/server.log b/server.log new file mode 100644 index 0000000..a565ede --- /dev/null +++ b/server.log @@ -0,0 +1,55 @@ +nohup: ignoring input +2026/05/12 02:55:47 Database connected and migrated +[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached. + +[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production. + - using env: export GIN_MODE=release + - using code: gin.SetMode(gin.ReleaseMode) + +[GIN-debug] GET /health --> main.main.func1 (3 handlers) +[GIN-debug] POST /faculty/complaint/report --> github.com/ayush00git/cms-web/controllers.FacultyReportComplaint (3 handlers) +[GIN-debug] POST /warden/complaint/report --> github.com/ayush00git/cms-web/controllers.WardenReportComplaint (3 handlers) +[GIN-debug] POST /centrehead/complaint/report --> github.com/ayush00git/cms-web/controllers.CentreHeadReportComplaint (3 handlers) +[GIN-debug] POST /admin/xen/complaint/status --> github.com/ayush00git/cms-web/controllers.XENUpdateStatus (3 handlers) +[GIN-debug] POST /admin/ae/complaint/status --> github.com/ayush00git/cms-web/controllers.AEUpdateStatus (3 handlers) +[GIN-debug] POST /admin/je/complaint/status --> github.com/ayush00git/cms-web/controllers.JEUpdateStatus (3 handlers) +[GIN-debug] GET /complaints/public --> github.com/ayush00git/cms-web/controllers.GetPublicDashboard (3 handlers) +[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value. +Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details. +[GIN-debug] Listening and serving HTTP on :8080 +====================================== +MOCK EMAIL DISPATCH +To: xen_civil@nit.edu +Subject: New Faculty Complaint +Body: A new Civil complaint was reported: Leaking pipe +====================================== +[GIN] 2026/05/12 - 02:55:58 | 200 | 36.69ms | ::1 | POST "/faculty/complaint/report" +====================================== +MOCK EMAIL DISPATCH +To: ae_civil@nit.edu +Subject: Complaint Forwarded to AE +Body: A complaint has been forwarded to you for action. +====================================== +[GIN] 2026/05/12 - 02:56:08 | 200 | 73.26ms | ::1 | POST "/admin/xen/complaint/status" +====================================== +MOCK EMAIL DISPATCH +To: je@nit.edu +Subject: Complaint Assigned to JE +Body: A new complaint has been assigned to you by AE. +====================================== +[GIN] 2026/05/12 - 02:56:26 | 200 | 37.73ms | ::1 | POST "/admin/ae/complaint/status" +====================================== +MOCK EMAIL DISPATCH +To: filer@nit.edu +Subject: Complaint Resolved +Body: Your complaint has been successfully resolved. +====================================== +[GIN] 2026/05/12 - 02:56:36 | 200 | 38.08ms | ::1 | POST "/admin/je/complaint/status" +[GIN] 2026/05/12 - 02:56:46 | 200 | 2.69ms | ::1 | GET "/complaints/public" +====================================== +MOCK EMAIL DISPATCH +To: xen_electrical@nit.edu +Subject: New Centre Head Complaint +Body: A new Electrical complaint was reported by Centre Head: Power outage +====================================== +[GIN] 2026/05/12 - 02:56:53 | 200 | 31.86ms | ::1 | POST "/centrehead/complaint/report" diff --git a/tests/auth_test.go b/tests/auth_test.go new file mode 100644 index 0000000..e123932 --- /dev/null +++ b/tests/auth_test.go @@ -0,0 +1,101 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ayush00git/cms-web/models" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" +) + +func TestAuthFlows(t *testing.T) { + db := SetupTestDB() + router := SetupTestRouter(db) + + t.Run("Faculty Signup and Login", func(t *testing.T) { + // Signup + signupData := map[string]string{ + "name": "Dr. Smith", + "email": "smith@nit.edu", + "password": "password123", + "department": "CSE", + "house_number": "101", + "block": "A", + "type": "Type-1", + "phone_number": "1234567890", + } + body, _ := json.Marshal(signupData) + req, _ := http.NewRequest("POST", "/api/auth/faculty/signup", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + // Login + loginData := map[string]string{ + "email": "smith@nit.edu", + "password": "password123", + } + body, _ = json.Marshal(loginData) + req, _ = http.NewRequest("POST", "/api/auth/faculty/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Set-Cookie"), "token=") + }) + + t.Run("Warden Signup and Login", func(t *testing.T) { + signupData := map[string]string{ + "email": "warden@nit.edu", + "password": "warden123", + "hostel": "Kailash", + "phone_number": "0987654321", + } + body, _ := json.Marshal(signupData) + req, _ := http.NewRequest("POST", "/api/auth/warden/signup", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + loginData := map[string]string{ + "email": "warden@nit.edu", + "password": "warden123", + } + body, _ = json.Marshal(loginData) + req, _ = http.NewRequest("POST", "/api/auth/warden/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("Admin Login", func(t *testing.T) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("password"), 10) + admin := models.Admin{ + Email: "admin@nit.edu", + Password: string(hashedPassword), + Post: "XEN_Civil", + IsVerified: true, + } + db.Create(&admin) + + loginData := map[string]string{ + "email": "admin@nit.edu", + "password": "password", + } + body, _ := json.Marshal(loginData) + req, _ := http.NewRequest("POST", "/api/auth/admin/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Set-Cookie"), "token=") + }) +} diff --git a/tests/post_test.go b/tests/post_test.go new file mode 100644 index 0000000..b9c23f8 --- /dev/null +++ b/tests/post_test.go @@ -0,0 +1,120 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ayush00git/cms-web/models" + "github.com/stretchr/testify/assert" +) + +func TestPostWorkflow(t *testing.T) { + db := SetupTestDB() + router := SetupTestRouter(db) + + var facultyPostID uint + + t.Run("Faculty Report Post", func(t *testing.T) { + postData := map[string]interface{}{ + "faculty_id": 1, + "place": "Departmental", + "type_of_post": "Civil", + "title": "Leaking Pipe", + "description": "The pipe in CSE lab is leaking.", + } + body, _ := json.Marshal(postData) + req, _ := http.NewRequest("POST", "/faculty/post/report", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + post := resp["post"].(map[string]interface{}) + facultyPostID = uint(post["ID"].(float64)) + }) + + t.Run("XEN Pass Post to AE", func(t *testing.T) { + updateData := map[string]interface{}{ + "source": "Faculty", + "post_id": facultyPostID, + "action": "pass", + "comment": "Forwarding to AE for technical review", + "admin_id": 1, + } + body, _ := json.Marshal(updateData) + req, _ := http.NewRequest("POST", "/admin/xen/post/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var post models.FacultyPost + db.First(&post, facultyPostID) + assert.Equal(t, models.StatusPendingAE, post.Status) + assert.Equal(t, models.StageAE, post.Stage) + }) + + t.Run("AE Pass Post to JE", func(t *testing.T) { + updateData := map[string]interface{}{ + "source": "Faculty", + "post_id": facultyPostID, + "action": "pass", + "comment": "Assigning to JE Sharma", + "select_je_id": 10, + "admin_id": 2, + } + body, _ := json.Marshal(updateData) + req, _ := http.NewRequest("POST", "/admin/ae/post/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var post models.FacultyPost + db.First(&post, facultyPostID) + assert.Equal(t, models.StatusPendingJE, post.Status) + assert.Equal(t, models.StageJE, post.Stage) + assert.Equal(t, uint(10), *post.AssignedJE_ID) + }) + + t.Run("JE Resolve Post", func(t *testing.T) { + updateData := map[string]interface{}{ + "source": "Faculty", + "post_id": facultyPostID, + "action": "resolve", + "comment": "Fixed the leak.", + "admin_id": 10, + } + body, _ := json.Marshal(updateData) + req, _ := http.NewRequest("POST", "/admin/je/post/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var post models.FacultyPost + db.First(&post, facultyPostID) + assert.Equal(t, models.StatusResolved, post.Status) + }) + + t.Run("Public Dashboard", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/posts/public", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + assert.NotNil(t, resp["faculty_posts"]) + }) +} diff --git a/tests/test_helper.go b/tests/test_helper.go new file mode 100644 index 0000000..2e17b9a --- /dev/null +++ b/tests/test_helper.go @@ -0,0 +1,43 @@ +package tests + +import ( + "github.com/ayush00git/cms-web/handlers" + "github.com/ayush00git/cms-web/models" + "github.com/ayush00git/cms-web/routes" + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func SetupTestDB() *gorm.DB { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + panic("failed to connect database") + } + + db.AutoMigrate( + &models.Admin{}, + &models.Faculty{}, + &models.Warden{}, + &models.CentreHead{}, + &models.FacultyPost{}, + &models.WardenPost{}, + &models.CentreHeadPost{}, + &models.Comment{}, + ) + + return db +} + +func SetupTestRouter(db *gorm.DB) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + + authHandler := &handlers.AuthHandler{DB: db} + postHandler := &handlers.PostHandler{DB: db} + + routes.AuthRoute(r, authHandler) + routes.PostRoute(r, postHandler) + + return r +}