Skip to content

Commit f851b11

Browse files
Merge pull request #36 from mrsumanbiswas/18-group-chat-create-manage-send-messages
#18 group chat create manage send messages
2 parents ffd1dff + 75b51f4 commit f851b11

12 files changed

Lines changed: 599 additions & 68 deletions

File tree

server/controllers/groups.go

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package controllers
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"strconv"
7+
"time"
8+
9+
"github.com/commandlinecoding/elephant/server/middlewares"
10+
"github.com/commandlinecoding/elephant/server/models"
11+
"github.com/commandlinecoding/elephant/server/repository"
12+
"github.com/go-chi/chi/v5"
13+
)
14+
15+
type CreateGroupReq struct {
16+
Name string `json:"name"`
17+
}
18+
19+
func HandleCreateGroup(w http.ResponseWriter, r *http.Request) {
20+
w.Header().Set("Content-Type", "application/json")
21+
uid, _ := r.Context().Value(middlewares.UserIDKey).(string)
22+
23+
var req CreateGroupReq
24+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
25+
w.WriteHeader(http.StatusBadRequest)
26+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid payload group name configuration"})
27+
return
28+
}
29+
30+
repo := repository.NewGroupRepository()
31+
group, err := repo.Create(r.Context(), req.Name, uid)
32+
if err != nil {
33+
w.WriteHeader(http.StatusInternalServerError)
34+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to create group channel"})
35+
return
36+
}
37+
38+
w.WriteHeader(http.StatusCreated)
39+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: group})
40+
}
41+
42+
func HandleListMyGroups(w http.ResponseWriter, r *http.Request) {
43+
w.Header().Set("Content-Type", "application/json")
44+
uid, _ := r.Context().Value(middlewares.UserIDKey).(string)
45+
46+
repo := repository.NewGroupRepository()
47+
groups, err := repo.ListByUser(r.Context(), uid)
48+
if err != nil {
49+
w.WriteHeader(http.StatusInternalServerError)
50+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to pull your joined channels list"})
51+
return
52+
}
53+
54+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: groups})
55+
}
56+
57+
type GroupMemberReq struct {
58+
UserID string `json:"user_id"`
59+
}
60+
61+
func HandleAddMember(w http.ResponseWriter, r *http.Request) {
62+
w.Header().Set("Content-Type", "application/json")
63+
groupID := chi.URLParam(r, "id")
64+
65+
var req GroupMemberReq
66+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" {
67+
w.WriteHeader(http.StatusBadRequest)
68+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing user target value"})
69+
return
70+
}
71+
72+
repo := repository.NewGroupRepository()
73+
if err := repo.AddMember(r.Context(), groupID, req.UserID); err != nil {
74+
w.WriteHeader(http.StatusInternalServerError)
75+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to add member to channel"})
76+
return
77+
}
78+
79+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "User attached to channel context cleanly"})
80+
}
81+
82+
func HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
83+
w.Header().Set("Content-Type", "application/json")
84+
groupID := chi.URLParam(r, "id")
85+
targetUserID := chi.URLParam(r, "userId")
86+
87+
if targetUserID == "" {
88+
w.WriteHeader(http.StatusBadRequest)
89+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing path target user selection identifier"})
90+
return
91+
}
92+
93+
repo := repository.NewGroupRepository()
94+
if err := repo.RemoveMember(r.Context(), groupID, targetUserID); err != nil {
95+
w.WriteHeader(http.StatusInternalServerError)
96+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to drop target user from channel"})
97+
return
98+
}
99+
100+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "Member detached from group context cleanly"})
101+
}
102+
103+
func HandleGetGroupMessages(w http.ResponseWriter, r *http.Request) {
104+
w.Header().Set("Content-Type", "application/json")
105+
groupID := chi.URLParam(r, "id")
106+
beforeStr := r.URL.Query().Get("before")
107+
limitStr := r.URL.Query().Get("limit")
108+
109+
limit, _ := strconv.Atoi(limitStr)
110+
if limit <= 0 || limit > 100 {
111+
limit = 50
112+
}
113+
114+
before := time.Now()
115+
if beforeStr != "" {
116+
if t, err := time.Parse(time.RFC3339, beforeStr); err == nil {
117+
before = t
118+
}
119+
}
120+
121+
repo := repository.NewGroupRepository()
122+
history, err := repo.GetGroupMessages(r.Context(), groupID, before, limit)
123+
if err != nil {
124+
w.WriteHeader(http.StatusInternalServerError)
125+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to pull channel message history stream"})
126+
return
127+
}
128+
129+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: history})
130+
}
131+
132+
func HandleListGroupMembers(w http.ResponseWriter, r *http.Request) {
133+
w.Header().Set("Content-Type", "application/json")
134+
groupID := chi.URLParam(r, "id")
135+
136+
repo := repository.NewGroupRepository()
137+
members, err := repo.GetMembersDetails(r.Context(), groupID)
138+
if err != nil {
139+
w.WriteHeader(http.StatusInternalServerError)
140+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to retrieve group members roster"})
141+
return
142+
}
143+
144+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: members})
145+
}
146+
147+
func HandleLeaveGroup(w http.ResponseWriter, r *http.Request) {
148+
w.Header().Set("Content-Type", "application/json")
149+
groupID := chi.URLParam(r, "id")
150+
uid, _ := r.Context().Value(middlewares.UserIDKey).(string)
151+
152+
repo := repository.NewGroupRepository()
153+
154+
if err := repo.RemoveMember(r.Context(), groupID, uid); err != nil {
155+
w.WriteHeader(http.StatusInternalServerError)
156+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to leave the group"})
157+
return
158+
}
159+
160+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "You have left the group"})
161+
}
162+
163+
func HandleGetGroupDetails(w http.ResponseWriter, r *http.Request) {
164+
w.Header().Set("Content-Type", "application/json")
165+
groupID := chi.URLParam(r, "id")
166+
167+
repo := repository.NewGroupRepository()
168+
group, err := repo.GetByID(r.Context(), groupID)
169+
if err != nil {
170+
w.WriteHeader(http.StatusNotFound)
171+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Group not found"})
172+
return
173+
}
174+
175+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: group})
176+
}
177+
178+
type UpdateGroupReq struct {
179+
Name string `json:"name"`
180+
}
181+
182+
func HandleUpdateGroup(w http.ResponseWriter, r *http.Request) {
183+
w.Header().Set("Content-Type", "application/json")
184+
groupID := chi.URLParam(r, "id")
185+
186+
var req UpdateGroupReq
187+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
188+
w.WriteHeader(http.StatusBadRequest)
189+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid group name"})
190+
return
191+
}
192+
193+
repo := repository.NewGroupRepository()
194+
if err := repo.UpdateName(r.Context(), groupID, req.Name); err != nil {
195+
w.WriteHeader(http.StatusInternalServerError)
196+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to update group name"})
197+
return
198+
}
199+
200+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "Group details updated successfully"})
201+
}

