diff --git a/.env.exampe b/.env.exampe index 9122f60..4f63543 100644 --- a/.env.exampe +++ b/.env.exampe @@ -4,4 +4,5 @@ DB_NAME= DB_PORT= JWT_SECRET= APP_PASSWORD= -SENDER_EMAIL= \ No newline at end of file +SENDER_EMAIL= +FRONTEND_URL= diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d3d4238..0fafb5c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to College Server +name: Push to production on: push: diff --git a/app/src/pages/admin/AdminPostView.tsx b/app/src/pages/admin/AdminPostView.tsx index 6f1fb8b..fa9fdc2 100644 --- a/app/src/pages/admin/AdminPostView.tsx +++ b/app/src/pages/admin/AdminPostView.tsx @@ -19,6 +19,7 @@ import { Info, Check, Clock, + Users, } from 'lucide-react'; import { MainLayout } from '../../components/layout/MainLayout'; @@ -77,6 +78,7 @@ interface FacultyPost { updated_at: string; comments: Comment[] | null; status_audit_logs?: StatusAudit[] | null; + people_in_thread?: string[] | null; } interface WardenPost { @@ -94,6 +96,7 @@ interface WardenPost { updated_at: string; comments: Comment[] | null; status_audit_logs?: StatusAudit[] | null; + people_in_thread?: string[] | null; } interface CentreHeadPost { @@ -110,6 +113,7 @@ interface CentreHeadPost { updated_at: string; comments: Comment[] | null; status_audit_logs?: StatusAudit[] | null; + people_in_thread?: string[] | null; } type Post = FacultyPost | WardenPost | CentreHeadPost; @@ -744,6 +748,26 @@ export function AdminPostView() { + {/* People in conversation thread */} +

+ People in conversation thread +

+
+ {post.people_in_thread && post.people_in_thread.length > 0 ? ( + post.people_in_thread.map((email) => ( + + + {email} + + )) + ) : ( + No one in thread yet + )} +
+ {/* ── Timeline & Comments ── */}

diff --git a/app/src/pages/post/PostView.tsx b/app/src/pages/post/PostView.tsx index 16cacdc..c6dd273 100644 --- a/app/src/pages/post/PostView.tsx +++ b/app/src/pages/post/PostView.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { Zap, Hammer, Trash2, Pencil, X, Check, Calendar, MapPin, BedDouble, MessageSquare, Wrench, ArrowLeft, Send, AlertCircle, - Clock, + Clock, Users, } from 'lucide-react'; import { MainLayout } from '../../components/layout/MainLayout'; import { POST_PLACES } from '../../constants/models'; @@ -37,6 +37,7 @@ interface ComplaintPost { updated_at?: string; comments?: ComplaintComment[] | null; status_audit_logs?: StatusAudit[] | null; + people_in_thread?: string[] | null; } interface EditForm { @@ -458,6 +459,30 @@ export function PostView() {

+ {/* People in conversation thread */} + {!isEditing && ( +
+

+ People in conversation thread +

+
+ {post.people_in_thread && post.people_in_thread.length > 0 ? ( + post.people_in_thread.map((email) => ( + + + {email} + + )) + ) : ( + No one in thread yet + )} +
+
+ )} + {/* Comments Section */} {!isEditing && (
diff --git a/handlers/admin_comment.go b/handlers/admin_comment.go index 3462ec9..1a647c0 100644 --- a/handlers/admin_comment.go +++ b/handlers/admin_comment.go @@ -2,11 +2,15 @@ package handlers import ( "errors" + "fmt" + "log" "strconv" "time" + "github.com/ayush00git/cms-web/helpers" "github.com/ayush00git/cms-web/middleware" "github.com/ayush00git/cms-web/models" + "github.com/ayush00git/cms-web/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -102,6 +106,64 @@ func (h *AdminHandler) AdminPostComment(c *gin.Context) { return } + // update the PeopleInThread field for post + switch p := postModel.(type) { + case *models.FacultyPost: + // add person to conversation thread and send mail to all people in thread + p.PeopleInThread = helpers.AppendUnique(p.PeopleInThread, admin.Email) + result := h.DB.Save(p) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed updating the conversation thread"}) + return + } + go func() { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/admin/posts/%s/%d`, frontendURL, "faculty", p.ID) + + if err := services.SendMailToPeopleInThread(p.PeopleInThread, admin.Email, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", p.ID, err) + return + } + log.Printf("notification emails sent for post #%d", p.ID) + }() + + case *models.WardenPost: + p.PeopleInThread = helpers.AppendUnique(p.PeopleInThread, admin.Email) + result := h.DB.Save(p) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed updating the conversation thread"}) + return + } + go func() { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/admin/posts/%s/%d`, frontendURL, "warden", p.ID) + + if err := services.SendMailToPeopleInThread(p.PeopleInThread, admin.Email, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", p.ID, err) + return + } + log.Printf("notification emails sent for post #%d", p.ID) + }() + + case *models.CentreheadPost: + p.PeopleInThread = helpers.AppendUnique(p.PeopleInThread, admin.Email) + result := h.DB.Save(p) + if result.Error != nil { + c.JSON(500, gin.H{"error": "failed updating the conversation thread"}) + return + } + go func() { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/admin/posts/%s/%d`, frontendURL, "centrehead", p.ID) + + if err := services.SendMailToPeopleInThread(p.PeopleInThread, admin.Email, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", p.ID, err) + return + } + log.Printf("notification emails sent for post #%d", p.ID) + }() + } + doc := models.Comment{ CommentableID: uint(postID), CommentableType: postType, diff --git a/handlers/centrehead_post.go b/handlers/centrehead_post.go index 1a65e89..f3be58b 100644 --- a/handlers/centrehead_post.go +++ b/handlers/centrehead_post.go @@ -65,6 +65,7 @@ func (h *PostHandler) CentreheadPost(c *gin.Context) { TypeOfPost: models.PostType(inputs.TypeOfPost), Title: inputs.Title, Description: inputs.Description, + PeopleInThread: []string{head.Email}, StatusAuditLogs: []models.StatusAudit{ { Event: string(PendingXEN), @@ -92,16 +93,23 @@ func (h *PostHandler) CentreheadPost(c *gin.Context) { } // through type of post send the mail to the corresponding civil/electrical XEN var xen models.Admin + result := h.DB.Where("position = ?", position).Take(&xen) if result.Error != nil { - log.Printf("failed to send XEN mail for post %d", post.ID) + log.Printf("failed fetching user at the moment %v", result.Error) return } // send mail to that user if err := services.SendPostMailToAdmins(xen.Email, postURL); err != nil { log.Printf("failed to send XEN mail for post %d: %v", post.ID, err) } - }() + // append xen's email to the post + post.PeopleInThread = append(post.PeopleInThread, xen.Email) + result = h.DB.Model(&post).Updates(post) + if result.Error != nil { + log.Printf("failed adding xen to the thread") + } + } () c.JSON(201, gin.H{"success": "post submitted successfully", "post": post}) } @@ -278,7 +286,7 @@ func (h *PostHandler) CentreheadPostComment(c *gin.Context) { // bind the input var inputs CommentType if err := c.ShouldBindJSON(&inputs); err != nil { - c.JSON(401, gin.H{"error": "invalid request body"}) + c.JSON(400, gin.H{"error": "invalid request body"}) return } @@ -298,5 +306,17 @@ func (h *PostHandler) CentreheadPostComment(c *gin.Context) { return } + // send email notification to people involved in the conversation + go func(post models.CentreheadPost, headEmail string) { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/centre-head/post/%d`, frontendURL, post.ID) + + if err := services.SendMailToPeopleInThread(post.PeopleInThread, headEmail, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", post.ID, err) + return + } + log.Printf("notification emails sent for post #%d", post.ID) + }(post, head.Email) + c.JSON(201, gin.H{"success": "comment posted!"}) } diff --git a/handlers/faculty_post.go b/handlers/faculty_post.go index c019890..8131749 100644 --- a/handlers/faculty_post.go +++ b/handlers/faculty_post.go @@ -20,22 +20,23 @@ type PostHandler struct { DB *gorm.DB } -// FacultyPostEditType -type FacultyPostEditType struct { +// FacultyPostType +type FacultyPostType struct { Place string `json:"place"` + TypeOfPost string `json:"type_of_post"` Title string `json:"title"` Description string `json:"description"` - UpdatedAt time.Time `json:"updated_at"` } -// FacultyPostType -type FacultyPostType struct { +// FacultyPostEditType +type FacultyPostEditType struct { Place string `json:"place"` - TypeOfPost string `json:"type_of_post"` Title string `json:"title"` Description string `json:"description"` + UpdatedAt time.Time `json:"updated_at"` } + // FacultyPost registers the post of faculty members. // forwards the post to the associated XEN. func (h *PostHandler) FacultyPost(c *gin.Context) { @@ -72,6 +73,7 @@ func (h *PostHandler) FacultyPost(c *gin.Context) { TypeOfPost: models.PostType(inputs.TypeOfPost), Title: inputs.Title, Description: inputs.Description, + PeopleInThread: []string{faculty.Email}, StatusAuditLogs: []models.StatusAudit{ { Event: string(PendingXEN), @@ -90,6 +92,7 @@ func (h *PostHandler) FacultyPost(c *gin.Context) { frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") postURL := fmt.Sprintf(`%s/admin/posts/%s/%d`, frontendURL, faculty.Role, post.ID) + // forward the post creation update to xen go func() { var position models.PositionType if post.TypeOfPost == "Civil" { @@ -99,16 +102,23 @@ func (h *PostHandler) FacultyPost(c *gin.Context) { } // through type of post send the mail to the corresponding civil/electrical XEN var xen models.Admin + result := h.DB.Where("position = ?", position).Take(&xen) if result.Error != nil { - log.Printf("failed to send XEN mail for post %d", post.ID) + log.Printf("failed fetching user at the moment %v", result.Error) return } // send mail to that user if err := services.SendPostMailToAdmins(xen.Email, postURL); err != nil { log.Printf("failed to send XEN mail for post %d: %v", post.ID, err) } - }() + // append xen's email to the post + post.PeopleInThread = append(post.PeopleInThread, xen.Email) + result = h.DB.Model(&post).Updates(post) + if result.Error != nil { + log.Printf("failed adding xen to the thread") + } + } () c.JSON(201, gin.H{"success": "post submitted successfully", "post": post}) } @@ -289,7 +299,7 @@ func (h *PostHandler) FacultyPostComment(c *gin.Context) { // bind input to json var inputs CommentType if err := c.ShouldBindJSON(&inputs); err != nil { - c.JSON(401, gin.H{"error": "invalid request body"}) + c.JSON(400, gin.H{"error": "invalid request body"}) return } @@ -309,5 +319,17 @@ func (h *PostHandler) FacultyPostComment(c *gin.Context) { return } + // send email notification to people involved in the conversation + go func(post models.FacultyPost, facultyEmail string) { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/faculty/post/%d`, frontendURL, post.ID) + + if err := services.SendMailToPeopleInThread(post.PeopleInThread, facultyEmail, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", post.ID, err) + return + } + log.Printf("notification emails sent for post #%d", post.ID) + }(post, faculty.Email) + c.JSON(201, gin.H{"success": "comment posted!"}) } diff --git a/handlers/warden_post.go b/handlers/warden_post.go index cfc298b..414c31b 100644 --- a/handlers/warden_post.go +++ b/handlers/warden_post.go @@ -69,6 +69,7 @@ func (h *PostHandler) WardenPost(c *gin.Context) { TypeOfPost: models.PostType(inputs.TypeOfPost), Title: inputs.Title, Description: inputs.Description, + PeopleInThread: []string{warden.Email}, StatusAuditLogs: []models.StatusAudit{ { Event: string(PendingXEN), @@ -96,16 +97,23 @@ func (h *PostHandler) WardenPost(c *gin.Context) { } // through type of post send the mail to the corresponding civil/electrical XEN var xen models.Admin + result := h.DB.Where("position = ?", position).Take(&xen) if result.Error != nil { - log.Printf("failed to send XEN mail for post %d", post.ID) + log.Printf("failed fetching user at the moment %v", result.Error) return } // send mail to that user if err := services.SendPostMailToAdmins(xen.Email, postURL); err != nil { log.Printf("failed to send XEN mail for post %d: %v", post.ID, err) } - }() + // append xen's email to the post + post.PeopleInThread = append(post.PeopleInThread, xen.Email) + result = h.DB.Model(&post).Updates(post) + if result.Error != nil { + log.Printf("failed adding xen to the thread") + } + } () c.JSON(201, gin.H{"success": "post submitted successfully", "post": post}) } @@ -282,7 +290,7 @@ func (h *PostHandler) WardenPostComment(c *gin.Context) { // bind the input var inputs CommentType if err := c.ShouldBindJSON(&inputs); err != nil { - c.JSON(401, gin.H{"error": "invalid request body"}) + c.JSON(400, gin.H{"error": "invalid request body"}) return } @@ -301,6 +309,18 @@ func (h *PostHandler) WardenPostComment(c *gin.Context) { c.JSON(500, gin.H{"error": "failed to comment at the moment"}) return } - + + // send email notification to people involved in the conversation + go func(post models.WardenPost, wardenEmail string) { + frontendURL := helpers.GetEnvWithDefault("FRONTEND_URL", "http://localhost:5173") + postURL := fmt.Sprintf(`%s/warden/post/%d`, frontendURL, post.ID) + + if err := services.SendMailToPeopleInThread(post.PeopleInThread, wardenEmail, postURL); err != nil { + log.Printf("failed sending notification emails for post #%d: %v", post.ID, err) + return + } + log.Printf("notification emails sent for post #%d", post.ID) + }(post, warden.Email) + c.JSON(201, gin.H{"success": "comment posted!"}) } diff --git a/helpers/append_unique.go b/helpers/append_unique.go new file mode 100644 index 0000000..7fefc7e --- /dev/null +++ b/helpers/append_unique.go @@ -0,0 +1,15 @@ +package helpers + + +// AppendUnique is a helper method which acts as a corollary +// to the `.append` method of strings and avoids storing +// duplicate emails to the PersonInThread slice +// restoring the set property. +func AppendUnique(emails []string, email string) []string { + for _, e := range emails { + if email == e { + return emails; + } + } + return append(emails, email) +} diff --git a/models/post.go b/models/post.go index 2e84729..08efca8 100644 --- a/models/post.go +++ b/models/post.go @@ -47,6 +47,7 @@ type FacultyPost struct { TypeOfPost PostType `gorm:"type:varchar(20);not null" json:"type_of_post" binding:"required"` Title string `gorm:"type:varchar(50);not null" json:"title" binding:"required"` Description string `gorm:"type:text;not null" json:"description" binding:"required"` + PeopleInThread []string `gorm:"serializer:json;" json:"people_in_thread"` Status string `gorm:"type:varchar(20);not null;default:'pending_xen'" json:"status"` StatusAuditLogs []StatusAudit `gorm:"serializer:json;" json:"status_audit_logs"` AssignedJE_ID *uint `json:"assigned_je_id"` @@ -64,6 +65,7 @@ type WardenPost struct { TypeOfPost PostType `gorm:"type:varchar(20);not null" json:"type_of_post" binding:"required"` Title string `gorm:"not null" json:"title" binding:"required"` Description string `gorm:"type:text;not null" json:"description" binding:"required"` + PeopleInThread []string `gorm:"serializer:json;" json:"people_in_thread"` Status string `gorm:"type:varchar(20);not null;default:'pending_xen'" json:"status"` StatusAuditLogs []StatusAudit `gorm:"serializer:json;" json:"status_audit_logs"` AssignedJE_ID *uint `json:"assigned_je_id"` @@ -80,6 +82,7 @@ type CentreheadPost struct { TypeOfPost PostType `gorm:"type:varchar(20);not null" json:"type_of_post" binding:"required"` Title string `gorm:"not null" json:"title" binding:"required"` Description string `gorm:"type:text;not null" json:"description" binding:"required"` + PeopleInThread []string `gorm:"serializer:json;" json:"people_in_thread"` Status string `gorm:"type:varchar(20);not null;default:'pending_xen'" json:"status"` StatusAuditLogs []StatusAudit `gorm:"serializer:json;" json:"status_audit_logs"` AssignedJE_ID *uint `json:"assigned_je_id"` diff --git a/services/email.go b/services/email.go index a24037c..fc15798 100644 --- a/services/email.go +++ b/services/email.go @@ -112,7 +112,6 @@ func SendPasswordResetMail(userID uint, email, role string) error { } func SendPostMailToAdmins(email, postURL string) error { - // send the email mail := fmt.Sprintf(` @@ -124,14 +123,14 @@ func SendPostMailToAdmins(email, postURL string) error { -

Reset your password

+

cms: updates on your recent complaint

Reset!

`, postURL) - +// sends the email err := SendMail(email, "New complaint recieved", mail) if err != nil { return err @@ -139,3 +138,38 @@ func SendPostMailToAdmins(email, postURL string) error { log.Printf("complaint mail was sent to %s", email) return nil } + +// Send email to peoples in the conversation thread expect the ignoreEmail +// one, to prevent sending the mail to user itself for its events +func SendMailToPeopleInThread(emails []string, ignoreEmail string, postURL string) error { + mail := fmt.Sprintf(` + + + + + + + +

cms: updates on your recent complaint

+

+ Reset! +

+ + + `, postURL) + + for _, email := range emails { + if email == ignoreEmail { + continue + } + if err := SendMail(email, "cms: updates on your recent complaint", mail); err != nil { // if sending mail to one user fails don't abort for others + log.Printf("failed sending mail to %s", email) + continue + } + log.Printf("complaint was sent to %s\n", email) + } + return nil +} diff --git a/test/post_comment_test.go b/test/post_comment_test.go index f22a84f..cc187e3 100644 --- a/test/post_comment_test.go +++ b/test/post_comment_test.go @@ -82,7 +82,7 @@ func TestFacultyPostComment_InvalidBody(t *testing.T) { e := newPostRouter(db, authAs(f.ID, f.Email)) rec := doRequestRaw(t, e, http.MethodPost, "/api/post/faculty/comment/1", "{not json") - assertStatus(t, rec, 401) + assertStatus(t, rec, 400) } // --- WardenPostComment ------------------------------------------------------ @@ -155,7 +155,7 @@ func TestWardenPostComment_InvalidBody(t *testing.T) { e := newPostRouter(db, authAs(w.ID, w.Email)) rec := doRequestRaw(t, e, http.MethodPost, "/api/post/warden/comment/1", "{not json") - assertStatus(t, rec, 401) + assertStatus(t, rec, 400) } // --- CentreheadPostComment -------------------------------------------------- @@ -228,5 +228,5 @@ func TestCentreheadPostComment_InvalidBody(t *testing.T) { e := newPostRouter(db, authAs(ch.ID, ch.Email)) rec := doRequestRaw(t, e, http.MethodPost, "/api/post/centrehead/comment/1", "{not json") - assertStatus(t, rec, 401) + assertStatus(t, rec, 400) }