import { authenticate, getKwArgs, logout, makeKwArgs, MockServer, MockServerError, models, serverState, unmakeKwArgs, } from "@web/../tests/web_test_helpers"; import { serializeDateTime } from "@web/core/l10n/dates"; import { registry } from "@web/core/registry"; import { groupBy } from "@web/core/utils/arrays"; export const DISCUSS_ACTION_ID = 104; /** * @template [T={}] * @typedef {import("@web/../tests/web_test_helpers").RouteCallback} RouteCallback */ const { DateTime } = luxon; /** @param {import("./mock_model").MailGuest} guest */ export const authenticateGuest = (guest) => { const { env } = MockServer; /** @type {import("mock_models").ResUsers} */ const ResUsers = env["res.users"]; if (!guest?.id) { throw new MockServerError("Unauthorized"); } const [publicUser] = ResUsers.read(serverState.publicUserId); env.cookie.set("dgid", guest.id); authenticate(publicUser.login, publicUser.password); env.uid = serverState.publicUserId; }; /** * Executes the given callback as the given guest, then restores the previous user. * * @param {number} guestId * @param {() => any} fn */ export async function withGuest(guestId, fn) { const { env } = MockServer; /** @type {import("mock_models").MailGuest} */ const MailGuest = env["mail.guest"]; const currentUser = env.user; const [targetGuest] = MailGuest.browse(guestId); authenticateGuest(targetGuest); let result; try { result = await fn(); } finally { if (currentUser) { authenticate(currentUser.login, currentUser.password); } else { logout(); env.cookie.delete("dgid"); } } return result; } /** @param {Request} request */ export const parseRequestParams = async (request) => { const response = await request.json(); return response.params; }; const onRpcBeforeGlobal = { cb: (route, args) => {} }; const onRpcAfterGlobal = { cb: (route, args) => {} }; // using a registry category to not expose for manual import // We should use `onRpcBefore`/`onRpcAfter` with 1st parameter being (route, args) callback function registry.category("mail.on_rpc_before_global").add(true, onRpcBeforeGlobal); registry.category("mail.on_rpc_after_global").add(true, onRpcAfterGlobal); export function registerRoute(route, handler) { const beforeCallableHandler = async function (request) { let args; try { args = await parseRequestParams(request); } catch { args = await request.text(); } let res = await onRpcBeforeGlobal.cb?.(route, args); if (res !== undefined) { return res; } res = await beforeCallableHandler.before?.(args); if (res !== undefined) { return res; } const response = handler.call(this, request); res = await beforeCallableHandler.after?.(response); if (res !== undefined) { return res; } return response; }; registry.category("mock_rpc").add(route, beforeCallableHandler); } // RPC handlers registerRoute("/mail/attachment/upload", mail_attachment_upload); /** @type {RouteCallback}} */ async function mail_attachment_upload(request) { /** @type {import("mock_models").DiscussVoiceMetadata} */ const DiscussVoiceMetadata = this.env["discuss.voice.metadata"]; /** @type {import("mock_models").IrAttachment} */ const IrAttachment = this.env["ir.attachment"]; const body = await request.text(); const ufile = body.get("ufile"); const is_pending = body.get("is_pending") === "true"; const model = is_pending ? "mail.compose.message" : body.get("thread_model"); const id = is_pending ? 0 : parseInt(body.get("thread_id")); const attachmentId = IrAttachment.create({ // datas, mimetype: ufile.type, name: ufile.name, res_id: id, res_model: model, }); if (body.get("voice")) { DiscussVoiceMetadata.create({ attachment_id: attachmentId }); } return { data: new mailDataHelpers.Store(IrAttachment.browse(attachmentId)).get_result(), }; } registerRoute("/mail/attachment/delete", mail_attachment_delete); /** @type {RouteCallback} */ async function mail_attachment_delete(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").IrAttachment} */ const IrAttachment = this.env["ir.attachment"]; /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; const { attachment_id } = await parseRequestParams(request); const [partner] = ResPartner.read(this.env.user.partner_id); BusBus._sendone(partner, "ir.attachment/delete", { id: attachment_id, }); return IrAttachment.unlink([attachment_id]); } registerRoute("/discuss/channel/attachments", load_attachments); /** @type {RouteCallback} */ async function load_attachments(request) { /** @type {import("mock_models").IrAttachment} */ const IrAttachment = this.env["ir.attachment"]; const { channel_id, limit = 30, older_attachment_id = null, } = await parseRequestParams(request); const attachmentIds = IrAttachment.filter( ({ id, res_id, res_model }) => res_id === channel_id && res_model === "discuss.channel" && (!older_attachment_id || id < older_attachment_id) ) .sort() .slice(0, limit) .map(({ id }) => id); return new mailDataHelpers.Store(IrAttachment.browse(attachmentIds)).get_result(); } registerRoute("/mail/rtc/channel/join_call", channel_call_join); /** @type {RouteCallback} */ async function channel_call_join(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; /** @type {import("mock_models").DiscussChannelRtcSession} */ const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"]; const { channel_id } = await parseRequestParams(request); const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id); const sessionId = DiscussChannelRtcSession.create({ channel_member_id: memberOfCurrentUser.id, channel_id, // on the server, this is a related field from channel_member_id and not explicitly set guest_id: memberOfCurrentUser.guest_id, partner_id: memberOfCurrentUser.partner_id, }); const channelMembers = DiscussChannelMember._filter([["channel_id", "=", channel_id]]); const rtcSessions = DiscussChannelRtcSession._filter([ ["channel_member_id", "in", channelMembers.map((channelMember) => channelMember.id)], ]); return new mailDataHelpers.Store(DiscussChannel.browse(channel_id), { rtcSessions: mailDataHelpers.Store.many(rtcSessions, "ADD"), }) .add("Rtc", { iceServers: false, selfSession: mailDataHelpers.Store.one(DiscussChannelRtcSession.browse(sessionId)), }) .get_result(); } registerRoute("/mail/rtc/channel/leave_call", channel_call_leave); /** @type {RouteCallback} */ async function channel_call_leave(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; /** @type {import("mock_models").DiscussChannelRtcSession} */ const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"]; /** @type {import("mock_models").MailGuest} */ const MailGuest = this.env["mail.guest"]; /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; const { channel_id } = await parseRequestParams(request); const channelMembers = DiscussChannelMember._filter([["channel_id", "=", channel_id]]); const rtcSessions = DiscussChannelRtcSession._filter([ ["channel_member_id", "in", channelMembers.map((channelMember) => channelMember.id)], ]); const notifications = []; const sessionsByChannelId = {}; for (const session of rtcSessions) { const [member] = DiscussChannelMember.browse(session.channel_member_id); if (!sessionsByChannelId[member.channel_id]) { sessionsByChannelId[member.channel_id] = []; } sessionsByChannelId[member.channel_id].push(session); } for (const [channelId, sessions] of Object.entries(sessionsByChannelId)) { const channel = DiscussChannel.search_read([["id", "=", parseInt(channelId)]])[0]; notifications.push([ channel, "mail.record/insert", new mailDataHelpers.Store(DiscussChannel.browse(Number(channelId)), { rtcSessions: mailDataHelpers.Store.many( DiscussChannelRtcSession.browse(sessions.map((session) => session.id)), "DELETE", makeKwArgs({ only_id: true }) ), }).get_result(), ]); } for (const rtcSession of rtcSessions) { const target = rtcSession.guest_id ? MailGuest.search_read([["id", "=", rtcSession.guest_id]])[0] : ResPartner.search_read([["id", "=", rtcSession.partner_id]])[0]; notifications.push([ target, "discuss.channel.rtc.session/ended", { sessionId: rtcSession.id }, ]); } BusBus._sendmany(notifications); } registerRoute("/discuss/channel/fold", discuss_channel_fold); /** @type {RouteCallback} */ async function discuss_channel_fold(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; const { channel_id, state, state_count } = await parseRequestParams(request); const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id); return DiscussChannelMember._channel_fold(memberOfCurrentUser.id, state, state_count); } registerRoute("/discuss/channel/info", discuss_channel_info); /** @type {RouteCallback} */ async function discuss_channel_info(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; const { channel_id } = await parseRequestParams(request); const channel = DiscussChannel.search([["id", "=", channel_id]]); if (!channel.length) { return; } return new mailDataHelpers.Store(channel).get_result(); } registerRoute("/discuss/channel/members", discuss_channel_members); /** @type {RouteCallback} */ async function discuss_channel_members(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; const { channel_id, known_member_ids } = await parseRequestParams(request); return DiscussChannel._load_more_members([channel_id], known_member_ids); } registerRoute("/discuss/channel/messages", discuss_channel_messages); /** @type {RouteCallback} */ async function discuss_channel_messages(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { after, around, before, channel_id, limit = 30, search_term, } = await parseRequestParams(request); const domain = [ ["res_id", "=", channel_id], ["model", "=", "discuss.channel"], ["message_type", "!=", "user_notification"], ]; const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit); const { messages } = res; delete res.messages; if (!around) { MailMessage.set_message_done(messages.map((message) => message.id)); } return { ...res, data: new mailDataHelpers.Store( MailMessage.browse(messages.map((message) => message.id)), makeKwArgs({ for_current_user: true }) ).get_result(), messages: mailDataHelpers.Store.many_ids(messages), }; } registerRoute("/discuss/channel/sub_channel/create", discuss_channel_sub_channel_create); async function discuss_channel_sub_channel_create(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; const { from_message_id, parent_channel_id, name } = await parseRequestParams(request); return DiscussChannel._create_sub_channel( [parent_channel_id], makeKwArgs({ from_message_id, name }) ); } registerRoute("/discuss/channel/sub_channel/fetch", discuss_channel_sub_channel_fetch); async function discuss_channel_sub_channel_fetch(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { parent_channel_id, before, limit } = await parseRequestParams(request); const domain = [["parent_channel_id", "=", parent_channel_id]]; if (before) { domain.push(["id", "<", before]); } const subChannels = DiscussChannel.search(domain, makeKwArgs({ limit, order: "id DESC" })); const store = new mailDataHelpers.Store(subChannels); const lastMessageIds = []; for (const channel of subChannels) { const lastMessageId = Math.max(channel.message_ids); if (lastMessageId) { lastMessageIds.push(lastMessageId); } } store.add(MailMessage.browse(lastMessageIds)); return store.get_result(); } registerRoute("/discuss/settings/mute", discuss_settings_mute); /** @type {RouteCallback} */ async function discuss_settings_mute(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").ResUsersSettings} */ const ResUsersSettings = this.env["res.users.settings"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; const { channel_id, minutes } = await parseRequestParams(request); let mute_until_dt; if (minutes === -1) { mute_until_dt = serializeDateTime(DateTime.fromISO("9999-12-31T23:59:59")); } else if (minutes) { mute_until_dt = serializeDateTime(DateTime.now().plus({ minutes })); } else { mute_until_dt = false; } if (channel_id) { const member = DiscussChannel._find_or_create_member_for_self(channel_id); DiscussChannelMember.write([member.id], { mute_until_dt }); const [partner] = ResPartner.read(this.env.user.partner_id); BusBus._sendone( partner, "mail.record/insert", new mailDataHelpers.Store(DiscussChannel.browse(member.channel_id), { mute_until_dt, }).get_result() ); } else { const settings = ResUsersSettings._find_or_create_for_user(this.env.user.id); ResUsersSettings.set_res_users_settings(settings.id, { mute_until_dt }); } return "dummy"; } registerRoute("/discuss/channel/notify_typing", discuss_channel_notify_typing); /** @type {RouteCallback} */ async function discuss_channel_notify_typing(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; const { channel_id, is_typing } = await parseRequestParams(request); const memberOfCurrentUser = DiscussChannel._find_or_create_member_for_self(channel_id); if (!memberOfCurrentUser) { return; } DiscussChannelMember.notify_typing([memberOfCurrentUser.id], is_typing); } registerRoute("/discuss/channel/ping", channel_ping); /** @type {RouteCallback} */ async function channel_ping(request) {} registerRoute("/discuss/channel/pinned_messages", discuss_channel_pins); /** @type {RouteCallback} */ async function discuss_channel_pins(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { channel_id } = await parseRequestParams(request); const messageIds = MailMessage.search([ ["model", "=", "discuss.channel"], ["res_id", "=", channel_id], ["pinned_at", "!=", false], ]); return new mailDataHelpers.Store( messageIds, makeKwArgs({ for_current_user: true }) ).get_result(); } registerRoute("/discuss/channel/mark_as_read", discuss_channel_mark_as_read); /** @type {RouteCallback} */ async function discuss_channel_mark_as_read(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannelMember = this.env["discuss.channel.member"]; const { channel_id, last_message_id, sync } = await parseRequestParams(request); const [partner, guest] = this.env["res.partner"]._get_current_persona(); const [memberId] = this.env["discuss.channel.member"].search([ ["channel_id", "=", channel_id], partner ? ["partner_id", "=", partner.id] : ["guest_id", "=", guest.id], ]); if (!memberId) { return; // ignore if the member left in the meantime } return DiscussChannelMember._mark_as_read([memberId], last_message_id, sync); } registerRoute("/discuss/channel/mark_as_unread", discuss_channel_mark_as_unread); /** @type {RouteCallback} */ async function discuss_channel_mark_as_unread(request) { const { channel_id, message_id } = await parseRequestParams(request); const [partner, guest] = this.env["res.partner"]._get_current_persona(); const [memberId] = this.env["discuss.channel.member"].search([ ["channel_id", "=", channel_id], partner ? ["partner_id", "=", partner.id] : ["guest_id", "=", guest.id], ]); return this.env["discuss.channel.member"]._set_new_message_separator( [memberId], message_id, true ); } registerRoute("/discuss/gif/favorites", get_favorites); /** @type {RouteCallback} */ async function get_favorites(request) { return [[]]; } registerRoute("/mail/history/messages", discuss_history_messages); /** @type {RouteCallback} */ async function discuss_history_messages(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; /** @type {import("mock_models").MailNotification} */ const MailNotification = this.env["mail.notification"]; const { after, around, before, limit = 30, search_term } = await parseRequestParams(request); const domain = [["needaction", "=", false]]; const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit); const { messages } = res; delete res.messages; const messagesWithNotification = messages.filter((message) => { const notifs = MailNotification.search_read([ ["mail_message_id", "=", message.id], ["is_read", "=", true], ["res_partner_id", "=", this.env.user.partner_id], ]); return notifs.length > 0; }); return { ...res, data: new mailDataHelpers.Store( MailMessage.browse(messagesWithNotification.map((message) => message.id)), makeKwArgs({ for_current_user: true }) ).get_result(), messages: mailDataHelpers.Store.many_ids(messages), }; } registerRoute("/mail/inbox/messages", discuss_inbox_messages); /** @type {RouteCallback} */ async function discuss_inbox_messages(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { after, around, before, limit = 30, search_term } = await parseRequestParams(request); const domain = [["needaction", "=", true]]; const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit); const { messages } = res; delete res.messages; return { ...res, data: new mailDataHelpers.Store( MailMessage.browse(messages.map((message) => message.id)), makeKwArgs({ for_current_user: true, add_followers: true }) ).get_result(), messages: mailDataHelpers.Store.many_ids(messages), }; } registerRoute("/mail/link_preview", mail_link_preview); /** @type {RouteCallback} */ async function mail_link_preview(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").MailLinkPreview} */ const MailLinkPreview = this.env["mail.link.preview"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { message_id } = await parseRequestParams(request); const [message] = MailMessage.search_read([["id", "=", message_id]]); if (message.body.includes("https://make-link-preview.com")) { const linkPreviewId = MailLinkPreview.create({ message_id: message.id, og_description: "test description", og_title: "Article title", og_type: "article", source_url: "https://make-link-preview.com", }); BusBus._sendone( MailMessage._bus_notification_target(message_id), "mail.record/insert", new mailDataHelpers.Store(MailLinkPreview.browse(linkPreviewId)).get_result() ); } } registerRoute("/mail/link_preview/hide", mail_link_preview_hide); /** @type {RouteCallback} */ async function mail_link_preview_hide(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").MailLinkPreview} */ const MailLinkPreview = this.env["mail.link.preview"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { link_preview_ids } = await parseRequestParams(request); for (const linkPreview of MailLinkPreview.browse(link_preview_ids)) { BusBus._sendone( MailMessage._bus_notification_target(linkPreview.message_id), "mail.record/insert", new mailDataHelpers.Store(MailMessage.browse(linkPreview.message_id), { linkPreviews: mailDataHelpers.Store.many( MailLinkPreview.browse(linkPreview.id), "DELETE", makeKwArgs({ only_id: true }) ), }).get_result() ); } return { link_preview_ids }; } registerRoute("/mail/message/post", mail_message_post); /** @type {RouteCallback} */ export async function mail_message_post(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; /** @type {import("mock_models").MailThread} */ const MailThread = this.env["mail.thread"]; /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; const { context, post_data, thread_id, thread_model, partner_emails, partner_additional_values, canned_response_ids, } = await parseRequestParams(request); if (canned_response_ids) { for (const cannedResponseId of canned_response_ids) { this.env["mail.canned.response"].write([cannedResponseId], { last_used: serializeDateTime(DateTime.now()), }); } } if (partner_emails) { post_data.partner_ids = post_data.partner_ids || []; for (const email of partner_emails) { const partner = ResPartner._filter([["email", "=", email]]); if (partner.length !== 0) { post_data.partner_ids.push(partner[0].id); } else { const partner_id = ResPartner.create( Object.assign({ email }, partner_additional_values[email] || {}) ); post_data.partner_ids.push(partner_id); } } } const finalData = {}; const allowedParams = [ "attachment_ids", "body", "message_type", "partner_ids", "subtype_xmlid", ]; if (thread_model === "discuss.channel") { allowedParams.push("parent_id", "special_mentions"); } for (const allowedParam of allowedParams) { if (post_data[allowedParam] !== undefined) { finalData[allowedParam] = post_data[allowedParam]; } } const kwargs = makeKwArgs({ ...finalData, context }); let messageId; if (thread_model === "discuss.channel") { messageId = DiscussChannel.message_post(thread_id, kwargs); } else { const model = this.env[thread_model]; messageId = MailThread.message_post.call(model, [thread_id], { ...kwargs, model: thread_model, }); } return new mailDataHelpers.Store( MailMessage.browse(messageId), makeKwArgs({ for_current_user: true }) ).get_result(); } registerRoute("/mail/message/reaction", mail_message_reaction); /** @type {RouteCallback} */ async function mail_message_reaction(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { action, content, message_id } = await parseRequestParams(request); const partner_id = this.env.user?.partner_id ?? false; const guest_id = this.env.cookie.get("dgid") ?? false; const store = new mailDataHelpers.Store(); MailMessage._message_reaction(message_id, content, partner_id, guest_id, action, store); return store.get_result(); } registerRoute("/mail/message/translate", translate); /** @type {RouteCallback} */ async function translate(request) {} registerRoute("/mail/message/update_content", mail_message_update_content); /** @type {RouteCallback} */ async function mail_message_update_content(request) { /** @type {import("mock_models").BusBus} */ const BusBus = this.env["bus.bus"]; /** @type {import("mock_models").IrAttachment} */ const IrAttachment = this.env["ir.attachment"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { attachment_ids, body, message_id } = await parseRequestParams(request); const [message] = MailMessage.browse(message_id); const msg_values = {}; if (body !== null) { const edit_label = ""; msg_values.body = body === "" && attachment_ids.length === 0 ? "" : body + edit_label; } if (attachment_ids.length === 0) { IrAttachment.unlink(message.attachment_ids); } else { const attachments = IrAttachment.browse(attachment_ids).filter( (attachment) => attachment.res_model === "mail.compose.message" && attachment.create_uid === this.env.user?.id ); IrAttachment.write( attachments.map((attachment) => attachment.id), { model: message.model, res_id: message.res_id, } ); msg_values.attachment_ids = attachment_ids; } MailMessage.write([message_id], msg_values); if (body === "" && attachment_ids.length === 0) { MailMessage.write([message_id], { pinned_at: false }); MailMessage._cleanup_side_records([message_id]); } BusBus._sendone( MailMessage._bus_notification_target(message.id), "mail.record/insert", new mailDataHelpers.Store(MailMessage.browse(message.id), { attachment_ids: mailDataHelpers.Store.many(IrAttachment.browse(message.attachment_ids)), body: message.body, pinned_at: message.pinned_at, recipients: mailDataHelpers.Store.many( this.env["res.partner"].browse(message.partner_ids), makeKwArgs({ fields: ["name", "write_date"] }) ), }).get_result() ); return new mailDataHelpers.Store( MailMessage.browse(message_id), makeKwArgs({ for_current_user: true }) ).get_result(); } registerRoute("/discuss/channel//partner//avatar_128", partnerAvatar128); /** @type {RouteCallback} */ async function partnerAvatar128(request, { cid, pid }) { return [cid, pid]; } registerRoute("/mail/partner/from_email", mail_thread_partner_from_email); /** @type {RouteCallback} */ async function mail_thread_partner_from_email(request) { /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; const { emails, additional_values = {} } = await parseRequestParams(request); const partners = emails.map((email) => ResPartner.search([["email", "=", email]])[0]); for (const index in partners) { if (!partners[index]) { const email = emails[index]; partners[index] = ResPartner.create({ email, name: email, ...(additional_values[email] || {}), }); } } return partners.map((partner_id) => { const [partner] = ResPartner.browse(partner_id); return { id: partner_id, name: partner.name, email: partner.email }; }); } registerRoute("/mail/read_subscription_data", read_subscription_data); /** @type {RouteCallback} */ async function read_subscription_data(request) { /** @type {import("mock_models").MailFollowers} */ const MailFollowers = this.env["mail.followers"]; /** @type {import("mock_models").MailMessageSubtype} */ const MailMessageSubtype = this.env["mail.message.subtype"]; const { follower_id } = await parseRequestParams(request); const [follower] = MailFollowers.browse(follower_id); const subtypes = MailMessageSubtype._filter([ "&", ["hidden", "=", false], "|", ["res_model", "=", follower.res_model], ["res_model", "=", false], ]); const subtypes_list = subtypes.map((subtype) => { const [parent] = MailMessageSubtype.browse(subtype.parent_id); return { default: subtype.default, followed: follower.subtype_ids.includes(subtype.id), id: subtype.id, internal: subtype.internal, name: subtype.name, parent_model: parent ? parent.res_model : false, res_model: subtype.res_model, sequence: subtype.sequence, }; }); // NOTE: server is also doing a sort here, not reproduced for simplicity return subtypes_list; } registerRoute("/mail/rtc/session/update_and_broadcast", session_update_and_broadcast); /** @type {RouteCallback} */ async function session_update_and_broadcast(request) { /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; /** @type {import("mock_models").DiscussChannelRtcSession} */ const DiscussChannelRtcSession = this.env["discuss.channel.rtc.session"]; const { session_id, values } = await parseRequestParams(request); const [session] = DiscussChannelRtcSession.search_read([["id", "=", session_id]]); const [currentChannelMember] = DiscussChannelMember.search_read([ ["id", "=", session.channel_member_id[0]], ]); if (session && currentChannelMember.partner_id[0] === serverState.partnerId) { DiscussChannelRtcSession._update_and_broadcast(session.id, values); } } registerRoute("/mail/starred/messages", discuss_starred_messages); /** @type {RouteCallback} */ async function discuss_starred_messages(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { after, before, limit = 30, search_term } = await parseRequestParams(request); const domain = [["starred_partner_ids", "in", [this.env.user.partner_id]]]; const res = MailMessage._message_fetch(domain, search_term, before, after, false, limit); const { messages } = res; delete res.messages; return { ...res, data: new mailDataHelpers.Store( MailMessage.browse(messages.map((message) => message.id)), makeKwArgs({ for_current_user: true }) ).get_result(), messages: mailDataHelpers.Store.many_ids(messages), }; } registerRoute("/mail/thread/data", mail_thread_data); /** @type {RouteCallback} */ export async function mail_thread_data(request) { const { request_list, thread_model, thread_id } = await parseRequestParams(request); const [thread] = this.env[thread_model].browse(thread_id); if (!thread) { return new mailDataHelpers.Store("mail.thread", { hasReadAccess: false, hasWriteAccess: false, id: thread_id, model: thread_model, }).get_result(); } return new mailDataHelpers.Store( this.env[thread_model].browse(thread_id), makeKwArgs({ as_thread: true, request_list }) ).get_result(); } registerRoute("/mail/thread/messages", mail_thread_messages); /** @type {RouteCallback} */ async function mail_thread_messages(request) { /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; const { after, around, before, limit, search_term, thread_id, thread_model } = await parseRequestParams(request); const domain = [ ["res_id", "=", thread_id], ["model", "=", thread_model], ["message_type", "!=", "user_notification"], ]; const res = MailMessage._message_fetch(domain, search_term, before, after, around, limit); const { messages } = res; delete res.messages; MailMessage.set_message_done(messages.map((message) => message.id)); return { ...res, data: new mailDataHelpers.Store( MailMessage.browse(messages.map((message) => message.id)), makeKwArgs({ for_current_user: true }) ).get_result(), messages: mailDataHelpers.Store.many_ids(messages), }; } registerRoute("/mail/action", mail_action); /** @type {RouteCallback} */ async function mail_action(request) { return (await mailDataHelpers.processRequest.call(this, request)).get_result(); } registerRoute("/mail/data", mail_data); /** @type {RouteCallback} */ async function mail_data(request) { return (await mailDataHelpers.processRequest.call(this, request)).get_result(); } /** @type {RouteCallback} */ async function processRequest(request) { /** @type {import("mock_models").DiscussChannel} */ const DiscussChannel = this.env["discuss.channel"]; /** @type {import("mock_models").DiscussChannelMember} */ const DiscussChannelMember = this.env["discuss.channel.member"]; /** @type {import("mock_models").MailGuest} */ const MailGuest = this.env["mail.guest"]; /** @type {import("mock_models").MailMessage} */ const MailMessage = this.env["mail.message"]; /** @type {import("mock_models").MailNotification} */ const MailNotification = this.env["mail.notification"]; /** @type {import("mock_models").ResPartner} */ const ResPartner = this.env["res.partner"]; /** @type {import("mock_models").ResUsers} */ const ResUsers = this.env["res.users"]; const store = new mailDataHelpers.Store(); const args = await parseRequestParams(request); if ("init_messaging" in args) { if (!MailGuest._get_guest_from_context() || !ResUsers._is_public(this.env.uid)) { ResUsers._init_messaging([this.env.uid], store, args.context); } const guest = ResUsers._is_public(this.env.uid) && MailGuest._get_guest_from_context(); const members = DiscussChannelMember._filter([ guest ? ["guest_id", "=", guest.id] : ["partner_id", "=", this.env.user.partner_id], "|", ["fold_state", "in", ["open", "folded"]], ["rtc_inviting_session_id", "!=", false], ]); const channelsDomain = [["id", "in", members.map((m) => m.channel_id)]]; const { channelTypes } = args.init_messaging; if (channelTypes) { channelsDomain.push(["channel_type", "in", channelTypes]); } store.add(DiscussChannel.search(channelsDomain)); } if (args.failures && this.env.user?.partner_id) { const [partner] = ResPartner.browse(this.env.user.partner_id); const messages = MailMessage._filter([ ["author_id", "=", partner.id], ["res_id", "!=", 0], ["model", "!=", false], ["message_type", "!=", "user_notification"], ]).filter((message) => { // Purpose is to simulate the following domain on mail.message: // ['notification_ids.notification_status', 'in', ['bounce', 'exception']], // But it's not supported by getRecords domain to follow a relation. const notifications = MailNotification._filter([ ["mail_message_id", "=", message.id], ["notification_status", "in", ["bounce", "exception"]], ]); return notifications.length > 0; }); messages.length = Math.min(messages.length, 100); MailMessage._message_notifications_to_store( messages.map((message) => message.id), store ); } if (args.systray_get_activities && this.env.user?.partner_id) { const bus_last_id = this.env["bus.bus"].lastBusNotificationId; const groups = ResUsers._get_activity_groups(); store.add({ activityCounter: groups.reduce( (counter, group) => counter + (group.total_count || 0), 0 ), activity_counter_bus_id: bus_last_id, activityGroups: groups, }); } if (args.channels_as_member) { const channels = DiscussChannel._get_channels_as_member(); store.add( MailMessage.browse( channels .map( (channel) => MailMessage._filter([ ["model", "=", "discuss.channel"], ["res_id", "=", channel.id], ]).sort((a, b) => b.id - a.id)[0] ) .filter((lastMessage) => lastMessage) .map((message) => message.id) ), makeKwArgs({ for_current_user: true }) ); store.add(channels.map((channel) => channel.id)); } if (args.canned_responses) { const domain = [ "|", ["create_uid", "=", this.env.user.id], ["group_ids", "in", this.env.user.groups_id], ]; store.add(this.env["mail.canned.response"].search(domain)); } return store; } const ids_by_model = { "mail.thread": ["model", "id"], MessageReactions: ["message", "content"], Rtc: [], Store: [], }; const MANY = Symbol("MANY"); const ONE = Symbol("ONE"); class Store { constructor(data, values, as_thread, _delete, kwargs) { this.data = new Map(); if (data) { this.add(...arguments); } } add(data, values, as_thread, _delete, kwargs) { if (!data) { return this; } kwargs = unmakeKwArgs(getKwArgs(arguments, "data", "values", "as_thread", "delete")); data = kwargs.data; delete kwargs.data; values = kwargs.values; delete kwargs.values; as_thread = kwargs.as_thread; delete kwargs.as_thread; _delete = kwargs.delete ?? false; delete kwargs.delete; let model_name; if (data instanceof models.Model) { if (values) { if (data.length !== 1) { throw new Error(`expected single recordset ${data} with values`); } if (Object.keys(kwargs).length) { throw new Error( `expected empty kwargs with recordset ${data} values: ${kwargs}` ); } if (_delete) { throw new Error(`deleted not expected for ${data} with values: ${values}`); } } if (_delete) { if (data.length !== 1) { throw new Error(`expected single record ${data} with delete`); } if (values) { throw new Error(`for ${data} expected empty values with delete: ${values}`); } } const ids = data.map((idOrRecord) => typeof idOrRecord === "number" ? idOrRecord : idOrRecord.id ); if (as_thread) { if (_delete) { this.add( "mail.thread", { id: data[0].id, model: data._name }, makeKwArgs({ delete: _delete }) ); } else if (values) { this.add("mail.thread", { id: data[0].id, model: data._name, ...values }); } else { MockServer.env["mail.thread"]._thread_to_store.call( MockServer.env[data._name], ids, this, makeKwArgs(kwargs) ); } } else { if (_delete) { this.add(data._name, { id: ids[0] }, makeKwArgs({ delete: _delete })); } else if (values) { this.add(data._name, { id: ids[0], ...values }); } else { MockServer.env[data._name]._to_store(ids, this, makeKwArgs(kwargs)); } } return this; } else if (typeof data === "object") { if (values) { throw new Error(`expected empty values with dict ${data}: ${values}`); } if (Object.keys(kwargs).length) { throw new Error(`expected empty kwargs with dict ${data}: ${kwargs}`); } if (as_thread) { throw new Error(`expected not as_thread with dict ${data}: ${values}`); } model_name = "Store"; values = data; } else { if (Object.keys(kwargs).length) { throw new Error(`expected empty kwargs with model name ${data}: ${kwargs}`); } if (as_thread) { throw new Error(`expected not as_thread with model name ${data}: ${values}`); } model_name = data; } if (typeof model_name !== "string") { throw new Error(`expected string for model name: ${model_name}: ${values}`); } const ids = ids_by_model[model_name] || ["id"]; // handle singleton model: update single record in place if (!ids.length) { if (typeof values !== "object") { throw new Error(`expected dict for singleton ${model_name}: ${values}`); } if (_delete) { throw new Error(`Singleton ${model_name} cannot be deleted`); } if (!this.data.has(model_name)) { this.data.set(model_name, {}); } this._add_values(values, model_name); return this; } // handle model with ids: add or update existing records based on ids if (!Array.isArray(values)) { if (!values) { return this; } values = [values]; } if (!values.length) { return this; } let records = this.data.get(model_name); if (!records) { records = new Map(); this.data.set(model_name, records); } for (const vals of values) { if (typeof vals !== "object") { throw new Error(`expected dict for ${model_name}: ${vals}`); } for (const i of ids) { if (!vals[i]) { throw new Error(`missing id ${i} in ${model_name}: ${vals}`); } } const index = ids.map((i) => vals[i]).join(" AND "); if (!records.has(index)) { records.set(index, {}); } this._add_values(vals, model_name, index); if (_delete) { records.get(index)._DELETE = true; } else { delete records.get(index)._DELETE; } } return this; } _add_values(values, model_name, index) { const target = index ? this.data.get(model_name).get(index) : this.data.get(model_name); for (const [key, val] of Object.entries(values)) { if (key === "_DELETE") { throw new Error(`invalid key ${key} in ${model_name}: ${values}`); } if (Array.isArray(val) && val[0] === ONE) { const [, subrecord, as_thread, only_id, subrecord_kwargs] = val; if (subrecord && !(subrecord instanceof models.Model)) { throw new Error(`expected recordset for one ${key}: ${subrecord}`); } if (subrecord && subrecord.length && !only_id) { this.add(subrecord, makeKwArgs({ as_thread, ...subrecord_kwargs })); } target[key] = Store.one_id(subrecord, makeKwArgs({ as_thread })); } else if (Array.isArray(val) && val[0] === MANY) { const [, subrecords, mode, as_thread, only_id, subrecords_kwargs] = val; if (subrecords && !(subrecords instanceof models.Model)) { throw new Error(`expected recordset for many ${key}: ${subrecords}`); } if (!["ADD", "DELETE", "REPLACE"].includes(mode)) { throw new Error(`invalid mode for many ${key}: ${mode} `); } if (subrecords && subrecords.length && !only_id) { this.add(subrecords, makeKwArgs({ as_thread, ...subrecords_kwargs })); } const rel_val = Store.many_ids(subrecords, mode, makeKwArgs({ as_thread })); target[key] = key in target && mode !== "REPLACE" ? target[key].concat(rel_val) : rel_val; } else { target[key] = val; } } } get_result() { const res = {}; for (const [model_name, records] of this.data) { const ids = ids_by_model[model_name] || ["id"]; if (!ids.length) { // singleton res[model_name] = { ...records }; } else { res[model_name] = [...records.values()].map((record) => ({ ...record })); } } return res; } toJSON() { throw Error( "Converting Store to JSON is not supported, you might want to call 'get_result()' instead." ); } static many(records, mode = "REPLACE", as_thread, only_id, kwargs) { kwargs = getKwArgs(arguments, "records", "mode"); records = kwargs.records; delete kwargs.records; mode = kwargs.mode ?? "REPLACE"; delete kwargs.mode; as_thread = kwargs.as_thread; delete kwargs.as_thread; only_id = kwargs.only_id; delete kwargs.only_id; if (records && !(records instanceof models.Model)) { throw new Error(`expected recordset for many: ${records}`); } return [MANY, records, mode, as_thread, only_id, makeKwArgs(kwargs)]; } static one(records, as_thread, only_id, kwargs) { kwargs = getKwArgs(arguments, "records"); records = kwargs.records; delete kwargs.records; as_thread = kwargs.as_thread; delete kwargs.as_thread; only_id = kwargs.only_id; delete kwargs.only_id; if (records && !(records instanceof models.Model)) { throw new Error(`expected recordset for one: ${records}`); } return [ONE, records, as_thread, only_id, makeKwArgs(kwargs)]; } static many_ids(records, mode = "REPLACE", as_thread) { const kwargs = getKwArgs(arguments, "records", "mode"); records = kwargs.records; mode = kwargs.mode ?? "REPLACE"; as_thread = kwargs.as_thread; if (records && !(records instanceof models.Model)) { throw new Error(`expected recordset for many_ids: ${records}`); } if (!["ADD", "DELETE", "REPLACE"].includes(mode)) { throw new Error(`invalid mode for many_ids: ${mode} `); } let res = records.map((record) => Store.one_id(records.browse(record.id), makeKwArgs({ as_thread })) ); if (records._name === "mail.message.reaction") { res = []; const reactionGroups = groupBy(records, (r) => [r.message_id, r.content]); for (const groupId in reactionGroups) { const { message_id, content } = reactionGroups[groupId][0]; res.push({ message: message_id, content: content }); } } if (mode === "ADD") { res = [["ADD", res]]; } else if (mode === "DELETE") { res = [["DELETE", res]]; } return res; } static one_id(records, as_thread) { const kwargs = getKwArgs(arguments, "records"); records = kwargs.records; as_thread = kwargs.as_thread; if (!records) { return false; } if (!(records instanceof models.Model)) { throw new Error(`expected recordset for one_id: ${records}`); } if (records.length > 1) { throw new Error(`expected none or single record for one_id: ${records}`); } const [record] = records; if (!record) { return false; } if (as_thread) { return { id: record.id, model: records._name }; } if (records._name === "discuss.channel") { return { id: record.id, model: "discuss.channel" }; } if (records._name === "mail.guest") { return { id: record.id, type: "guest" }; } if (records._name === "res.partner") { return { id: record.id, type: "partner" }; } return record.id; } } export const mailDataHelpers = { processRequest, Store, };