server/middlewares/group_auth.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package middlewares
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
8+
"github.com/commandlinecoding/elephant/server/models"
9+
"github.com/commandlinecoding/elephant/server/repository"
10+
"github.com/go-chi/chi/v5"
11+
)
12+
13+
type groupContextKey string
14+
15+
const GroupRoleKey groupContextKey = "groupRole"
16+
17+
func GroupAuthGuard(next http.Handler) http.Handler {
18+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
w.Header().Set("Content-Type", "application/json")
20+
21+
uid, _ := r.Context().Value(UserIDKey).(string)
22+
groupID := chi.URLParam(r, "id")
23+
24+
if groupID == "" {
25+
w.WriteHeader(http.StatusBadRequest)
26+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing channel identifier path parameter"})
27+
return
28+
}
29+
30+
repo := repository.NewGroupRepository()
31+
role, err := repo.GetRole(r.Context(), groupID, uid)
32+
if err != nil {
33+
w.WriteHeader(http.StatusForbidden)
34+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Access denied: you are not a member of this group"})
35+
return
36+
}
37+
38+
ctx := context.WithValue(r.Context(), GroupRoleKey, role)
39+
next.ServeHTTP(w, r.WithContext(ctx))
40+
})
41+
}
42+
43+
func GroupAdminGuard(next http.Handler) http.Handler {
44+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45+
w.Header().Set("Content-Type", "application/json")
46+
47+
role, _ := r.Context().Value(GroupRoleKey).(string)
48+
if role != "admin" {
49+
w.WriteHeader(http.StatusForbidden)
50+
_ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Administrative clearance privileges required"})
51+
return
52+
}
53+
next.ServeHTTP(w, r)
54+
})
55+
}

