Add workspace management and isolated worktree environments
This commit is contained in:
parent
e9555b8a22
commit
81e64e9fce
32 changed files with 1462 additions and 200 deletions
|
|
@ -49,12 +49,8 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
}
|
||||
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -74,9 +70,8 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agentToResponse(agent))
|
||||
|
|
@ -92,17 +87,21 @@ type CreateAgentRequest struct {
|
|||
}
|
||||
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
ownerID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ownerID := r.Header.Get("X-User-ID")
|
||||
|
||||
if req.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
|
|
@ -152,6 +151,13 @@ type UpdateAgentRequest struct {
|
|||
|
||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, ok := h.loadAgentForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/multica-ai/multica/server/internal/auth"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("multica-dev-secret-change-in-production")
|
||||
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -48,6 +49,9 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
|
||||
if req.Email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
|
|
@ -56,6 +60,11 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
// Try to find existing user
|
||||
user, err := h.Queries.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
|
|
@ -69,6 +78,15 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else if req.Name != "" && req.Name != user.Name {
|
||||
user, err = h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{
|
||||
ID: user.ID,
|
||||
Name: req.Name,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
|
|
@ -80,7 +98,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
"iat": time.Now().Unix(),
|
||||
})
|
||||
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
tokenString, err := token.SignedString(auth.JWTSecret())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
|
|
@ -93,9 +111,8 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -107,3 +124,52 @@ func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
writeJSON(w, http.StatusOK, userToResponse(user))
|
||||
}
|
||||
|
||||
type UpdateMeRequest struct {
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateMeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, err := h.Queries.GetUser(r.Context(), parseUUID(userID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
name := currentUser.Name
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
params := db.UpdateUserParams{
|
||||
ID: currentUser.ID,
|
||||
Name: name,
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
params.AvatarUrl = pgtype.Text{String: strings.TrimSpace(*req.AvatarURL), Valid: true}
|
||||
}
|
||||
|
||||
updatedUser, err := h.Queries.UpdateUser(r.Context(), params)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, userToResponse(updatedUser))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,12 @@ func commentToResponse(c db.Comment) CommentResponse {
|
|||
|
||||
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
comments, err := h.Queries.ListComments(r.Context(), parseUUID(issueID))
|
||||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := h.Queries.ListComments(r.Context(), issue.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
||||
return
|
||||
|
|
@ -55,9 +60,13 @@ type CreateCommentRequest struct {
|
|||
|
||||
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
||||
issueID := chi.URLParam(r, "id")
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
issue, ok := h.loadIssueForUser(w, r, issueID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +85,7 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
||||
IssueID: parseUUID(issueID),
|
||||
IssueID: issue.ID,
|
||||
AuthorType: "member",
|
||||
AuthorID: parseUUID(userID),
|
||||
Content: req.Content,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/internal/realtime"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
Hub *realtime.Hub
|
||||
type txStarter interface {
|
||||
Begin(ctx context.Context) (pgx.Tx, error)
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, hub *realtime.Hub) *Handler {
|
||||
return &Handler{Queries: queries, Hub: hub}
|
||||
type Handler struct {
|
||||
Queries *db.Queries
|
||||
TxStarter txStarter
|
||||
Hub *realtime.Hub
|
||||
}
|
||||
|
||||
func New(queries *db.Queries, txStarter txStarter, hub *realtime.Hub) *Handler {
|
||||
return &Handler{Queries: queries, TxStarter: txStarter, Hub: hub}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
|
|
@ -112,3 +121,147 @@ func (h *Handler) broadcast(eventType string, payload any) {
|
|||
}
|
||||
h.Hub.Broadcast(data)
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
return errors.Is(err, pgx.ErrNoRows)
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
}
|
||||
|
||||
func requestUserID(r *http.Request) string {
|
||||
return r.Header.Get("X-User-ID")
|
||||
}
|
||||
|
||||
func requireUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
userID := requestUserID(r)
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
return "", false
|
||||
}
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func resolveWorkspaceID(r *http.Request) string {
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID != "" {
|
||||
return workspaceID
|
||||
}
|
||||
return r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
|
||||
func roleAllowed(role string, roles ...string) bool {
|
||||
for _, candidate := range roles {
|
||||
if role == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countOwners(members []db.Member) int {
|
||||
owners := 0
|
||||
for _, member := range members {
|
||||
if member.Role == "owner" {
|
||||
owners++
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func (h *Handler) getWorkspaceMember(ctx context.Context, userID, workspaceID string) (db.Member, error) {
|
||||
return h.Queries.GetMemberByUserAndWorkspace(ctx, db.GetMemberByUserAndWorkspaceParams{
|
||||
UserID: parseUUID(userID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) requireWorkspaceMember(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string) (db.Member, bool) {
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
return db.Member{}, false
|
||||
}
|
||||
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return db.Member{}, false
|
||||
}
|
||||
|
||||
member, err := h.getWorkspaceMember(r.Context(), userID, workspaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, notFoundMsg)
|
||||
return db.Member{}, false
|
||||
}
|
||||
|
||||
return member, true
|
||||
}
|
||||
|
||||
func (h *Handler) requireWorkspaceRole(w http.ResponseWriter, r *http.Request, workspaceID, notFoundMsg string, roles ...string) (db.Member, bool) {
|
||||
member, ok := h.requireWorkspaceMember(w, r, workspaceID, notFoundMsg)
|
||||
if !ok {
|
||||
return db.Member{}, false
|
||||
}
|
||||
if !roleAllowed(member.Role, roles...) {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return db.Member{}, false
|
||||
}
|
||||
return member, true
|
||||
}
|
||||
|
||||
func (h *Handler) loadIssueForUser(w http.ResponseWriter, r *http.Request, issueID string) (db.Issue, bool) {
|
||||
if _, ok := requireUserID(w, r); !ok {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
|
||||
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(issueID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "issue not found")
|
||||
return db.Issue{}, false
|
||||
}
|
||||
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(issue.WorkspaceID), "issue not found"); !ok {
|
||||
return db.Issue{}, false
|
||||
}
|
||||
|
||||
return issue, true
|
||||
}
|
||||
|
||||
func (h *Handler) loadAgentForUser(w http.ResponseWriter, r *http.Request, agentID string) (db.Agent, bool) {
|
||||
if _, ok := requireUserID(w, r); !ok {
|
||||
return db.Agent{}, false
|
||||
}
|
||||
|
||||
agent, err := h.Queries.GetAgent(r.Context(), parseUUID(agentID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "agent not found")
|
||||
return db.Agent{}, false
|
||||
}
|
||||
|
||||
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(agent.WorkspaceID), "agent not found"); !ok {
|
||||
return db.Agent{}, false
|
||||
}
|
||||
|
||||
return agent, true
|
||||
}
|
||||
|
||||
func (h *Handler) loadInboxItemForUser(w http.ResponseWriter, r *http.Request, itemID string) (db.InboxItem, bool) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return db.InboxItem{}, false
|
||||
}
|
||||
|
||||
item, err := h.Queries.GetInboxItem(r.Context(), parseUUID(itemID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "inbox item not found")
|
||||
return db.InboxItem{}, false
|
||||
}
|
||||
|
||||
if item.RecipientType != "member" || uuidToString(item.RecipientID) != userID {
|
||||
writeError(w, http.StatusNotFound, "inbox item not found")
|
||||
return db.InboxItem{}, false
|
||||
}
|
||||
|
||||
return item, true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func TestMain(m *testing.M) {
|
|||
queries := db.New(pool)
|
||||
hub := realtime.NewHub()
|
||||
go hub.Run()
|
||||
testHandler = New(queries, hub)
|
||||
testHandler = New(queries, pool, hub)
|
||||
|
||||
// Get seed user and workspace IDs
|
||||
row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,8 @@ func inboxToResponse(i db.InboxItem) InboxItemResponse {
|
|||
}
|
||||
|
||||
func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +80,9 @@ func (h *Handler) ListInbox(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.Queries.MarkInboxRead(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to mark read")
|
||||
|
|
@ -91,6 +93,9 @@ func (h *Handler) MarkInboxRead(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) ArchiveInboxItem(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadInboxItemForUser(w, r, id); !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.Queries.ArchiveInboxItem(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to archive")
|
||||
|
|
|
|||
|
|
@ -79,12 +79,8 @@ func issueToResponse(i db.Issue) IssueResponse {
|
|||
func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -124,9 +120,8 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
issue, err := h.Queries.GetIssue(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "issue not found")
|
||||
issue, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, issueToResponse(issue))
|
||||
|
|
@ -157,19 +152,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
workspaceID := r.URL.Query().Get("workspace_id")
|
||||
if workspaceID == "" {
|
||||
workspaceID = r.Header.Get("X-Workspace-ID")
|
||||
}
|
||||
if workspaceID == "" {
|
||||
writeError(w, http.StatusBadRequest, "workspace_id is required")
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Get creator from context (set by auth middleware)
|
||||
creatorID := r.Header.Get("X-User-ID")
|
||||
if creatorID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
creatorID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -265,6 +255,9 @@ type UpdateIssueRequest struct {
|
|||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
@ -330,6 +323,10 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) DeleteIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Queries.DeleteIssue(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete issue")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handler
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
|
@ -57,9 +58,8 @@ func memberToResponse(m db.Member) MemberResponse {
|
|||
}
|
||||
|
||||
func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +79,10 @@ func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) GetWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceMember(w, r, id, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(id))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "workspace not found")
|
||||
|
|
@ -94,9 +98,8 @@ type CreateWorkspaceRequest struct {
|
|||
}
|
||||
|
||||
func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "user not authenticated")
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -106,23 +109,36 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
|
||||
if req.Name == "" || req.Slug == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and slug are required")
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := h.Queries.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
|
||||
tx, err := h.TxStarter.Begin(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
ws, err := qtx.CreateWorkspace(r.Context(), db.CreateWorkspaceParams{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
Description: ptrToText(req.Description),
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "workspace slug already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create workspace: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Add creator as owner
|
||||
_, err = h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
|
||||
_, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{
|
||||
WorkspaceID: ws.ID,
|
||||
UserID: parseUUID(userID),
|
||||
Role: "owner",
|
||||
|
|
@ -132,6 +148,11 @@ func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create workspace")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, workspaceToResponse(ws))
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +164,9 @@ type UpdateWorkspaceRequest struct {
|
|||
|
||||
func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceRole(w, r, id, "workspace not found", "owner", "admin"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateWorkspaceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
|
|
@ -154,7 +178,12 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
ID: parseUUID(id),
|
||||
}
|
||||
if req.Name != nil {
|
||||
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
params.Name = pgtype.Text{String: name, Valid: true}
|
||||
}
|
||||
if req.Description != nil {
|
||||
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
||||
|
|
@ -175,6 +204,10 @@ func (h *Handler) UpdateWorkspace(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (h *Handler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
members, err := h.Queries.ListMembers(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
||||
|
|
@ -202,6 +235,10 @@ type MemberWithUserResponse struct {
|
|||
|
||||
func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
members, err := h.Queries.ListMembersWithUser(r.Context(), parseUUID(workspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list members")
|
||||
|
|
@ -224,3 +261,240 @@ func (h *Handler) ListMembersWithUser(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type CreateMemberRequest struct {
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func memberWithUserResponse(member db.Member, user db.User) MemberWithUserResponse {
|
||||
return MemberWithUserResponse{
|
||||
ID: uuidToString(member.ID),
|
||||
WorkspaceID: uuidToString(member.WorkspaceID),
|
||||
UserID: uuidToString(member.UserID),
|
||||
Role: member.Role,
|
||||
CreatedAt: timestampToString(member.CreatedAt),
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
AvatarURL: textToPtr(user.AvatarUrl),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMemberRole(role string) (string, bool) {
|
||||
if role == "" {
|
||||
return "member", true
|
||||
}
|
||||
|
||||
role = strings.TrimSpace(role)
|
||||
switch role {
|
||||
case "owner", "admin", "member":
|
||||
return role, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) CreateMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if email == "" {
|
||||
writeError(w, http.StatusBadRequest, "email is required")
|
||||
return
|
||||
}
|
||||
|
||||
role, valid := normalizeMemberRole(req.Role)
|
||||
if !valid {
|
||||
writeError(w, http.StatusBadRequest, "invalid member role")
|
||||
return
|
||||
}
|
||||
if role == "owner" && requester.Role != "owner" {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Queries.GetUserByEmail(r.Context(), email)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
|
||||
member, err := h.Queries.CreateMember(r.Context(), db.CreateMemberParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: user.ID,
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "user is already a member")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create member")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, memberWithUserResponse(member, user))
|
||||
}
|
||||
|
||||
type UpdateMemberRequest struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
memberID := chi.URLParam(r, "memberId")
|
||||
target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
|
||||
if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
|
||||
writeError(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Role) == "" {
|
||||
writeError(w, http.StatusBadRequest, "role is required")
|
||||
return
|
||||
}
|
||||
|
||||
role, valid := normalizeMemberRole(req.Role)
|
||||
if !valid {
|
||||
writeError(w, http.StatusBadRequest, "invalid member role")
|
||||
return
|
||||
}
|
||||
|
||||
if (target.Role == "owner" || role == "owner") && requester.Role != "owner" {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
if target.Role == "owner" && role != "owner" {
|
||||
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update member")
|
||||
return
|
||||
}
|
||||
if countOwners(members) <= 1 {
|
||||
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updatedMember, err := h.Queries.UpdateMemberRole(r.Context(), db.UpdateMemberRoleParams{
|
||||
ID: target.ID,
|
||||
Role: role,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update member")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.Queries.GetUser(r.Context(), updatedMember.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to load member")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, memberWithUserResponse(updatedMember, user))
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteMember(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
requester, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
memberID := chi.URLParam(r, "memberId")
|
||||
target, err := h.Queries.GetMember(r.Context(), parseUUID(memberID))
|
||||
if err != nil || uuidToString(target.WorkspaceID) != workspaceID {
|
||||
writeError(w, http.StatusNotFound, "member not found")
|
||||
return
|
||||
}
|
||||
|
||||
if target.Role == "owner" && requester.Role != "owner" {
|
||||
writeError(w, http.StatusForbidden, "insufficient permissions")
|
||||
return
|
||||
}
|
||||
|
||||
if target.Role == "owner" {
|
||||
members, err := h.Queries.ListMembers(r.Context(), target.WorkspaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete member")
|
||||
return
|
||||
}
|
||||
if countOwners(members) <= 1 {
|
||||
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteMember(r.Context(), target.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete member")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) LeaveWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
member, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if member.Role == "owner" {
|
||||
members, err := h.Queries.ListMembers(r.Context(), member.WorkspaceID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
|
||||
return
|
||||
}
|
||||
if countOwners(members) <= 1 {
|
||||
writeError(w, http.StatusBadRequest, "workspace must have at least one owner")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteMember(r.Context(), member.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to leave workspace")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
workspaceID := chi.URLParam(r, "id")
|
||||
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner"); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteWorkspace(r.Context(), parseUUID(workspaceID)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete workspace")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue