mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 04:18:07 +00:00
- user_search: return tenant_user UUID in new "uuid" field so callers
forwarding a tenant_user foreign key receive the primary key instead
of the user_id string (root cause of HTTP 400 "invalid JSON" on
/v1/contacts/merge when linking to an existing tenant user)
- use-user-picker: add opt-in valueMode prop ("user_id" default | "uuid")
so only merge-contacts-dialog commits UUID; other pickers (tenant
add-user, MCP/CLI credentials) keep returning user_id string
- pg.MigrateUserDataOnMerge: split tClauseIns/tClauseDel with correct
placeholder offsets (N+2 for INSERT/UPDATE, N+1 for DELETE) — reusing
one clause caused "no parameter \$N+2" errors that silently skipped
every cleanup DELETE, leaving orphan per-user rows after a merge
122 lines
3.6 KiB
Go
122 lines
3.6 KiB
Go
package http
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// UserSearchResult is a unified result from contacts + tenant_users.
|
|
// ID is the user_id string (human-facing identifier). UUID is the tenant_user
|
|
// primary key (only populated when Source == "tenant_user"); callers that need
|
|
// to reference a tenant_user by foreign key (e.g. contact merge) must use UUID.
|
|
type UserSearchResult struct {
|
|
ID string `json:"id"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
DisplayName *string `json:"display_name,omitempty"`
|
|
Username *string `json:"username,omitempty"`
|
|
Source string `json:"source"` // "contact" or "tenant_user"
|
|
ChannelType *string `json:"channel_type,omitempty"`
|
|
PeerKind *string `json:"peer_kind,omitempty"`
|
|
MergedTenantUserID *string `json:"merged_tenant_user_id,omitempty"`
|
|
Role *string `json:"role,omitempty"`
|
|
}
|
|
|
|
// handleSearchUsers returns unified results from channel_contacts + tenant_users.
|
|
// GET /v1/users/search?q=&limit=30&peer_kind=
|
|
// Empty q → return most recent. With q → ILIKE search across both tables.
|
|
func (h *ChannelInstancesHandler) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query().Get("q")
|
|
peerKind := r.URL.Query().Get("peer_kind")
|
|
source := r.URL.Query().Get("source") // "contact", "tenant_user", or "" (both)
|
|
limit := 30
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tid := store.TenantIDFromContext(ctx)
|
|
var results []UserSearchResult
|
|
mergedUserIDs := make(map[string]bool) // for deduplication between contacts and tenant_users
|
|
|
|
// 1. Search channel_contacts (skip if source=tenant_user)
|
|
if h.contactStore != nil && source != "tenant_user" {
|
|
opts := store.ContactListOpts{
|
|
Search: q,
|
|
PeerKind: peerKind,
|
|
Limit: limit,
|
|
}
|
|
contacts, err := h.contactStore.ListContacts(ctx, opts)
|
|
if err != nil {
|
|
slog.Warn("user_search.contacts", "error", err)
|
|
}
|
|
for _, c := range contacts {
|
|
r := UserSearchResult{
|
|
ID: c.SenderID,
|
|
DisplayName: c.DisplayName,
|
|
Username: c.Username,
|
|
Source: "contact",
|
|
ChannelType: &c.ChannelType,
|
|
PeerKind: c.PeerKind,
|
|
}
|
|
if c.MergedID != nil {
|
|
if resolved, err := h.contactStore.ResolveTenantUserID(ctx, c.ChannelType, c.SenderID); err == nil && resolved != "" {
|
|
r.MergedTenantUserID = &resolved
|
|
mergedUserIDs[resolved] = true
|
|
}
|
|
}
|
|
results = append(results, r)
|
|
}
|
|
}
|
|
|
|
// 2. Search tenant_users (skip if source=contact)
|
|
if h.tenantStore != nil && tid != uuid.Nil && source != "contact" {
|
|
users, err := h.tenantStore.ListUsers(ctx, tid)
|
|
if err != nil {
|
|
slog.Warn("user_search.tenant_users", "error", err)
|
|
}
|
|
for _, u := range users {
|
|
if mergedUserIDs[u.UserID] {
|
|
continue
|
|
}
|
|
if q != "" && !containsInsensitive(u.UserID, q) && !containsInsensitive(ptrStr(u.DisplayName), q) {
|
|
continue
|
|
}
|
|
if len(results) >= limit {
|
|
break
|
|
}
|
|
role := u.Role
|
|
results = append(results, UserSearchResult{
|
|
ID: u.UserID,
|
|
UUID: u.ID.String(),
|
|
DisplayName: u.DisplayName,
|
|
Source: "tenant_user",
|
|
Role: &role,
|
|
})
|
|
}
|
|
}
|
|
|
|
if results == nil {
|
|
results = []UserSearchResult{}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
|
}
|
|
|
|
func ptrStr(s *string) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
return *s
|
|
}
|
|
|
|
func containsInsensitive(s, substr string) bool {
|
|
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
|
}
|