Files
viettranx 3c39b7d2a0 fix(contacts): merge contact into existing tenant user
- 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
2026-04-17 19:07:01 +07:00

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