diff --git a/chat.go b/chat.go index 1fb2b76..18a6375 100644 --- a/chat.go +++ b/chat.go @@ -2,10 +2,13 @@ package bm import ( "context" + "database/sql" "errors" chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts" + chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/jakub/sm-bm-plugin/classes" "gorm.io/gorm" ) @@ -24,9 +27,10 @@ const ( ) // registerChat makes jz.bm the host of the BM chat: it registers the bm -// context alias on golem15.chat's registry. Without golem15.chat it does -// nothing, so jz.bm keeps working (and its tests keep activating) without -// the chat plugins; jz.bm does not Require them. +// context alias on golem15.chat's registry and installs the channel access +// policy. Without golem15.chat it does nothing, so jz.bm keeps working (and +// its tests keep activating) without the chat plugins; jz.bm does not +// Require them. func (p *Plugin) registerChat(app *backpack.App) error { if app == nil || !app.HasPlugin("golem15.chat") { return nil @@ -35,17 +39,44 @@ func (p *Plugin) registerChat(app *backpack.App) error { if !ok || reg == nil { return errors.New("jz.bm: golem15.chat must be listed before jz.bm in summer.yaml") } - return reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}) + if err := reg.Add(p.ID(), chatcontracts.Registration{Alias: classes.ChatContextType, Loader: p.loadChatContext}); err != nil { + return err + } + return p.installChatAccess(app) } // loadChatContext is the loader of the bm alias. The only context is id 1; // any other id is unknown, which golem15.chat answers with its not-a-member // error. -func (p *Plugin) loadChatContext(_ context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) { - if id != classes.ChatContextID { - return nil, nil +func (p *Plugin) loadChatContext(ctx context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) { + return classes.ChatContextLoader(p.chatGroup, p.chatModeratorGroup)(ctx, db, id) +} + +// installChatAccess installs BM's access policy on golem15.chatchannels' +// channel service. The service is published from chatchannels' own +// database hook, which runs before this one because chatchannels boots +// first; under a database-less boot (tests, route:list) neither runs. +func (p *Plugin) installChatAccess(app *backpack.App) error { + if app == nil || !app.HasPlugin("golem15.chatchannels") { + return nil } - return classes.NewChatContext(db, p.chatGroup(), p.chatModeratorGroup()), nil + return lagoon.OnDatabase(app, func(_ *sql.DB, _ *gorm.DB) error { + svc, ok := app.Lookup[*chanclasses.ChannelService]() + if !ok || svc == nil { + return errors.New("jz.bm: golem15.chatchannels must be listed before jz.bm in summer.yaml") + } + svc.SetAccessResolver(classes.NewChatAccess(p.database, p.chatGroup(), nil)) + return nil + }) +} + +// database returns the published application database, or nil. +func (p *Plugin) database() *gorm.DB { + if p.app == nil { + return nil + } + db, _ := p.app.Lookup[*gorm.DB]() + return db } // chatGroup is the code of the user group whose members are in the chat. diff --git a/classes/chat_context.go b/classes/chat_context.go index 8f508a5..5e43996 100644 --- a/classes/chat_context.go +++ b/classes/chat_context.go @@ -4,11 +4,15 @@ import ( "context" chatcontracts "git.golem15.com/golem15/sm-chat-plugin/contracts" + chancontracts "git.golem15.com/golem15/sm-chatchannels-plugin/contracts" userclasses "git.golem15.com/golem15/sm-user-plugin/classes" "gorm.io/gorm" ) -var _ chatcontracts.Context = (*ChatContext)(nil) +var ( + _ chatcontracts.Context = (*ChatContext)(nil) + _ chancontracts.AccessResolver = (*ChatAccess)(nil) +) // The BM chat context: one context of type bm with id 1, whose members are // the members of one user group. @@ -29,6 +33,46 @@ const ( ChatBroadcastChannel = "chat:context:1" ) +// MemberFunc reports whether userID belongs to the user group with code +// group. IsChatMember is the production check; tests inject their own. +type MemberFunc func(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error) + +// IsChatMember reports whether userID is in the chat group group. No user, +// no group code or no database is never a member. +func IsChatMember(ctx context.Context, db *gorm.DB, userID uint, group string) (bool, error) { + if userID == 0 || group == "" || db == nil { + return false, nil + } + return userclasses.HasGroupCode(ctx, db, userID, group) +} + +// IsChatModerator reports whether userID is in the moderator group +// moderatorGroup. +func IsChatModerator(ctx context.Context, db *gorm.DB, userID uint, moderatorGroup string) (bool, error) { + return IsChatMember(ctx, db, userID, moderatorGroup) +} + +// ChatMemberIDs lists the ids of the users in group that are not deleted, +// ordered by id. A group code with no group row has no member. +func ChatMemberIDs(ctx context.Context, db *gorm.DB, group string) ([]uint, error) { + ids := []uint{} + if group == "" || db == nil { + return ids, nil + } + err := db.WithContext(ctx). + Table("users_groups"). + Joins("JOIN user_groups ON user_groups.id = users_groups.user_group_id"). + Joins("JOIN users ON users.id = users_groups.user_id"). + Where("user_groups.code = ? AND users.deleted_at IS NULL", group). + Distinct(). + Order("users_groups.user_id"). + Pluck("users_groups.user_id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + // ChatContext is the golem15.chat context of the BM chat. Membership is the // user group named by group; nobody oversees other people's chats, so a // moderator reads no DM and edits no foreign message. @@ -36,6 +80,7 @@ type ChatContext struct { db *gorm.DB group string moderatorGroup string + isMember MemberFunc } // NewChatContext returns the BM chat context over db for the chat group @@ -44,6 +89,12 @@ func NewChatContext(db *gorm.DB, group, moderatorGroup string) *ChatContext { return &ChatContext{db: db, group: group, moderatorGroup: moderatorGroup} } +// WithMembership replaces the group check (IsChatMember) and returns c. +func (c *ChatContext) WithMembership(fn MemberFunc) *ChatContext { + c.isMember = fn + return c +} + func (c *ChatContext) ID() uint { return ChatContextID } func (c *ChatContext) Type() string { return ChatContextType } func (c *ChatContext) ChatEnabled() bool { return true } @@ -67,25 +118,63 @@ func (c *ChatContext) HasMember(ctx context.Context, userID uint) (bool, error) if userID == 0 || c.group == "" { return false, nil } - return userclasses.HasGroupCode(ctx, c.db, userID, c.group) + if c.isMember != nil { + return c.isMember(ctx, c.db, userID, c.group) + } + return IsChatMember(ctx, c.db, userID, c.group) } // Members lists the ids of the chat group's users that are not deleted. func (c *ChatContext) Members(ctx context.Context) ([]uint, error) { - ids := []uint{} - if c.group == "" { - return ids, nil - } - err := c.db.WithContext(ctx). - Table("users_groups"). - Joins("JOIN user_groups ON user_groups.id = users_groups.user_group_id"). - Joins("JOIN users ON users.id = users_groups.user_id"). - Where("user_groups.code = ? AND users.deleted_at IS NULL", c.group). - Distinct(). - Order("users_groups.user_id"). - Pluck("users_groups.user_id", &ids).Error - if err != nil { - return nil, err - } - return ids, nil + return ChatMemberIDs(ctx, c.db, c.group) +} + +// ChatContextLoader is the golem15.chat loader of the bm alias. The only +// context is id 1; any other id is unknown (nil, nil), which golem15.chat +// answers with its not-a-member error. The group codes are read per load, +// so a config change needs no re-registration. +func ChatContextLoader(group, moderatorGroup func() string) chatcontracts.Loader { + return func(_ context.Context, db *gorm.DB, id uint) (chatcontracts.Context, error) { + if id != ChatContextID { + return nil, nil + } + return NewChatContext(db, group(), moderatorGroup()), nil + } +} + +// ChatAccess is golem15.chatchannels' access policy for BM: chat-group +// members may access context bm/1 and nothing else, and nobody manages it, +// so no channel is ever created through the channel service. +type ChatAccess struct { + db func() *gorm.DB + group string + isMember MemberFunc +} + +// NewChatAccess returns the access policy for the chat group group. db is +// resolved per check; isMember nil means IsChatMember. +func NewChatAccess(db func() *gorm.DB, group string, isMember MemberFunc) *ChatAccess { + if isMember == nil { + isMember = IsChatMember + } + return &ChatAccess{db: db, group: group, isMember: isMember} +} + +// CanAccessContext is true only for a chat-group member and context bm/1. +// Any error, or no database, denies. +func (a *ChatAccess) CanAccessContext(ctx context.Context, userID uint, contextType string, contextID uint) bool { + if contextType != ChatContextType || contextID != ChatContextID || userID == 0 || a.group == "" || a.db == nil { + return false + } + db := a.db() + if db == nil { + return false + } + ok, err := a.isMember(ctx, db, userID, a.group) + return err == nil && ok +} + +// CanManageContext is always false: nobody creates or edits channels. +func (a *ChatAccess) CanManageContext(context.Context, uint, string, uint) bool { + return false } diff --git a/classes/chat_sync.go b/classes/chat_sync.go new file mode 100644 index 0000000..8886aa2 --- /dev/null +++ b/classes/chat_sync.go @@ -0,0 +1,196 @@ +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 +} diff --git a/console/chat_sync.go b/console/chat_sync.go new file mode 100644 index 0000000..e9a946e --- /dev/null +++ b/console/chat_sync.go @@ -0,0 +1,59 @@ +package console + +import ( + "context" + "fmt" + + "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/jakub/sm-bm-plugin/classes" + "gorm.io/gorm" +) + +// chatSyncName is the name of the chat sync command. +const chatSyncName = "bm:chat-sync" + +// ChatSyncCommand returns the chat sync console command. withDB runs the +// command's work with the application's database; groups returns the chat +// group code and the moderator group code. It never adds anyone to a group: +// members are assigned by hand in the backend. +func ChatSyncCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error, groups func() (string, string)) bonfire.Command { + return bonfire.Command{ + Name: chatSyncName, + Description: "Create the chat groups if missing, sync ogólny participants with the chat group and give members a handle", + Run: func(ctx context.Context, _ bonfire.Input, out bonfire.Output) error { + group, moderatorGroup := groups() + return withDB(ctx, func(db *gorm.DB) error { + created, err := classes.EnsureChatGroups(ctx, db, group, moderatorGroup) + if err != nil { + return fmt.Errorf("%s: groups: %w", chatSyncName, err) + } + general, err := classes.FindGeneral(ctx, db) + if err != nil { + return fmt.Errorf("%s: ogólny: %w", chatSyncName, err) + } + var generalID uint + if general != nil { + generalID = general.ID + } + added, removed, err := classes.SyncChatParticipants(ctx, db, generalID, group) + if err != nil { + return fmt.Errorf("%s: participants: %w", chatSyncName, err) + } + handles, err := classes.AssignMissingHandles(ctx, db, group) + if err != nil { + return fmt.Errorf("%s: handles: %w", chatSyncName, err) + } + out.Printf("groups created: %d\n", created) + if general == nil { + out.Printf("ogólny does not exist yet; it is created when a member first opens the chat\n") + } else { + out.Printf("ogólny participants added: %d\n", added) + } + out.Printf("participant rows removed: %d\n", removed) + out.Printf("handles assigned: %d\n", handles) + out.Success(chatSyncName + ": chat group " + group + " synced") + return nil + }) + }, + } +} diff --git a/controllers/api/chat.go b/controllers/api/chat.go index f6b135c..5f7343f 100644 --- a/controllers/api/chat.go +++ b/controllers/api/chat.go @@ -2,18 +2,105 @@ package api import ( "net/http" + "sort" - userclasses "git.golem15.com/golem15/sm-user-plugin/classes" + 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" + usermodels "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/jakub/sm-bm-plugin/classes" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" ) -// chatBootstrapJSON is the answer of /chat. A user outside the chat group -// gets only member: false. -type chatBootstrapJSON struct { +// chatNonMemberJSON is the whole answer of /chat for a user outside the +// chat group. +type chatNonMemberJSON struct { Member bool `json:"member"` } -// Chat answers GET /chat, the chat bootstrap: whether the caller is in the -// chat group. +// chatBootstrapJSON is the answer of /chat for a chat-group member. +type chatBootstrapJSON struct { + Member bool `json:"member"` + Moderator bool `json:"moderator"` + Context chatContextJSON `json:"context"` + General chatGeneralJSON `json:"general"` + Conversations []chatConversationJSON `json:"conversations"` + Limits chatLimitsJSON `json:"limits"` +} + +type chatContextJSON struct { + Type string `json:"type"` + ID uint `json:"id"` +} + +// chatGeneralJSON is ogólny: its conversation, unread messages and unread +// mentions of the caller. +type chatGeneralJSON struct { + ConversationID uint `json:"conversation_id"` + Name string `json:"name"` + Unread int64 `json:"unread"` + Mentions int64 `json:"mentions"` +} + +// chatConversationJSON is one of the caller's DMs. +type chatConversationJSON struct { + ID uint `json:"id"` + Type string `json:"type"` + OtherUserID uint `json:"other_user_id"` + Unread int64 `json:"unread"` + LastMessage *chatLastMessageJSON `json:"last_message"` +} + +type chatLastMessageJSON struct { + ID uint `json:"id"` + SenderID uint `json:"sender_id"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +// chatLimitsJSON are the composer limits: attachments per message, their +// total size and embeds per message. +type chatLimitsJSON struct { + MaxFiles int `json:"max_files"` + MaxBytes int `json:"max_bytes"` + MaxEmbeds int `json:"max_embeds"` +} + +// chatMemberJSON is one chat-group member as the mention picker and the DM +// picker show them. +type chatMemberJSON struct { + ID uint `json:"id"` + Name string `json:"name"` + Surname string `json:"surname"` + Handle string `json:"handle"` + IsBot bool `json:"is_bot"` +} + +// chatServices returns golem15.chat's and golem15.chatchannels' services, +// or answers an opaque 500 and false while either is missing. +func (h *Handlers) chatServices(w http.ResponseWriter) (*chatclasses.Service, *chanclasses.ChannelService, bool) { + var chat *chatclasses.Service + var channels *chanclasses.ChannelService + if h.ChatService != nil { + chat = h.ChatService() + } + if h.Channels != nil { + channels = h.Channels() + } + if chat == nil || channels == nil { + writeOpaque500(w) + return nil, nil, false + } + return chat, channels, true +} + +// Chat answers GET /chat, the chat bootstrap. A user outside the chat group +// gets only member: false and loses any participant rows left in the BM +// chat. A member is joined to ogólny (created and adopted as a channel on +// first use), the participants are synced with the chat group, and the +// answer carries ogólny's unread and mention counts, the caller's DMs +// (newest message first), the moderator flag and the composer limits. func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) { userID, ok := h.caller(w, r) if !ok { @@ -23,14 +110,209 @@ func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) { if !ok { return } - member := false - if h.ChatGroup != "" { - var err error - member, err = userclasses.HasGroupCode(r.Context(), db, userID, h.ChatGroup) - if err != nil { + chat, channels, ok := h.chatServices(w) + if !ok { + return + } + ctx := r.Context() + member, err := classes.IsChatMember(ctx, db, userID, h.ChatGroup) + if err != nil { + writeOpaque500(w) + return + } + if !member { + if err := classes.PruneChatParticipant(ctx, db, userID); err != nil { writeOpaque500(w) return } + writeData(w, chatNonMemberJSON{Member: false}) + return } - writeData(w, chatBootstrapJSON{Member: member}) + general, channel, err := classes.EnsureGeneral(ctx, db, chat, channels, userID) + if err != nil { + writeOpaque500(w) + return + } + if _, _, err := classes.SyncChatParticipants(ctx, db, general.ID, h.ChatGroup); err != nil { + writeOpaque500(w) + return + } + views, err := chat.GetConversationsForUser(ctx, userID, classes.ChatContextType, classes.ChatContextID) + if err != nil { + writeOpaque500(w) + return + } + mentions, err := channels.MentionCountFor(ctx, userID, channel) + if err != nil { + writeOpaque500(w) + return + } + moderator, err := classes.IsChatModerator(ctx, db, userID, h.ChatModeratorGroup) + if err != nil { + writeOpaque500(w) + return + } + out := chatBootstrapJSON{ + Member: true, + Moderator: moderator, + Context: chatContextJSON{Type: classes.ChatContextType, ID: classes.ChatContextID}, + General: chatGeneralJSON{ConversationID: general.ID, Name: classes.ChatGeneralName, Mentions: mentions}, + Limits: chatLimitsJSON{ + MaxFiles: chatclasses.MaxAttachments, + MaxBytes: h.ChatMaxUploadBytes, + MaxEmbeds: h.ChatMaxEmbeds, + }, + } + dms := []chatclasses.ConversationView{} + for _, v := range views { + switch { + case v.Conversation.ID == general.ID: + out.General.Unread = v.UnreadCount + case v.Conversation.Type == chatmodels.TypeDM: + dms = append(dms, v) + } + } + others, err := otherParticipants(db, dms, userID) + if err != nil { + writeOpaque500(w) + return + } + out.Conversations = newChatConversationsJSON(dms, others) + writeData(w, out) +} + +// otherParticipants maps each DM to the participant who is not userID. +func otherParticipants(db *gorm.DB, dms []chatclasses.ConversationView, userID uint) (map[uint]uint, error) { + others := map[uint]uint{} + if len(dms) == 0 { + return others, nil + } + ids := make([]uint, len(dms)) + for i, v := range dms { + ids[i] = v.Conversation.ID + } + var rows []chatmodels.Participant + err := db.Select("conversation_id", "user_id"). + Where("conversation_id IN ? AND user_id <> ?", ids, userID). + Order("id").Find(&rows).Error + if err != nil { + return nil, err + } + for _, p := range rows { + if _, ok := others[p.ConversationID]; !ok { + others[p.ConversationID] = p.UserID + } + } + return others, nil +} + +// newChatConversationsJSON lists the DMs newest message first; DMs without +// a message come last, newest conversation first. +func newChatConversationsJSON(dms []chatclasses.ConversationView, others map[uint]uint) []chatConversationJSON { + sort.SliceStable(dms, func(i, j int) bool { + a, b := dms[i].LastMessage, dms[j].LastMessage + switch { + case a == nil && b == nil: + return dms[i].Conversation.ID > dms[j].Conversation.ID + case a == nil || b == nil: + return b == nil + case a.CreatedAt == nil || b.CreatedAt == nil: + return a.ID > b.ID + case !a.CreatedAt.Equal(*b.CreatedAt): + return a.CreatedAt.After(*b.CreatedAt) + } + return a.ID > b.ID + }) + out := make([]chatConversationJSON, 0, len(dms)) + for _, v := range dms { + row := chatConversationJSON{ + ID: v.Conversation.ID, + Type: chatmodels.TypeDM, + OtherUserID: others[v.Conversation.ID], + Unread: v.UnreadCount, + } + if m := v.LastMessage; m != nil { + last := &chatLastMessageJSON{ID: m.ID, SenderID: m.SenderID} + if m.Content != nil { + last.Content = *m.Content + } + if m.CreatedAt != nil { + last.CreatedAt = models.FormatDateTime(*m.CreatedAt) + } + row.LastMessage = last + } + out = append(out, row) + } + return out +} + +// ChatMembers answers GET /chat/members: the chat-group members with their +// name, surname, @handle and bot flag, ordered by id. Members without a +// handle get one first. Anyone outside the chat group gets 404. +func (h *Handlers) ChatMembers(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + ctx := r.Context() + member, err := classes.IsChatMember(ctx, db, userID, h.ChatGroup) + if err != nil { + writeOpaque500(w) + return + } + if !member { + writeNotFound(w) + return + } + if _, err := classes.AssignMissingHandles(ctx, db, h.ChatGroup); err != nil { + writeOpaque500(w) + return + } + ids, err := classes.ChatMemberIDs(ctx, db, h.ChatGroup) + if err != nil { + writeOpaque500(w) + return + } + out := make([]chatMemberJSON, 0, len(ids)) + if len(ids) == 0 { + writeData(w, out) + return + } + var users []usermodels.User + if err := db.Select("id", "name", "surname", "handle").Where("id IN ?", ids).Order("id").Find(&users).Error; err != nil { + writeOpaque500(w) + return + } + bots := map[uint]bool{} + if h.ChatBots { + var botIDs []uint + if err := db.Table("golem15_chatbots_bot_flags").Where("is_bot AND user_id IN ?", ids).Pluck("user_id", &botIDs).Error; err != nil { + writeOpaque500(w) + return + } + for _, id := range botIDs { + bots[id] = true + } + } + for _, u := range users { + out = append(out, chatMemberJSON{ + ID: u.ID, + Name: deref(u.Name), + Surname: deref(u.Surname), + Handle: deref(u.Handle), + IsBot: bots[u.ID], + }) + } + writeData(w, out) +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s } diff --git a/plugin.go b/plugin.go index b0063d8..445f906 100644 --- a/plugin.go +++ b/plugin.go @@ -99,10 +99,14 @@ func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() } -// Commands returns the generated commands, bm:seed and websockets:health. -// API tokens are managed application-wide by golem15.user. +// Commands returns the generated commands, bm:seed, bm:chat-sync and +// websockets:health. API tokens are managed application-wide by +// golem15.user. func (p *Plugin) Commands() []bonfire.Command { cmds := append(generatedCommands(), console.SeedCommand(p.withDB)) + cmds = append(cmds, console.ChatSyncCommand(p.withDB, func() (string, string) { + return p.chatGroup(), p.chatModeratorGroup() + })) return append(cmds, centrifugo.Commands(p.app)...) } diff --git a/registry.gen.go b/registry.gen.go index da1df19..a184189 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -43,6 +43,7 @@ func generatedMigrations() []*gormigrate.Migration { updates.MakeCourseIDNullable(), updates.AddLecturerToClassSessions(), updates.CreateReadingOwnership(), + updates.SeedChatGroups(), } } diff --git a/routes.go b/routes.go index e3113f0..a0578f7 100644 --- a/routes.go +++ b/routes.go @@ -131,6 +131,7 @@ func (p *Plugin) Routes(r pact.Router) error { g.Delete("/calendar-feed", h.DeleteCalendarFeed) if p.app != nil && p.app.HasPlugin("golem15.chat") { g.Get("/chat", h.Chat, bucketChat) + g.Get("/chat/members", h.ChatMembers, bucketChat) } }) // The management API: what bm:seed does, plus materials, diff --git a/updates/20261026130000_seed_jz_bm_chat_groups.go b/updates/20261026130000_seed_jz_bm_chat_groups.go new file mode 100644 index 0000000..edb1673 --- /dev/null +++ b/updates/20261026130000_seed_jz_bm_chat_groups.go @@ -0,0 +1,31 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// SeedChatGroups returns the 20261026130000_seed_jz_bm_chat_groups gormigrate +// entry: the user groups studenci (the chat's members) and moderatorzy (its +// moderators), each created only when no group with that code exists +// (user_groups.code is not unique). No user is put into either group; +// Jakub assigns members in the backend. +// +// Rollback leaves both groups in place: they may already carry memberships +// assigned in the backend, or have existed before this migration. +func SeedChatGroups() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261026130000_seed_jz_bm_chat_groups", + Migrate: func(tx *gorm.DB) error { + return execAll(tx, `INSERT INTO user_groups (name, code, description, created_at, updated_at) +SELECT 'Studenci', 'studenci', 'Czat BM (jz.bm)', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = 'studenci'); +INSERT INTO user_groups (name, code, description, created_at, updated_at) +SELECT 'Moderatorzy czatu', 'moderatorzy', 'Czat BM (jz.bm)', NOW(), NOW() +WHERE NOT EXISTS (SELECT 1 FROM user_groups WHERE code = 'moderatorzy')`) + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + } +}