package handler import ( "encoding/json" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" db "github.com/multica-ai/multica/server/pkg/db/generated" ) type WorkspaceResponse struct { ID string `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Description *string `json:"description"` Settings any `json:"settings"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } func workspaceToResponse(w db.Workspace) WorkspaceResponse { var settings any if w.Settings != nil { json.Unmarshal(w.Settings, &settings) } if settings == nil { settings = map[string]any{} } return WorkspaceResponse{ ID: uuidToString(w.ID), Name: w.Name, Slug: w.Slug, Description: textToPtr(w.Description), Settings: settings, CreatedAt: timestampToString(w.CreatedAt), UpdatedAt: timestampToString(w.UpdatedAt), } } type MemberResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` UserID string `json:"user_id"` Role string `json:"role"` CreatedAt string `json:"created_at"` } func memberToResponse(m db.Member) MemberResponse { return MemberResponse{ ID: uuidToString(m.ID), WorkspaceID: uuidToString(m.WorkspaceID), UserID: uuidToString(m.UserID), Role: m.Role, CreatedAt: timestampToString(m.CreatedAt), } } func (h *Handler) ListWorkspaces(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { return } workspaces, err := h.Queries.ListWorkspaces(r.Context(), parseUUID(userID)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list workspaces") return } resp := make([]WorkspaceResponse, len(workspaces)) for i, ws := range workspaces { resp[i] = workspaceToResponse(ws) } writeJSON(w, http.StatusOK, resp) } 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") return } writeJSON(w, http.StatusOK, workspaceToResponse(ws)) } type CreateWorkspaceRequest struct { Name string `json:"name"` Slug string `json:"slug"` Description *string `json:"description"` } func (h *Handler) CreateWorkspace(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { return } var req CreateWorkspaceRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") 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 } 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 } _, err = qtx.CreateMember(r.Context(), db.CreateMemberParams{ WorkspaceID: ws.ID, UserID: parseUUID(userID), Role: "owner", }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to add owner: "+err.Error()) return } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "failed to create workspace") return } writeJSON(w, http.StatusCreated, workspaceToResponse(ws)) } type UpdateWorkspaceRequest struct { Name *string `json:"name"` Description *string `json:"description"` Settings any `json:"settings"` } 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 { writeError(w, http.StatusBadRequest, "invalid request body") return } params := db.UpdateWorkspaceParams{ ID: parseUUID(id), } if req.Name != nil { 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} } if req.Settings != nil { s, _ := json.Marshal(req.Settings) params.Settings = s } ws, err := h.Queries.UpdateWorkspace(r.Context(), params) if err != nil { writeError(w, http.StatusInternalServerError, "failed to update workspace: "+err.Error()) return } writeJSON(w, http.StatusOK, workspaceToResponse(ws)) } 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") return } resp := make([]MemberResponse, len(members)) for i, m := range members { resp[i] = memberToResponse(m) } writeJSON(w, http.StatusOK, resp) } type MemberWithUserResponse struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` UserID string `json:"user_id"` Role string `json:"role"` CreatedAt string `json:"created_at"` Name string `json:"name"` Email string `json:"email"` AvatarURL *string `json:"avatar_url"` } 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") return } resp := make([]MemberWithUserResponse, len(members)) for i, m := range members { resp[i] = MemberWithUserResponse{ ID: uuidToString(m.ID), WorkspaceID: uuidToString(m.WorkspaceID), UserID: uuidToString(m.UserID), Role: m.Role, CreatedAt: timestampToString(m.CreatedAt), Name: m.UserName, Email: m.UserEmail, AvatarURL: textToPtr(m.UserAvatarUrl), } } 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) }