feat: add trusted customer service friendships
Deploy im-server production / deploy (push) Successful in 11m38s

This commit is contained in:
richjohnrambo
2026-10-20 22:24:34 +08:00
parent 93a6ff0d03
commit 39b0392545
4 changed files with 102 additions and 0 deletions
+6
View File
@@ -147,6 +147,7 @@ func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID
type testRelationsStore struct {
context *types.RelationshipContext
err error
directAdded bool
created *types.FriendRequest
blocked *types.UserBlock
deleted bool
@@ -964,6 +965,11 @@ func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.Fr
return req, nil
}
func (s *testRelationsStore) AddFriendDirect(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
s.directAdded = true
return s.err
}
func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) {
return s.friendRequests, "", nil
}
+30
View File
@@ -116,6 +116,8 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
serveInternalUserCreate(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 5 && parts[2] == "users" && parts[4] == "action":
serveInternalUserAction(wrt, req, tenant.ID, parts[3])
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct":
serveInternalFriendDirectAdd(wrt, req, tenant.ID)
case req.Method == http.MethodPost && len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release":
serveInternalLoginLockoutRelease(wrt, req, tenant.ID)
case req.Method == http.MethodGet && len(parts) == 3 && parts[2] == "users":
@@ -166,9 +168,37 @@ func serveInternalAdminQuery(wrt http.ResponseWriter, req *http.Request, prefix
func isInternalAdminPostRoute(parts []string) bool {
return (len(parts) == 3 && parts[2] == "users") ||
(len(parts) == 5 && parts[2] == "users" && parts[4] == "action") ||
(len(parts) == 4 && parts[2] == "friends" && parts[3] == "direct") ||
(len(parts) == 4 && parts[2] == "login-lockouts" && parts[3] == "release")
}
type internalDirectFriendRequest struct {
UserID string `json:"user_id"`
PeerID string `json:"peer_user_id"`
}
func serveInternalFriendDirectAdd(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID) {
var body internalDirectFriendRequest
if err := json.NewDecoder(io.LimitReader(req.Body, 1<<20)).Decode(&body); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
userID := types.ParseUserId(strings.TrimSpace(body.UserID))
peerID := types.ParseUserId(strings.TrimSpace(body.PeerID))
if userID.IsZero() || peerID.IsZero() || userID == peerID {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed user IDs", nil)
return
}
if err := store.Relations.AddFriendDirect(tenantID, userID, peerID, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{
"relationship": "friend",
"topic": userID.P2PName(peerID),
})
}
type internalUserCreateRequest struct {
Username string `json:"username"`
Nickname string `json:"nickname"`
+30
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -37,6 +38,35 @@ func (internalAdminQueryNotFound) QueryOne(types.TenantID, string, []any) (map[s
return nil, types.ErrNotFound
}
func TestServeInternalFriendDirectAddCreatesRelationshipWithoutRequest(t *testing.T) {
oldRelations := store.Relations
relations := &testRelationsStore{}
store.Relations = relations
t.Cleanup(func() { store.Relations = oldRelations })
req := httptest.NewRequest(http.MethodPost, "/internal/admin-query/tenants/default/friends/direct", strings.NewReader(`{"user_id":"usrAQAAAAAAAAA","peer_user_id":"usrAgAAAAAAAAA"}`))
wrt := httptest.NewRecorder()
serveInternalFriendDirectAdd(wrt, req, types.TenantID(1))
if wrt.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", wrt.Code, http.StatusOK, wrt.Body.String())
}
if !relations.directAdded || relations.created != nil {
t.Fatalf("direct_added=%v friend_request=%v, want direct add and no request", relations.directAdded, relations.created)
}
var payload struct {
Data struct {
Relationship string `json:"relationship"`
} `json:"data"`
}
if err := json.Unmarshal(wrt.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.Data.Relationship != types.RelationshipFriend {
t.Fatalf("relationship = %q, want %q", payload.Data.Relationship, types.RelationshipFriend)
}
}
func TestServeInternalUserQueryMissingUserReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+36
View File
@@ -3065,6 +3065,7 @@ func validUserProfilePatch(patch types.UserProfilePatch) bool {
}
type RelationsPersistenceInterface interface {
AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error
CreateFriendRequest(tenantID types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error)
ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error)
CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error)
@@ -3153,6 +3154,41 @@ func (relationsMapper) CreateFriendRequest(tenantID types.TenantID, req *types.F
return tenantAdp.TenantFriendRequestCreate(tenantID, req)
}
// AddFriendDirect creates a symmetric friendship for trusted server-side
// workflows, such as opening a customer-service conversation. It deliberately
// does not create a friend-request record, but it still honors either user's
// block list.
func (relationsMapper) AddFriendDirect(tenantID types.TenantID, userID, peerID types.Uid, now time.Time) error {
if userID.IsZero() || peerID.IsZero() || userID == peerID {
return types.ErrMalformed
}
if _, err := Users.Get(tenantID, userID); err != nil {
return err
}
if _, err := Users.Get(tenantID, peerID); err != nil {
return err
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
relationship, err := relationshipContext(tenantAdp, tenantID, userID, peerID, "")
if err != nil {
return err
}
if relationship.Relationship == types.RelationshipBlockedByMe || relationship.Relationship == types.RelationshipBlockedByPeer {
return types.ErrPermissionDenied
}
if now.IsZero() {
now = time.Now().UTC()
}
if err = tenantAdp.TenantFriendshipUpsertPair(tenantID, userID, peerID, userID.P2PName(peerID), now); err != nil {
return err
}
cacheFriendPair(tenantID, userID, peerID)
return nil
}
func (relationsMapper) ListFriendRequests(tenantID types.TenantID, uid types.Uid, direction, status, cursor string, limit int) ([]types.FriendRequestListItem, string, error) {
if uid.IsZero() {
return nil, "", types.ErrMalformed