Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions app/src/components/ComplaintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down Expand Up @@ -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(
<div
Expand All @@ -261,7 +266,7 @@ function PostModal({
</div>

<div className="flex items-center gap-1 shrink-0">
{!isEditing && (
{!isEditing && !editExpired && (
<button
onClick={() => onStartEdit(post)}
disabled={isBusy}
Expand All @@ -283,12 +288,14 @@ function PostModal({
</button>
</>
)}
<button onClick={() => onDelete(post.id)} disabled={isBusy} title="Delete"
className="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors disabled:opacity-40 cursor-pointer">
{isBusy && !isEditing
? <div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
{!editExpired && (
<button onClick={() => onDelete(post.id)} disabled={isBusy} title="Delete"
className="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors disabled:opacity-40 cursor-pointer">
{isBusy && !isEditing
? <div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-4 h-4" />}
</button>
)}
<div className="w-px h-5 bg-gray-300 mx-1" />
<button onClick={onClose} title="Close"
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-900 hover:bg-white/70 transition-colors cursor-pointer">
Expand Down Expand Up @@ -391,6 +398,7 @@ export function ComplaintCard({
const comments = post.comments ?? [];
const isFaculty = role === 'faculty';
const isWarden = role === 'warden';
const editExpired = isEditWindowExpired(post.created_at);

return (
<>
Expand Down Expand Up @@ -418,14 +426,18 @@ export function ComplaintCard({
</span>

<div className="ml-auto flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<button onClick={() => onStartEdit(post)} disabled={isBusy} title="Edit"
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-white/80 transition-colors disabled:opacity-40 cursor-pointer">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => onDelete(post.id)} disabled={isBusy} title="Delete"
className="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors disabled:opacity-40 cursor-pointer">
<Trash2 className="w-3.5 h-3.5" />
</button>
{!editExpired && (
<button onClick={() => onStartEdit(post)} disabled={isBusy} title="Edit"
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-white/80 transition-colors disabled:opacity-40 cursor-pointer">
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{!editExpired && (
<button onClick={() => onDelete(post.id)} disabled={isBusy} title="Delete"
className="p-1.5 rounded-lg text-gray-400 hover:text-rose-600 hover:bg-rose-50 transition-colors disabled:opacity-40 cursor-pointer">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>

Expand Down
11 changes: 10 additions & 1 deletion app/src/pages/profile/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -254,6 +254,15 @@ export function Profile() {
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-[#E5E5E5]">
<Inbox className="w-4 h-4 text-[#666666]" />
<h3 className="text-sm font-bold text-[#111111] uppercase tracking-widest">Your Complaints</h3>
<div className="relative group">
<Info className="w-3.5 h-3.5 text-[#999999] cursor-default" />
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-gray-900 text-white text-xs font-medium px-3 py-1.5 rounded-lg whitespace-nowrap shadow-lg">
Posts can only be edited within 30 minutes of creation
</div>
<div className="w-2 h-2 bg-gray-900 rotate-45 mx-auto -mt-1" />
</div>
</div>
{!postsLoading && (
<span className="bg-[#F5F5F5] border border-[#E5E5E5] text-[#666666] text-xs font-bold px-2 py-0.5 rounded-lg">{posts.length}</span>
)}
Expand Down
12 changes: 12 additions & 0 deletions handlers/centrehead_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions handlers/faculty_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions handlers/warden_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions routes/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions test/centrehead_post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test
import (
"net/http"
"testing"
"time"

"github.com/ayush00git/cms-web/models"
)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions test/faculty_post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test
import (
"net/http"
"testing"
"time"

"github.com/ayush00git/cms-web/models"
)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions test/warden_post_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package test
import (
"net/http"
"testing"
"time"

"github.com/ayush00git/cms-web/models"
)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading