package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initMailChannel registers mail.channel and mail.channel.member models. // Mirrors: odoo/addons/mail/models/discuss_channel.py func initMailChannel() { m := orm.NewModel("mail.channel", orm.ModelOpts{ Description: "Discussion Channel", Order: "name", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), orm.Selection("channel_type", []orm.SelectionItem{ {Value: "channel", Label: "Channel"}, {Value: "chat", Label: "Direct Message"}, {Value: "group", Label: "Group"}, }, orm.FieldOpts{String: "Type", Default: "channel", Required: true}), orm.Text("description", orm.FieldOpts{String: "Description"}), orm.Many2one("create_uid", "res.users", orm.FieldOpts{String: "Created By", Readonly: true}), orm.Boolean("public", orm.FieldOpts{String: "Public", Default: true, Help: "If true, any internal user can join. If false, invitation only."}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), orm.Many2one("group_id", "res.groups", orm.FieldOpts{String: "Authorized Group"}), orm.One2many("member_ids", "mail.channel.member", "channel_id", orm.FieldOpts{String: "Members"}), orm.Integer("member_count", orm.FieldOpts{ String: "Member Count", Compute: "_compute_member_count", }), orm.Many2one("last_message_id", "mail.message", orm.FieldOpts{String: "Last Message"}), orm.Datetime("last_message_date", orm.FieldOpts{String: "Last Message Date"}), ) m.RegisterCompute("member_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var count int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM mail_channel_member WHERE channel_id = $1`, id, ).Scan(&count); err != nil { return orm.Values{"member_count": int64(0)}, nil } return orm.Values{"member_count": count}, nil }) // action_join: Current user joins the channel. m.RegisterMethod("action_join", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() channelID := rs.IDs()[0] // Get current user's partner var partnerID int64 if err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&partnerID); err != nil || partnerID == 0 { return nil, fmt.Errorf("mail.channel: cannot find partner for user %d", env.UID()) } // Check not already member var exists bool env.Tx().QueryRow(env.Ctx(), `SELECT EXISTS(SELECT 1 FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2)`, channelID, partnerID, ).Scan(&exists) if exists { return true, nil // Already a member } memberRS := env.Model("mail.channel.member") if _, err := memberRS.Create(orm.Values{ "channel_id": channelID, "partner_id": partnerID, }); err != nil { return nil, fmt.Errorf("mail.channel: join %d: %w", channelID, err) } return true, nil }) // action_leave: Current user leaves the channel. m.RegisterMethod("action_leave", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() channelID := rs.IDs()[0] var partnerID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&partnerID) if _, err := env.Tx().Exec(env.Ctx(), `DELETE FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID); err != nil { return nil, fmt.Errorf("mail.channel: leave %d: %w", channelID, err) } return true, nil }) // message_post: Post a message to the channel. m.RegisterMethod("message_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() channelID := rs.IDs()[0] body := "" if len(args) > 0 { if kw, ok := args[0].(map[string]interface{}); ok { if v, ok := kw["body"].(string); ok { body = v } } } var authorID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&authorID) var msgID int64 err := env.Tx().QueryRow(env.Ctx(), `INSERT INTO mail_message (model, res_id, body, message_type, author_id, date, create_uid, write_uid, create_date, write_date) VALUES ('mail.channel', $1, $2, 'comment', $3, NOW(), $4, $4, NOW(), NOW()) RETURNING id`, channelID, body, authorID, env.UID(), ).Scan(&msgID) if err != nil { return nil, fmt.Errorf("mail.channel: post message: %w", err) } // Update channel last message env.Tx().Exec(env.Ctx(), `UPDATE mail_channel SET last_message_id = $1, last_message_date = NOW() WHERE id = $2`, msgID, channelID) return msgID, nil }) // get_messages: Get messages for a channel. m.RegisterMethod("get_messages", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() channelID := rs.IDs()[0] limit := 50 if len(args) > 0 { if kw, ok := args[0].(map[string]interface{}); ok { if v, ok := kw["limit"].(float64); ok && v > 0 { limit = int(v) } } } rows, err := env.Tx().Query(env.Ctx(), `SELECT m.id, m.body, m.date, m.author_id, COALESCE(p.name, '') FROM mail_message m LEFT JOIN res_partner p ON p.id = m.author_id WHERE m.model = 'mail.channel' AND m.res_id = $1 ORDER BY m.id DESC LIMIT $2`, channelID, limit) if err != nil { return nil, fmt.Errorf("mail.channel: get_messages: %w", err) } defer rows.Close() var messages []map[string]interface{} for rows.Next() { var id, authorID int64 var body, authorName string var date interface{} if err := rows.Scan(&id, &body, &date, &authorID, &authorName); err != nil { continue } msg := map[string]interface{}{ "id": id, "body": body, "date": date, } if authorID > 0 { msg["author_id"] = []interface{}{authorID, authorName} } else { msg["author_id"] = false } messages = append(messages, msg) } if messages == nil { messages = []map[string]interface{}{} } return messages, nil }) // channel_get: Get or create a direct message channel between current user and partner. // Mirrors: odoo/addons/mail/models/discuss_channel.py channel_get() m.RegisterMethod("channel_get", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() if len(args) < 1 { return nil, fmt.Errorf("mail.channel: channel_get requires partner_ids") } var partnerIDs []int64 if kw, ok := args[0].(map[string]interface{}); ok { if pids, ok := kw["partner_ids"].([]interface{}); ok { for _, pid := range pids { switch v := pid.(type) { case float64: partnerIDs = append(partnerIDs, int64(v)) case int64: partnerIDs = append(partnerIDs, v) } } } } // Add current user's partner var myPartnerID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&myPartnerID) if myPartnerID > 0 { partnerIDs = append(partnerIDs, myPartnerID) } if len(partnerIDs) < 2 { return nil, fmt.Errorf("mail.channel: need at least 2 partners for DM") } // Check if DM channel already exists between these partners var existingID int64 env.Tx().QueryRow(env.Ctx(), `SELECT c.id FROM mail_channel c WHERE c.channel_type = 'chat' AND (SELECT COUNT(*) FROM mail_channel_member m WHERE m.channel_id = c.id) = $1 AND NOT EXISTS ( SELECT 1 FROM mail_channel_member m WHERE m.channel_id = c.id AND m.partner_id != ALL($2) ) LIMIT 1`, len(partnerIDs), partnerIDs, ).Scan(&existingID) if existingID > 0 { return map[string]interface{}{"id": existingID}, nil } // Create new DM channel var partnerName string for _, pid := range partnerIDs { if pid != myPartnerID { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '') FROM res_partner WHERE id = $1`, pid, ).Scan(&partnerName) break } } channelRS := env.Model("mail.channel") channel, err := channelRS.Create(orm.Values{ "name": partnerName, "channel_type": "chat", "public": false, }) if err != nil { return nil, fmt.Errorf("mail.channel: create DM: %w", err) } channelID := channel.ID() // Add members memberRS := env.Model("mail.channel.member") for _, pid := range partnerIDs { memberRS.Create(orm.Values{ "channel_id": channelID, "partner_id": pid, }) } return map[string]interface{}{"id": channelID}, nil }) // -- mail.channel.member -- initMailChannelMember() } func initMailChannelMember() { m := orm.NewModel("mail.channel.member", orm.ModelOpts{ Description: "Channel Member", Order: "id", }) m.AddFields( orm.Many2one("channel_id", "mail.channel", orm.FieldOpts{ String: "Channel", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{ String: "Partner", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Datetime("last_seen_dt", orm.FieldOpts{String: "Last Seen"}), orm.Many2one("last_seen_message_id", "mail.message", orm.FieldOpts{String: "Last Seen Message"}), orm.Boolean("is_pinned", orm.FieldOpts{String: "Pinned", Default: true}), orm.Boolean("is_muted", orm.FieldOpts{String: "Muted", Default: false}), ) m.AddSQLConstraint( "unique_channel_partner", "UNIQUE(channel_id, partner_id)", "A partner can only be a member of a channel once.", ) // mark_as_read: Update last seen timestamp and message. m.RegisterMethod("mark_as_read", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE mail_channel_member SET last_seen_dt = NOW(), last_seen_message_id = ( SELECT MAX(m.id) FROM mail_message m WHERE m.model = 'mail.channel' AND m.res_id = (SELECT channel_id FROM mail_channel_member WHERE id = $1) ) WHERE id = $1`, id); err != nil { return nil, fmt.Errorf("mail.channel.member: mark_as_read %d: %w", id, err) } } return true, nil }) } // initMailChannelExtensions adds unread count compute after message model is registered. func initMailChannelExtensions() { ch := orm.ExtendModel("mail.channel") ch.AddFields( orm.Integer("message_unread_count", orm.FieldOpts{ String: "Unread Messages", Compute: "_compute_message_unread_count", }), ) ch.RegisterCompute("message_unread_count", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() channelID := rs.IDs()[0] var partnerID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&partnerID) if partnerID == 0 { return orm.Values{"message_unread_count": int64(0)}, nil } var lastSeenID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(last_seen_message_id, 0) FROM mail_channel_member WHERE channel_id = $1 AND partner_id = $2`, channelID, partnerID, ).Scan(&lastSeenID) var count int64 env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM mail_message WHERE model = 'mail.channel' AND res_id = $1 AND id > $2`, channelID, lastSeenID, ).Scan(&count) return orm.Values{"message_unread_count": count}, nil }) } // initDiscussBus registers the message bus polling endpoint logic. func initDiscussBus() { ch := orm.ExtendModel("mail.channel") // channel_fetch_preview: Get channel list with last message for discuss sidebar. // Mirrors: odoo/addons/mail/models/discuss_channel.py channel_fetch_preview() ch.RegisterMethod("channel_fetch_preview", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() var partnerID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_id, 0) FROM res_users WHERE id = $1`, env.UID(), ).Scan(&partnerID) if partnerID == 0 { return []map[string]interface{}{}, nil } rows, err := env.Tx().Query(env.Ctx(), `SELECT c.id, c.name, c.channel_type, c.last_message_date, COALESCE(m.body, ''), COALESCE(p.name, ''), (SELECT COUNT(*) FROM mail_message msg WHERE msg.model = 'mail.channel' AND msg.res_id = c.id AND msg.id > COALESCE(cm.last_seen_message_id, 0)) AS unread FROM mail_channel c JOIN mail_channel_member cm ON cm.channel_id = c.id AND cm.partner_id = $1 LEFT JOIN mail_message m ON m.id = c.last_message_id LEFT JOIN res_partner p ON p.id = m.author_id WHERE c.active = true AND cm.is_pinned = true ORDER BY c.last_message_date DESC NULLS LAST`, partnerID) if err != nil { return nil, fmt.Errorf("mail.channel: fetch_preview: %w", err) } defer rows.Close() var channels []map[string]interface{} for rows.Next() { var id int64 var name, channelType, lastBody, lastAuthor string var lastDate *time.Time var unread int64 if err := rows.Scan(&id, &name, &channelType, &lastDate, &lastBody, &lastAuthor, &unread); err != nil { continue } channels = append(channels, map[string]interface{}{ "id": id, "name": name, "channel_type": channelType, "last_message": lastBody, "last_author": lastAuthor, "last_date": lastDate, "unread_count": unread, }) } if channels == nil { channels = []map[string]interface{}{} } return channels, nil }) }