diff --git a/server/controllers/groups.go b/server/controllers/groups.go new file mode 100644 index 0000000..f54b82a --- /dev/null +++ b/server/controllers/groups.go @@ -0,0 +1,201 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/commandlinecoding/elephant/server/middlewares" + "github.com/commandlinecoding/elephant/server/models" + "github.com/commandlinecoding/elephant/server/repository" + "github.com/go-chi/chi/v5" +) + +type CreateGroupReq struct { + Name string `json:"name"` +} + +func HandleCreateGroup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + uid, _ := r.Context().Value(middlewares.UserIDKey).(string) + + var req CreateGroupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid payload group name configuration"}) + return + } + + repo := repository.NewGroupRepository() + group, err := repo.Create(r.Context(), req.Name, uid) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to create group channel"}) + return + } + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: group}) +} + +func HandleListMyGroups(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + uid, _ := r.Context().Value(middlewares.UserIDKey).(string) + + repo := repository.NewGroupRepository() + groups, err := repo.ListByUser(r.Context(), uid) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to pull your joined channels list"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: groups}) +} + +type GroupMemberReq struct { + UserID string `json:"user_id"` +} + +func HandleAddMember(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + + var req GroupMemberReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.UserID == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing user target value"}) + return + } + + repo := repository.NewGroupRepository() + if err := repo.AddMember(r.Context(), groupID, req.UserID); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to add member to channel"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "User attached to channel context cleanly"}) +} + +func HandleRemoveMember(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + targetUserID := chi.URLParam(r, "userId") + + if targetUserID == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing path target user selection identifier"}) + return + } + + repo := repository.NewGroupRepository() + if err := repo.RemoveMember(r.Context(), groupID, targetUserID); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to drop target user from channel"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "Member detached from group context cleanly"}) +} + +func HandleGetGroupMessages(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + beforeStr := r.URL.Query().Get("before") + limitStr := r.URL.Query().Get("limit") + + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 || limit > 100 { + limit = 50 + } + + before := time.Now() + if beforeStr != "" { + if t, err := time.Parse(time.RFC3339, beforeStr); err == nil { + before = t + } + } + + repo := repository.NewGroupRepository() + history, err := repo.GetGroupMessages(r.Context(), groupID, before, limit) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to pull channel message history stream"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: history}) +} + +func HandleListGroupMembers(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + + repo := repository.NewGroupRepository() + members, err := repo.GetMembersDetails(r.Context(), groupID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to retrieve group members roster"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: members}) +} + +func HandleLeaveGroup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + uid, _ := r.Context().Value(middlewares.UserIDKey).(string) + + repo := repository.NewGroupRepository() + + if err := repo.RemoveMember(r.Context(), groupID, uid); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to leave the group"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "You have left the group"}) +} + +func HandleGetGroupDetails(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + + repo := repository.NewGroupRepository() + group, err := repo.GetByID(r.Context(), groupID) + if err != nil { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Group not found"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: group}) +} + +type UpdateGroupReq struct { + Name string `json:"name"` +} + +func HandleUpdateGroup(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + groupID := chi.URLParam(r, "id") + + var req UpdateGroupReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Invalid group name"}) + return + } + + repo := repository.NewGroupRepository() + if err := repo.UpdateName(r.Context(), groupID, req.Name); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to update group name"}) + return + } + + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: true, Data: "Group details updated successfully"}) +} diff --git a/server/middlewares/group_auth.go b/server/middlewares/group_auth.go new file mode 100644 index 0000000..1d63121 --- /dev/null +++ b/server/middlewares/group_auth.go @@ -0,0 +1,55 @@ +package middlewares + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/commandlinecoding/elephant/server/models" + "github.com/commandlinecoding/elephant/server/repository" + "github.com/go-chi/chi/v5" +) + +type groupContextKey string + +const GroupRoleKey groupContextKey = "groupRole" + +func GroupAuthGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + uid, _ := r.Context().Value(UserIDKey).(string) + groupID := chi.URLParam(r, "id") + + if groupID == "" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Missing channel identifier path parameter"}) + return + } + + repo := repository.NewGroupRepository() + role, err := repo.GetRole(r.Context(), groupID, uid) + if err != nil { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Access denied: you are not a member of this group"}) + return + } + + ctx := context.WithValue(r.Context(), GroupRoleKey, role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GroupAdminGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + role, _ := r.Context().Value(GroupRoleKey).(string) + if role != "admin" { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Administrative clearance privileges required"}) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/server/migrations/006_create_groups_table_up.sql b/server/migrations/006_create_groups_table_up.sql index d008ae8..003b8ed 100644 --- a/server/migrations/006_create_groups_table_up.sql +++ b/server/migrations/006_create_groups_table_up.sql @@ -8,12 +8,21 @@ CREATE TABLE IF NOT EXISTS groups ( CREATE TABLE IF NOT EXISTS group_members ( group_id UUID REFERENCES groups(id) ON DELETE CASCADE, user_id UUID REFERENCES users(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')), joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (group_id, user_id) ); --- Optimize message query routing for group contexts ALTER TABLE messages ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE; -ALTER TABLE messages ALTER COLUMN receiver_id DROP NOT NULL; -- Can be null if it's a group broadcast +ALTER TABLE messages ALTER COLUMN receiver_id DROP NOT NULL; -CREATE INDEX IF NOT EXISTS idx_messages_group ON messages(group_id) WHERE group_id IS NOT NULL; \ No newline at end of file + +ALTER TABLE messages ADD CONSTRAINT chk_message_destination + CHECK ( + (receiver_id IS NOT NULL AND group_id IS NULL) OR + (receiver_id IS NULL AND group_id IS NOT NULL) + ); + +CREATE INDEX IF NOT EXISTS idx_messages_group_timeline + ON messages(group_id, created_at DESC) + WHERE group_id IS NOT NULL; \ No newline at end of file diff --git a/server/migrations/007_add_group_last_read_up.sql b/server/migrations/007_add_group_last_read_up.sql new file mode 100644 index 0000000..a2a136a --- /dev/null +++ b/server/migrations/007_add_group_last_read_up.sql @@ -0,0 +1,3 @@ +ALTER TABLE group_members ADD COLUMN last_read_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + +CREATE INDEX IF NOT EXISTS idx_group_members_read_tracking ON group_members(group_id, user_id, last_read_at); \ No newline at end of file diff --git a/server/models/conversation.go b/server/models/conversation.go index 40d7d1b..1b4ae7c 100644 --- a/server/models/conversation.go +++ b/server/models/conversation.go @@ -3,8 +3,9 @@ package models import "time" type Conversation struct { - ChatUserID string `json:"chat_user_id"` - Username string `json:"username"` + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` DisplayName string `json:"display_name"` LastMessage string `json:"last_message"` LastMessageTime time.Time `json:"last_message_time"` diff --git a/server/models/groups.go b/server/models/groups.go index 0c87d78..a39178c 100644 --- a/server/models/groups.go +++ b/server/models/groups.go @@ -12,5 +12,14 @@ type Group struct { type GroupMember struct { GroupID string `json:"group_id"` UserID string `json:"user_id"` + Role string `json:"role"` JoinedAt time.Time `json:"joined_at"` } + +type GroupMemberDetail struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + JoinedAt time.Time `json:"joined_at"` +} \ No newline at end of file diff --git a/server/models/message.go b/server/models/message.go index aad7aa1..b8d0791 100644 --- a/server/models/message.go +++ b/server/models/message.go @@ -5,7 +5,8 @@ import "time" type Message struct { ID string `json:"id"` SenderID string `json:"sender_id"` - ReceiverID string `json:"receiver_id"` + ReceiverID string `json:"receiver_id,omitempty"` + GroupID string `json:"group_id,omitempty"` Content string `json:"content"` CreatedAt time.Time `json:"created_at"` IsRead bool `json:"is_read"` diff --git a/server/repository/groups.go b/server/repository/groups.go index 05669b6..6494de5 100644 --- a/server/repository/groups.go +++ b/server/repository/groups.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" "github.com/commandlinecoding/elephant/server/config" "github.com/commandlinecoding/elephant/server/models" @@ -14,20 +15,73 @@ func NewGroupRepository() *GroupRepository { } func (r *GroupRepository) Create(ctx context.Context, name, creatorID string) (*models.Group, error) { + tx, err := config.DB.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + var g models.Group + query := `INSERT INTO groups (name, created_by) VALUES ($1, $2) RETURNING id, name, created_by, created_at;` + err = tx.QueryRow(ctx, query, name, creatorID).Scan(&g.ID, &g.Name, &g.CreatedBy, &g.CreatedAt) + if err != nil { + return nil, err + } + + memberQuery := `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'admin');` + _, err = tx.Exec(ctx, memberQuery, g.ID, creatorID) + if err != nil { + return nil, err + } + + return &g, tx.Commit(ctx) +} + +func (r *GroupRepository) ListByUser(ctx context.Context, userID string) ([]models.Group, error) { query := ` - INSERT INTO groups (name, created_by) - VALUES ($1, $2) - RETURNING id, name, created_by, created_at; + SELECT g.id, g.name, g.created_by, g.created_at + FROM groups g + JOIN group_members gm ON g.id = gm.group_id + WHERE gm.user_id = $1 + ORDER BY g.created_at DESC; ` - var g models.Group - err := config.DB.QueryRow(ctx, query, name, creatorID).Scan(&g.ID, &g.Name, &g.CreatedBy, &g.CreatedAt) + rows, err := config.DB.Query(ctx, query, userID) if err != nil { return nil, err } + defer rows.Close() + + groups := []models.Group{} + for rows.Next() { + var g models.Group + if err := rows.Scan(&g.ID, &g.Name, &g.CreatedBy, &g.CreatedAt); err != nil { + return nil, err + } + groups = append(groups, g) + } + return groups, nil +} + +func (r *GroupRepository) GetRole(ctx context.Context, groupID, userID string) (string, error) { + query := `SELECT role FROM group_members WHERE group_id = $1 AND user_id = $2;` + var role string + err := config.DB.QueryRow(ctx, query, groupID, userID).Scan(&role) + if err != nil { + return "", err + } + return role, nil +} + +func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID string) error { + query := `INSERT INTO group_members (group_id, user_id, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING;` + _, err := config.DB.Exec(ctx, query, groupID, userID) + return err +} - joinQuery := `INSERT INTO group_members (group_id, user_id) VALUES ($1, $2);` - _, err = config.DB.Exec(ctx, joinQuery, g.ID, creatorID) - return &g, err +func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID string) error { + query := `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2;` + _, err := config.DB.Exec(ctx, query, groupID, userID) + return err } func (r *GroupRepository) GetGroupMembers(ctx context.Context, groupID string) ([]string, error) { @@ -38,13 +92,81 @@ func (r *GroupRepository) GetGroupMembers(ctx context.Context, groupID string) ( } defer rows.Close() - var userIDs []string + var members []string for rows.Next() { var uid string if err := rows.Scan(&uid); err != nil { return nil, err } - userIDs = append(userIDs, uid) + members = append(members, uid) + } + return members, nil +} + +func (r *GroupRepository) GetGroupMessages(ctx context.Context, groupID string, before time.Time, limit int) ([]models.Message, error) { + query := ` + SELECT id, sender_id, content, created_at + FROM messages + WHERE group_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3; + ` + rows, err := config.DB.Query(ctx, query, groupID, before, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + messages := []models.Message{} + for rows.Next() { + var m models.Message + if err := rows.Scan(&m.ID, &m.SenderID, &m.Content, &m.CreatedAt); err != nil { + return nil, err + } + m.GroupID = groupID + messages = append(messages, m) + } + return messages, nil +} + +func (r *GroupRepository) GetMembersDetails(ctx context.Context, groupID string) ([]models.GroupMemberDetail, error) { + query := ` + SELECT gm.user_id, u.username, COALESCE(u.display_name, '') AS display_name, gm.role, gm.joined_at + FROM group_members gm + JOIN users u ON gm.user_id = u.id + WHERE gm.group_id = $1 + ORDER BY CASE WHEN gm.role = 'admin' THEN 1 ELSE 2 END, gm.joined_at ASC; + ` + rows, err := config.DB.Query(ctx, query, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + + var members []models.GroupMemberDetail = []models.GroupMemberDetail{} + for rows.Next() { + var m models.GroupMemberDetail + err := rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role, &m.JoinedAt) + if err != nil { + return nil, err + } + members = append(members, m) } - return userIDs, nil + return members, nil +} + +func (r *GroupRepository) GetByID(ctx context.Context, groupID string) (*models.Group, error) { + query := `SELECT id, name, created_by, created_at FROM groups WHERE id = $1;` + var g models.Group + err := config.DB.QueryRow(ctx, query, groupID).Scan(&g.ID, &g.Name, &g.CreatedBy, &g.CreatedAt) + if err != nil { + return nil, err + } + return &g, nil +} + +func (r *GroupRepository) UpdateName(ctx context.Context, groupID, newName string) error { + query := `UPDATE groups SET name = $1 WHERE id = $2;` + _, err := config.DB.Exec(ctx, query, newName, groupID) + return err } diff --git a/server/repository/message.go b/server/repository/message.go index bc9daff..674221d 100644 --- a/server/repository/message.go +++ b/server/repository/message.go @@ -63,36 +63,75 @@ func (r *MessageRepository) GetChatHistory(ctx context.Context, userA, userB str func (r *MessageRepository) GetConversations(ctx context.Context, userID string) ([]models.Conversation, error) { query := ` - WITH last_messages AS ( - SELECT DISTINCT ON (chat_user_id) - CASE WHEN sender_id = $1 THEN receiver_id ELSE sender_id END AS chat_user_id, + WITH raw_conversations AS ( + -- Enclosing Part A inside explicit parentheses to isolate its internal ORDER BY + (SELECT DISTINCT ON (CASE WHEN sender_id = $1::uuid THEN receiver_id ELSE sender_id END) + CASE WHEN sender_id = $1::uuid THEN receiver_id ELSE sender_id END AS chat_user_id, + 'direct' AS type, content, sender_id, created_at, - is_read + is_read, + NULL::UUID AS group_id FROM messages - WHERE sender_id = $1 OR receiver_id = $1 - ORDER BY chat_user_id, created_at DESC + WHERE (sender_id = $1::uuid OR receiver_id = $1::uuid) AND group_id IS NULL + ORDER BY CASE WHEN sender_id = $1::uuid THEN receiver_id ELSE sender_id END, created_at DESC) + + UNION ALL + + -- Enclosing Part B inside explicit parentheses to isolate its internal ORDER BY + (SELECT DISTINCT ON (m.group_id) + NULL::UUID AS chat_user_id, + 'group' AS type, + m.content, + m.sender_id, + m.created_at, + FALSE AS is_read, + m.group_id + FROM messages m + JOIN group_members gm ON m.group_id = gm.group_id + WHERE gm.user_id = $1::uuid + ORDER BY m.group_id, m.created_at DESC) ), - unread_counts AS ( + dm_unread AS ( SELECT sender_id, COUNT(*) AS count FROM messages - WHERE receiver_id = $1 AND is_read = FALSE + WHERE receiver_id = $1::uuid AND is_read = FALSE AND group_id IS NULL GROUP BY sender_id + ), + group_unread AS ( + SELECT m.group_id, COUNT(*) AS count + FROM messages m + JOIN group_members gm ON m.group_id = gm.group_id + WHERE gm.user_id = $1::uuid AND m.created_at > gm.last_read_at AND m.sender_id != $1::uuid + GROUP BY m.group_id ) SELECT - lm.chat_user_id, - u.username, - u.display_name, - lm.content, - lm.created_at, - lm.sender_id, - lm.is_read, - COALESCE(uc.count, 0)::INT AS unread_count - FROM last_messages lm - JOIN users u ON u.id = lm.chat_user_id - LEFT JOIN unread_counts uc ON uc.sender_id = lm.chat_user_id - ORDER BY lm.created_at DESC; + COALESCE(rc.chat_user_id::TEXT, rc.group_id::TEXT, '') AS id, + COALESCE(rc.type, 'direct') AS type, + COALESCE(u.username, g.name, 'Unknown Channel') AS name, + COALESCE(u.display_name, '') AS display_name, + COALESCE(rc.content, '') AS last_message, + COALESCE(rc.created_at, NOW()) AS last_message_time, + COALESCE(rc.sender_id::TEXT, '') AS sender_id, + COALESCE( + CASE + WHEN rc.type = 'direct' THEN rc.is_read + ELSE (rc.created_at <= gm2.last_read_at) + END, + FALSE + ) AS is_read, + CASE + WHEN rc.type = 'direct' THEN COALESCE(du.count, 0)::INT + ELSE COALESCE(gu.count, 0)::INT + END AS unread_count + FROM raw_conversations rc + LEFT JOIN users u ON rc.chat_user_id = u.id + LEFT JOIN groups g ON rc.group_id = g.id + LEFT JOIN dm_unread du ON rc.chat_user_id = du.sender_id + LEFT JOIN group_unread gu ON rc.group_id = gu.group_id + LEFT JOIN group_members gm2 ON gm2.group_id = rc.group_id AND gm2.user_id = $1::uuid + ORDER BY last_message_time DESC; ` rows, err := config.DB.Query(ctx, query, userID) @@ -105,8 +144,9 @@ func (r *MessageRepository) GetConversations(ctx context.Context, userID string) for rows.Next() { var c models.Conversation err := rows.Scan( - &c.ChatUserID, - &c.Username, + &c.ID, + &c.Type, + &c.Name, &c.DisplayName, &c.LastMessage, &c.LastMessageTime, @@ -130,4 +170,14 @@ func (r *MessageRepository) MarkAsRead(ctx context.Context, receiverID, senderID ` _, err := config.DB.Exec(ctx, query, receiverID, senderID) return err -} \ No newline at end of file +} + +func (r *MessageRepository) UpdateGroupLastRead(ctx context.Context, groupID, userID string) error { + query := ` + UPDATE group_members + SET last_read_at = CURRENT_TIMESTAMP + WHERE group_id = $1 AND user_id = $2; + ` + _, err := config.DB.Exec(ctx, query, groupID, userID) + return err +} diff --git a/server/routes/api.go b/server/routes/api.go index a2d0910..8734115 100644 --- a/server/routes/api.go +++ b/server/routes/api.go @@ -12,4 +12,5 @@ func ApiRoute(router chi.Router) { router.Route("/auth", AuthRoute) router.Route("/users", UserRoute) router.Route("/messages", MessageRoute) + router.Route("/groups", GroupRoute) } diff --git a/server/routes/groups.go b/server/routes/groups.go new file mode 100644 index 0000000..9a44257 --- /dev/null +++ b/server/routes/groups.go @@ -0,0 +1,27 @@ +package routes + +import ( + "github.com/commandlinecoding/elephant/server/controllers" + "github.com/commandlinecoding/elephant/server/middlewares" + "github.com/go-chi/chi/v5" +) + +func GroupRoute(router chi.Router) { + router.Use(middlewares.AuthGuard) + + router.Post("/", controllers.HandleCreateGroup) // POST /api/groups + router.Get("/", controllers.HandleListMyGroups) // GET /api/groups + + router.Route("/{id}", func(sr chi.Router) { + sr.Use(middlewares.GroupAuthGuard) + + sr.Get("/", controllers.HandleGetGroupDetails) // GET /api/groups/{id} + sr.Get("/messages", controllers.HandleGetGroupMessages) // GET /api/groups/{id}/messages + sr.Get("/members", controllers.HandleListGroupMembers) // GET /api/groups/{id}/members + sr.Post("/leave", controllers.HandleLeaveGroup) // POST /api/groups/{id}/leave + + sr.With(middlewares.GroupAdminGuard).Patch("/", controllers.HandleUpdateGroup) // PATCH /api/groups/{id} + sr.With(middlewares.GroupAdminGuard).Post("/members", controllers.HandleAddMember) // POST /api/groups/{id}/members + sr.With(middlewares.GroupAdminGuard).Delete("/members/{userId}", controllers.HandleRemoveMember) // DELETE /api/groups/{id}/members/{userId} + }) +} diff --git a/server/services/ws.go b/server/services/ws.go index 605d8bc..0a88cb1 100644 --- a/server/services/ws.go +++ b/server/services/ws.go @@ -59,7 +59,6 @@ func (h *WSHub) Run() { select { case client := <-h.Register: h.mu.Lock() - // FIXED: Store keys in lowercase to make lookups case-insensitive h.clients[strings.ToLower(client.UserID)] = client h.mu.Unlock() go h.broadcastStatus(client.UserID, true) @@ -106,7 +105,6 @@ func (c *WSClient) ReadPump() { switch incoming.Type { case "request_status": Hub.mu.RLock() - // FIXED: Standardize lookup to lowercase _, online := Hub.clients[strings.ToLower(incoming.ReceiverID)] Hub.mu.RUnlock() @@ -125,42 +123,96 @@ func (c *WSClient) ReadPump() { incoming.SenderID = c.UserID outboundBytes, _ := json.Marshal(incoming) - Hub.mu.RLock() - targetClient, online := Hub.clients[strings.ToLower(incoming.ReceiverID)] - Hub.mu.RUnlock() + if incoming.GroupID != "" { + groupRepo := repository.NewGroupRepository() + members, err := groupRepo.GetGroupMembers(context.Background(), incoming.GroupID) + if err != nil { + log.Printf("Failed to resolve group membership for typing indicator: %v", err) + continue + } - if online { - select { - case targetClient.Send <- outboundBytes: - default: - Hub.Unregister <- targetClient - targetClient.Conn.Close() + Hub.mu.RLock() + for _, memberID := range members { + if memberID == c.UserID { + continue + } + if targetClient, online := Hub.clients[strings.ToLower(memberID)]; online { + select { + case targetClient.Send <- outboundBytes: + default: + Hub.Unregister <- targetClient + targetClient.Conn.Close() + } + } + } + Hub.mu.RUnlock() + + } else { + Hub.mu.RLock() + targetClient, online := Hub.clients[strings.ToLower(incoming.ReceiverID)] + Hub.mu.RUnlock() + + if online { + select { + case targetClient.Send <- outboundBytes: + default: + Hub.Unregister <- targetClient + targetClient.Conn.Close() + } } } case "read_receipt": incoming.SenderID = c.UserID incoming.Timestamp = time.Now() + outboundBytes, _ := json.Marshal(incoming) - go func(receiverID, senderID string) { - err := msgRepo.MarkAsRead(context.Background(), receiverID, senderID) - if err != nil { - log.Printf("Failed to update read state flags over WS link: %v", err) - } - }(c.UserID, incoming.ReceiverID) + if incoming.GroupID != "" { + go func(groupID, userID string) { + err := msgRepo.UpdateGroupLastRead(context.Background(), groupID, userID) + if err != nil { + log.Printf("Async group timestamp update failed over WebSocket: %v", err) + } + }(incoming.GroupID, c.UserID) - outboundBytes, _ := json.Marshal(incoming) + groupRepo := repository.NewGroupRepository() + members, err := groupRepo.GetGroupMembers(context.Background(), incoming.GroupID) + if err == nil { + Hub.mu.RLock() + for _, memberID := range members { + if memberID == c.UserID { + continue + } + if targetClient, online := Hub.clients[strings.ToLower(memberID)]; online { + select { + case targetClient.Send <- outboundBytes: + default: + Hub.Unregister <- targetClient + targetClient.Conn.Close() + } + } + } + Hub.mu.RUnlock() + } + } else { + go func(receiverID, senderID string) { + err := msgRepo.MarkAsRead(context.Background(), receiverID, senderID) + if err != nil { + log.Printf("Failed to update read state flags over WS link: %v", err) + } + }(c.UserID, incoming.ReceiverID) - Hub.mu.RLock() - targetClient, online := Hub.clients[strings.ToLower(incoming.ReceiverID)] - Hub.mu.RUnlock() + Hub.mu.RLock() + targetClient, online := Hub.clients[strings.ToLower(incoming.ReceiverID)] + Hub.mu.RUnlock() - if online { - select { - case targetClient.Send <- outboundBytes: - default: - Hub.Unregister <- targetClient - targetClient.Conn.Close() + if online { + select { + case targetClient.Send <- outboundBytes: + default: + Hub.Unregister <- targetClient + targetClient.Conn.Close() + } } } @@ -256,4 +308,4 @@ func (c *WSClient) WritePump() { } } } -} \ No newline at end of file +}