package handlers import ( "encoding/json" "net/http" "path/filepath" "strconv" "github.com/go-chi/chi/v5" "xorm.io/xorm" "github.com/forgeo/forgebucket/internal/api/middleware" "github.com/forgeo/forgebucket/internal/config" "github.com/forgeo/forgebucket/internal/models" ) type WorkspaceHandler struct { db *xorm.Engine cfg *config.Config } func NewWorkspaceHandler(db *xorm.Engine, cfg *config.Config) *WorkspaceHandler { return &WorkspaceHandler{db: db, cfg: cfg} } // ── Response shapes ─────────────────────────────────────────────────────────── type workspaceResponse struct { models.Workspace MemberCount int `json:"memberCount"` RepoCount int `json:"repoCount"` MyRole string `json:"myRole"` // caller's role, empty if not a member } // ── List ────────────────────────────────────────────────────────────────────── // ListWorkspaces returns all workspaces the current user belongs to. func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.UserIDFromContext(r.Context()) var memberships []models.WorkspaceMember h.db.Where("user_id = ?", userID).Find(&memberships) if len(memberships) == 0 { jsonOK(w, []workspaceResponse{}) return } wsIDs := make([]int64, len(memberships)) roleByWS := map[int64]string{} for i, m := range memberships { wsIDs[i] = m.WorkspaceID roleByWS[m.WorkspaceID] = string(m.Role) } var workspaces []models.Workspace h.db.In("id", wsIDs).Find(&workspaces) result := make([]workspaceResponse, len(workspaces)) for i, ws := range workspaces { memberCount, _ := h.db.Where("workspace_id = ?", ws.ID).Count(&models.WorkspaceMember{}) repoCount, _ := h.db.Where("workspace_id = ?", ws.ID).Count(&models.Repository{}) result[i] = workspaceResponse{ Workspace: ws, MemberCount: int(memberCount), RepoCount: int(repoCount), MyRole: roleByWS[ws.ID], } } jsonOK(w, result) } // ── Create ──────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { userID, _ := middleware.UserIDFromContext(r.Context()) var body struct { Handle string `json:"handle"` DisplayName string `json:"displayName"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } if body.Handle == "" { jsonError(w, "handle is required", http.StatusBadRequest) return } if !isValidHandle(body.Handle) { jsonError(w, "handle may only contain letters, numbers, hyphens, and underscores", http.StatusBadRequest) return } // Handle must not collide with any username or existing workspace handle. var existingUser models.User if found, _ := h.db.Where("username = ?", body.Handle).Get(&existingUser); found { jsonError(w, "handle is already taken by a user", http.StatusConflict) return } var existingWS models.Workspace if found, _ := h.db.Where("handle = ?", body.Handle).Get(&existingWS); found { jsonError(w, "handle is already taken by a workspace", http.StatusConflict) return } ws := &models.Workspace{ Handle: body.Handle, DisplayName: body.DisplayName, Description: body.Description, CreatedBy: userID, } if _, err := h.db.Insert(ws); err != nil { jsonError(w, "could not create workspace", http.StatusInternalServerError) return } // Creator becomes owner automatically. var creator models.User h.db.ID(userID).Cols("username").Get(&creator) member := &models.WorkspaceMember{ WorkspaceID: ws.ID, UserID: userID, Username: creator.Username, Role: models.WorkspaceRoleOwner, } h.db.Insert(member) //nolint:errcheck resp := workspaceResponse{Workspace: *ws, MemberCount: 1, MyRole: string(models.WorkspaceRoleOwner)} w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(resp) //nolint:errcheck } // ── Get ─────────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) GetWorkspace(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } memberCount, _ := h.db.Where("workspace_id = ?", ws.ID).Count(&models.WorkspaceMember{}) repoCount, _ := h.db.Where("workspace_id = ?", ws.ID).Count(&models.Repository{}) jsonOK(w, workspaceResponse{Workspace: *ws, MemberCount: int(memberCount), RepoCount: int(repoCount), MyRole: myRole}) } // ── Update ──────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } if myRole != string(models.WorkspaceRoleOwner) && myRole != string(models.WorkspaceRoleAdmin) { jsonError(w, "admin or owner required", http.StatusForbidden) return } var body struct { DisplayName *string `json:"displayName"` Description *string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } cols := []string{} if body.DisplayName != nil { ws.DisplayName = *body.DisplayName cols = append(cols, "display_name") } if body.Description != nil { ws.Description = *body.Description cols = append(cols, "description") } if len(cols) > 0 { h.db.ID(ws.ID).Cols(cols...).Update(ws) //nolint:errcheck } jsonOK(w, ws) } // ── Delete ──────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } if myRole != string(models.WorkspaceRoleOwner) { jsonError(w, "only the workspace owner can delete it", http.StatusForbidden) return } // Refuse if the workspace still has repos. count, _ := h.db.Where("workspace_id = ?", ws.ID).Count(&models.Repository{}) if count > 0 { jsonError(w, "delete or transfer all repositories before deleting the workspace", http.StatusConflict) return } h.db.Where("workspace_id = ?", ws.ID).Delete(&models.WorkspaceMember{}) //nolint:errcheck h.db.ID(ws.ID).Delete(&models.Workspace{}) //nolint:errcheck w.WriteHeader(http.StatusNoContent) } // ── Repos in workspace ──────────────────────────────────────────────────────── func (h *WorkspaceHandler) ListRepos(w http.ResponseWriter, r *http.Request) { ws, _, ok := h.resolveWorkspace(w, r) if !ok { return } var repos []models.Repository h.db.Where("workspace_id = ?", ws.ID).Find(&repos) if repos == nil { repos = []models.Repository{} } // Return the same enriched shape that the repo list uses. type wsRepoResponse struct { models.Repository OwnerName string `json:"ownerName"` } result := make([]wsRepoResponse, len(repos)) for i, r := range repos { result[i] = wsRepoResponse{Repository: r, OwnerName: ws.Handle} } jsonOK(w, result) } // ── Members ─────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) ListMembers(w http.ResponseWriter, r *http.Request) { ws, _, ok := h.resolveWorkspace(w, r) if !ok { return } var members []models.WorkspaceMember h.db.Where("workspace_id = ?", ws.ID).Find(&members) if members == nil { members = []models.WorkspaceMember{} } jsonOK(w, members) } func (h *WorkspaceHandler) AddMember(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } if myRole != string(models.WorkspaceRoleOwner) && myRole != string(models.WorkspaceRoleAdmin) { jsonError(w, "admin or owner required", http.StatusForbidden) return } var body struct { Username string `json:"username"` Role string `json:"role"` // "admin" | "member" } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } role := models.WorkspaceRole(body.Role) if role != models.WorkspaceRoleAdmin && role != models.WorkspaceRoleMember { role = models.WorkspaceRoleMember } var user models.User if found, _ := h.db.Where("username = ?", body.Username).Get(&user); !found { jsonError(w, "user not found", http.StatusNotFound) return } // Idempotent: update role if already a member. var existing models.WorkspaceMember if found, _ := h.db.Where("workspace_id = ? AND user_id = ?", ws.ID, user.ID).Get(&existing); found { existing.Role = role h.db.ID(existing.ID).Cols("role").Update(&existing) //nolint:errcheck jsonOK(w, existing) return } member := &models.WorkspaceMember{ WorkspaceID: ws.ID, UserID: user.ID, Username: user.Username, Role: role, } if _, err := h.db.Insert(member); err != nil { jsonError(w, "could not add member", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(member) //nolint:errcheck } func (h *WorkspaceHandler) UpdateMember(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } if myRole != string(models.WorkspaceRoleOwner) && myRole != string(models.WorkspaceRoleAdmin) { jsonError(w, "admin or owner required", http.StatusForbidden) return } targetUsername := chi.URLParam(r, "username") var body struct{ Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { jsonError(w, "invalid request body", http.StatusBadRequest) return } var target models.User if found, _ := h.db.Where("username = ?", targetUsername).Get(&target); !found { jsonError(w, "user not found", http.StatusNotFound) return } var member models.WorkspaceMember if found, _ := h.db.Where("workspace_id = ? AND user_id = ?", ws.ID, target.ID).Get(&member); !found { jsonError(w, "member not found", http.StatusNotFound) return } // Cannot demote the last owner. if member.Role == models.WorkspaceRoleOwner && models.WorkspaceRole(body.Role) != models.WorkspaceRoleOwner { count, _ := h.db.Where("workspace_id = ? AND role = 'owner'", ws.ID).Count(&models.WorkspaceMember{}) if count <= 1 { jsonError(w, "workspace must have at least one owner", http.StatusConflict) return } } member.Role = models.WorkspaceRole(body.Role) h.db.ID(member.ID).Cols("role").Update(&member) //nolint:errcheck jsonOK(w, member) } func (h *WorkspaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { ws, myRole, ok := h.resolveWorkspace(w, r) if !ok { return } callerID, _ := middleware.UserIDFromContext(r.Context()) targetUsername := chi.URLParam(r, "username") var target models.User if found, _ := h.db.Where("username = ?", targetUsername).Get(&target); !found { jsonError(w, "user not found", http.StatusNotFound) return } // Members can remove themselves; admins/owners can remove others. if target.ID != callerID { if myRole != string(models.WorkspaceRoleOwner) && myRole != string(models.WorkspaceRoleAdmin) { jsonError(w, "admin or owner required", http.StatusForbidden) return } } var member models.WorkspaceMember if found, _ := h.db.Where("workspace_id = ? AND user_id = ?", ws.ID, target.ID).Get(&member); !found { jsonError(w, "member not found", http.StatusNotFound) return } if member.Role == models.WorkspaceRoleOwner { count, _ := h.db.Where("workspace_id = ? AND role = 'owner'", ws.ID).Count(&models.WorkspaceMember{}) if count <= 1 { jsonError(w, "cannot remove the last owner", http.StatusConflict) return } } h.db.ID(member.ID).Delete(&models.WorkspaceMember{}) //nolint:errcheck w.WriteHeader(http.StatusNoContent) } // ── Helpers ─────────────────────────────────────────────────────────────────── func (h *WorkspaceHandler) resolveWorkspace(w http.ResponseWriter, r *http.Request) (*models.Workspace, string, bool) { handle := chi.URLParam(r, "handle") var ws models.Workspace if found, _ := h.db.Where("handle = ?", handle).Get(&ws); !found { jsonError(w, "workspace not found", http.StatusNotFound) return nil, "", false } userID, _ := middleware.UserIDFromContext(r.Context()) var member models.WorkspaceMember myRole := "" if found, _ := h.db.Where("workspace_id = ? AND user_id = ?", ws.ID, userID).Get(&member); found { myRole = string(member.Role) } return &ws, myRole, true } // diskPathForWorkspaceRepo returns the on-disk bare repo path for workspace-owned repos. func diskPathForWorkspaceRepo(repoRoot string, workspaceID int64, repoName string) string { return filepath.Join(repoRoot, "ws_"+strconv.FormatInt(workspaceID, 10), repoName+".git") } func isValidHandle(h string) bool { if len(h) == 0 || len(h) > 64 { return false } for _, c := range h { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { return false } } return true }