feat: persist group business state in tables

This commit is contained in:
richjohnrambo
2026-08-17 09:50:31 +08:00
parent d8d1639d5b
commit ddf0f7a55e
10 changed files with 2065 additions and 130 deletions
+334 -124
View File
@@ -2956,6 +2956,40 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.CreateSettings(principal.TenantID, &types.GroupSettings{
Topic: topicName,
OwnerUserID: principal.Uid,
Name: name,
AvatarFileID: strings.TrimSpace(body.AvatarFileID),
GroupStatus: types.GroupStatusActive,
MemberUploadFiles: true,
MemberPrivateChat: true,
MemberInvite: true,
MaxAdminCount: groupMaxAdminCount,
MemberCount: 1,
Version: 1,
CreatedBy: principal.Uid,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{
Topic: topicName,
UserID: principal.Uid,
Role: types.GroupMemberRoleOwner,
MemberStatus: types.GroupMemberStatusActive,
JoinSource: "create",
JoinedBy: principal.Uid,
JoinedAt: now,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
var members []*types.Subscription
seen := map[types.Uid]bool{principal.Uid: true}
for _, rawID := range body.MemberUserIDs {
@@ -2978,6 +3012,30 @@ func serveIMGroupCreate(wrt http.ResponseWriter, req *http.Request) {
writeIMRelationError(wrt, req, err)
return
}
for _, sub := range members {
uid := types.ParseUid(sub.User)
if uid.IsZero() {
uid = types.ParseUserId(sub.User)
}
if uid.IsZero() {
continue
}
if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{
Topic: topicName,
UserID: uid,
Role: types.GroupMemberRoleMember,
MemberStatus: types.GroupMemberStatusActive,
JoinSource: "create",
JoinedBy: principal.Uid,
JoinedAt: now,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
}
_ = store.Topics.UpdateSubCnt(principal.TenantID, topicName)
}
created, err := store.Topics.Get(principal.TenantID, topicName)
@@ -3186,7 +3244,11 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "group not found", nil)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(group, selfSub))
payload := groupDetailPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
writeIMSuccess(wrt, req, payload)
case http.MethodPatch:
access := selfSub.ModeGiven & selfSub.ModeWant
if !access.IsAdmin() {
@@ -3239,12 +3301,18 @@ func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string
writeIMRelationError(wrt, req, err)
return
}
patch := types.GroupSettingsPatch{Name: body.Name, AvatarFileID: body.AvatarFileID, Description: body.Description, Version: body.Version}
_, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, patch)
updated, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, groupDetailPayload(updated, selfSub))
payload := groupDetailPayload(updated, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
}
writeIMSuccess(wrt, req, payload)
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -3264,7 +3332,12 @@ func serveIMGroupSettings(wrt http.ResponseWriter, req *http.Request, topic stri
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, groupSettingsPayload(group, selfSub))
payload := groupSettingsPayload(group, selfSub)
if settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic); err == nil {
payload = mergeGroupSettingsPayload(payload, settings)
payload["governance"] = groupGovernancePayloadFromSettings(settings, selfSub)
}
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topic string) {
@@ -3293,6 +3366,7 @@ func serveIMGroupMemberSettings(wrt http.ResponseWriter, req *http.Request, topi
writeIMRelationError(wrt, req, err)
return
}
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Pinned: body.Pinned, MuteNotifications: body.MuteNotifications})
selfSub.Private = private
writeIMSuccess(wrt, req, groupMemberSettingsPayload(selfSub))
}
@@ -3332,6 +3406,7 @@ func serveIMGroupMyProfile(wrt http.ResponseWriter, req *http.Request, topic str
writeIMRelationError(wrt, req, err)
return
}
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Nickname: &nickname, Version: body.Version})
writeIMSuccess(wrt, req, map[string]any{"nickname": nickname, "version": currentVersion + 1, "updated_at": updatedAt.Format(time.RFC3339Nano)})
}
@@ -3352,6 +3427,10 @@ func serveIMGroupLeave(wrt http.ResponseWriter, req *http.Request, topic string)
writeIMRelationError(wrt, req, err)
return
}
leftAt := time.Now().UTC().Round(time.Millisecond)
leftStatus := types.GroupMemberStatusLeft
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{MemberStatus: &leftStatus, LeftAt: &leftAt})
_, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{MemberCountDelta: -1})
_ = store.Topics.UpdateSubCnt(principal.TenantID, topic)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "status": "left"})
}
@@ -3448,6 +3527,43 @@ func serveIMGroupInvitations(wrt http.ResponseWriter, req *http.Request, topic s
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
} else {
now := time.Now().UTC().Round(time.Millisecond)
if _, err := store.GroupBusiness.CreateInvitation(principal.TenantID, &types.GroupInvitation{
InvitationID: "ginv_" + store.Store.GetUidString(),
Topic: topic,
InviterUserID: principal.Uid,
TargetUserID: uid,
Message: strings.TrimSpace(body.Message),
Status: types.GroupInvitationStatusJoined,
Result: "joined",
ExpiresAt: now.Add(7 * 24 * time.Hour),
HandledAt: &now,
CreatedAt: now,
UpdatedAt: now,
IdempotencyKey: strings.TrimSpace(req.Header.Get("Idempotency-Key")),
}); err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
if _, err := store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{
Topic: topic,
UserID: uid,
Role: types.GroupMemberRoleMember,
MemberStatus: types.GroupMemberStatusActive,
JoinSource: "invitation",
JoinedBy: principal.Uid,
JoinedAt: now,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
item["status"] = "failed"
item["reason"] = relationErrorReason(err)
results = append(results, item)
continue
}
item["status"] = "joined"
}
results = append(results, item)
@@ -3467,50 +3583,42 @@ func serveIMGroupJoinRequests(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
status := strings.TrimSpace(req.URL.Query().Get("status"))
if status == "" {
status = "pending"
}
items := filterGroupJoinRequests(groupJoinRequests(group), status)
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": items, "next_cursor": ""})
items, nextCursor, err := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, status, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil {
if !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
items = nil
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupJoinRequestPayloads(items), "next_cursor": nextCursor})
case http.MethodPost:
var body imGroupJoinRequestCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
aux := groupAuxMap(group.Aux)
requests := groupJoinRequests(group)
now := time.Now().UTC().Round(time.Millisecond)
requestID := "gjr_" + store.Store.GetUidString()
item := map[string]any{
"request_id": requestID,
"user_id": principal.Uid.UserId(),
"inviter_id": "",
"source": defaultString(strings.TrimSpace(body.Source), "manual"),
"message": strings.TrimSpace(body.Message),
"status": "pending",
"version": 1,
"created_at": now.Format(time.RFC3339Nano),
"updated_at": now.Format(time.RFC3339Nano),
"qr_token": strings.TrimSpace(body.QRToken),
}
requests = append(requests, item)
aux["join_requests"] = requests
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
item, err := store.GroupBusiness.CreateJoinRequest(principal.TenantID, &types.GroupJoinRequest{
RequestID: requestID,
Topic: topic,
ApplicantUserID: principal.Uid,
Source: defaultString(strings.TrimSpace(body.Source), "manual"),
Message: strings.TrimSpace(body.Message),
Status: types.GroupJoinRequestStatusPending,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, item)
writeIMSuccess(wrt, req, groupJoinRequestPayload(item))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -3538,25 +3646,13 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t
if !decodeIMJSONBody(wrt, req, &body) {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
updated, err := store.GroupBusiness.ResolveJoinRequest(principal.TenantID, topic, requestID, principal.Uid, action, "", body.Version, time.Now().UTC().Round(time.Millisecond))
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
aux := groupAuxMap(group.Aux)
requests := groupJoinRequests(group)
updated, found := resolveGroupJoinRequest(requests, requestID, action, principal.Uid, body.Version)
if !found {
writeIMError(wrt, req, http.StatusNotFound, 30004, imReasonMalformedRequest, "join request not found", nil)
return
}
aux["join_requests"] = requests
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
if action == "accept" {
uid := types.ParseUserId(stringFromAny(updated["user_id"]))
uid := updated.ApplicantUserID
if !uid.IsZero() {
_ = store.Subs.Create(principal.TenantID, &types.Subscription{
TenantID: principal.TenantID,
@@ -3566,10 +3662,12 @@ func serveIMGroupJoinRequestAction(wrt http.ResponseWriter, req *http.Request, t
ModeGiven: types.ModeCPublic,
Private: groupMemberPrivate("", false, false, 1, time.Now().UTC().Round(time.Millisecond)),
})
now := time.Now().UTC().Round(time.Millisecond)
_, _ = store.GroupBusiness.UpsertMember(principal.TenantID, &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, JoinSource: "approval", JoinedBy: principal.Uid, JoinedAt: now, Version: 1, CreatedAt: now, UpdatedAt: now})
_ = store.Topics.UpdateSubCnt(principal.TenantID, topic)
}
}
writeIMSuccess(wrt, req, updated)
writeIMSuccess(wrt, req, groupJoinRequestPayload(updated))
}
func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic string) {
@@ -3581,16 +3679,12 @@ func serveIMGroupSummary(wrt http.ResponseWriter, req *http.Request, topic strin
if !ok {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
announcement := latestGroupAnnouncement(group)
requests, _, _ := store.GroupBusiness.ListJoinRequests(principal.TenantID, topic, types.GroupJoinRequestStatusPending, "", 100)
announcement, _ := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
writeIMSuccess(wrt, req, map[string]any{
"topic": topic,
"pending_join_request_count": len(filterGroupJoinRequests(groupJoinRequests(group), "pending")),
"announcement": announcement,
"pending_join_request_count": len(requests),
"announcement": groupAnnouncementPayload(announcement),
"permissions": groupPermissions(selfSub.ModeGiven & selfSub.ModeWant),
})
}
@@ -3600,14 +3694,14 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
if !ok {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
switch {
case tail == "announcements/latest" && req.Method == http.MethodGet:
writeIMSuccess(wrt, req, latestGroupAnnouncement(group))
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
case tail == "announcements/current" && req.Method == http.MethodPut:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
@@ -3622,36 +3716,35 @@ func serveIMGroupAnnouncements(wrt http.ResponseWriter, req *http.Request, topic
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "content"})
return
}
aux := groupAuxMap(group.Aux)
current := latestGroupAnnouncement(group)
version := intFromAny(current["version"])
if version <= 0 {
version = 1
}
if body.Version > 0 && body.Version != version {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": version})
return
}
now := time.Now().UTC().Round(time.Millisecond)
announcement := map[string]any{
"announcement_id": defaultString(stringFromAny(current["announcement_id"]), "ann_"+store.Store.GetUidString()),
"content": content,
"author": principal.Uid.UserId(),
"version": version + 1,
"published_at": now.Format(time.RFC3339Nano),
}
aux["announcement"] = announcement
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
announcement, err := store.GroupBusiness.PutAnnouncement(principal.TenantID, &types.GroupAnnouncement{
AnnouncementID: "ann_" + store.Store.GetUidString(),
Topic: topic,
AuthorUserID: principal.Uid,
Content: content,
Status: types.GroupAnnouncementStatusCurrent,
PublishedAt: now,
}, body.Version)
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, announcement)
writeIMSuccess(wrt, req, groupAnnouncementPayload(announcement))
case strings.HasPrefix(tail, "announcements/") && strings.HasSuffix(tail, "/read") && req.Method == http.MethodPost:
announcementID := strings.TrimSuffix(strings.TrimPrefix(tail, "announcements/"), "/read")
now := time.Now().UTC().Round(time.Millisecond)
if err := store.GroupBusiness.ReadAnnouncement(principal.TenantID, topic, announcementID, principal.Uid, now); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"announcement_id": announcementID, "read_at": now.Format(time.RFC3339Nano)})
case strings.HasPrefix(tail, "announcements/") && req.Method == http.MethodGet:
writeIMSuccess(wrt, req, latestGroupAnnouncement(group))
ann, err := store.GroupBusiness.LatestAnnouncement(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, groupAnnouncementPayload(ann))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -3662,13 +3755,18 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
if !ok {
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
switch req.Method {
case http.MethodGet:
settings, err := store.GroupBusiness.GetSettings(principal.TenantID, topic)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
if settings != nil {
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
return
}
group, _ := store.Topics.Get(principal.TenantID, topic)
writeIMSuccess(wrt, req, groupGovernancePayload(group, selfSub))
case http.MethodPatch:
if !(selfSub.ModeGiven & selfSub.ModeWant).IsAdmin() {
@@ -3679,28 +3777,19 @@ func serveIMGroupGovernance(wrt http.ResponseWriter, req *http.Request, topic st
if !decodeIMJSONBody(wrt, req, &body) {
return
}
aux := groupAuxMap(group.Aux)
governance := groupGovernanceMap(aux["governance"])
currentVersion := intFromAny(governance["version"])
if currentVersion <= 0 {
currentVersion = 1
}
if body.Version > 0 && body.Version != currentVersion {
writeIMError(wrt, req, http.StatusConflict, 30006, "VERSION_CONFLICT", "version conflict", map[string]any{"current_version": currentVersion})
return
}
applyBoolPatch(governance, "join_approval", body.JoinApproval)
applyBoolPatch(governance, "mute_all", body.MuteAll)
applyBoolPatch(governance, "member_upload_files", body.MemberUploadFiles)
applyBoolPatch(governance, "member_private_chat", body.MemberPrivateChat)
applyBoolPatch(governance, "member_invite", body.MemberInvite)
governance["version"] = currentVersion + 1
aux["governance"] = governance
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
settings, err := store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{
JoinApproval: body.JoinApproval,
MuteAll: body.MuteAll,
MemberUploadFiles: body.MemberUploadFiles,
MemberPrivateChat: body.MemberPrivateChat,
MemberInvite: body.MemberInvite,
Version: body.Version,
})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, governance)
writeIMSuccess(wrt, req, groupGovernancePayloadFromSettings(settings, selfSub))
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
}
@@ -3770,6 +3859,8 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail
item["ok"] = false
item["reason"] = relationErrorReason(err)
} else {
role := types.GroupMemberRoleAdmin
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, uid, types.GroupMemberPatch{Role: &role})
item["ok"] = true
}
results = append(results, item)
@@ -3787,6 +3878,8 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail
writeIMRelationError(wrt, req, err)
return
}
role := types.GroupMemberRoleMember
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, uid, types.GroupMemberPatch{Role: &role})
writeIMSuccess(wrt, req, map[string]any{"user_id": uid.UserId(), "role": "member", "updated_at": time.Now().UTC().Format(time.RFC3339Nano)})
default:
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -3829,6 +3922,11 @@ func serveIMGroupOwnerTransfer(wrt http.ResponseWriter, req *http.Request, topic
return
}
_ = store.Topics.OwnerChange(principal.TenantID, topic, target)
adminRole := types.GroupMemberRoleAdmin
ownerRole := types.GroupMemberRoleOwner
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, principal.Uid, types.GroupMemberPatch{Role: &adminRole})
_, _ = store.GroupBusiness.UpdateMember(principal.TenantID, topic, target, types.GroupMemberPatch{Role: &ownerRole})
_, _ = store.GroupBusiness.UpdateSettings(principal.TenantID, topic, types.GroupSettingsPatch{OwnerUserID: &target, Version: body.Version})
writeIMSuccess(wrt, req, map[string]any{"owner": target.UserId(), "previous_owner": principal.Uid.UserId(), "version": body.Version + 1})
}
@@ -3841,15 +3939,14 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
writeIMError(wrt, req, http.StatusForbidden, 30005, imReasonPermissionDenied, "permission denied", nil)
return
}
group, err := store.Topics.Get(principal.TenantID, topic)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
aux := groupAuxMap(group.Aux)
switch {
case tail == "mutes" && req.Method == http.MethodGet:
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMuteItems(aux), "next_cursor": ""})
items, nextCursor, err := store.GroupBusiness.ListMutes(principal.TenantID, topic, strings.TrimSpace(req.URL.Query().Get("cursor")), parseIMLimit(req.URL.Query().Get("limit"), 50, 100))
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(items), "next_cursor": nextCursor})
case tail == "mutes" && req.Method == http.MethodPost:
var body imGroupMuteBatchRequest
if !decodeIMJSONBody(wrt, req, &body) {
@@ -3859,29 +3956,34 @@ func serveIMGroupMutes(wrt http.ResponseWriter, req *http.Request, topic, tail s
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "members"})
return
}
mutes := groupMuteItems(aux)
now := time.Now().UTC().Round(time.Millisecond)
mutes := make([]types.GroupMute, 0, len(body.Members))
for _, member := range body.Members {
uid := types.ParseUserId(strings.TrimSpace(member.UserID))
if uid.IsZero() {
continue
}
mutes = upsertGroupMute(mutes, map[string]any{"user_id": uid.UserId(), "muted_by": principal.Uid.UserId(), "muted_at": now.Format(time.RFC3339Nano), "expires_at": strings.TrimSpace(member.ExpiresAt), "reason": strings.TrimSpace(body.Reason)})
var expiresAt *time.Time
if raw := strings.TrimSpace(member.ExpiresAt); raw != "" {
if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil {
expiresAt = &parsed
}
}
mute, err := store.GroupBusiness.UpsertMute(principal.TenantID, &types.GroupMute{Topic: topic, UserID: uid, MutedBy: principal.Uid, MutedAt: now, ExpiresAt: expiresAt, Reason: strings.TrimSpace(body.Reason), Status: types.GroupMuteStatusActive})
if err != nil {
writeIMRelationError(wrt, req, err)
return
}
mutes = append(mutes, *mute)
}
aux["mutes"] = mutes
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
writeIMRelationError(wrt, req, err)
return
}
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": mutes})
writeIMSuccess(wrt, req, map[string]any{"topic": topic, "items": groupMutePayloads(mutes)})
case strings.HasPrefix(tail, "mutes/") && req.Method == http.MethodDelete:
uid := types.ParseUserId(strings.TrimSpace(strings.TrimPrefix(tail, "mutes/")))
if uid.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "user_id"})
return
}
aux["mutes"] = removeGroupMute(groupMuteItems(aux), uid.UserId())
if err = store.Topics.Update(principal.TenantID, topic, map[string]any{"Aux": aux}); err != nil {
if err := store.GroupBusiness.DeleteMute(principal.TenantID, topic, uid, principal.Uid, time.Now().UTC().Round(time.Millisecond)); err != nil {
writeIMRelationError(wrt, req, err)
return
}
@@ -4919,6 +5021,114 @@ func groupGovernancePayload(group *types.Topic, selfSub *types.Subscription) map
return governance
}
func groupGovernancePayloadFromSettings(settings *types.GroupSettings, selfSub *types.Subscription) map[string]any {
governance := defaultGroupGovernance()
if settings != nil {
governance["join_approval"] = settings.JoinApproval
governance["mute_all"] = settings.MuteAll
governance["member_upload_files"] = settings.MemberUploadFiles
governance["member_private_chat"] = settings.MemberPrivateChat
governance["member_invite"] = settings.MemberInvite
governance["version"] = settings.Version
}
editable := false
if selfSub != nil {
editable = (selfSub.ModeGiven & selfSub.ModeWant).IsAdmin()
}
for _, key := range []string{"join_approval", "mute_all", "member_upload_files", "member_private_chat", "member_invite"} {
governance[key+"_editable"] = editable
}
return governance
}
func mergeGroupSettingsPayload(payload map[string]any, settings *types.GroupSettings) map[string]any {
if payload == nil {
payload = map[string]any{}
}
if settings == nil {
return payload
}
payload["topic"] = settings.Topic
payload["name"] = settings.Name
payload["avatar_file_id"] = settings.AvatarFileID
payload["description"] = settings.Description
payload["owner"] = settings.OwnerUserID.UserId()
payload["member_count"] = settings.MemberCount
payload["version"] = settings.Version
return payload
}
func groupJoinRequestPayload(req *types.GroupJoinRequest) map[string]any {
if req == nil {
return map[string]any{}
}
data := map[string]any{
"request_id": req.RequestID,
"user_id": req.ApplicantUserID.UserId(),
"inviter_id": req.InviterUserID.UserId(),
"source": req.Source,
"message": req.Message,
"status": req.Status,
"version": req.Version,
"created_at": req.CreatedAt.UTC().Format(time.RFC3339Nano),
"updated_at": req.UpdatedAt.UTC().Format(time.RFC3339Nano),
}
if req.HandledBy != types.ZeroUid {
data["handled_by"] = req.HandledBy.UserId()
}
if req.HandledAt != nil {
data["handled_at"] = req.HandledAt.UTC().Format(time.RFC3339Nano)
}
if req.RejectReason != "" {
data["reject_reason"] = req.RejectReason
}
return data
}
func groupJoinRequestPayloads(items []types.GroupJoinRequest) []map[string]any {
out := make([]map[string]any, 0, len(items))
for i := range items {
out = append(out, groupJoinRequestPayload(&items[i]))
}
return out
}
func groupAnnouncementPayload(ann *types.GroupAnnouncement) map[string]any {
if ann == nil {
return map[string]any{}
}
return map[string]any{
"announcement_id": ann.AnnouncementID,
"content": ann.Content,
"author": ann.AuthorUserID.UserId(),
"version": ann.Version,
"published_at": ann.PublishedAt.UTC().Format(time.RFC3339Nano),
}
}
func groupMutePayload(mute types.GroupMute) map[string]any {
data := map[string]any{
"user_id": mute.UserID.UserId(),
"muted_by": mute.MutedBy.UserId(),
"muted_at": mute.MutedAt.UTC().Format(time.RFC3339Nano),
"reason": mute.Reason,
"status": mute.Status,
"version": mute.Version,
}
if mute.ExpiresAt != nil {
data["expires_at"] = mute.ExpiresAt.UTC().Format(time.RFC3339Nano)
}
return data
}
func groupMutePayloads(items []types.GroupMute) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, item := range items {
out = append(out, groupMutePayload(item))
}
return out
}
func groupAuxMap(value any) map[string]any {
switch aux := value.(type) {
case map[string]any: