- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
425 lines
13 KiB
Go
425 lines
13 KiB
Go
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
|
|
})
|
|
}
|