diff --git a/app/src/components/ComplaintCard.tsx b/app/src/components/ComplaintCard.tsx index f1110a4..ed2ac8e 100644 --- a/app/src/components/ComplaintCard.tsx +++ b/app/src/components/ComplaintCard.tsx @@ -80,6 +80,10 @@ function statusStyle(s: string): StatusStyle { const STAGES = ['XEN', 'AE', 'JE']; +function isEditWindowExpired(createdAt: string): boolean { + return Date.now() - new Date(createdAt).getTime() >= 30 * 60 * 1000; +} + function formatDate(iso: string) { return new Date(iso).toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' }); } @@ -238,6 +242,7 @@ function PostModal({ const theme = typeTheme(isElectrical); const comments = post.comments ?? []; const currentStageIdx = STAGES.indexOf(post.stage); + const editExpired = isEditWindowExpired(post.created_at); return createPortal(
- {!isEditing && ( + {!isEditing && !editExpired && ( )} - + {!editExpired && ( + + )}
- + {!editExpired && ( + + )} + {!editExpired && ( + + )}
diff --git a/app/src/pages/profile/Profile.tsx b/app/src/pages/profile/Profile.tsx index 121b2a0..cb74a88 100644 --- a/app/src/pages/profile/Profile.tsx +++ b/app/src/pages/profile/Profile.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { ShieldCheck, LogOut, PlusCircle, AlertCircle, Pencil, UserCheck, - Inbox, ServerCrash, + Inbox, ServerCrash, Info, } from 'lucide-react'; import { MainLayout } from '../../components/layout/MainLayout'; import { ComplaintCard } from '../../components/ComplaintCard'; @@ -254,6 +254,15 @@ export function Profile() {

Your Complaints

+
+ +
+
+ Posts can only be edited within 30 minutes of creation +
+
+
+
{!postsLoading && ( {posts.length} )} diff --git a/handlers/centrehead_post.go b/handlers/centrehead_post.go index 9e31e12..dba8fc3 100644 --- a/handlers/centrehead_post.go +++ b/handlers/centrehead_post.go @@ -127,6 +127,12 @@ func (h *PostHandler) CentreheadPostEdit(c *gin.Context) { c.JSON(403, gin.H{"error": "you are not authorized for this action"}) return } + + // limit the edit window only for 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "edit window has been expired"}) + return + } var inputs CentreheadPostEditType inputs.UpdatedAt = time.Now() @@ -173,6 +179,12 @@ func (h *PostHandler) CentreheadPostDelete(c *gin.Context) { return } + // restrict post deletion after 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "deletion window has been expired"}) + return + } + if result := h.DB.Delete(&post); result.Error != nil { c.JSON(500, gin.H{"error": "failed deleting the post"}) return diff --git a/handlers/faculty_post.go b/handlers/faculty_post.go index 8883a1d..2eb29cf 100644 --- a/handlers/faculty_post.go +++ b/handlers/faculty_post.go @@ -136,6 +136,12 @@ func (h *PostHandler) FacultyPostEdit(c *gin.Context) { return } + // limit the edit window only for 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "edit window has been expired"}) + return + } + var inputs FacultyPostEditType inputs.UpdatedAt = time.Now() if err := c.ShouldBindJSON(&inputs); err != nil { @@ -175,11 +181,18 @@ func (h *PostHandler) FacultyPostDelete(c *gin.Context) { return } + // check if the author is trying to delete if post.FacultyID != userID.(uint) { c.JSON(403, gin.H{"error": "you are not authorized for this action"}) return } + // restrict post deletion after 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "deletion window has been expired"}) + return + } + if result := h.DB.Delete(&post); result.Error != nil { c.JSON(500, gin.H{"error": "failed deleting the post"}) return diff --git a/handlers/warden_post.go b/handlers/warden_post.go index f7289a4..51fede3 100644 --- a/handlers/warden_post.go +++ b/handlers/warden_post.go @@ -132,6 +132,12 @@ func (h *PostHandler) WardenPostEdit(c *gin.Context) { return } + // limit the edit window only for 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "edit window has been expired"}) + return + } + var inputs WardenPostEditType inputs.UpdatedAt = time.Now() if err := c.ShouldBindJSON(&inputs); err != nil { @@ -177,6 +183,12 @@ func (h *PostHandler) WardenPostDelete(c *gin.Context) { return } + // restrict post deletion after 30 minutes + if time.Since(post.CreatedAt) >= 30*time.Minute { + c.JSON(403, gin.H{"error": "deletion window has been expired"}) + return + } + if result := h.DB.Delete(&post); result.Error != nil { c.JSON(500, gin.H{"error": "failed deleting the post"}) return diff --git a/routes/post.go b/routes/post.go index a619df8..ea60f4c 100644 --- a/routes/post.go +++ b/routes/post.go @@ -8,18 +8,22 @@ import ( ) func PostRoute(e *gin.Engine, h *handlers.PostHandler) { + // APIs for new post e.POST("/api/post/faculty", middleware.IsAuthenticated(), h.FacultyPost) e.POST("/api/post/warden", middleware.IsAuthenticated(), h.WardenPost) e.POST("/api/post/centrehead", middleware.IsAuthenticated(), h.CentreheadPost) + // APIs for updating the post e.PATCH("/api/post/faculty/edit/:post_id", middleware.IsAuthenticated(), h.FacultyPostEdit) e.PATCH("/api/post/warden/edit/:post_id", middleware.IsAuthenticated(), h.WardenPostEdit) e.PATCH("/api/post/centrehead/edit/:post_id", middleware.IsAuthenticated(), h.CentreheadPostEdit) + // APIs for deleting the post e.DELETE("/api/post/faculty/delete/:post_id", middleware.IsAuthenticated(), h.FacultyPostDelete) e.DELETE("/api/post/warden/delete/:post_id", middleware.IsAuthenticated(), h.WardenPostDelete) e.DELETE("/api/post/centrehead/delete/:post_id", middleware.IsAuthenticated(), h.CentreheadPostDelete) + // APIs for getting the posts e.GET("/api/post/faculty", middleware.IsAuthenticated(), h.GetFacultyPosts) e.GET("/api/post/warden", middleware.IsAuthenticated(), h.GetWardenPosts) e.GET("/api/post/centrehead", middleware.IsAuthenticated(), h.GetCentreheadPosts) diff --git a/test/centrehead_post_test.go b/test/centrehead_post_test.go index 259ac05..a508ffa 100644 --- a/test/centrehead_post_test.go +++ b/test/centrehead_post_test.go @@ -3,6 +3,7 @@ package test import ( "net/http" "testing" + "time" "github.com/ayush00git/cms-web/models" ) @@ -104,6 +105,18 @@ func TestCentreheadPostEdit_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestCentreheadPostEdit_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + ch := seedCentrehead(t, db, "ch.expired@iit.ac.in") + post := models.CentreheadPost{CentreheadID: ch.ID, TypeOfPost: models.TypeCivil, Title: "old", Description: "old"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(ch.ID, ch.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/post/centrehead/edit/1", map[string]any{"title": "new"}) + assertStatus(t, rec, 403) +} + // --- CentreheadPostDelete --------------------------------------------------- func TestCentreheadPostDelete_Success(t *testing.T) { @@ -151,6 +164,18 @@ func TestCentreheadPostDelete_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestCentreheadPostDelete_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + ch := seedCentrehead(t, db, "ch.delexpired@iit.ac.in") + post := models.CentreheadPost{CentreheadID: ch.ID, TypeOfPost: models.TypeCivil, Title: "t", Description: "d"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(ch.ID, ch.Email)) + rec := doRequest(t, e, http.MethodDelete, "/api/post/centrehead/delete/1", nil) + assertStatus(t, rec, 403) +} + // --- GetCentreheadPosts ----------------------------------------------------- func TestGetCentreheadPosts_Success(t *testing.T) { diff --git a/test/faculty_post_test.go b/test/faculty_post_test.go index b04032e..f1fa323 100644 --- a/test/faculty_post_test.go +++ b/test/faculty_post_test.go @@ -3,6 +3,7 @@ package test import ( "net/http" "testing" + "time" "github.com/ayush00git/cms-web/models" ) @@ -116,6 +117,18 @@ func TestFacultyPostEdit_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestFacultyPostEdit_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + f := seedFaculty(t, db, "fac.expired@iit.ac.in") + post := models.FacultyPost{FacultyID: f.ID, Place: models.PlaceDepartmental, TypeOfPost: models.TypeCivil, Title: "old", Description: "old desc"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(f.ID, f.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/post/faculty/edit/1", map[string]any{"title": "new"}) + assertStatus(t, rec, 403) +} + // --- FacultyPostDelete ------------------------------------------------------ func TestFacultyPostDelete_Success(t *testing.T) { @@ -164,6 +177,18 @@ func TestFacultyPostDelete_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestFacultyPostDelete_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + f := seedFaculty(t, db, "fac.delexpired@iit.ac.in") + post := models.FacultyPost{FacultyID: f.ID, Place: models.PlaceDepartmental, TypeOfPost: models.TypeCivil, Title: "t", Description: "d"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(f.ID, f.Email)) + rec := doRequest(t, e, http.MethodDelete, "/api/post/faculty/delete/1", nil) + assertStatus(t, rec, 403) +} + // --- GetFacultyPosts -------------------------------------------------------- func TestGetFacultyPosts_Success(t *testing.T) { diff --git a/test/warden_post_test.go b/test/warden_post_test.go index 1a7faee..72af71c 100644 --- a/test/warden_post_test.go +++ b/test/warden_post_test.go @@ -3,6 +3,7 @@ package test import ( "net/http" "testing" + "time" "github.com/ayush00git/cms-web/models" ) @@ -105,6 +106,18 @@ func TestWardenPostEdit_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestWardenPostEdit_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + w := seedWarden(t, db, "war.expired@iit.ac.in") + post := models.WardenPost{WardenID: w.ID, RoomNumber: "A-1", TypeOfPost: models.TypeCivil, Title: "old", Description: "old"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(w.ID, w.Email)) + rec := doRequest(t, e, http.MethodPatch, "/api/post/warden/edit/1", map[string]any{"title": "new"}) + assertStatus(t, rec, 403) +} + // --- WardenPostDelete ------------------------------------------------------- func TestWardenPostDelete_Success(t *testing.T) { @@ -152,6 +165,18 @@ func TestWardenPostDelete_Unauthenticated(t *testing.T) { assertStatus(t, rec, 401) } +func TestWardenPostDelete_ExpiredWindow(t *testing.T) { + db := newTestDB(t) + w := seedWarden(t, db, "war.delexpired@iit.ac.in") + post := models.WardenPost{WardenID: w.ID, RoomNumber: "A-1", TypeOfPost: models.TypeCivil, Title: "t", Description: "d"} + db.Create(&post) + db.Model(&post).Update("created_at", time.Now().Add(-31*time.Minute)) + + e := newPostRouter(db, authAs(w.ID, w.Email)) + rec := doRequest(t, e, http.MethodDelete, "/api/post/warden/delete/1", nil) + assertStatus(t, rec, 403) +} + // --- GetWardenPosts --------------------------------------------------------- func TestGetWardenPosts_Success(t *testing.T) {