Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
2098 lines
75 KiB
Go
2098 lines
75 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Tencent/WeKnora/internal/application/service"
|
|
apperrors "github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
)
|
|
|
|
// OrganizationHandler implements HTTP request handlers for organization management
|
|
type OrganizationHandler struct {
|
|
orgService interfaces.OrganizationService
|
|
shareService interfaces.KBShareService
|
|
agentShareService interfaces.AgentShareService
|
|
customAgentService interfaces.CustomAgentService
|
|
userService interfaces.UserService
|
|
// tenantService is used to resolve tenant_name in member listings
|
|
// and to back the tenant-centric invite picker. Plan 3 lifts org
|
|
// membership to the tenant level, so the UI needs to surface the
|
|
// tenant identity rather than the representative user alone.
|
|
tenantService interfaces.TenantService
|
|
kbService interfaces.KnowledgeBaseService
|
|
knowledgeRepo interfaces.KnowledgeRepository
|
|
chunkRepo interfaces.ChunkRepository
|
|
}
|
|
|
|
// NewOrganizationHandler creates a new organization handler
|
|
func NewOrganizationHandler(
|
|
orgService interfaces.OrganizationService,
|
|
shareService interfaces.KBShareService,
|
|
agentShareService interfaces.AgentShareService,
|
|
customAgentService interfaces.CustomAgentService,
|
|
userService interfaces.UserService,
|
|
tenantService interfaces.TenantService,
|
|
kbService interfaces.KnowledgeBaseService,
|
|
knowledgeRepo interfaces.KnowledgeRepository,
|
|
chunkRepo interfaces.ChunkRepository,
|
|
) *OrganizationHandler {
|
|
return &OrganizationHandler{
|
|
orgService: orgService,
|
|
shareService: shareService,
|
|
agentShareService: agentShareService,
|
|
customAgentService: customAgentService,
|
|
userService: userService,
|
|
tenantService: tenantService,
|
|
kbService: kbService,
|
|
knowledgeRepo: knowledgeRepo,
|
|
chunkRepo: chunkRepo,
|
|
}
|
|
}
|
|
|
|
// CreateOrganization creates a new organization
|
|
// @Summary 创建组织
|
|
// @Description 创建新的组织,创建者自动成为管理员
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body types.CreateOrganizationRequest true "组织信息"
|
|
// @Success 201 {object} map[string]interface{}
|
|
// @Failure 400 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations [post]
|
|
func (h *OrganizationHandler) CreateOrganization(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.CreateOrganizationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
logger.Errorf(ctx, "Invalid request parameters: %v", err)
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
org, err := h.orgService.CreateOrganization(ctx, userID, tenantID, &req)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to create organization: %v", err)
|
|
if errors.Is(err, service.ErrInvalidValidityDays) {
|
|
c.Error(apperrors.NewValidationError(err.Error()))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to create organization").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Organization created: %s", org.ID)
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": h.toOrgResponse(ctx, org, userID),
|
|
})
|
|
}
|
|
|
|
// GetOrganization gets an organization by ID
|
|
// @Summary 获取组织详情
|
|
// @Description 根据ID获取组织详情
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id} [get]
|
|
func (h *OrganizationHandler) GetOrganization(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
org, err := h.orgService.GetOrganization(ctx, orgID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get organization: %v", err)
|
|
c.Error(apperrors.NewNotFoundError("Organization not found"))
|
|
return
|
|
}
|
|
|
|
// Membership / visibility gate. Without this, any authenticated
|
|
// user could enumerate organizations by guessing UUIDs and learn
|
|
// names / owner_id / counts. We allow access when either:
|
|
// 1. the caller's tenant is a member of the org, or
|
|
// 2. the org has opted in to discovery (Searchable = true), which
|
|
// is the same surface area returned by GET /organizations/search.
|
|
// Anything else returns 404 (not 403) so we don't even confirm the
|
|
// org's existence to non-members of private orgs.
|
|
if !org.Searchable {
|
|
if _, err := h.orgService.GetTenantMember(ctx, orgID, tenantID); err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Organization not found"))
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": h.toOrgResponse(ctx, org, userID),
|
|
})
|
|
}
|
|
|
|
// ListMyOrganizations lists organizations that the current tenant belongs to.
|
|
// Response includes resource_counts (per-org KB/agent counts) for list sidebar so frontend does not need a separate GET /me/resource-counts.
|
|
// @Summary 获取我的组织列表
|
|
// @Description 获取当前空间所属的所有组织,并附带各空间内知识库/智能体数量
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Success 200 {object} types.ListOrganizationsResponse
|
|
// @Security Bearer
|
|
// @Router /organizations [get]
|
|
func (h *OrganizationHandler) ListMyOrganizations(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
orgs, err := h.orgService.ListTenantOrganizations(ctx, tenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list organizations: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list organizations").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
response := make([]types.OrganizationResponse, 0, len(orgs))
|
|
for _, org := range orgs {
|
|
response = append(response, h.toOrgResponse(ctx, org, userID))
|
|
}
|
|
|
|
resp := types.ListOrganizationsResponse{
|
|
Organizations: response,
|
|
Total: int64(len(response)),
|
|
}
|
|
// 附带各空间资源数量,供知识库/智能体列表页侧栏展示
|
|
resp.ResourceCounts = h.buildResourceCountsByOrg(ctx, orgs, userID, tenantID)
|
|
if resp.ResourceCounts != nil {
|
|
// 补齐未出现在 map 中的 org 为 0
|
|
for _, o := range orgs {
|
|
if _, ok := resp.ResourceCounts.KnowledgeBases.ByOrganization[o.ID]; !ok {
|
|
resp.ResourceCounts.KnowledgeBases.ByOrganization[o.ID] = 0
|
|
}
|
|
if _, ok := resp.ResourceCounts.Agents.ByOrganization[o.ID]; !ok {
|
|
resp.ResourceCounts.Agents.ByOrganization[o.ID] = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": resp,
|
|
})
|
|
}
|
|
|
|
// buildResourceCountsByOrg 返回各空间内知识库数与智能体数,供 ListMyOrganizations 和侧栏使用;失败时返回 nil。
|
|
// 使用批量接口:一次拉取所有空间的直接共享 KB ID、一次拉取所有空间的智能体列表,再在内存中按空间合并计数。
|
|
func (h *OrganizationHandler) buildResourceCountsByOrg(ctx context.Context, orgs []*types.Organization, userID string, tenantID uint64) *types.ResourceCountsByOrgResponse {
|
|
orgIDs := make([]string, 0, len(orgs))
|
|
for _, o := range orgs {
|
|
orgIDs = append(orgIDs, o.ID)
|
|
}
|
|
agentCounts, err := h.agentShareService.CountByOrganizations(ctx, orgIDs)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "buildResourceCountsByOrg CountByOrganizations: %v", err)
|
|
return nil
|
|
}
|
|
directKBIDsByOrg, err := h.shareService.ListSharedKnowledgeBaseIDsByOrganizations(ctx, orgIDs, tenantID)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "buildResourceCountsByOrg ListSharedKnowledgeBaseIDsByOrganizations: %v", err)
|
|
return nil
|
|
}
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
agentListByOrg, err := h.agentShareService.ListSharedAgentsInOrganizations(ctx, orgIDs, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "buildResourceCountsByOrg ListSharedAgentsInOrganizations: %v", err)
|
|
return nil
|
|
}
|
|
_ = userID
|
|
byOrgKB := make(map[string]int)
|
|
tenantKBCache := make(map[uint64][]string) // cache ListKnowledgeBasesByTenantID by tenantID
|
|
for _, o := range orgs {
|
|
oid := o.ID
|
|
directIDs := directKBIDsByOrg[oid]
|
|
directSet := make(map[string]bool)
|
|
for _, id := range directIDs {
|
|
directSet[id] = true
|
|
}
|
|
count := len(directIDs)
|
|
for _, item := range agentListByOrg[oid] {
|
|
if item.Agent == nil {
|
|
continue
|
|
}
|
|
agent := item.Agent
|
|
mode := agent.Config.KBSelectionMode
|
|
if mode == "none" {
|
|
continue
|
|
}
|
|
var kbIDs []string
|
|
switch mode {
|
|
case "selected":
|
|
if len(agent.Config.KnowledgeBases) == 0 {
|
|
continue
|
|
}
|
|
kbIDs = agent.Config.KnowledgeBases
|
|
case "all":
|
|
tid := agent.TenantID
|
|
if _, ok := tenantKBCache[tid]; !ok {
|
|
kbs, err := h.kbService.ListKnowledgeBasesByTenantID(ctx, tid)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "ListKnowledgeBasesByTenantID tenant %d: %v", tid, err)
|
|
tenantKBCache[tid] = nil
|
|
continue
|
|
}
|
|
ids := make([]string, 0, len(kbs))
|
|
for _, kb := range kbs {
|
|
if kb != nil && kb.ID != "" {
|
|
ids = append(ids, kb.ID)
|
|
}
|
|
}
|
|
tenantKBCache[tid] = ids
|
|
}
|
|
kbIDs = tenantKBCache[tid]
|
|
default:
|
|
if len(agent.Config.KnowledgeBases) > 0 {
|
|
kbIDs = agent.Config.KnowledgeBases
|
|
}
|
|
}
|
|
for _, kbID := range kbIDs {
|
|
if kbID != "" && !directSet[kbID] {
|
|
directSet[kbID] = true
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
byOrgKB[oid] = count
|
|
}
|
|
byOrgAgent := make(map[string]int)
|
|
for _, o := range orgs {
|
|
byOrgAgent[o.ID] = 0
|
|
}
|
|
for id, n := range agentCounts {
|
|
byOrgAgent[id] = int(n)
|
|
}
|
|
return &types.ResourceCountsByOrgResponse{
|
|
KnowledgeBases: struct {
|
|
ByOrganization map[string]int `json:"by_organization"`
|
|
}{ByOrganization: byOrgKB},
|
|
Agents: struct {
|
|
ByOrganization map[string]int `json:"by_organization"`
|
|
}{ByOrganization: byOrgAgent},
|
|
}
|
|
}
|
|
|
|
// UpdateOrganization updates an organization
|
|
// @Summary 更新组织
|
|
// @Description 更新组织信息(需要管理员权限)
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param request body types.UpdateOrganizationRequest true "更新信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id} [put]
|
|
func (h *OrganizationHandler) UpdateOrganization(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.UpdateOrganizationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
org, err := h.orgService.UpdateOrganization(ctx, orgID, userID, tenantID, &req)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to update organization: %v", err)
|
|
if errors.Is(err, service.ErrInvalidValidityDays) {
|
|
c.Error(apperrors.NewValidationError(err.Error()))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrOrgMemberLimitTooLow) {
|
|
c.Error(apperrors.NewValidationError("当前成员数已超过新的上限,请先移除成员或设置更大的上限"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or organization not found"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": h.toOrgResponse(ctx, org, userID),
|
|
})
|
|
}
|
|
|
|
// DeleteOrganization deletes an organization
|
|
// @Summary 删除组织
|
|
// @Description 删除组织(仅组织创建者可操作)
|
|
// @Tags 组织管理
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id} [delete]
|
|
func (h *OrganizationHandler) DeleteOrganization(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
if err := h.orgService.DeleteOrganization(ctx, orgID, userID, tenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to delete organization: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or organization not found"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Organization deleted successfully",
|
|
})
|
|
}
|
|
|
|
// ListMembers lists all tenant-members of an organization
|
|
// @Summary 获取组织成员列表
|
|
// @Description 获取组织的所有成员(按空间)
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} types.ListMembersResponse
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/members [get]
|
|
func (h *OrganizationHandler) ListMembers(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Member roster is sensitive: it surfaces every tenant in the org
|
|
// plus the representative user (username/email/avatar). Only orgs
|
|
// the caller's tenant actually belongs to may be listed; non-members
|
|
// get 403 — mirrors ListOrgShares / ListOrgAgentShares which already
|
|
// gate on GetTenantMember.
|
|
if _, err := h.orgService.GetTenantMember(ctx, orgID, tenantID); err != nil {
|
|
c.Error(apperrors.NewForbiddenError("Your workspace is not a member of this organization"))
|
|
return
|
|
}
|
|
|
|
members, err := h.orgService.ListTenantMembers(ctx, orgID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list members: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list members").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Collect tenant IDs to resolve tenant names in one round-trip.
|
|
tenantIDs := make([]uint64, 0, len(members))
|
|
for _, m := range members {
|
|
tenantIDs = append(tenantIDs, m.TenantID)
|
|
}
|
|
tenantByID, _ := h.tenantService.GetTenantsByIDs(ctx, tenantIDs)
|
|
|
|
response := make([]types.OrganizationMemberResponse, 0, len(members))
|
|
for _, m := range members {
|
|
resp := types.OrganizationMemberResponse{
|
|
ID: m.ID,
|
|
UserID: m.RepresentativeUserID,
|
|
RepresentativeUserID: m.RepresentativeUserID,
|
|
Role: string(m.Role),
|
|
TenantID: m.TenantID,
|
|
JoinedAt: m.CreatedAt,
|
|
}
|
|
if t, ok := tenantByID[m.TenantID]; ok && t != nil {
|
|
resp.TenantName = t.Name
|
|
}
|
|
if m.RepresentativeUser != nil {
|
|
resp.Username = m.RepresentativeUser.Username
|
|
resp.Email = m.RepresentativeUser.Email
|
|
resp.Avatar = m.RepresentativeUser.Avatar
|
|
}
|
|
response = append(response, resp)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": types.ListMembersResponse{
|
|
Members: response,
|
|
Total: int64(len(response)),
|
|
},
|
|
})
|
|
}
|
|
|
|
// UpdateMemberRole updates a tenant-member's role
|
|
// @Summary 更新成员角色
|
|
// @Description 更新组织成员(空间)的角色(需要管理员权限)
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param tenant_id path string true "成员空间ID"
|
|
// @Param request body types.UpdateMemberRoleRequest true "角色信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/members/{tenant_id} [put]
|
|
func (h *OrganizationHandler) UpdateMemberRole(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
memberTenantIDStr := c.Param("tenant_id")
|
|
memberTenantID, err := strconv.ParseUint(memberTenantIDStr, 10, 64)
|
|
if err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid workspace ID"))
|
|
return
|
|
}
|
|
operatorUserID := c.GetString(types.UserIDContextKey.String())
|
|
operatorTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.UpdateMemberRoleRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := h.orgService.UpdateTenantMemberRole(ctx, orgID, memberTenantID, req.Role, operatorUserID, operatorTenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to update member role: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or invalid operation"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Member role updated successfully",
|
|
})
|
|
}
|
|
|
|
// RemoveMember removes a tenant-member from an organization
|
|
// @Summary 移除成员
|
|
// @Description 从组织中移除成员空间(需要管理员权限)
|
|
// @Tags 组织管理
|
|
// @Param id path string true "组织ID"
|
|
// @Param tenant_id path string true "成员空间ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/members/{tenant_id} [delete]
|
|
func (h *OrganizationHandler) RemoveMember(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
memberTenantIDStr := c.Param("tenant_id")
|
|
memberTenantID, err := strconv.ParseUint(memberTenantIDStr, 10, 64)
|
|
if err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid workspace ID"))
|
|
return
|
|
}
|
|
operatorUserID := c.GetString(types.UserIDContextKey.String())
|
|
operatorTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
if err := h.orgService.RemoveTenantMember(ctx, orgID, memberTenantID, operatorUserID, operatorTenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to remove member: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or invalid operation"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Member removed successfully",
|
|
})
|
|
}
|
|
|
|
// GenerateInviteCode generates a new invite code
|
|
// @Summary 生成邀请码
|
|
// @Description 生成新的组织邀请码(需要管理员权限)
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/invite-code [post]
|
|
func (h *OrganizationHandler) GenerateInviteCode(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
code, err := h.orgService.GenerateInviteCode(ctx, orgID, userID, tenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to generate invite code: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied"))
|
|
return
|
|
}
|
|
|
|
// Wrap in `data` to match the ApiResponse<{invite_code}> contract both the
|
|
// web frontend and the Go SDK expect; a flat top-level field is silently
|
|
// dropped by those clients (they read data.invite_code).
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": gin.H{
|
|
"invite_code": code,
|
|
},
|
|
})
|
|
}
|
|
|
|
// PreviewByInviteCode previews organization info by invite code (without joining)
|
|
// @Summary 通过邀请码预览组织
|
|
// @Description 通过邀请码获取组织基本信息(不加入)
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param code path string true "邀请码"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/preview/{code} [get]
|
|
func (h *OrganizationHandler) PreviewByInviteCode(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
inviteCode := c.Param("code")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Get organization by invite code
|
|
org, err := h.orgService.GetOrganizationByInviteCode(ctx, inviteCode)
|
|
if err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Invalid invite code"))
|
|
return
|
|
}
|
|
|
|
// Get member count
|
|
members, _ := h.orgService.ListTenantMembers(ctx, org.ID)
|
|
memberCount := len(members)
|
|
|
|
// Get shared knowledge bases count
|
|
shares, _ := h.shareService.ListSharesByOrganization(ctx, org.ID)
|
|
shareCount := len(shares)
|
|
// Get shared agents count
|
|
agentShares, _ := h.agentShareService.ListSharesByOrganization(ctx, org.ID)
|
|
agentShareCount := len(agentShares)
|
|
|
|
// Check if caller's tenant is already a member
|
|
_, memberErr := h.orgService.GetTenantMember(ctx, org.ID, tenantID)
|
|
isAlreadyMember := memberErr == nil
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": gin.H{
|
|
"id": org.ID,
|
|
"name": org.Name,
|
|
"description": org.Description,
|
|
"avatar": org.Avatar,
|
|
"member_count": memberCount,
|
|
"share_count": shareCount,
|
|
"agent_share_count": agentShareCount,
|
|
"is_already_member": isAlreadyMember,
|
|
"require_approval": org.RequireApproval,
|
|
"created_at": org.CreatedAt,
|
|
},
|
|
})
|
|
}
|
|
|
|
// JoinByInviteCode joins an organization by invite code
|
|
// @Summary 通过邀请码加入组织
|
|
// @Description 使用邀请码加入组织
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body types.JoinOrganizationRequest true "邀请码"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 404 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/join [post]
|
|
func (h *OrganizationHandler) JoinByInviteCode(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.JoinOrganizationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
org, err := h.orgService.JoinByInviteCode(ctx, req.InviteCode, userID, tenantID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to join organization: %v", err)
|
|
if errors.Is(err, service.ErrOrgMemberLimitReached) {
|
|
c.Error(apperrors.NewValidationError("该空间成员已满,无法加入"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewNotFoundError("Invalid invite code"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "User %s joined organization %s", secutils.SanitizeForLog(userID), org.ID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": h.toOrgResponse(ctx, org, userID),
|
|
})
|
|
}
|
|
|
|
// SubmitJoinRequest submits a join request for organizations that require approval
|
|
// @Summary 提交加入申请
|
|
// @Description 对需要审核的组织提交加入申请
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body types.SubmitJoinRequestRequest true "申请信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/join-request [post]
|
|
func (h *OrganizationHandler) SubmitJoinRequest(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.SubmitJoinRequestRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Get organization by invite code
|
|
org, err := h.orgService.GetOrganizationByInviteCode(ctx, req.InviteCode)
|
|
if err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Invalid invite code"))
|
|
return
|
|
}
|
|
|
|
// Check if organization requires approval
|
|
if !org.RequireApproval {
|
|
c.Error(apperrors.NewValidationError("This organization does not require approval. Use the join endpoint instead."))
|
|
return
|
|
}
|
|
|
|
// Check if caller's tenant is already a member
|
|
_, memberErr := h.orgService.GetTenantMember(ctx, org.ID, tenantID)
|
|
if memberErr == nil {
|
|
c.Error(apperrors.NewValidationError("Your workspace is already a member of this organization"))
|
|
return
|
|
}
|
|
|
|
// Validate requested role: only viewer/editor/admin allowed
|
|
requestedRole := req.Role
|
|
if requestedRole != "" && !requestedRole.IsValid() {
|
|
c.Error(apperrors.NewValidationError("Invalid role; must be viewer, editor, or admin"))
|
|
return
|
|
}
|
|
|
|
// Submit join request (service defaults to viewer if role empty)
|
|
request, err := h.orgService.SubmitJoinRequest(ctx, org.ID, userID, tenantID, req.Message, requestedRole)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to submit join request: %v", err)
|
|
if errors.Is(err, service.ErrOrgMemberLimitReached) {
|
|
c.Error(apperrors.NewValidationError("该空间成员已满,无法提交加入申请"))
|
|
return
|
|
}
|
|
if err.Error() == "pending request already exists" {
|
|
c.Error(apperrors.NewValidationError("You have already submitted a request to join this organization"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to submit join request"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "User %s submitted join request for organization %s", secutils.SanitizeForLog(userID), org.ID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": request,
|
|
})
|
|
}
|
|
|
|
// SearchOrganizations returns searchable (discoverable) organizations
|
|
// @Summary 搜索可加入的空间
|
|
// @Description 搜索已开放可被搜索的空间,用于发现并加入
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param q query string false "搜索关键词(空间名称或描述)"
|
|
// @Param limit query int false "返回数量限制" default(20)
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /organizations/search [get]
|
|
func (h *OrganizationHandler) SearchOrganizations(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
query := c.Query("q")
|
|
limit := 20
|
|
if l := c.Query("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 100 {
|
|
limit = n
|
|
}
|
|
}
|
|
resp, err := h.orgService.SearchSearchableOrganizations(ctx, tenantID, query, limit)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to search organizations: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to search organizations"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": resp.Organizations,
|
|
"total": resp.Total,
|
|
})
|
|
}
|
|
|
|
// JoinByOrganizationID joins a searchable organization by ID (no invite code)
|
|
// @Summary 通过空间 ID 加入(可搜索空间)
|
|
// @Description 加入已开放可被搜索的空间,无需邀请码
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body types.JoinByOrganizationIDRequest true "空间 ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/join-by-id [post]
|
|
func (h *OrganizationHandler) JoinByOrganizationID(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
var req types.JoinByOrganizationIDRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
// Validate requested role if provided
|
|
requestedRole := req.Role
|
|
if requestedRole != "" && !requestedRole.IsValid() {
|
|
c.Error(apperrors.NewValidationError("Invalid role; must be viewer, editor, or admin"))
|
|
return
|
|
}
|
|
org, err := h.orgService.JoinByOrganizationID(ctx, req.OrganizationID, userID, tenantID, req.Message, requestedRole)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to join organization by ID: %v", err)
|
|
if errors.Is(err, service.ErrOrgNotFound) {
|
|
c.Error(apperrors.NewNotFoundError("Organization not found or not open for search"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrOrgPermissionDenied) {
|
|
c.Error(apperrors.NewForbiddenError("Organization not open for search"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrOrgMemberLimitReached) {
|
|
c.Error(apperrors.NewValidationError("该空间成员已满,无法加入"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrInvalidRole) {
|
|
c.Error(apperrors.NewValidationError("Invalid role"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to join organization"))
|
|
return
|
|
}
|
|
logger.Infof(ctx, "User %s joined organization %s by ID", secutils.SanitizeForLog(userID), org.ID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": h.toOrgResponse(ctx, org, userID),
|
|
})
|
|
}
|
|
|
|
// RequestRoleUpgrade submits a request to upgrade role in an organization
|
|
// @Summary 申请权限升级
|
|
// @Description 现有成员申请更高权限
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param request body types.RequestRoleUpgradeRequest true "申请信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/request-upgrade [post]
|
|
func (h *OrganizationHandler) RequestRoleUpgrade(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.RequestRoleUpgradeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Validate requested role
|
|
if !req.RequestedRole.IsValid() {
|
|
c.Error(apperrors.NewValidationError("Invalid role; must be viewer, editor, or admin"))
|
|
return
|
|
}
|
|
|
|
request, err := h.orgService.RequestRoleUpgrade(ctx, orgID, userID, tenantID, req.RequestedRole, req.Message)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to submit role upgrade request: %v", err)
|
|
if err.Error() == "pending request already exists" {
|
|
c.Error(apperrors.NewValidationError("You already have a pending upgrade request"))
|
|
return
|
|
}
|
|
if err.Error() == "user is not a member of this organization" {
|
|
c.Error(apperrors.NewValidationError("You are not a member of this organization"))
|
|
return
|
|
}
|
|
if err.Error() == "user is already an admin" {
|
|
c.Error(apperrors.NewValidationError("You are already an admin"))
|
|
return
|
|
}
|
|
if err.Error() == "cannot request upgrade to same or lower role" {
|
|
c.Error(apperrors.NewValidationError("Cannot request upgrade to same or lower role"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to submit upgrade request"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "User %s submitted role upgrade request for organization %s", secutils.SanitizeForLog(userID), orgID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": request,
|
|
})
|
|
}
|
|
|
|
// LeaveOrganization allows a user to leave an organization
|
|
// @Summary 退出组织
|
|
// @Description 退出指定组织
|
|
// @Tags 组织管理
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/leave [post]
|
|
func (h *OrganizationHandler) LeaveOrganization(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check if caller's tenant is the owner tenant. Post-Plan-3, "owner
|
|
// can't leave" is a tenant-level rule: the owner_tenant_id row is the
|
|
// one that may not depart the org. Legacy rows with OwnerTenantID == 0
|
|
// fall back to the user-level rule so we don't break pre-000046 data.
|
|
org, err := h.orgService.GetOrganization(ctx, orgID)
|
|
if err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Organization not found"))
|
|
return
|
|
}
|
|
|
|
isOwnerTenant := org.OwnerTenantID != 0 && org.OwnerTenantID == tenantID
|
|
if isOwnerTenant || (org.OwnerTenantID == 0 && org.OwnerID == userID) {
|
|
c.Error(apperrors.NewForbiddenError("Organization owner cannot leave. Please transfer ownership or delete the organization."))
|
|
return
|
|
}
|
|
|
|
// Remove the caller's tenant from the organization (self-leave)
|
|
if err := h.orgService.RemoveTenantMember(ctx, orgID, tenantID, userID, tenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to leave organization: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to leave organization"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Left organization successfully",
|
|
})
|
|
}
|
|
|
|
// ListJoinRequests lists pending join requests for an organization (admin only)
|
|
// @Summary 获取待审核加入申请列表
|
|
// @Description 获取组织的待审核加入申请(仅管理员)
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/join-requests [get]
|
|
func (h *OrganizationHandler) ListJoinRequests(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check admin: caller's tenant must be admin in the org
|
|
isAdmin, err := h.orgService.IsTenantOrgAdmin(ctx, orgID, tenantID)
|
|
if err != nil || !isAdmin {
|
|
c.Error(apperrors.NewForbiddenError("Only organization admins can view join requests"))
|
|
return
|
|
}
|
|
|
|
requests, err := h.orgService.ListJoinRequests(ctx, orgID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list join requests: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list join requests"))
|
|
return
|
|
}
|
|
|
|
// Only return pending requests for approval UI
|
|
resp := make([]types.JoinRequestResponse, 0)
|
|
for _, r := range requests {
|
|
if r.Status != types.JoinRequestStatusPending {
|
|
continue
|
|
}
|
|
item := types.JoinRequestResponse{
|
|
ID: r.ID,
|
|
UserID: r.UserID,
|
|
Message: r.Message,
|
|
RequestType: string(r.RequestType),
|
|
PrevRole: string(r.PrevRole),
|
|
RequestedRole: string(r.RequestedRole),
|
|
Status: string(r.Status),
|
|
CreatedAt: r.CreatedAt,
|
|
ReviewedAt: r.ReviewedAt,
|
|
}
|
|
// Default request_type to 'join' for backward compatibility
|
|
if item.RequestType == "" {
|
|
item.RequestType = string(types.JoinRequestTypeJoin)
|
|
}
|
|
if r.User != nil {
|
|
item.Username = r.User.Username
|
|
item.Email = r.User.Email
|
|
}
|
|
resp = append(resp, item)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": types.ListJoinRequestsResponse{
|
|
Requests: resp,
|
|
Total: int64(len(resp)),
|
|
},
|
|
})
|
|
}
|
|
|
|
// ReviewJoinRequest approves or rejects a join request (admin only)
|
|
// @Summary 审核加入申请
|
|
// @Description 通过或拒绝加入申请(仅管理员)
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param request_id path string true "申请ID"
|
|
// @Param request body types.ReviewJoinRequestRequest true "审核结果"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/join-requests/{request_id}/review [put]
|
|
func (h *OrganizationHandler) ReviewJoinRequest(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
requestID := c.Param("request_id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check admin: caller's tenant must be admin in the org
|
|
isAdmin, err := h.orgService.IsTenantOrgAdmin(ctx, orgID, tenantID)
|
|
if err != nil || !isAdmin {
|
|
c.Error(apperrors.NewForbiddenError("Only organization admins can review join requests"))
|
|
return
|
|
}
|
|
|
|
var req types.ReviewJoinRequestRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
var assignRole *types.OrgMemberRole
|
|
if req.Role != "" {
|
|
if !req.Role.IsValid() {
|
|
c.Error(apperrors.NewValidationError("Invalid role; must be viewer, editor, or admin"))
|
|
return
|
|
}
|
|
assignRole = &req.Role
|
|
}
|
|
|
|
if err := h.orgService.ReviewJoinRequest(ctx, orgID, requestID, req.Approved, userID, tenantID, req.Message, assignRole); err != nil {
|
|
logger.Errorf(ctx, "Failed to review join request: %v", err)
|
|
if errors.Is(err, service.ErrOrgMemberLimitReached) {
|
|
c.Error(apperrors.NewValidationError("空间成员已满,无法通过该加入申请"))
|
|
return
|
|
}
|
|
if err.Error() == "request has already been reviewed" {
|
|
c.Error(apperrors.NewValidationError("Request has already been reviewed"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to review join request"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Review completed",
|
|
})
|
|
}
|
|
|
|
// ShareKnowledgeBase shares a knowledge base to an organization
|
|
// @Summary 共享知识库到组织
|
|
// @Description 将知识库共享到指定组织
|
|
// @Tags 知识库共享
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Param request body types.ShareKnowledgeBaseRequest true "共享信息"
|
|
// @Success 201 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /knowledge-bases/{id}/shares [post]
|
|
func (h *OrganizationHandler) ShareKnowledgeBase(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
kbID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.ShareKnowledgeBaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
share, err := h.shareService.ShareKnowledgeBase(ctx, kbID, req.OrganizationID, userID, tenantID, req.Permission)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to share knowledge base: %v", err)
|
|
if errors.Is(err, service.ErrOrgRoleCannotShare) {
|
|
c.Error(apperrors.NewForbiddenError("Only editors and admins can share knowledge bases to this organization"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or invalid operation"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"success": true,
|
|
"data": share,
|
|
})
|
|
}
|
|
|
|
// ListKBShares lists all shares for a knowledge base
|
|
// @Summary 获取知识库的共享列表
|
|
// @Description 获取知识库的所有共享记录
|
|
// @Tags 知识库共享
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Success 200 {object} types.ListSharesResponse
|
|
// @Security Bearer
|
|
// @Router /knowledge-bases/{id}/shares [get]
|
|
func (h *OrganizationHandler) ListKBShares(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
kbID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if tenantID == 0 {
|
|
c.Error(apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
|
|
shares, err := h.shareService.ListSharesByKnowledgeBase(ctx, kbID, tenantID)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrKBNotFound) {
|
|
c.Error(apperrors.NewNotFoundError("Knowledge base not found"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrNotKBOwner) {
|
|
c.Error(apperrors.NewForbiddenError("Only the knowledge base owner can list its shares"))
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Failed to list shares: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shares"))
|
|
return
|
|
}
|
|
|
|
response := make([]types.KnowledgeBaseShareResponse, 0, len(shares))
|
|
for _, s := range shares {
|
|
resp := types.KnowledgeBaseShareResponse{
|
|
ID: s.ID,
|
|
KnowledgeBaseID: s.KnowledgeBaseID,
|
|
OrganizationID: s.OrganizationID,
|
|
SharedByUserID: s.SharedByUserID,
|
|
SourceTenantID: s.SourceTenantID,
|
|
Permission: string(s.Permission),
|
|
CreatedAt: s.CreatedAt,
|
|
}
|
|
if s.Organization != nil {
|
|
resp.OrganizationName = s.Organization.Name
|
|
}
|
|
response = append(response, resp)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": types.ListSharesResponse{
|
|
Shares: response,
|
|
Total: int64(len(response)),
|
|
},
|
|
})
|
|
}
|
|
|
|
// UpdateSharePermission updates the permission of a share
|
|
// @Summary 更新共享权限
|
|
// @Description 更新知识库共享的权限级别
|
|
// @Tags 知识库共享
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "知识库ID"
|
|
// @Param share_id path string true "共享记录ID"
|
|
// @Param request body types.UpdateSharePermissionRequest true "权限信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /knowledge-bases/{id}/shares/{share_id} [put]
|
|
func (h *OrganizationHandler) UpdateSharePermission(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
shareID := c.Param("share_id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.UpdateSharePermissionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
if err := h.shareService.UpdateSharePermission(ctx, shareID, req.Permission, userID, tenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to update share permission: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Share permission updated successfully",
|
|
})
|
|
}
|
|
|
|
// RemoveShare removes a share
|
|
// @Summary 取消共享
|
|
// @Description 取消知识库的共享
|
|
// @Tags 知识库共享
|
|
// @Param id path string true "知识库ID"
|
|
// @Param share_id path string true "共享记录ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /knowledge-bases/{id}/shares/{share_id} [delete]
|
|
func (h *OrganizationHandler) RemoveShare(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
shareID := c.Param("share_id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
if err := h.shareService.RemoveShare(ctx, shareID, userID, tenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to remove share: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied"))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Share removed successfully",
|
|
})
|
|
}
|
|
|
|
// ListOrgShares lists all knowledge bases shared to a specific organization
|
|
// @Summary 获取组织的共享知识库列表
|
|
// @Description 获取共享到指定组织的所有知识库
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} types.ListSharesResponse
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/shares [get]
|
|
func (h *OrganizationHandler) ListOrgShares(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check if caller's tenant is a member and get its role for effective-permission calculation
|
|
member, err := h.orgService.GetTenantMember(ctx, orgID, tenantID)
|
|
if err != nil {
|
|
c.Error(apperrors.NewForbiddenError("Your workspace is not a member of this organization"))
|
|
return
|
|
}
|
|
myRoleInOrg := member.Role
|
|
|
|
shares, err := h.shareService.ListSharesByOrganization(ctx, orgID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list organization shares: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shares"))
|
|
return
|
|
}
|
|
|
|
response := make([]types.KnowledgeBaseShareResponse, 0, len(shares))
|
|
for _, s := range shares {
|
|
// Effective permission for current user = min(share permission, my role in org)
|
|
effectivePerm := s.Permission
|
|
if !myRoleInOrg.HasPermission(s.Permission) {
|
|
effectivePerm = myRoleInOrg
|
|
}
|
|
resp := types.KnowledgeBaseShareResponse{
|
|
ID: s.ID,
|
|
KnowledgeBaseID: s.KnowledgeBaseID,
|
|
OrganizationID: s.OrganizationID,
|
|
SharedByUserID: s.SharedByUserID,
|
|
SourceTenantID: s.SourceTenantID,
|
|
Permission: string(s.Permission),
|
|
MyRoleInOrg: string(myRoleInOrg),
|
|
MyPermission: string(effectivePerm),
|
|
CreatedAt: s.CreatedAt,
|
|
}
|
|
if s.KnowledgeBase != nil {
|
|
resp.KnowledgeBaseName = s.KnowledgeBase.Name
|
|
resp.KnowledgeBaseType = s.KnowledgeBase.Type
|
|
// Get knowledge count for document type
|
|
if count, err := h.knowledgeRepo.CountKnowledgeByKnowledgeBaseID(ctx, s.SourceTenantID, s.KnowledgeBaseID); err == nil {
|
|
resp.KnowledgeCount = count
|
|
}
|
|
// Get chunk count for FAQ type
|
|
if count, err := h.chunkRepo.CountChunksByKnowledgeBaseID(ctx, s.SourceTenantID, s.KnowledgeBaseID); err == nil {
|
|
resp.ChunkCount = count
|
|
}
|
|
}
|
|
// Get shared by user info
|
|
if user, err := h.userService.GetUserByID(ctx, s.SharedByUserID); err == nil && user != nil {
|
|
resp.SharedByUsername = user.Username
|
|
}
|
|
response = append(response, resp)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": types.ListSharesResponse{
|
|
Shares: response,
|
|
Total: int64(len(response)),
|
|
},
|
|
})
|
|
}
|
|
|
|
// ListSharedKnowledgeBases lists all knowledge bases shared to the current user
|
|
// @Summary 获取共享给我的知识库列表
|
|
// @Description 获取通过组织共享给当前用户的所有知识库
|
|
// @Tags 知识库共享
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /shared-knowledge-bases [get]
|
|
func (h *OrganizationHandler) ListSharedKnowledgeBases(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
tenantID := types.MustTenantIDFromContext(ctx)
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
|
|
sharedKBs, err := h.shareService.ListSharedKnowledgeBases(ctx, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list shared knowledge bases: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shared knowledge bases"))
|
|
return
|
|
}
|
|
|
|
// Each row goes through sharedKBRow so the embedded KnowledgeBase
|
|
// payload runs SharedStoreDisplay() before serialization. This is
|
|
// the cross-tenant strip path: callers never receive the owning
|
|
// tenant's vector_store_id, vector_store_name, or
|
|
// vector_store_engine_type from the share endpoints. The share
|
|
// metadata (share_id, organization_id, etc.) is preserved as-is.
|
|
rows := make([]map[string]interface{}, 0, len(sharedKBs))
|
|
for _, info := range sharedKBs {
|
|
rows = append(rows, sharedKBRow(info, nil))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// ShareAgent shares an agent to an organization
|
|
func (h *OrganizationHandler) ShareAgent(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
agentID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
var req types.ShareKnowledgeBaseRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
share, err := h.agentShareService.ShareAgent(ctx, agentID, req.OrganizationID, userID, tenantID, req.Permission)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to share agent: %v", err)
|
|
if errors.Is(err, service.ErrOrgRoleCannotShareAgent) {
|
|
c.Error(apperrors.NewForbiddenError("Only editors and admins can share agents to this organization"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrAgentNotConfigured) {
|
|
c.Error(apperrors.NewValidationError("Agent is not fully configured. Please set the chat model, and set the rerank model if the knowledge_search tool is enabled in agent settings."))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewForbiddenError("Permission denied or invalid operation"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, gin.H{"success": true, "data": share})
|
|
}
|
|
|
|
// ListAgentShares lists all shares for an agent
|
|
func (h *OrganizationHandler) ListAgentShares(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
agentID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if tenantID == 0 {
|
|
c.Error(apperrors.NewUnauthorizedError("Unauthorized"))
|
|
return
|
|
}
|
|
shares, err := h.agentShareService.ListSharesByAgent(ctx, agentID, tenantID)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrAgentNotFoundForShare) {
|
|
c.Error(apperrors.NewNotFoundError("Agent not found"))
|
|
return
|
|
}
|
|
if errors.Is(err, service.ErrNotAgentOwner) {
|
|
c.Error(apperrors.NewForbiddenError("Only the agent owner can list its shares"))
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Failed to list agent shares: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shares"))
|
|
return
|
|
}
|
|
response := make([]types.AgentShareResponse, 0, len(shares))
|
|
for _, s := range shares {
|
|
resp := types.AgentShareResponse{
|
|
ID: s.ID, AgentID: s.AgentID, OrganizationID: s.OrganizationID,
|
|
SharedByUserID: s.SharedByUserID, SourceTenantID: s.SourceTenantID,
|
|
Permission: string(s.Permission), CreatedAt: s.CreatedAt,
|
|
}
|
|
if s.Organization != nil {
|
|
resp.OrganizationName = s.Organization.Name
|
|
}
|
|
response = append(response, resp)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"shares": response, "total": len(response)}})
|
|
}
|
|
|
|
// RemoveAgentShare removes an agent share.
|
|
//
|
|
// RemoveAgentShare godoc
|
|
// @Summary 取消智能体共享
|
|
// @Description 从智能体的共享列表中移除指定共享关系
|
|
// @Tags 组织
|
|
// @Produce json
|
|
// @Param id path string true "智能体 ID"
|
|
// @Param share_id path string true "共享记录 ID"
|
|
// @Success 200 {object} map[string]interface{} "success: true"
|
|
// @Failure 403 {object} apperrors.AppError "无权限"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /agents/{id}/shares/{share_id} [delete]
|
|
func (h *OrganizationHandler) RemoveAgentShare(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
shareID := c.Param("share_id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if err := h.agentShareService.RemoveShare(ctx, shareID, userID, tenantID); err != nil {
|
|
logger.Errorf(ctx, "Failed to remove agent share: %v", err)
|
|
c.Error(apperrors.NewForbiddenError("Permission denied"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Share removed successfully"})
|
|
}
|
|
|
|
// ListOrgAgentShares lists all agents shared to an organization.
|
|
//
|
|
// ListOrgAgentShares godoc
|
|
// @Summary 获取共享到本组织的智能体
|
|
// @Description 返回所有被共享到指定组织的智能体(含我的有效权限)
|
|
// @Tags 组织
|
|
// @Produce json
|
|
// @Param id path string true "组织 ID"
|
|
// @Success 200 {object} map[string]interface{} "智能体共享列表 + total"
|
|
// @Failure 403 {object} apperrors.AppError "非组织成员"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /organizations/{id}/agent-shares [get]
|
|
func (h *OrganizationHandler) ListOrgAgentShares(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
member, err := h.orgService.GetTenantMember(ctx, orgID, tenantID)
|
|
if err != nil {
|
|
c.Error(apperrors.NewForbiddenError("Your workspace is not a member of this organization"))
|
|
return
|
|
}
|
|
myRoleInOrg := member.Role
|
|
shares, err := h.agentShareService.ListSharesByOrganization(ctx, orgID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list organization agent shares: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shares"))
|
|
return
|
|
}
|
|
response := make([]types.AgentShareResponse, 0, len(shares))
|
|
for _, s := range shares {
|
|
effectivePerm := s.Permission
|
|
if !myRoleInOrg.HasPermission(s.Permission) {
|
|
effectivePerm = myRoleInOrg
|
|
}
|
|
resp := types.AgentShareResponse{
|
|
ID: s.ID, AgentID: s.AgentID, OrganizationID: s.OrganizationID,
|
|
SharedByUserID: s.SharedByUserID, SourceTenantID: s.SourceTenantID,
|
|
Permission: string(s.Permission), MyRoleInOrg: string(myRoleInOrg), MyPermission: string(effectivePerm), CreatedAt: s.CreatedAt,
|
|
}
|
|
if s.Agent != nil {
|
|
// Built-in rows carry display fields frozen in the writer's
|
|
// language; re-localize for the caller.
|
|
types.ApplyBuiltinAgentLocalization(ctx, s.Agent)
|
|
resp.AgentName = s.Agent.Name
|
|
resp.AgentAvatar = s.Agent.Avatar
|
|
cfg := &s.Agent.Config
|
|
if cfg.KBSelectionMode != "" {
|
|
resp.ScopeKB = cfg.KBSelectionMode
|
|
if cfg.KBSelectionMode == "selected" && len(cfg.KnowledgeBases) > 0 {
|
|
resp.ScopeKBCount = len(cfg.KnowledgeBases)
|
|
}
|
|
} else {
|
|
resp.ScopeKB = "none"
|
|
}
|
|
resp.ScopeWebSearch = cfg.WebSearchEnabled
|
|
if cfg.MCPSelectionMode != "" {
|
|
resp.ScopeMCP = cfg.MCPSelectionMode
|
|
if cfg.MCPSelectionMode == "selected" && len(cfg.MCPServices) > 0 {
|
|
resp.ScopeMCPCount = len(cfg.MCPServices)
|
|
}
|
|
} else {
|
|
resp.ScopeMCP = "none"
|
|
}
|
|
}
|
|
if s.Organization != nil {
|
|
resp.OrganizationName = s.Organization.Name
|
|
}
|
|
if u, err := h.userService.GetUserByID(ctx, s.SharedByUserID); err == nil && u != nil {
|
|
resp.SharedByUsername = u.Username
|
|
}
|
|
response = append(response, resp)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"shares": response, "total": len(response)}})
|
|
}
|
|
|
|
// ListSharedAgents lists agents shared to the current user.
|
|
//
|
|
// ListSharedAgents godoc
|
|
// @Summary 获取我可访问的共享智能体
|
|
// @Description 返回所有共享给当前用户所在组织的智能体
|
|
// @Tags 组织
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]interface{} "智能体列表 + total"
|
|
// @Failure 500 {object} apperrors.AppError "服务器错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /shared-agents [get]
|
|
func (h *OrganizationHandler) ListSharedAgents(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
list, err := h.agentShareService.ListSharedAgents(ctx, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to list shared agents: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shared agents"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
|
}
|
|
|
|
// listSpaceKnowledgeBasesInOrganization returns merged list of direct shared KBs and agent-carried KBs in the org (for list and count).
|
|
func (h *OrganizationHandler) listSpaceKnowledgeBasesInOrganization(ctx context.Context, orgID string, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.OrganizationSharedKnowledgeBaseItem, error) {
|
|
directList, err := h.shareService.ListSharedKnowledgeBasesInOrganization(ctx, orgID, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
directKbIDs := make(map[string]bool)
|
|
for _, item := range directList {
|
|
if item.KnowledgeBase != nil && item.KnowledgeBase.ID != "" {
|
|
directKbIDs[item.KnowledgeBase.ID] = true
|
|
}
|
|
}
|
|
|
|
agentList, err := h.agentShareService.ListSharedAgentsInOrganization(ctx, orgID, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
return directList, nil
|
|
}
|
|
|
|
orgName := ""
|
|
if len(agentList) > 0 && agentList[0].OrganizationID == orgID {
|
|
orgName = agentList[0].OrgName
|
|
}
|
|
if orgName == "" {
|
|
if org, err := h.orgService.GetOrganization(ctx, orgID); err == nil && org != nil {
|
|
orgName = org.Name
|
|
}
|
|
}
|
|
|
|
merged := make([]*types.OrganizationSharedKnowledgeBaseItem, 0, len(directList)+64)
|
|
merged = append(merged, directList...)
|
|
|
|
for _, agentItem := range agentList {
|
|
if agentItem.Agent == nil {
|
|
continue
|
|
}
|
|
agent := agentItem.Agent
|
|
mode := agent.Config.KBSelectionMode
|
|
if mode == "none" {
|
|
continue
|
|
}
|
|
|
|
var kbIDs []string
|
|
switch mode {
|
|
case "selected":
|
|
if len(agent.Config.KnowledgeBases) == 0 {
|
|
continue
|
|
}
|
|
kbIDs = agent.Config.KnowledgeBases
|
|
case "all":
|
|
kbs, err := h.kbService.ListKnowledgeBasesByTenantID(ctx, agent.TenantID)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "ListKnowledgeBasesByTenantID for agent %s: %v", agent.ID, err)
|
|
continue
|
|
}
|
|
kbIDs = make([]string, 0, len(kbs))
|
|
for _, kb := range kbs {
|
|
if kb != nil && kb.ID != "" {
|
|
kbIDs = append(kbIDs, kb.ID)
|
|
}
|
|
}
|
|
default:
|
|
if len(agent.Config.KnowledgeBases) > 0 {
|
|
kbIDs = agent.Config.KnowledgeBases
|
|
}
|
|
}
|
|
|
|
agentName := agent.Name
|
|
if agentName == "" {
|
|
agentName = agent.ID
|
|
}
|
|
sourceTenantID := agent.TenantID
|
|
|
|
for _, kbID := range kbIDs {
|
|
if kbID == "" || directKbIDs[kbID] {
|
|
continue
|
|
}
|
|
kb, err := h.kbService.GetKnowledgeBaseByIDOnly(ctx, kbID)
|
|
if err != nil || kb == nil {
|
|
continue
|
|
}
|
|
if kb.TenantID != sourceTenantID {
|
|
continue
|
|
}
|
|
directKbIDs[kbID] = true
|
|
|
|
switch kb.Type {
|
|
case types.KnowledgeBaseTypeDocument:
|
|
if count, err := h.knowledgeRepo.CountKnowledgeByKnowledgeBaseID(ctx, sourceTenantID, kb.ID); err == nil {
|
|
kb.KnowledgeCount = count
|
|
}
|
|
case types.KnowledgeBaseTypeFAQ:
|
|
if count, err := h.chunkRepo.CountChunksByKnowledgeBaseID(ctx, sourceTenantID, kb.ID); err == nil {
|
|
kb.ChunkCount = count
|
|
}
|
|
}
|
|
|
|
merged = append(merged, &types.OrganizationSharedKnowledgeBaseItem{
|
|
SharedKnowledgeBaseInfo: types.SharedKnowledgeBaseInfo{
|
|
KnowledgeBase: kb,
|
|
ShareID: "",
|
|
OrganizationID: orgID,
|
|
OrgName: orgName,
|
|
Permission: types.OrgRoleViewer,
|
|
SourceTenantID: sourceTenantID,
|
|
SharedAt: agentItem.SharedAt,
|
|
},
|
|
// 即便 KB 是「被共享智能体捎带进来」的,只要它属于当前空间
|
|
// 就应该归到「我共享的」分组——否则用户会在共享空间里看到
|
|
// 自己的 KB 出现在「共享给我·仅查看」组里,非常迷惑。
|
|
IsMine: sourceTenantID == tenantID,
|
|
SourceFromAgent: &types.SourceFromAgentInfo{
|
|
AgentID: agent.ID,
|
|
AgentName: agentName,
|
|
KBSelectionMode: agent.Config.KBSelectionMode,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return merged, nil
|
|
}
|
|
|
|
// ListOrganizationSharedKnowledgeBases lists all knowledge bases in the given organization (including those shared by the current tenant and those from shared agents), for the list page when a space is selected.
|
|
// @Summary 获取空间内全部知识库(含我共享的、含智能体携带的)
|
|
// @Description 获取指定空间下所有共享知识库,包含直接共享的与通过共享智能体可见的,用于列表页空间视角
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/shared-knowledge-bases [get]
|
|
func (h *OrganizationHandler) ListOrganizationSharedKnowledgeBases(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
|
|
list, err := h.listSpaceKnowledgeBasesInOrganization(ctx, orgID, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrTenantNotInOrg) {
|
|
c.Error(apperrors.NewForbiddenError("Your workspace is not a member of this organization"))
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Failed to list organization shared knowledge bases: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shared knowledge bases"))
|
|
return
|
|
}
|
|
|
|
// Project each row through sharedKBRow so cross-tenant strip applies
|
|
// uniformly across the space view as well. is_mine and the optional
|
|
// source_from_agent payload are passed through as extras so the
|
|
// frontend can keep its current rendering branches. Rows where
|
|
// is_mine is true are still strip-projected here — callers see the
|
|
// rich view of their own bindings on the regular KB list / detail
|
|
// endpoints, so dropping the owner-side enrichment from the space
|
|
// view trades a small UI nicety for a strictly simpler invariant
|
|
// ("share endpoints never leak vector-store metadata").
|
|
rows := make([]map[string]interface{}, 0, len(list))
|
|
for _, item := range list {
|
|
extras := map[string]interface{}{"is_mine": item.IsMine}
|
|
if item.SourceFromAgent != nil {
|
|
extras["source_from_agent"] = item.SourceFromAgent
|
|
}
|
|
rows = append(rows, sharedKBRow(&item.SharedKnowledgeBaseInfo, extras))
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": rows, "total": len(rows)})
|
|
}
|
|
|
|
// ListOrganizationSharedAgents lists all agents in the given organization (including those shared by the current tenant), for the list page when a space is selected.
|
|
// @Summary 获取空间内全部智能体(含我共享的)
|
|
// @Description 获取指定空间下所有共享智能体,包含他人共享的与我共享的,用于列表页空间视角
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/shared-agents [get]
|
|
func (h *OrganizationHandler) ListOrganizationSharedAgents(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
orgID := c.Param("id")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
callerTenantRole := types.TenantRoleFromContext(ctx)
|
|
|
|
list, err := h.agentShareService.ListSharedAgentsInOrganization(ctx, orgID, tenantID, callerTenantRole)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrTenantNotInOrg) {
|
|
c.Error(apperrors.NewForbiddenError("Your workspace is not a member of this organization"))
|
|
return
|
|
}
|
|
logger.Errorf(ctx, "Failed to list organization shared agents: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to list shared agents"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true, "data": list, "total": len(list)})
|
|
}
|
|
|
|
// SetSharedAgentDisabledByMeRequest is the body for POST /shared-agents/disabled
|
|
type SetSharedAgentDisabledByMeRequest struct {
|
|
AgentID string `json:"agent_id" binding:"required"`
|
|
Disabled bool `json:"disabled"`
|
|
}
|
|
|
|
// SetSharedAgentDisabledByMe sets whether the current tenant has disabled this shared agent for their conversation dropdown
|
|
func (h *OrganizationHandler) SetSharedAgentDisabledByMe(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
uid := userID
|
|
tid := tenantID
|
|
|
|
var req SetSharedAgentDisabledByMeRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewBadRequestError("Invalid request").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
// Derive sourceTenantID: own agent (current tenant) or from shared list
|
|
var sourceTenantID uint64
|
|
agent, err := h.customAgentService.GetAgentByID(ctx, req.AgentID)
|
|
if err == nil && agent != nil && agent.TenantID == tid {
|
|
sourceTenantID = tid
|
|
} else {
|
|
share, err := h.agentShareService.GetShareByAgentIDForTenant(ctx, tid, req.AgentID, tid)
|
|
if err != nil || share == nil {
|
|
c.Error(apperrors.NewForbiddenError("No access to this agent"))
|
|
return
|
|
}
|
|
sourceTenantID = share.SourceTenantID
|
|
}
|
|
_ = uid
|
|
if err := h.agentShareService.SetSharedAgentDisabledByMe(ctx, tid, req.AgentID, sourceTenantID, req.Disabled); err != nil {
|
|
logger.Errorf(ctx, "SetSharedAgentDisabledByMe failed: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to update preference"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// toOrgResponse converts an organization to response format
|
|
func (h *OrganizationHandler) toOrgResponse(ctx context.Context, org *types.Organization, currentUserID string) types.OrganizationResponse {
|
|
currentTenantID := types.MustTenantIDFromContext(ctx)
|
|
// Post-Plan-3 the canonical "is the caller the owner side?" check
|
|
// is tenant-based: org.OwnerTenantID is the pinned column; legacy
|
|
// rows with OwnerTenantID == 0 (pre-000046, unlikely in prod)
|
|
// fall back to the user-id check so we don't show the wrong tenant
|
|
// as "owner" in those edge cases.
|
|
isOwner := false
|
|
if org.OwnerTenantID != 0 {
|
|
isOwner = org.OwnerTenantID == currentTenantID
|
|
} else {
|
|
isOwner = org.OwnerID == currentUserID
|
|
}
|
|
resp := types.OrganizationResponse{
|
|
ID: org.ID,
|
|
Name: org.Name,
|
|
Description: org.Description,
|
|
Avatar: org.Avatar,
|
|
OwnerID: org.OwnerID,
|
|
OwnerTenantID: org.OwnerTenantID,
|
|
IsOwner: isOwner,
|
|
RequireApproval: org.RequireApproval,
|
|
Searchable: org.Searchable,
|
|
MemberLimit: org.MemberLimit,
|
|
InviteCodeValidityDays: org.InviteCodeValidityDays,
|
|
CreatedAt: org.CreatedAt,
|
|
UpdatedAt: org.UpdatedAt,
|
|
}
|
|
|
|
// Get member count (per-tenant)
|
|
if members, err := h.orgService.ListTenantMembers(ctx, org.ID); err == nil {
|
|
resp.MemberCount = len(members)
|
|
}
|
|
|
|
// Get shared knowledge base count for this organization
|
|
if shares, err := h.shareService.ListSharesByOrganization(ctx, org.ID); err == nil {
|
|
resp.ShareCount = len(shares)
|
|
}
|
|
// Get shared agent count for this organization
|
|
if agentShares, err := h.agentShareService.ListSharesByOrganization(ctx, org.ID); err == nil {
|
|
resp.AgentShareCount = len(agentShares)
|
|
}
|
|
|
|
// Get current tenant's role in this organization
|
|
isAdmin := false
|
|
if role, err := h.orgService.GetTenantRoleInOrg(ctx, org.ID, currentTenantID); err == nil {
|
|
resp.MyRole = string(role)
|
|
isAdmin = (role == types.OrgRoleAdmin)
|
|
}
|
|
// Invite-code / pending-request visibility is keyed on whether the
|
|
// caller can administer the org. Post-Plan-3 that's "isAdmin in the
|
|
// caller's tenant context, OR the caller's tenant is the owner
|
|
// tenant"; we already computed isOwner with the tenant-first logic
|
|
// above, so reuse it instead of comparing user IDs again.
|
|
if isAdmin || isOwner {
|
|
resp.InviteCode = org.InviteCode
|
|
resp.InviteCodeExpiresAt = org.InviteCodeExpiresAt
|
|
if n, err := h.orgService.CountPendingJoinRequests(ctx, org.ID); err == nil {
|
|
resp.PendingJoinRequestCount = int(n)
|
|
}
|
|
}
|
|
|
|
// Check if current tenant has pending upgrade request
|
|
if _, err := h.orgService.GetPendingUpgradeRequest(ctx, org.ID, currentTenantID); err == nil {
|
|
resp.HasPendingUpgrade = true
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// SearchTenantsForInvite searches candidate tenants for inviting to organization.
|
|
//
|
|
// Plan 3 (#1303) makes the tenant the unit of membership. Because a single user
|
|
// may belong to multiple workspaces, matching by username/email is ambiguous
|
|
// (which workspace did the admin mean?), so this endpoint matches strictly by
|
|
// tenant (workspace) name: it resolves matching tenants, filters out tenants
|
|
// already in the org, and returns one row per candidate tenant.
|
|
//
|
|
// @Summary 搜索可邀请的空间
|
|
// @Description 按空间名搜索可邀请的空间(排除已加入的空间)用于邀请加入组织;按空间去重
|
|
// @Tags 组织管理
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param q query string true "搜索关键词(空间名)"
|
|
// @Param limit query int false "返回数量限制" default(10)
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/search-tenants [get]
|
|
func (h *OrganizationHandler) SearchTenantsForInvite(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
query := c.Query("q")
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check admin permission: caller's tenant must be org admin.
|
|
isAdmin, err := h.orgService.IsTenantOrgAdmin(ctx, orgID, tenantID)
|
|
if err != nil || !isAdmin {
|
|
c.Error(apperrors.NewForbiddenError("Only organization admins can invite members"))
|
|
return
|
|
}
|
|
|
|
if query == "" {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": []types.TenantInviteCandidate{},
|
|
})
|
|
return
|
|
}
|
|
|
|
limit := 10
|
|
if l := c.Query("limit"); l != "" {
|
|
if n, errConv := strconv.Atoi(l); errConv == nil && n > 0 && n <= 50 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
// Exclude tenants already in the org.
|
|
existingMembers, _ := h.orgService.ListTenantMembers(ctx, orgID)
|
|
existingTenantIDs := make(map[uint64]bool, len(existingMembers))
|
|
for _, m := range existingMembers {
|
|
existingTenantIDs[m.TenantID] = true
|
|
}
|
|
|
|
// Match tenants by name only. A single user may belong to multiple
|
|
// workspaces, so resolving a query to "the user's tenant" is ambiguous;
|
|
// the membership unit is the tenant, so we invite by workspace name.
|
|
// SearchTenants uses page/pageSize; pageSize=limit*2 is a safe ceiling
|
|
// given the soft cap of 50 above.
|
|
tenantsByName, _, err := h.tenantService.SearchTenants(ctx, query, 0, 1, limit*2)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to search tenants: %v", err)
|
|
c.Error(apperrors.NewInternalServerError("Failed to search candidates"))
|
|
return
|
|
}
|
|
|
|
// Insertion-ordered map: first match wins, preserving search ordering.
|
|
type entry struct {
|
|
idx int // preserve search ordering
|
|
candidate types.TenantInviteCandidate
|
|
}
|
|
seen := make(map[uint64]*entry)
|
|
addTenantByID := func(tid uint64) {
|
|
if tid == 0 || existingTenantIDs[tid] {
|
|
return
|
|
}
|
|
if _, ok := seen[tid]; ok {
|
|
return
|
|
}
|
|
seen[tid] = &entry{
|
|
idx: len(seen),
|
|
candidate: types.TenantInviteCandidate{
|
|
TenantID: tid,
|
|
},
|
|
}
|
|
}
|
|
for _, t := range tenantsByName {
|
|
if t == nil {
|
|
continue
|
|
}
|
|
addTenantByID(t.ID)
|
|
}
|
|
|
|
// Resolve tenant names for all candidates in one round-trip.
|
|
ids := make([]uint64, 0, len(seen))
|
|
for tid := range seen {
|
|
ids = append(ids, tid)
|
|
}
|
|
tenantByID, _ := h.tenantService.GetTenantsByIDs(ctx, ids)
|
|
for tid, e := range seen {
|
|
if t, ok := tenantByID[tid]; ok && t != nil {
|
|
e.candidate.TenantName = t.Name
|
|
}
|
|
}
|
|
|
|
// Restore insertion order (idx is unique in [0, len(seen))).
|
|
byIdx := make([]types.TenantInviteCandidate, len(seen))
|
|
for _, e := range seen {
|
|
byIdx[e.idx] = e.candidate
|
|
}
|
|
// Drop tenants we couldn't resolve a name for (defunct rows or
|
|
// deleted tenants) and cap at `limit`.
|
|
sorted := make([]types.TenantInviteCandidate, 0, limit)
|
|
for _, c := range byIdx {
|
|
if c.TenantName == "" {
|
|
continue
|
|
}
|
|
sorted = append(sorted, c)
|
|
if len(sorted) >= limit {
|
|
break
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": sorted,
|
|
})
|
|
}
|
|
|
|
// SearchUsersForInvite is retained as a thin compatibility shim that
|
|
// delegates to SearchTenantsForInvite, so older frontends still get
|
|
// tenant-grouped results without breaking the call site. The response
|
|
// shape here is (intentionally) the new tenant-candidate shape; the
|
|
// previous shape returned one row per matching user, which leaked the
|
|
// pre-Plan-3 mental model.
|
|
//
|
|
// @Deprecated Use SearchTenantsForInvite. Kept for one release.
|
|
// @Router /organizations/{id}/search-users [get]
|
|
func (h *OrganizationHandler) SearchUsersForInvite(c *gin.Context) {
|
|
h.SearchTenantsForInvite(c)
|
|
}
|
|
|
|
// InviteMember directly adds a user to organization
|
|
// @Summary 邀请成员
|
|
// @Description 管理员直接添加用户为组织成员
|
|
// @Tags 组织管理
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "组织ID"
|
|
// @Param request body types.InviteMemberRequest true "邀请信息"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Failure 400 {object} apperrors.AppError
|
|
// @Failure 403 {object} apperrors.AppError
|
|
// @Security Bearer
|
|
// @Router /organizations/{id}/invite [post]
|
|
func (h *OrganizationHandler) InviteMember(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
|
|
orgID := c.Param("id")
|
|
userID := c.GetString(types.UserIDContextKey.String())
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
|
|
// Check admin permission: caller's tenant must be org admin
|
|
isAdmin, err := h.orgService.IsTenantOrgAdmin(ctx, orgID, tenantID)
|
|
if err != nil || !isAdmin {
|
|
c.Error(apperrors.NewForbiddenError("Only organization admins can invite members"))
|
|
return
|
|
}
|
|
|
|
var req types.InviteMemberRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.Error(apperrors.NewValidationError("Invalid request parameters").WithDetails(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Validate role
|
|
if !req.Role.IsValid() {
|
|
c.Error(apperrors.NewValidationError("Invalid role; must be viewer, editor, or admin"))
|
|
return
|
|
}
|
|
|
|
// Plan 3: resolve the (target tenant, representative user) pair.
|
|
//
|
|
// - Preferred: caller supplies tenant_id directly (and optionally
|
|
// representative_user_id) — this matches the tenant-centric mental
|
|
// model and lets admins invite any user as the rep.
|
|
// - Legacy: caller supplies only user_id — handler looks up that
|
|
// user's tenant and uses the same user as the rep, preserving the
|
|
// pre-Plan-3 SDK contract.
|
|
targetTenantID := req.TenantID
|
|
representativeUserID := req.RepresentativeUserID
|
|
switch {
|
|
case targetTenantID != 0:
|
|
// Tenant-id path: validate the tenant exists; pick a sensible
|
|
// representative when the caller didn't pin one.
|
|
if _, err := h.tenantService.GetTenantByID(ctx, targetTenantID); err != nil {
|
|
c.Error(apperrors.NewNotFoundError("Workspace not found"))
|
|
return
|
|
}
|
|
if representativeUserID == "" {
|
|
// Fall back to the legacy user_id field if it was sent, so
|
|
// existing clients that learned to send both keep working.
|
|
representativeUserID = req.UserID
|
|
}
|
|
if representativeUserID != "" {
|
|
// If a representative is named, sanity-check it belongs to
|
|
// the target tenant. We don't hard-fail when it doesn't —
|
|
// the membership row is keyed by tenant_id, the rep field
|
|
// is informational — but we strip the inconsistent value
|
|
// so the audit log doesn't lie.
|
|
if u, err := h.userService.GetUserByID(ctx, representativeUserID); err != nil || u == nil || u.TenantID != targetTenantID {
|
|
logger.Warnf(ctx, "representative_user_id %s does not belong to tenant %d; dropping",
|
|
secutils.SanitizeForLog(representativeUserID), targetTenantID)
|
|
representativeUserID = ""
|
|
}
|
|
}
|
|
case req.UserID != "":
|
|
// Legacy path: resolve target tenant from the user.
|
|
invitedUser, err := h.userService.GetUserByID(ctx, req.UserID)
|
|
if err != nil {
|
|
c.Error(apperrors.NewNotFoundError("User not found"))
|
|
return
|
|
}
|
|
targetTenantID = invitedUser.TenantID
|
|
if representativeUserID == "" {
|
|
representativeUserID = req.UserID
|
|
}
|
|
default:
|
|
c.Error(apperrors.NewValidationError("Either tenant_id or user_id is required"))
|
|
return
|
|
}
|
|
|
|
// Check if target tenant is already a member of this org.
|
|
if _, memberErr := h.orgService.GetTenantMember(ctx, orgID, targetTenantID); memberErr == nil {
|
|
c.Error(apperrors.NewValidationError("Workspace is already a member of this organization"))
|
|
return
|
|
}
|
|
|
|
// Add tenant member with the chosen representative.
|
|
if err := h.orgService.AddTenantMember(ctx, orgID, targetTenantID, representativeUserID, req.Role); err != nil {
|
|
logger.Errorf(ctx, "Failed to add member: %v", err)
|
|
if errors.Is(err, service.ErrOrgMemberLimitReached) {
|
|
c.Error(apperrors.NewValidationError("该空间成员已满,无法添加新成员"))
|
|
return
|
|
}
|
|
c.Error(apperrors.NewInternalServerError("Failed to add member"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "User %s invited tenant %d (rep user %s) to organization %s with role %s",
|
|
secutils.SanitizeForLog(userID),
|
|
targetTenantID,
|
|
secutils.SanitizeForLog(representativeUserID),
|
|
orgID,
|
|
req.Role)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"message": "Member added successfully",
|
|
})
|
|
}
|