server/migrations/006_create_groups_table_up.sql

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@ CREATE TABLE IF NOT EXISTS groups (
88
CREATE TABLE IF NOT EXISTS group_members (
99
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
1010
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
11+
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
1112
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
1213
PRIMARY KEY (group_id, user_id)
1314
);
1415

15-
-- Optimize message query routing for group contexts
1616
ALTER TABLE messages ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
17-
ALTER TABLE messages ALTER COLUMN receiver_id DROP NOT NULL; -- Can be null if it's a group broadcast
17+
ALTER TABLE messages ALTER COLUMN receiver_id DROP NOT NULL;
1818

19-
CREATE INDEX IF NOT EXISTS idx_messages_group ON messages(group_id) WHERE group_id IS NOT NULL;
19+
20+
ALTER TABLE messages ADD CONSTRAINT chk_message_destination
21+
CHECK (
22+
(receiver_id IS NOT NULL AND group_id IS NULL) OR
23+
(receiver_id IS NULL AND group_id IS NOT NULL)
24+
);
25+
26+
CREATE INDEX IF NOT EXISTS idx_messages_group_timeline
27+
ON messages(group_id, created_at DESC)
28+
WHERE group_id IS NOT NULL;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE group_members ADD COLUMN last_read_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP;
2+
3+
CREATE INDEX IF NOT EXISTS idx_group_members_read_tracking ON group_members(group_id, user_id, last_read_at);

server/models/conversation.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ package models
33
import "time"
44

55
type Conversation struct {
6-
ChatUserID string `json:"chat_user_id"`
7-
Username string `json:"username"`
6+
ID string `json:"id"`
7+
Type string `json:"type"`
8+
Name string `json:"name"`
89
DisplayName string `json:"display_name"`
910
LastMessage string `json:"last_message"`
1011
LastMessageTime time.Time `json:"last_message_time"`

server/models/groups.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,14 @@ type Group struct {
1212
type GroupMember struct {
1313
GroupID string `json:"group_id"`
1414
UserID string `json:"user_id"`
15+
Role string `json:"role"`
1516
JoinedAt time.Time `json:"joined_at"`
1617
}
18+
19+
type GroupMemberDetail struct {
20+
UserID string `json:"user_id"`
21+
Username string `json:"username"`
22+
DisplayName string `json:"display_name"`
23+
Role string `json:"role"`
24+
JoinedAt time.Time `json:"joined_at"`
25+
}

server/models/message.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import "time"
55
type Message struct {
66
ID string `json:"id"`
77
SenderID string `json:"sender_id"`
8-
ReceiverID string `json:"receiver_id"`
8+
ReceiverID string `json:"receiver_id,omitempty"`
9+
GroupID string `json:"group_id,omitempty"`
910
Content string `json:"content"`
1011
CreatedAt time.Time `json:"created_at"`
1112
IsRead bool `json:"is_read"`

0 commit comments

Comments
 (0)