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
201 changes: 201 additions & 0 deletions server/controllers/groups.go
Original file line number Diff line number Diff line change
@@ -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"})
}
55 changes: 55 additions & 0 deletions server/middlewares/group_auth.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
15 changes: 12 additions & 3 deletions server/migrations/006_create_groups_table_up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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;
3 changes: 3 additions & 0 deletions server/migrations/007_add_group_last_read_up.sql
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 3 additions & 2 deletions server/models/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
9 changes: 9 additions & 0 deletions server/models/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
3 changes: 2 additions & 1 deletion server/models/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading