Add workspace management and isolated worktree environments

This commit is contained in:
Jiayuan Zhang 2026-03-23 18:12:11 +08:00
parent e9555b8a22
commit 81e64e9fce
32 changed files with 1462 additions and 200 deletions

View file

@ -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 {

View file

@ -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))
}

View file

@ -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,

View file

@ -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
}

View file

@ -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'`)

View file

@ -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")

View file

@ -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")

View file

@ -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)
}