diff --git a/server/admin_internal_config_test.go b/server/admin_internal_config_test.go index 3748ae0..95681a2 100644 --- a/server/admin_internal_config_test.go +++ b/server/admin_internal_config_test.go @@ -38,7 +38,7 @@ func TestAdminInternalClientLatestAppRelease(t *testing.T) { config: adminInternalAPIConfig{BaseURL: server.URL, ServiceName: "im-server", Secret: "test-secret"}, http: server.Client(), } - release, err := client.LatestAppRelease(" IOS ", "STABLE") + release, err := client.LatestAppRelease(" IM ", " IOS ", "STABLE") if err != nil { t.Fatal(err) } diff --git a/server/db/adapter.go b/server/db/adapter.go index 6c6bab0..2650568 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -405,6 +405,7 @@ type TenantBusinessAdapter interface { TenantGroupMemberUpsert(t.TenantID, *t.GroupMember) (*t.GroupMember, error) TenantGroupMemberGet(t.TenantID, string, t.Uid) (*t.GroupMember, error) TenantGroupMemberList(t.TenantID, string, t.GroupMemberListOptions) ([]t.GroupMember, string, error) + TenantGroupMembersCount(t.TenantID, []string) (map[string]int64, error) TenantGroupMemberUpdate(t.TenantID, string, t.Uid, t.GroupMemberPatch) (*t.GroupMember, error) TenantUsersShareOpenPrivateGroup(t.TenantID, t.Uid, t.Uid) (bool, error) TenantGroupInvitationCreate(t.TenantID, *t.GroupInvitation) (*t.GroupInvitation, error) diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 5e55849..c26d362 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -394,6 +394,50 @@ func (a *adapter) TenantGroupMemberList(id t.TenantID, topic string, opts t.Grou return items, next, rows.Err() } +// TenantGroupMembersCount counts active members for the given topics using the +// same visibility rules as TenantGroupMemberList: membership active, subscription +// not deleted, user not deleted. Returns counts keyed by topic; topics with no +// active members are absent from the map. +func (a *adapter) TenantGroupMembersCount(id t.TenantID, topics []string) (map[string]int64, error) { + if tenantAdp, err := a.routeGroupTenant(id); err != nil { + return nil, err + } else if tenantAdp != a { + return tenantAdp.TenantGroupMembersCount(id, topics) + } + if len(topics) == 0 { + return map[string]int64{}, nil + } + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(topics)), ",") + args := make([]any, 0, len(topics)+3) + args = append(args, t.StateDeleted, id, t.GroupMemberStatusActive) + for _, topic := range topics { + args = append(args, topic) + } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + rows, err := a.db.QueryContext(ctx, tenantShardSQL(id, `SELECT gm.topic, COUNT(DISTINCT gm.user_id) FROM im_group_members AS gm + JOIN im_subscriptions AS s ON s.tenant_id=gm.tenant_id AND s.topic=gm.topic AND s.userid=gm.user_id AND s.deletedat IS NULL + JOIN im_users AS u ON u.tenant_id=gm.tenant_id AND u.id=gm.user_id AND u.state!=? + WHERE gm.tenant_id=? AND gm.member_status=? AND gm.topic IN (`+placeholders+`) + GROUP BY gm.topic`), args...) + if err != nil { + return nil, err + } + defer rows.Close() + counts := make(map[string]int64, len(topics)) + for rows.Next() { + var topic string + var count int64 + if err = rows.Scan(&topic, &count); err != nil { + return nil, err + } + counts[topic] = count + } + return counts, rows.Err() +} + func (a *adapter) TenantUsersShareOpenPrivateGroup(id t.TenantID, userID, peerID t.Uid) (bool, error) { if tenantAdp, err := a.routeGroupTenant(id); err != nil { return false, err diff --git a/server/im_http.go b/server/im_http.go index c4eb21e..d310f09 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -4217,6 +4217,25 @@ func serveIMGroupList(wrt http.ResponseWriter, req *http.Request) { items = append(items, item) seen++ } + if len(items) > 0 { + listTopics := make([]string, 0, len(items)) + for _, item := range items { + if topic, ok := item["topic"].(string); ok && topic != "" { + listTopics = append(listTopics, topic) + } + } + if counts, err := store.GroupBusiness.MembersCount(principal.TenantID, listTopics); err == nil { + for _, item := range items { + if topic, ok := item["topic"].(string); ok { + if count, ok := counts[topic]; ok { + item["member_count"] = count + } + } + } + } else { + logs.Warn.Printf("serveIMGroupList: failed to count members for %d topics: %v", len(listTopics), err) + } + } writeIMSuccess(wrt, req, map[string]any{"items": items, "next_cursor": nextCursor, "has_more": nextCursor != ""}) } @@ -4310,7 +4329,7 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) { writeIMRelationError(wrt, req, err) return } - writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub)) + writeIMSuccess(wrt, req, groupDetailPayload(created, selfSub, groupMemberCountFor(principal.TenantID, topicName))) } func serveIMGroupResource(wrt http.ResponseWriter, req *http.Request) { @@ -4607,7 +4626,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil) return } - payload := groupDetailPayload(group, selfSub) + payload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic)) if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { if settings != nil && settings.GroupStatus == types.GroupStatusDissolved { writeIMError(wrt, req, http.StatusGone, 30004, imReasonGroupDissolved, "group dissolved", map[string]any{"topic": topic}) @@ -4703,7 +4722,7 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - payload := groupDetailPayload(updated, selfSub) + payload := groupDetailPayload(updated, selfSub, groupMemberCountFor(principal.TenantID, topic)) if settingsUpdated { if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) @@ -4769,7 +4788,7 @@ func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic stri writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - payload := groupSettingsPayload(group, selfSub) + payload := groupSettingsPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic)) if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil { payload = mergeGroupSettingsPayload(payload, settings) payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub) @@ -4946,7 +4965,7 @@ func serveIMGroupQRCodes(wrt http.ResponseWriter, req *http.Request, topic strin writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil) return } - groupPayload := groupDetailPayload(group, selfSub) + groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, topic)) writeIMSuccess(wrt, req, map[string]any{ "qr_token": token, "qr_payload": "im://qr?token=" + url.QueryEscape(token) + "&purpose=join_group", @@ -6088,7 +6107,7 @@ func serveIMQRCodesResolve(wrt http.ResponseWriter, req *http.Request) { if selfSub != nil && (selfSub.ModeGiven & selfSub.ModeWant).IsJoiner() { status = "joined" } - groupPayload := groupDetailPayload(group, selfSub) + groupPayload := groupDetailPayload(group, selfSub, groupMemberCountFor(principal.TenantID, claims.Topic)) writeIMSuccess(wrt, req, map[string]any{ "purpose": claims.Purpose, "expires_at": time.Unix(claims.Expires, 0).UTC().Format(time.RFC3339Nano), @@ -7505,15 +7524,27 @@ func mergeGroupListSettingsPayload(payload map[string]any, settings *types.Group return payload } -func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { +// groupMemberCountFor returns the live member count for a single group topic, +// mirroring the visibility rules of the group members list endpoint. +func groupMemberCountFor(tenantID types.TenantID, topic string) int64 { + if topic == "" { + return 0 + } + counts, err := store.GroupBusiness.MembersCount(tenantID, []string{topic}) + if err != nil { + logs.Warn.Printf("group[%s]: failed to count members: %v", topic, err) + return 0 + } + return counts[topic] +} + +func groupDetailPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any { public := map[string]any{} topic := "" - memberCount := 0 ownerID := "" if group != nil { public = groupPublicMap(group.Public) topic = group.Id - memberCount = group.SubCnt if owner := types.ParseUid(group.Owner); !owner.IsZero() { ownerID = owner.UserId() } @@ -7542,8 +7573,8 @@ func groupDetailPayload(group *types.Topic, selfSub *types.Subscription) map[str } } -func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription) map[string]any { - data := groupDetailPayload(group, selfSub) +func groupSettingsPayload(group *types.Topic, selfSub *types.Subscription, memberCount int64) map[string]any { + data := groupDetailPayload(group, selfSub, memberCount) private := map[string]any{} if selfSub != nil { private = groupPrivateMap(selfSub.Private) diff --git a/server/im_http_test.go b/server/im_http_test.go index e6455ef..d3124ff 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -273,6 +273,7 @@ type testGroupBusinessStore struct { listMembers []types.GroupMember listMembersOpts types.GroupMemberListOptions listMembersNext string + membersCounts map[string]int64 updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch updatedRoleMode types.AccessMode @@ -354,6 +355,13 @@ func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts ty return s.listMembers, s.listMembersNext, nil } +func (s *testGroupBusinessStore) MembersCount(_ types.TenantID, _ []string) (map[string]int64, error) { + if s.membersCounts == nil { + return map[string]int64{}, nil + } + return s.membersCounts, nil +} + func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) { if s.updateMemberErr != nil { return nil, s.updateMemberErr @@ -730,7 +738,7 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch return privacy, nil } -func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) { +func (s *testAppReleaseStore) Latest(_, platform, channel string) (*types.AppRelease, error) { if s.err != nil { return nil, s.err } diff --git a/server/store/local_message_crypto_integration_test.go b/server/store/local_message_crypto_integration_test.go index 51a0e73..e74a4d3 100644 --- a/server/store/local_message_crypto_integration_test.go +++ b/server/store/local_message_crypto_integration_test.go @@ -37,7 +37,7 @@ func (p localCryptoProvider) RegistrationSMSEnabled() (bool, error) { func (p localCryptoProvider) ThirdPartyConfig(string, ...string) (map[string]string, error) { return nil, types.ErrUnsupported } -func (p localCryptoProvider) LatestAppRelease(string, string) (*types.AppRelease, error) { +func (p localCryptoProvider) LatestAppRelease(string, string, string) (*types.AppRelease, error) { return nil, types.ErrUnsupported } func (p localCryptoProvider) ModerationProvider() (*types.ModerationProviderConfig, error) { diff --git a/server/store/store.go b/server/store/store.go index 2002c12..840e7f9 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1892,6 +1892,7 @@ type GroupBusinessPersistenceInterface interface { UpsertMember(types.TenantID, *types.GroupMember) (*types.GroupMember, error) GetMember(types.TenantID, string, types.Uid) (*types.GroupMember, error) ListMembers(types.TenantID, string, types.GroupMemberListOptions) ([]types.GroupMember, string, error) + MembersCount(types.TenantID, []string) (map[string]int64, error) UpdateMember(types.TenantID, string, types.Uid, types.GroupMemberPatch) (*types.GroupMember, error) CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error) AcceptInvitation(types.TenantID, string, types.Uid, any, time.Time) (*types.GroupInvitation, error) @@ -2064,6 +2065,27 @@ func (groupBusinessMapper) GetMember(tenantID types.TenantID, topic string, uid return member, err } +func (groupBusinessMapper) MembersCount(tenantID types.TenantID, topics []string) (map[string]int64, error) { + cleaned := make([]string, 0, len(topics)) + seen := make(map[string]bool, len(topics)) + for _, topic := range topics { + topic = strings.TrimSpace(topic) + if topic == "" || seen[topic] { + continue + } + seen[topic] = true + cleaned = append(cleaned, topic) + } + if len(cleaned) == 0 { + return map[string]int64{}, nil + } + tenantAdp, err := tenantBusinessAdapter(tenantID) + if err != nil { + return nil, err + } + return tenantAdp.TenantGroupMembersCount(tenantID, cleaned) +} + func (groupBusinessMapper) ListMembers(tenantID types.TenantID, topic string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) { if strings.TrimSpace(topic) == "" { return nil, "", types.ErrMalformed