Files
sm-bm-plugin/classes/chat_sync.go
Jakub Zych 0ff943b6af fix: keep DM participant rows when a user leaves the chat group
The participant sync and the non-member prune now remove only ogólny's
rows. A user taken out of studenci, even briefly, keeps their DMs (the
live membership check denies access while they are out) and gets them
back when re-added, instead of losing them for good.
2026-10-26 19:34:53 +01:00

202 lines
7.4 KiB
Go

package classes
import (
"context"
"errors"
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
chatmodels "git.golem15.com/golem15/sm-chat-plugin/models"
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
chanmodels "git.golem15.com/golem15/sm-chatchannels-plugin/models"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// chatGeneralLock is the transaction advisory lock that serialises finding
// or creating ogólny, so members bootstrapping at the same moment end with
// one group conversation and one channel.
const chatGeneralLock int64 = 0x6a7a626d63686174
// chatGroupNames are the display names of the default group codes; any
// other code is named after itself.
var chatGroupNames = map[string]string{
"studenci": "Studenci",
"moderatorzy": "Moderatorzy czatu",
}
// chatMembersSQL selects the ids of the users in the group with code ?
// that are not deleted.
const chatMembersSQL = `SELECT DISTINCT users_groups.user_id
FROM users_groups
JOIN user_groups ON user_groups.id = users_groups.user_group_id
JOIN users ON users.id = users_groups.user_id
WHERE user_groups.code = ? AND users.deleted_at IS NULL`
// chatGroupConversationsSQL selects the ids of the group conversations of
// context bm/1 (ogólny). DMs are never listed: their participant rows are
// kept when someone leaves the chat group, so the history comes back when
// they are re-added. Access while they are out is denied by golem15.chat's
// live membership check (ChatContext.RequireLiveMembership).
const chatGroupConversationsSQL = `SELECT id FROM golem15_chat_conversations WHERE context_type = ? AND context_id = ? AND type = ?`
// EnsureChatGroups creates a user group for each code that has none and
// returns how many it created. It never assigns a user to a group: members
// are added by hand in the backend.
func EnsureChatGroups(ctx context.Context, db *gorm.DB, codes ...string) (int64, error) {
if db == nil {
return 0, errors.New("jz.bm: chat groups need a database")
}
var created int64
seen := map[string]bool{}
for _, code := range codes {
if code == "" || seen[code] {
continue
}
seen[code] = true
name := chatGroupNames[code]
if name == "" {
name = code
}
res := db.WithContext(ctx).Exec(`INSERT INTO user_groups (name, code, description, created_at, updated_at)
SELECT ?, ?, ?, NOW(), NOW()
WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = ?)`, name, code, "Czat BM (jz.bm)", code)
if res.Error != nil {
return created, res.Error
}
created += res.RowsAffected
}
return created, nil
}
// EnsureGeneral finds or creates the group conversation of context bm/1
// (golem15.chat GetGroupChat, which also joins actorID) and adopts it as
// the system default channel ogólny, so mentions accrue on it. Adoption is
// idempotent. actorID must be a chat-group member.
func EnsureGeneral(ctx context.Context, db *gorm.DB, chat *chatclasses.Service, channels *chanclasses.ChannelService, actorID uint) (*chatmodels.Conversation, *chanmodels.Channel, error) {
if db == nil || chat == nil || channels == nil {
return nil, nil, errors.New("jz.bm: ogólny needs the database and the chat services")
}
var conv *chatmodels.Conversation
var ch *chanmodels.Channel
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", chatGeneralLock).Error; err != nil {
return err
}
c, err := chat.WithDB(tx).GetGroupChat(ctx, actorID, ChatContextType, ChatContextID)
if err != nil {
return err
}
yes := true
adopted, err := channels.WithDB(tx).AdoptConversation(ctx, ChatContextType, ChatContextID, c.ID, chanclasses.AdoptConversationInput{
Name: ChatGeneralName,
Slug: ChatGeneralSlug,
IsSystem: &yes,
IsDefault: &yes,
})
if err != nil {
return err
}
conv, ch = c, adopted
return nil
})
if err != nil {
return nil, nil, err
}
return conv, ch, nil
}
// FindGeneral returns the group conversation of context bm/1, or nil when
// nobody has opened the chat yet.
func FindGeneral(ctx context.Context, db *gorm.DB) (*chatmodels.Conversation, error) {
var conv chatmodels.Conversation
err := db.WithContext(ctx).
Where("type = ? AND context_type = ? AND context_id = ?", chatmodels.TypeGroup, ChatContextType, ChatContextID).
Order("id").Take(&conv).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &conv, nil
}
// SyncChatParticipants makes ogólny's participants follow the chat group,
// in one transaction: every member missing from ogólny (generalID) joins
// it, and users no longer in the group leave it. DM participant rows are
// kept (a re-added user gets their DMs back; until then golem15.chat's
// live membership check denies access). Nothing is pruned while no group
// with that code exists. It returns how many rows it added and removed.
func SyncChatParticipants(ctx context.Context, db *gorm.DB, generalID uint, group string) (int64, int64, error) {
if db == nil {
return 0, 0, errors.New("jz.bm: chat sync needs a database")
}
if group == "" {
return 0, 0, errors.New("jz.bm: chat sync needs a chat group code")
}
var added, removed int64
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
if generalID != 0 {
res := tx.Exec(`INSERT INTO golem15_chat_participants (conversation_id, user_id, joined_at)
SELECT ?, m.user_id, NOW() FROM (`+chatMembersSQL+`) m
ON CONFLICT (conversation_id, user_id) DO NOTHING`, generalID, group)
if res.Error != nil {
return res.Error
}
added = res.RowsAffected
}
res := tx.Exec(`DELETE FROM golem15_chat_participants
WHERE conversation_id IN (`+chatGroupConversationsSQL+`)
AND user_id NOT IN (`+chatMembersSQL+`)
AND EXISTS (SELECT 1 FROM user_groups WHERE code = ?)`, ChatContextType, ChatContextID, chatmodels.TypeGroup, group, group)
if res.Error != nil {
return res.Error
}
removed = res.RowsAffected
return nil
})
return added, removed, err
}
// PruneChatParticipant removes userID from ogólny (the group
// conversations of context bm/1); their DM rows are kept, as in
// SyncChatParticipants. It runs when someone outside the chat group asks
// for the chat.
func PruneChatParticipant(ctx context.Context, db *gorm.DB, userID uint) error {
if db == nil {
return errors.New("jz.bm: chat prune needs a database")
}
return db.WithContext(ctx).Exec(`DELETE FROM golem15_chat_participants
WHERE user_id = ? AND conversation_id IN (`+chatGroupConversationsSQL+`)`, userID, ChatContextType, ChatContextID, chatmodels.TypeGroup).Error
}
// AssignMissingHandles gives every chat-group member without a handle one
// (golem15.user AssignHandle), so they can be @mentioned, and returns how
// many it assigned.
func AssignMissingHandles(ctx context.Context, db *gorm.DB, group string) (int, error) {
if db == nil {
return 0, errors.New("jz.bm: handles need a database")
}
if group == "" {
return 0, nil
}
var users []usermodels.User
err := db.WithContext(ctx).
Where("id IN ("+chatMembersSQL+")", group).
Where("handle IS NULL OR handle = ''").
Order("id").Find(&users).Error
if err != nil {
return 0, err
}
n := 0
for i := range users {
if err := userclasses.AssignHandle(ctx, db, &users[i]); err != nil {
return n, err
}
n++
}
return n, nil
}