package handlers import ( "database/sql" "net/http" "github.com/gin-gonic/gin" "chat-switchboard/models" "chat-switchboard/store" ) // ── Request / Response Types ──────────────── type createProjectRequest struct { Name string `json:"name" binding:"required,max=200"` Description string `json:"description,omitempty"` Color *string `json:"color,omitempty"` Icon *string `json:"icon,omitempty"` } type updateProjectRequest = models.ProjectPatch type addChannelRequest struct { ChannelID string `json:"channel_id" binding:"required"` Position int `json:"position"` } type reorderChannelsRequest struct { ChannelIDs []string `json:"channel_ids" binding:"required"` } type addKBRequest struct { KBID string `json:"kb_id" binding:"required"` AutoSearch bool `json:"auto_search"` } type addNoteRequest struct { NoteID string `json:"note_id" binding:"required"` } // ProjectHandler handles project CRUD and associations. type ProjectHandler struct { stores store.Stores } // NewProjectHandler creates a new project handler. func NewProjectHandler(s store.Stores) *ProjectHandler { return &ProjectHandler{stores: s} } // ── CRUD ──────────────────────────────────── func (h *ProjectHandler) List(c *gin.Context) { userID := getUserID(c) teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) includeArchived := c.Query("include_archived") == "true" projects, err := h.stores.Projects.ListForUser(c.Request.Context(), userID, teamIDs, includeArchived) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) return } if projects == nil { projects = []models.Project{} } c.JSON(http.StatusOK, gin.H{"data": projects}) } func (h *ProjectHandler) Create(c *gin.Context) { userID := getUserID(c) var req createProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } p := &models.Project{ Name: req.Name, Description: req.Description, Color: req.Color, Icon: req.Icon, Scope: models.ScopePersonal, OwnerID: userID, } if err := h.stores.Projects.Create(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create project"}) return } c.JSON(http.StatusCreated, p) } func (h *ProjectHandler) Get(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } c.JSON(http.StatusOK, project) } func (h *ProjectHandler) Update(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } var req updateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Projects.Update(c.Request.Context(), project.ID, req); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update project"}) return } // Return refreshed project updated, err := h.stores.Projects.GetByID(c.Request.Context(), project.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload project"}) return } c.JSON(http.StatusOK, updated) } func (h *ProjectHandler) Delete(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } // Only owner or admin can delete userID := getUserID(c) if project.OwnerID != userID { role, _ := c.Get("role") if role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "only the project owner can delete it"}) return } } if err := h.stores.Projects.Delete(c.Request.Context(), project.ID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete project"}) return } c.JSON(http.StatusOK, gin.H{"message": "project deleted"}) } // ── Channel Association ───────────────────── func (h *ProjectHandler) AddChannel(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } var req addChannelRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Verify the user owns the channel userID := getUserID(c) owns, err := h.stores.Channels.UserOwns(c.Request.Context(), req.ChannelID, userID) if err != nil || !owns { c.JSON(http.StatusForbidden, gin.H{"error": "channel not found or not owned"}) return } if err := h.stores.Projects.AddChannel(c.Request.Context(), project.ID, req.ChannelID, req.Position); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add channel"}) return } c.JSON(http.StatusOK, gin.H{"message": "channel added"}) } func (h *ProjectHandler) RemoveChannel(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } channelID := c.Param("channelId") if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id required"}) return } if err := h.stores.Projects.RemoveChannel(c.Request.Context(), project.ID, channelID); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "channel not in project"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove channel"}) return } c.JSON(http.StatusOK, gin.H{"message": "channel removed"}) } func (h *ProjectHandler) ListChannels(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } channels, err := h.stores.Projects.ListChannels(c.Request.Context(), project.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list channels"}) return } if channels == nil { channels = []models.ProjectChannel{} } c.JSON(http.StatusOK, gin.H{"data": channels}) } func (h *ProjectHandler) ReorderChannels(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } var req reorderChannelsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Projects.ReorderChannels(c.Request.Context(), project.ID, req.ChannelIDs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reorder channels"}) return } c.JSON(http.StatusOK, gin.H{"message": "channels reordered"}) } // ── KB Association ────────────────────────── func (h *ProjectHandler) AddKB(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } var req addKBRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Projects.AddKB(c.Request.Context(), project.ID, req.KBID, req.AutoSearch); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add KB"}) return } c.JSON(http.StatusOK, gin.H{"message": "KB added"}) } func (h *ProjectHandler) RemoveKB(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } kbID := c.Param("kbId") if kbID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "kb_id required"}) return } if err := h.stores.Projects.RemoveKB(c.Request.Context(), project.ID, kbID); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "KB not in project"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove KB"}) return } c.JSON(http.StatusOK, gin.H{"message": "KB removed"}) } func (h *ProjectHandler) ListKBs(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } kbs, err := h.stores.Projects.ListKBs(c.Request.Context(), project.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list KBs"}) return } if kbs == nil { kbs = []models.ProjectKB{} } c.JSON(http.StatusOK, gin.H{"data": kbs}) } // ── Note Association ──────────────────────── func (h *ProjectHandler) AddNote(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } var req addNoteRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.stores.Projects.AddNote(c.Request.Context(), project.ID, req.NoteID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add note"}) return } c.JSON(http.StatusOK, gin.H{"message": "note added"}) } func (h *ProjectHandler) RemoveNote(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } noteID := c.Param("noteId") if noteID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "note_id required"}) return } if err := h.stores.Projects.RemoveNote(c.Request.Context(), project.ID, noteID); err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "note not in project"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove note"}) return } c.JSON(http.StatusOK, gin.H{"message": "note removed"}) } func (h *ProjectHandler) ListNotes(c *gin.Context) { project, ok := h.loadAndAuthorize(c) if !ok { return } notes, err := h.stores.Projects.ListNotes(c.Request.Context(), project.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notes"}) return } if notes == nil { notes = []models.ProjectNote{} } c.JSON(http.StatusOK, gin.H{"data": notes}) } // ── Auth Helper ───────────────────────────── // ── Admin ──────────────────────────────────── // AdminList returns all projects (no scope filtering). Admin only. func (h *ProjectHandler) AdminList(c *gin.Context) { ctx := c.Request.Context() includeArchived := c.Query("include_archived") == "true" projects, err := h.stores.Projects.AdminList(ctx, includeArchived) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"}) return } type adminProject struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Scope string `json:"scope"` OwnerID string `json:"owner_id"` TeamID *string `json:"team_id,omitempty"` IsArchived bool `json:"is_archived"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` ChannelCount int `json:"channel_count"` KBCount int `json:"kb_count"` NoteCount int `json:"note_count"` OwnerName string `json:"owner_name"` } result := make([]adminProject, 0, len(projects)) for _, p := range projects { result = append(result, adminProject{ ID: p.ID, Name: p.Name, Description: p.Description, Scope: p.Scope, OwnerID: p.OwnerID, TeamID: p.TeamID, IsArchived: p.IsArchived, CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: p.UpdatedAt.Format("2006-01-02T15:04:05Z"), ChannelCount: p.ChannelCount, KBCount: p.KBCount, NoteCount: p.NoteCount, OwnerName: p.OwnerName, }) } c.JSON(http.StatusOK, gin.H{"data": result}) } // ── Auth Helper ───────────────────────────── // loadAndAuthorize loads the project by :id param and checks user access. // Returns (project, true) on success, or writes an error response and returns (nil, false). func (h *ProjectHandler) loadAndAuthorize(c *gin.Context) (*models.Project, bool) { projectID := c.Param("id") if projectID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "project id required"}) return nil, false } userID := getUserID(c) // Admins can access all projects if c.GetString("role") != "admin" { teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "access check failed"}) return nil, false } if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "project not found"}) return nil, false } } project, err := h.stores.Projects.GetByID(c.Request.Context(), projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load project"}) return nil, false } return project, true }