472 lines
13 KiB
Go
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
|
|
}
|