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` // chatConversationsSQL selects the ids of the conversations of context // bm/1 (ogólny and every DM). const chatConversationsSQL = `SELECT id FROM golem15_chat_conversations WHERE context_type = ? AND context_id = ?` // 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 the chat'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 lose their participant rows // in every conversation of context bm/1. Nothing is pruned while no group // with that code exists, so a mistyped code cannot wipe the DMs. 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 (`+chatConversationsSQL+`) AND user_id NOT IN (`+chatMembersSQL+`) AND EXISTS (SELECT 1 FROM user_groups WHERE code = ?)`, ChatContextType, ChatContextID, group, group) if res.Error != nil { return res.Error } removed = res.RowsAffected return nil }) return added, removed, err } // PruneChatParticipant removes userID from every conversation of context // bm/1. 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 (`+chatConversationsSQL+`)`, userID, ChatContextType, ChatContextID).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 }