This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/projects.go
2026-03-17 16:28:47 +00:00

462 lines
12 KiB
Go

package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/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
}