Changeset 0.19.0.1 (#82)
This commit is contained in:
471
server/handlers/projects.go
Normal file
471
server/handlers/projects.go
Normal file
@@ -0,0 +1,471 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user