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-02-28 23:46:23 +00:00

472 lines
13 KiB
Go

package handlers
import (
"database/sql"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"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"
// Admin sees everything — query with no user scope
rows, err := database.DB.QueryContext(ctx, database.Q(`
SELECT p.id, p.name, p.description, p.scope,
p.owner_id, p.team_id, p.is_archived,
p.created_at, p.updated_at,
(SELECT COUNT(*) FROM project_channels WHERE project_id = p.id),
(SELECT COUNT(*) FROM project_knowledge_bases WHERE project_id = p.id),
(SELECT COUNT(*) FROM project_notes WHERE project_id = p.id),
u.username
FROM projects p
LEFT JOIN users u ON u.id = p.owner_id
WHERE ($1 OR p.is_archived = false)
ORDER BY p.updated_at DESC`), includeArchived)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
return
}
defer rows.Close()
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"`
}
var projects []adminProject
for rows.Next() {
var p adminProject
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Scope,
&p.OwnerID, &p.TeamID, &p.IsArchived,
&p.CreatedAt, &p.UpdatedAt,
&p.ChannelCount, &p.KBCount, &p.NoteCount,
&p.OwnerName,
); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "scan error"})
return
}
projects = append(projects, p)
}
if projects == nil {
projects = []adminProject{}
}
c.JSON(http.StatusOK, gin.H{"data": projects})
}
// ── 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)
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
}