// Package groups provides HTTP handlers for lab group management package groups import ( "encoding/binary" "encoding/json" "net/http" "strconv" "time" "github.com/gorilla/websocket" "github.com/jfraeys/fetch_ml/internal/api/errors" "github.com/jfraeys/fetch_ml/internal/auth" "github.com/jfraeys/fetch_ml/internal/logging" "github.com/jfraeys/fetch_ml/internal/storage" ) // Handler provides group management HTTP handlers type Handler struct { db *storage.DB logger *logging.Logger } // NewHandler creates a new groups handler func NewHandler(db *storage.DB, logger *logging.Logger) *Handler { return &Handler{ db: db, logger: logger, } } // CreateGroupRequest represents a request to create a new group type CreateGroupRequest struct { Name string `json:"name"` Description string `json:"description,omitempty"` } // CreateGroup handles POST /api/groups func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } // Only admins can create groups if !user.Admin { http.Error(w, "forbidden: admin required", http.StatusForbidden) return } var req CreateGroupRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.Name == "" { http.Error(w, "name is required", http.StatusBadRequest) return } group, err := h.db.CreateGroup(req.Name, req.Description, user.Name) if err != nil { h.logger.Error("failed to create group", "error", err) http.Error(w, "failed to create group", http.StatusInternalServerError) return } h.logger.Info("group created", "group_id", group.ID, "name", group.Name, "created_by", user.Name) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(group); err != nil { h.logger.Error("failed to encode response", "error", err) } } // ListGroups handles GET /api/groups func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } groups, err := h.db.ListGroupsForUser(user.Name) if err != nil { h.logger.Error("failed to list groups", "error", err) http.Error(w, "failed to list groups", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ "groups": groups, "count": len(groups), }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // CreateInvitation handles POST /api/groups/{id}/invitations func (h *Handler) CreateInvitation(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } groupID := r.PathValue("id") if groupID == "" { http.Error(w, "group id required", http.StatusBadRequest) return } // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { http.Error(w, "forbidden: group admin required", http.StatusForbidden) return } var req struct { InvitedUserID string `json:"invited_user_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if req.InvitedUserID == "" { http.Error(w, "invited_user_id is required", http.StatusBadRequest) return } invitation, err := h.db.CreateGroupInvitation(groupID, req.InvitedUserID, user.Name) if err != nil { h.logger.Error("failed to create invitation", "error", err) http.Error(w, "failed to create invitation", http.StatusInternalServerError) return } h.logger.Info("invitation created", "invitation_id", invitation.ID, "group_id", groupID, "invited_user", req.InvitedUserID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) if err := json.NewEncoder(w).Encode(invitation); err != nil { h.logger.Error("failed to encode response", "error", err) } } // ListInvitations handles GET /api/invitations func (h *Handler) ListInvitations(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } invitations, err := h.db.ListPendingInvitationsForUser(user.Name) if err != nil { h.logger.Error("failed to list invitations", "error", err) http.Error(w, "failed to list invitations", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ "invitations": invitations, "count": len(invitations), }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // AcceptInvitation handles POST /api/invitations/{id}/accept func (h *Handler) AcceptInvitation(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } invitationID := r.PathValue("id") if invitationID == "" { http.Error(w, "invitation id required", http.StatusBadRequest) return } // Verify invitation belongs to this user and is pending invitation, err := h.db.GetInvitation(invitationID) if err != nil { http.Error(w, "invitation not found", http.StatusNotFound) return } if invitation.InvitedUserID != user.Name { http.Error(w, "forbidden", http.StatusForbidden) return } if invitation.Status != "pending" { http.Error(w, "invitation already processed", http.StatusConflict) return } // Check if expired (7 days default) if invitation.ExpiresAt != nil && time.Now().After(*invitation.ExpiresAt) { http.Error(w, "invitation expired", http.StatusGone) return } if err := h.db.AcceptInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to accept invitation", "error", err) http.Error(w, "failed to accept invitation", http.StatusInternalServerError) return } h.logger.Info("invitation accepted", "invitation_id", invitationID, "user", user.Name, "group_id", invitation.GroupID) w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]string{ "status": "accepted", "group_id": invitation.GroupID, }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // DeclineInvitation handles POST /api/invitations/{id}/decline func (h *Handler) DeclineInvitation(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } invitationID := r.PathValue("id") if invitationID == "" { http.Error(w, "invitation id required", http.StatusBadRequest) return } invitation, err := h.db.GetInvitation(invitationID) if err != nil { http.Error(w, "invitation not found", http.StatusNotFound) return } if invitation.InvitedUserID != user.Name { http.Error(w, "forbidden", http.StatusForbidden) return } if err := h.db.DeclineInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to decline invitation", "error", err) http.Error(w, "failed to decline invitation", http.StatusInternalServerError) return } h.logger.Info("invitation declined", "invitation_id", invitationID, "user", user.Name) w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]string{ "status": "declined", }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // RemoveMember handles DELETE /api/groups/{id}/members/{user} func (h *Handler) RemoveMember(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } groupID := r.PathValue("id") memberID := r.PathValue("user") if groupID == "" || memberID == "" { http.Error(w, "group id and user id required", http.StatusBadRequest) return } // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { http.Error(w, "forbidden: group admin required", http.StatusForbidden) return } // Cannot remove yourself (use leave group endpoint instead) if memberID == user.Name { http.Error(w, "cannot remove yourself; use leave group endpoint", http.StatusBadRequest) return } if err := h.db.RemoveGroupMember(groupID, memberID); err != nil { h.logger.Error("failed to remove member", "error", err) http.Error(w, "failed to remove member", http.StatusInternalServerError) return } h.logger.Info("member removed", "group_id", groupID, "member", memberID, "removed_by", user.Name) w.WriteHeader(http.StatusNoContent) } // ListGroupTasks handles GET /api/groups/{id}/tasks func (h *Handler) ListGroupTasks(w http.ResponseWriter, r *http.Request) { user := auth.GetUserFromContext(r.Context()) if user == nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } groupID := r.PathValue("id") if groupID == "" { http.Error(w, "group id required", http.StatusBadRequest) return } // Check if user is member of group isMember, err := h.db.IsGroupMember(user.Name, groupID) if err != nil || !isMember { http.Error(w, "forbidden: group membership required", http.StatusForbidden) return } // Parse pagination options limit := 100 if l := r.URL.Query().Get("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { limit = parsed } } cursor := r.URL.Query().Get("cursor") opts := storage.ListTasksOptions{ Limit: limit, Cursor: cursor, } tasks, nextCursor, err := h.db.ListTasksForGroup(groupID, opts) if err != nil { h.logger.Error("failed to list group tasks", "error", err) http.Error(w, "failed to list tasks", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]any{ "tasks": tasks, "next_cursor": nextCursor, "count": len(tasks), }); err != nil { h.logger.Error("failed to encode response", "error", err) } } // sendErrorPacket sends an error response packet to the client func (h *Handler) sendErrorPacket(conn *websocket.Conn, code string, message, details string) error { return errors.SendErrorPacket(conn, code, message, details) } // sendSuccessPacket sends a success response packet func (h *Handler) sendSuccessPacket(conn *websocket.Conn, data map[string]any) error { return errors.SendSuccessPacket(conn, data) } // HandleCreateGroup handles WebSocket group creation. // Protocol: [api_key_hash:16][name_len:1][name:var][desc_len:2][desc:var] func (h *Handler) HandleCreateGroup(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+1+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 nameLen := int(payload[offset]) offset++ if nameLen <= 0 || len(payload) < offset+nameLen+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid name length", "") } name := string(payload[offset : offset+nameLen]) offset += nameLen descLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if descLen < 0 || len(payload) < offset+descLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid description length", "") } description := string(payload[offset : offset+descLen]) // Only admins can create groups if !user.Admin { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: admin required", "") } if name == "" { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "name is required", "") } group, err := h.db.CreateGroup(name, description, user.Name) if err != nil { h.logger.Error("failed to create group", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to create group", err.Error()) } h.logger.Info("group created", "group_id", group.ID, "name", group.Name, "created_by", user.Name) return h.sendSuccessPacket(conn, map[string]any{ "group": group, }) } // HandleListGroups handles WebSocket group listing. // Protocol: [api_key_hash:16] func (h *Handler) HandleListGroups(conn *websocket.Conn, payload []byte, user *auth.User) error { groups, err := h.db.ListGroupsForUser(user.Name) if err != nil { h.logger.Error("failed to list groups", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list groups", err.Error()) } return h.sendSuccessPacket(conn, map[string]any{ "groups": groups, "count": len(groups), }) } // HandleCreateInvitation handles WebSocket invitation creation. // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var] func (h *Handler) HandleCreateInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if userIDLen <= 0 || len(payload) < offset+userIDLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid user_id length", "") } invitedUserID := string(payload[offset : offset+userIDLen]) // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group admin required", "") } invitation, err := h.db.CreateGroupInvitation(groupID, invitedUserID, user.Name) if err != nil { h.logger.Error("failed to create invitation", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to create invitation", err.Error()) } h.logger.Info("invitation created", "invitation_id", invitation.ID, "group_id", groupID, "invited_user", invitedUserID) return h.sendSuccessPacket(conn, map[string]any{ "invitation": invitation, }) } // HandleListInvitations handles WebSocket invitation listing. // Protocol: [api_key_hash:16] func (h *Handler) HandleListInvitations(conn *websocket.Conn, payload []byte, user *auth.User) error { invitations, err := h.db.ListPendingInvitationsForUser(user.Name) if err != nil { h.logger.Error("failed to list invitations", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list invitations", err.Error()) } return h.sendSuccessPacket(conn, map[string]any{ "invitations": invitations, "count": len(invitations), }) } // HandleAcceptInvitation handles WebSocket invitation acceptance. // Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var] func (h *Handler) HandleAcceptInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if invIDLen <= 0 || len(payload) < offset+invIDLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid invitation_id length", "") } invitationID := string(payload[offset : offset+invIDLen]) // Verify invitation belongs to this user and is pending invitation, err := h.db.GetInvitation(invitationID) if err != nil { return h.sendErrorPacket(conn, errors.CodeResourceNotFound, "invitation not found", "") } if invitation.InvitedUserID != user.Name { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden", "") } if invitation.Status != "pending" { return h.sendErrorPacket(conn, errors.CodeResourceAlreadyExists, "invitation already processed", "") } // Check if expired (7 days default) if invitation.ExpiresAt != nil && time.Now().After(*invitation.ExpiresAt) { return h.sendErrorPacket(conn, errors.CodeTimeout, "invitation expired", "") } if err := h.db.AcceptInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to accept invitation", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to accept invitation", err.Error()) } h.logger.Info("invitation accepted", "invitation_id", invitationID, "user", user.Name, "group_id", invitation.GroupID) return h.sendSuccessPacket(conn, map[string]any{ "status": "accepted", "group_id": invitation.GroupID, }) } // HandleDeclineInvitation handles WebSocket invitation decline. // Protocol: [api_key_hash:16][invitation_id_len:2][invitation_id:var] func (h *Handler) HandleDeclineInvitation(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 invIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if invIDLen <= 0 || len(payload) < offset+invIDLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid invitation_id length", "") } invitationID := string(payload[offset : offset+invIDLen]) invitation, err := h.db.GetInvitation(invitationID) if err != nil { return h.sendErrorPacket(conn, errors.CodeResourceNotFound, "invitation not found", "") } if invitation.InvitedUserID != user.Name { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden", "") } if err := h.db.DeclineInvitation(invitationID, user.Name); err != nil { h.logger.Error("failed to decline invitation", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to decline invitation", err.Error()) } h.logger.Info("invitation declined", "invitation_id", invitationID, "user", user.Name) return h.sendSuccessPacket(conn, map[string]any{ "status": "declined", }) } // HandleRemoveMember handles WebSocket member removal. // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][user_id_len:2][user_id:var] func (h *Handler) HandleRemoveMember(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen userIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if userIDLen <= 0 || len(payload) < offset+userIDLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid user_id length", "") } memberID := string(payload[offset : offset+userIDLen]) // Check if user is group admin isAdmin, err := h.db.IsGroupAdmin(user.Name, groupID) if err != nil || !isAdmin { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group admin required", "") } // Cannot remove yourself if memberID == user.Name { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "cannot remove yourself; use leave group endpoint", "") } if err := h.db.RemoveGroupMember(groupID, memberID); err != nil { h.logger.Error("failed to remove member", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to remove member", err.Error()) } h.logger.Info("member removed", "group_id", groupID, "member", memberID, "removed_by", user.Name) return h.sendSuccessPacket(conn, map[string]any{ "message": "Member removed", }) } // HandleListGroupTasks handles WebSocket group task listing. // Protocol: [api_key_hash:16][group_id_len:2][group_id:var][limit:1][cursor_len:2][cursor:var] func (h *Handler) HandleListGroupTasks(conn *websocket.Conn, payload []byte, user *auth.User) error { if len(payload) < 16+2+1+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "payload too short", "") } offset := 16 groupIDLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 if groupIDLen <= 0 || len(payload) < offset+groupIDLen+1+2 { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid group_id length", "") } groupID := string(payload[offset : offset+groupIDLen]) offset += groupIDLen // Check if user is member of group isMember, err := h.db.IsGroupMember(user.Name, groupID) if err != nil || !isMember { return h.sendErrorPacket(conn, errors.CodePermissionDenied, "forbidden: group membership required", "") } limit := int(payload[offset]) offset++ if limit <= 0 || limit > 100 { limit = 100 } cursorLen := int(binary.BigEndian.Uint16(payload[offset : offset+2])) offset += 2 var cursor string if cursorLen > 0 { if len(payload) < offset+cursorLen { return h.sendErrorPacket(conn, errors.CodeInvalidRequest, "invalid cursor length", "") } cursor = string(payload[offset : offset+cursorLen]) } opts := storage.ListTasksOptions{ Limit: limit, Cursor: cursor, } tasks, nextCursor, err := h.db.ListTasksForGroup(groupID, opts) if err != nil { h.logger.Error("failed to list group tasks", "error", err) return h.sendErrorPacket(conn, errors.CodeDatabaseError, "failed to list tasks", err.Error()) } return h.sendSuccessPacket(conn, map[string]any{ "tasks": tasks, "next_cursor": nextCursor, "count": len(tasks), }) }