From cfab33f45aec1efdc75a9d9058c52d076224481b Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Fri, 11 Sep 2026 17:24:08 +0800 Subject: [PATCH] fix: link group invitations to join requests --- server/db/mysql/tenant_groups.go | 33 +++++++++++- server/im_http.go | 21 ++++++-- server/im_http_test.go | 91 ++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 30e4a90..22cfda4 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -419,16 +419,37 @@ func (a *adapter) TenantGroupInvitationCreate(id t.TenantID, inv *t.GroupInvitat if inv.ExpiresAt.IsZero() { inv.ExpiresAt = now.Add(7 * 24 * time.Hour) } - ctx, cancel := a.getContext() + ctx, cancel := a.getContextForTx() if cancel != nil { defer cancel() } - _, err := a.db.ExecContext(ctx, `INSERT INTO im_group_invitations(invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at) + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + _, err = tx.ExecContext(ctx, `INSERT INTO im_group_invitations(invitation_id,topic,inviter_user_id,target_user_id,message,status,result,join_request_id,idempotency_key,expires_at,handled_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, inv.InvitationID, inv.Topic, decodeUID(inv.InviterUserID), decodeUID(inv.TargetUserID), inv.Message, inv.Status, inv.Result, inv.JoinRequestID, inv.IdempotencyKey, inv.ExpiresAt, inv.HandledAt, now, now) if err != nil { return nil, err } + if strings.TrimSpace(inv.JoinRequestID) != "" { + _, err = tx.ExecContext(ctx, `INSERT INTO im_group_join_requests(request_id,topic,applicant_user_id,inviter_user_id,source,qr_token_hash,message,status,reject_reason,expires_at,idempotency_key,version,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + inv.JoinRequestID, inv.Topic, decodeUID(inv.TargetUserID), nullableUID(inv.InviterUserID), "invitation", "", inv.Message, t.GroupJoinRequestStatusPending, "", inv.ExpiresAt, inv.IdempotencyKey, 1, now, now) + if err != nil { + return nil, err + } + if err = insertGroupEventTx(tx, inv.Topic, inv.TargetUserID, "group.join_request.created", map[string]any{ + "request_id": inv.JoinRequestID, "applicant_user_id": inv.TargetUserID.UserId(), "source": "invitation", "invitation_id": inv.InvitationID, + }); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } return inv, nil } @@ -509,6 +530,14 @@ func (a *adapter) TenantGroupInvitationAccept(id t.TenantID, invitationID string t.GroupInvitationStatusJoined, "joined", now, now, inv.InvitationID); err != nil { return nil, err } + if strings.TrimSpace(inv.JoinRequestID) != "" { + if _, err = tx.ExecContext(ctx, `UPDATE im_group_join_requests + SET status=?,handled_by=?,handled_at=?,updated_at=?,version=version+1 + WHERE topic=? AND request_id=? AND status=?`, + t.GroupJoinRequestStatusAccepted, decodeUID(uid), now, now, inv.Topic, inv.JoinRequestID, t.GroupJoinRequestStatusPending); err != nil { + return nil, err + } + } if err = insertGroupEventTx(tx, inv.Topic, uid, "group.member.joined", map[string]any{ "user_id": uid.UserId(), "source": "invitation", "invitation_id": inv.InvitationID, }); err != nil { diff --git a/server/im_http.go b/server/im_http.go index 7bd7de8..46753e1 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -5297,12 +5297,12 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s return } access := selfSub.ModeGiven & selfSub.ModeWant + settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) + if err != nil { + writeIMRelationError(wrt, req, err) + return + } if !access.IsAdmin() { - settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic) - if err != nil { - writeIMRelationError(wrt, req, err) - return - } if settings == nil || !settings.MemberInvite { writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "member invitation is disabled", nil) return @@ -5351,6 +5351,10 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s } else { idempotencyKey = invitationID } + joinRequestID := "" + if settings != nil && settings.JoinApproval { + joinRequestID = "gjr_" + store.Store.GetUidString() + } inv, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{ InvitationID: invitationID, Topic: topic, @@ -5359,6 +5363,7 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s Message: strings.TrimSpace(body.Message), Status: types.GroupInvitationStatusPending, Result: "", + JoinRequestID: joinRequestID, IdempotencyKey: idempotencyKey, ExpiresAt: now.Add(7 * 24 * time.Hour), CreatedAt: now, @@ -5373,6 +5378,9 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s item["invitation_id"] = inv.InvitationID item["status"] = inv.Status item["expires_at"] = inv.ExpiresAt.Format(time.RFC3339Nano) + if inv.JoinRequestID != "" { + item["join_request_id"] = inv.JoinRequestID + } if imReliableHTTPEventsEnabled(req) { publishIMConversationSystemNotice(principal.TenantID, principal.Uid.P2PName(uid), "system_action", "group_invitation_created", "邀请你加入群聊。", @@ -7109,6 +7117,9 @@ func groupInvitationPayload(inv *types.GroupInvitation) map[string]any { "result": inv.Result, "expires_at": inv.ExpiresAt.Format(time.RFC3339Nano), } + if inv.JoinRequestID != "" { + data["join_request_id"] = inv.JoinRequestID + } if inv.HandledAt != nil { data["handled_at"] = inv.HandledAt.Format(time.RFC3339Nano) } diff --git a/server/im_http_test.go b/server/im_http_test.go index f201e46..ecdb42a 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -426,6 +426,23 @@ func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, ui func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.GroupInvitation) (*types.GroupInvitation, error) { s.invitations = append(s.invitations, *inv) + if inv.JoinRequestID != "" { + expiresAt := inv.ExpiresAt + s.joinRequests = append(s.joinRequests, types.GroupJoinRequest{ + RequestID: inv.JoinRequestID, + Topic: inv.Topic, + ApplicantUserID: inv.TargetUserID, + InviterUserID: inv.InviterUserID, + Source: "invitation", + Message: inv.Message, + Status: types.GroupJoinRequestStatusPending, + ExpiresAt: &expiresAt, + IdempotencyKey: inv.IdempotencyKey, + Version: 1, + CreatedAt: inv.CreatedAt, + UpdatedAt: inv.UpdatedAt, + }) + } return inv, nil } @@ -4722,6 +4739,80 @@ func TestServeIMGroupInvitationAddsNonMember(t *testing.T) { } } +func TestServeIMGroupInvitationCreatesApprovalRequest(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 + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{ + Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, + JoinApproval: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1, + }} + store.Subs = subs + store.GroupBusiness = groupStore + defer func() { + store.Subs = oldSubs + store.GroupBusiness = oldGroupBusiness + }() + owner := types.Uid(42) + target := types.Uid(43) + ownerSub := &types.Subscription{ + TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, + } + subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) + 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-1") + 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.GroupInvitationStatusPending { + t.Fatalf("unexpected invitation response: %#v", data) + } + if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].RequestID != "gjr_request" || + groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" { + t.Fatalf("join request was not created: %#v", groupStore.joinRequests) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/summary", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMGroupResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("summary status: got %d body=%s", rec.Code, rec.Body.String()) + } + summary := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if summary["pending_join_request_count"] != float64(1) { + t.Fatalf("unexpected summary response: %#v", summary) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/join-requests?status=pending", nil) + req.Header.Set("Authorization", "Bearer token-1") + rec = httptest.NewRecorder() + serveIMGroupResource(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("join requests status: got %d body=%s", rec.Code, rec.Body.String()) + } + list := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + requests := list["items"].([]any) + if len(requests) != 1 || requests[0].(map[string]any)["request_id"] != "gjr_request" { + t.Fatalf("unexpected join requests response: %#v", list) + } +} + func TestServeIMGroupInvitationAccept(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()