From ca3957e3b3f77f5c17ed0e72801c53f8c7a49165 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sat, 12 Sep 2026 11:20:48 +0800 Subject: [PATCH] feat: support group invite privacy verification --- docs/IM-前后端对接文档.md | 6 +- docs/IM-数据库变更汇总.sql | 1 + docs/sql/IM-隐私设置邀我入群验证SQL.sql | 17 ++++++ scripts/apply_hotgo_tenant_db.go | 2 + server/db/mysql/adapter.go | 1 + server/db/mysql/tenant_adapter.go | 40 +++++++------ server/db/mysql/tenant_groups.go | 6 ++ server/im_http.go | 76 +++++++++++++++++-------- server/im_http_test.go | 76 +++++++++++++++++++++++-- server/store/store.go | 2 +- server/store/types/groups.go | 5 +- server/store/types/profile.go | 28 ++++----- 12 files changed, 197 insertions(+), 63 deletions(-) create mode 100644 docs/sql/IM-隐私设置邀我入群验证SQL.sql diff --git a/docs/IM-前后端对接文档.md b/docs/IM-前后端对接文档.md index 7832aef..ef9c7d4 100644 --- a/docs/IM-前后端对接文档.md +++ b/docs/IM-前后端对接文档.md @@ -2714,7 +2714,7 @@ X-MeetingIM-APIKey: <预置的普通 API Key> **状态:新增。** -- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,version` 和各字段 `editable`。 +- `GET /v1/im/me/privacy` 返回 `add_me_methods,profile_visibility,last_seen_visibility,read_receipts,group_invite_verification,version` 和各字段 `editable`;`group_invite_verification=true` 表示别人邀请我入群时需要走入群审批。 - `PATCH /v1/im/me/privacy` 只提交变更字段和 `version`;成功返回完整最新配置并向其他设备推送 `privacy.updated`。 - 黑名单入口使用 5.12。服务端在搜索、主页、回执等实际读写路径执行隐私规则,不能只保存开关。 @@ -2822,8 +2822,8 @@ X-MeetingIM-APIKey: <预置的普通 API Key> | `GET /v1/im/me` | Query only | `{"user_id":"usr...","profile":{"public_account":"usr...","nickname":"张三","avatar_file_id":"","signature":"","version":1},"profile_version":1,"realname":{"status":"unverified"},"youth_mode":{"enabled":false}}` | | `GET /v1/im/me/profile` | Query only | `{"avatar_file_id":"","nickname":"张三","birth_date":"1990-01-01","signature":"签名","gender":"male","region":"上海","email":"a@example.com","public_account":"usr...","version":1,"field_rules":{"nickname":{"min_length":1,"max_length":32},"signature":{"max_length":120}}}` | | `PATCH /v1/im/me/profile` | `{"nickname":"新昵称","signature":"新签名","version":1}` | `{"avatar_file_id":"","nickname":"新昵称","signature":"新签名","version":2,"updated_at":"2026-08-20T09:00:00Z"}` | -| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true}}` | -| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | +| `GET /v1/im/me/privacy` | Query only | `{"add_me_methods":{"account":true,"phone":true,"qr":true,"group":true},"profile_visibility":"friends","last_seen_visibility":"friends","read_receipts":true,"group_invite_verification":false,"version":1,"editable":{"add_me_methods":true,"profile_visibility":true,"last_seen_visibility":true,"read_receipts":true,"group_invite_verification":true}}` | +| `PATCH /v1/im/me/privacy` | `{"add_me_methods":{"phone":false},"read_receipts":false,"group_invite_verification":true,"version":1}` | `{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":2,"updated_at":"2026-08-20T09:00:00Z"}` | | `GET /v1/im/settings/youth-mode` | Query only | `{"enabled":false,"policy_version":1,"restrictions":["night_quiet","content_filter","payment_guard"],"restriction_items":[{"code":"night_quiet","title":"深夜免打扰","description":"夜间减少消息提醒打扰,帮助用户保持健康作息。","enforced":false},{"code":"content_filter","title":"内容过滤","description":"对不适合青少年的内容进行识别和限制展示。","enforced":false},{"code":"payment_guard","title":"支付守护","description":"限制或确认支付、充值等敏感消费行为。","enforced":false}],"password_set":false,"can_enable":false,"updated_at":"2026-08-20T09:00:00Z"}` | | `POST /v1/im/settings/independent-password` | `{"password":"1234","purpose":"youth_mode","action_token":"action_token"}` | `{"status":"ok","password_set":true,"purpose":"youth_mode","enable_action_token":"enable_token","enable_action_expires_at":"2026-08-20T09:05:00Z","next_action":"enable_youth_mode"}` | | `POST /v1/im/settings/independent-password/verify` | `{"password":"1234","purpose":"close_youth_mode"}` | `{"action_token":"action_token","action":"close_youth_mode","expires_at":"2026-08-20T09:05:00Z"}` | diff --git a/docs/IM-数据库变更汇总.sql b/docs/IM-数据库变更汇总.sql index f714b9c..c0fbf70 100644 --- a/docs/IM-数据库变更汇总.sql +++ b/docs/IM-数据库变更汇总.sql @@ -732,6 +732,7 @@ CREATE TABLE IF NOT EXISTS tinode.im_user_privacy_settings ( profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', read_receipts TINYINT NOT NULL DEFAULT 1, + group_invite_verification TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), diff --git a/docs/sql/IM-隐私设置邀我入群验证SQL.sql b/docs/sql/IM-隐私设置邀我入群验证SQL.sql new file mode 100644 index 0000000..a629584 --- /dev/null +++ b/docs/sql/IM-隐私设置邀我入群验证SQL.sql @@ -0,0 +1,17 @@ +-- Add personal privacy switch for requiring approval when others invite the user into a group. +-- Default is off to preserve existing behavior. + +SET @im_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = 'tinode' + AND table_name = 'im_user_privacy_settings' + AND column_name = 'group_invite_verification' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; diff --git a/scripts/apply_hotgo_tenant_db.go b/scripts/apply_hotgo_tenant_db.go index 846d042..b8e0550 100644 --- a/scripts/apply_hotgo_tenant_db.go +++ b/scripts/apply_hotgo_tenant_db.go @@ -383,6 +383,7 @@ func main() { profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', read_receipts TINYINT NOT NULL DEFAULT 1, + group_invite_verification TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), @@ -539,6 +540,7 @@ func main() { "ALTER TABLE tinode.im_youth_mode_settings ADD COLUMN enabled_at DATETIME(3) AFTER restrictions", "ALTER TABLE tinode.im_users ADD COLUMN phone VARCHAR(32) NULL AFTER useragent", "ALTER TABLE tinode.im_user_profiles ADD COLUMN email VARCHAR(128) NOT NULL DEFAULT '' AFTER region", + "ALTER TABLE tinode.im_user_privacy_settings ADD COLUMN group_invite_verification TINYINT NOT NULL DEFAULT 0 AFTER read_receipts", } { if _, err = db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "Duplicate column name") { fatal(fmt.Errorf("exec failed: %s: %w", stmt, err)) diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 620861b..b716a5f 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -1851,6 +1851,7 @@ func createIMUserProductTables(tx *sql.Tx) error { profile_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', last_seen_visibility VARCHAR(32) NOT NULL DEFAULT 'friends', read_receipts TINYINT NOT NULL DEFAULT 1, + group_invite_verification TINYINT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 1, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index f95aaaa..1ba5f3a 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -6201,6 +6201,10 @@ func (a *adapter) TenantUserPrivacyUpdate(id t.TenantID, uid t.Uid, patch t.User sets = append(sets, "read_receipts=?") args = append(args, *patch.ReadReceipts) } + if patch.GroupInviteVerification != nil { + sets = append(sets, "group_invite_verification=?") + args = append(args, *patch.GroupInviteVerification) + } args = append(args, decodeUID(uid), patch.Version) res, err := a.db.ExecContext(ctx, `UPDATE im_user_privacy_settings SET `+strings.Join(sets, ",")+` WHERE user_id=? AND version=?`, args...) if err != nil { @@ -6825,28 +6829,30 @@ func (a *adapter) userProfileByUID(ctx context.Context, uid t.Uid) (*t.UserProfi func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPrivacySettings, error) { row := struct { - UserID int64 `db:"user_id"` - AddMeMethods []byte `db:"add_me_methods"` - ProfileVisibility string `db:"profile_visibility"` - LastSeenVisibility string `db:"last_seen_visibility"` - ReadReceipts bool `db:"read_receipts"` - Version int - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + UserID int64 `db:"user_id"` + AddMeMethods []byte `db:"add_me_methods"` + ProfileVisibility string `db:"profile_visibility"` + LastSeenVisibility string `db:"last_seen_visibility"` + ReadReceipts bool `db:"read_receipts"` + GroupInviteVerification bool `db:"group_invite_verification"` + Version int + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` }{} if err := sqlx.GetContext(ctx, a.db, &row, `SELECT user_id,add_me_methods,profile_visibility,last_seen_visibility, - read_receipts,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { + read_receipts,group_invite_verification,version,created_at,updated_at FROM im_user_privacy_settings WHERE user_id=?`, decodeUID(uid)); err != nil { return nil, err } settings := &t.UserPrivacySettings{ - UserID: store.EncodeUid(row.UserID), - AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, - ProfileVisibility: row.ProfileVisibility, - LastSeenVisibility: row.LastSeenVisibility, - ReadReceipts: row.ReadReceipts, - Version: row.Version, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + UserID: store.EncodeUid(row.UserID), + AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, + ProfileVisibility: row.ProfileVisibility, + LastSeenVisibility: row.LastSeenVisibility, + ReadReceipts: row.ReadReceipts, + GroupInviteVerification: row.GroupInviteVerification, + Version: row.Version, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } _ = json.Unmarshal(row.AddMeMethods, &settings.AddMeMethods) return settings, nil diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 22cfda4..140bf83 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -499,6 +499,12 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string if inv.Status == t.GroupInvitationStatusJoined || inv.Result == "joined" { return &inv, nil } + if strings.TrimSpace(inv.JoinRequestID) != "" { + if err = tx.Commit(); err != nil { + return nil, err + } + return &inv, nil + } var deletedAt sql.NullTime err = tx.QueryRowxContext(ctx, "SELECT deletedat FROM im_subscriptions WHERE topic=? AND userid=?", inv.Topic, decodeUID(uid)).Scan(&deletedAt) diff --git a/server/im_http.go b/server/im_http.go index ece51fb..24c7828 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -3177,21 +3177,23 @@ func serveIMMePrivacy(wrt http.ResponseWriter, req *http.Request) { writeIMSuccess(wrt, req, userPrivacyPayload(settings)) case http.MethodPatch: var body struct { - AddMeMethods map[string]bool `json:"add_me_methods"` - ProfileVisibility *string `json:"profile_visibility"` - LastSeenVisibility *string `json:"last_seen_visibility"` - ReadReceipts *bool `json:"read_receipts"` - Version int `json:"version"` + AddMeMethods map[string]bool `json:"add_me_methods"` + ProfileVisibility *string `json:"profile_visibility"` + LastSeenVisibility *string `json:"last_seen_visibility"` + ReadReceipts *bool `json:"read_receipts"` + GroupInviteVerification *bool `json:"group_invite_verification"` + Version int `json:"version"` } if !decodeIMJSONBody(wrt, req, &body) { return } settings, err := store.Profiles.UpdatePrivacy(principal.TenantID, principal.Uid, types.UserPrivacyPatch{ - AddMeMethods: body.AddMeMethods, - ProfileVisibility: body.ProfileVisibility, - LastSeenVisibility: body.LastSeenVisibility, - ReadReceipts: body.ReadReceipts, - Version: body.Version, + AddMeMethods: body.AddMeMethods, + ProfileVisibility: body.ProfileVisibility, + LastSeenVisibility: body.LastSeenVisibility, + ReadReceipts: body.ReadReceipts, + GroupInviteVerification: body.GroupInviteVerification, + Version: body.Version, }) if err != nil { writeIMRelationError(wrt, req, err) @@ -5351,8 +5353,24 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s } else { idempotencyKey = invitationID } + targetRequiresApproval := settings != nil && settings.JoinApproval + if !targetRequiresApproval && store.Profiles != nil { + privacy, privacyErr := store.Profiles.GetPrivacy(principal.TenantID, uid) + if privacyErr != nil { + if errors.Is(privacyErr, types.ErrNotFound) || errors.Is(privacyErr, types.ErrUnsupported) { + privacyErr = nil + } + } + if privacyErr != nil { + item["status"] = "failed" + item["reason"] = relationErrorReason(privacyErr) + results = append(results, item) + continue + } + targetRequiresApproval = privacy != nil && privacy.GroupInviteVerification + } joinRequestID := "" - if settings != nil && settings.JoinApproval { + if targetRequiresApproval { joinRequestID = "gjr_" + store.Store.GetUidString() } inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{ @@ -5376,7 +5394,7 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s continue } item["invitation_id"] = inv.InvitationID - item["status"] = inv.Status + item["status"] = groupInvitationDisplayStatus(inv) item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano) if inv.JoinRequestID != "" { item["join_request_id"] = inv.JoinRequestID @@ -7112,7 +7130,7 @@ func groupInvitationPayload(inv *types.GroupInvitation) map[string]any { "topic": inv.Topic, "inviter_id": inv.InviterUserID.UserId(), "target_user_id": inv.TargetUserID.UserId(), - "status": inv.Status, + "status": groupInvitationDisplayStatus(inv), "result": inv.Result, "expires_at": inv.ExpiresAt.Format(time.RFC3339Nano), } @@ -7125,6 +7143,16 @@ func groupInvitationPayload(inv *types.GroupInvitation) map[string]any { return data } +func groupInvitationDisplayStatus(inv *types.GroupInvitation) string { + if inv == nil { + return "" + } + if inv.Status == types.GroupInvitationStatusPending && strings.TrimSpace(inv.JoinRequestID) != "" { + return types.GroupInvitationStatusPendingApproval + } + return inv.Status +} + func groupFilePayloads(files []types.GroupFile) []map[string]any { items := make([]map[string]any, 0, len(files)) for i := range files { @@ -9210,17 +9238,19 @@ func validIMSecurityChallengeScene(scene string) bool { func userPrivacyPayload(settings *types.UserPrivacySettings) map[string]any { return map[string]any{ - "add_me_methods": settings.AddMeMethods, - "profile_visibility": settings.ProfileVisibility, - "last_seen_visibility": settings.LastSeenVisibility, - "read_receipts": settings.ReadReceipts, - "version": settings.Version, - "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), + "add_me_methods": settings.AddMeMethods, + "profile_visibility": settings.ProfileVisibility, + "last_seen_visibility": settings.LastSeenVisibility, + "read_receipts": settings.ReadReceipts, + "group_invite_verification": settings.GroupInviteVerification, + "version": settings.Version, + "updated_at": settings.UpdatedAt.UTC().Format(time.RFC3339Nano), "editable": map[string]any{ - "add_me_methods": true, - "profile_visibility": true, - "last_seen_visibility": true, - "read_receipts": true, + "add_me_methods": true, + "profile_visibility": true, + "last_seen_visibility": true, + "read_receipts": true, + "group_invite_verification": true, }, } } diff --git a/server/im_http_test.go b/server/im_http_test.go index 1b4717f..edf9bf1 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -451,6 +451,9 @@ func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID for i := range s.invitations { if s.invitations[i].InvitationID == invitationID { s.invitations[i].TargetUserID = uid + if s.invitations[i].JoinRequestID != "" { + return &s.invitations[i], nil + } s.invitations[i].Status = types.GroupInvitationStatusJoined s.invitations[i].Result = "joined" s.invitations[i].HandledAt = &now @@ -1063,7 +1066,7 @@ func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types. now := time.Now().UTC().Round(time.Millisecond) s.privacy = &types.UserPrivacySettings{ UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, - ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, Version: 1, CreatedAt: now, UpdatedAt: now, + ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now, } } return s.privacy, nil @@ -1083,6 +1086,9 @@ func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch if patch.ReadReceipts != nil { privacy.ReadReceipts = *patch.ReadReceipts } + if patch.GroupInviteVerification != nil { + privacy.GroupInviteVerification = *patch.GroupInviteVerification + } privacy.Version = patch.Version + 1 privacy.UpdatedAt = time.Now().UTC().Round(time.Millisecond) return privacy, nil @@ -3716,7 +3722,7 @@ func TestServeIMMePrivacy(t *testing.T) { store.Profiles = profiles defer func() { store.Profiles = oldProfiles }() - req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"version":1}`)) + req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"group_invite_verification":true,"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMePrivacy(rec, req) @@ -3726,7 +3732,9 @@ func TestServeIMMePrivacy(t *testing.T) { } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) methods := data["add_me_methods"].(map[string]any) - if methods["phone"] != false || data["read_receipts"] != false || data["version"].(float64) != 2 { + editable := data["editable"].(map[string]any) + if methods["phone"] != false || data["read_receipts"] != false || data["group_invite_verification"] != true || + editable["group_invite_verification"] != true || data["version"].(float64) != 2 { t.Fatalf("unexpected privacy: %#v", data) } } @@ -4802,7 +4810,7 @@ func TestServeIMGroupInvitationCreatesApprovalRequest(t *testing.T) { data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) item := items[0].(map[string]any) - if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPending { + if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval { t.Fatalf("unexpected invitation response: %#v", data) } if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].RequestID != "gjr_request" || @@ -4850,6 +4858,66 @@ func TestServeIMGroupInvitationCreatesApprovalRequest(t *testing.T) { } } +func TestServeIMGroupInvitationRespectsTargetPrivacyApproval(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42) + mockStore.EXPECT().GetUidString().Return("invite") + mockStore.EXPECT().GetUidString().Return("request") + oldSubs := store.Subs + oldGroupBusiness := store.GroupBusiness + oldProfiles := store.Profiles + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{ + Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, + JoinApproval: false, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1, + }} + store.Subs = subs + store.GroupBusiness = groupStore + store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{ + UserID: types.Uid(43), + AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true}, + ProfileVisibility: "friends", + LastSeenVisibility: "friends", + ReadReceipts: true, + GroupInviteVerification: true, + Version: 1, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }} + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + store.Profiles = oldProfiles + }() + owner := types.Uid(42) + target := types.Uid(43) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{ + TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, + }, nil) + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", + bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`)) + req.Header.Set("Authorization", "Bearer token-1") + req.Header.Set("Idempotency-Key", "invite-privacy") + rec := httptest.NewRecorder() + serveIMGroupResource(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + items := data["items"].([]any) + item := items[0].(map[string]any) + if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval { + t.Fatalf("unexpected invitation response: %#v", data) + } + if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" { + t.Fatalf("join request was not created from privacy setting: %#v", groupStore.joinRequests) + } +} + func TestServeIMGroupInvitationAccept(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/server/store/store.go b/server/store/store.go index f3ca110..1a516ca 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -3020,7 +3020,7 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch } func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool { - if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil { + if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil && patch.GroupInviteVerification == nil { return false } for method := range patch.AddMeMethods { diff --git a/server/store/types/groups.go b/server/store/types/groups.go index 144939f..e9c7bcd 100644 --- a/server/store/types/groups.go +++ b/server/store/types/groups.go @@ -12,8 +12,9 @@ const ( GroupMemberStatusActive = "active" GroupMemberStatusLeft = "left" - GroupInvitationStatusPending = "pending" - GroupInvitationStatusJoined = "joined" + GroupInvitationStatusPending = "pending" + GroupInvitationStatusPendingApproval = "pending_approval" + GroupInvitationStatusJoined = "joined" GroupJoinRequestStatusPending = "pending" GroupJoinRequestStatusAccepted = "accepted" diff --git a/server/store/types/profile.go b/server/store/types/profile.go index d72f7db..f7d3119 100644 --- a/server/store/types/profile.go +++ b/server/store/types/profile.go @@ -29,22 +29,24 @@ type UserProfilePatch struct { } type UserPrivacySettings struct { - UserID Uid - AddMeMethods map[string]bool - ProfileVisibility string - LastSeenVisibility string - ReadReceipts bool - Version int - CreatedAt time.Time - UpdatedAt time.Time + UserID Uid + AddMeMethods map[string]bool + ProfileVisibility string + LastSeenVisibility string + ReadReceipts bool + GroupInviteVerification bool + Version int + CreatedAt time.Time + UpdatedAt time.Time } type UserPrivacyPatch struct { - AddMeMethods map[string]bool - ProfileVisibility *string - LastSeenVisibility *string - ReadReceipts *bool - Version int + AddMeMethods map[string]bool + ProfileVisibility *string + LastSeenVisibility *string + ReadReceipts *bool + GroupInviteVerification *bool + Version int } type AppRelease struct {