События магазина вместо ручных рассылок: подписчики и расписания в Medusa
Разбираю, как письма и уведомления в магазине уходят сами: подписчик ловит событие вроде order.placed, scheduled job работает по времени. С кодом и границей между ними.
Ручная рассылка живёт до первой пятницы, когда нажать кнопку некому. Дальше по накатанной: заказ оплатили вчера, письмо ушло сегодня после обеда, клиент уже написал в директ «вы вообще живые?». Лечится одним сдвигом в голове: пусть магазин сам рассказывает, что с ним произошло. Ваше дело — слушать.
Разберу на Medusa. Я сейчас вожусь с миграцией neon.boutique с Тильды на Astro + Medusa, поэтому примеры из живого проекта.
Событие как источник правды
Заказ размещён, товар создан, клиент зарегистрировался — каждое заметное действие публикует событие. Событие — это имя (order.placed) и небольшая полезная нагрузка, обычно id объекта. Дальше фреймворк смотрит, кто подписан на это имя, и зовёт обработчики.
Важное: событие ничего не знает про письма. Это просто факт. Одно order.placed может одновременно уйти в почту клиенту, в телеграм владельцу и в очередь на печать этикетки. Каждый получатель сидит в своём файле и падает независимо от соседей.
Подписчик: реакция на действие
Подписчик — файл в src/subscribers. Внутри две вещи: функция-обработчик и конфиг с именем события.
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
import { Modules } from "@medusajs/framework/utils"
export default async function orderPlacedHandler({
event: { data },
container,
}: SubscriberArgs<{ id: string }>) {
const notification = container.resolve(Modules.NOTIFICATION)
await notification.createNotifications({
to: "client@example.com",
channel: "email",
template: "order-placed",
data: { order_id: data.id },
})
}
export const config: SubscriberConfig = {
event: "order.placed",
}
Что стоит держать в голове:
- Обработчик обязан быть тонким. Достал данные, передал дальше. Логику расчётов внутрь лучше не тащить.
- Payload минимален. Приходит id, полный заказ вы забираете сами через контейнер. Так вы гарантированно работаете со свежим состоянием.
- Падение подписчика не отменяет заказ. Обработчик крутится в фоне, покупатель к этому моменту уже смотрит на страницу «спасибо». Обратная сторона — тишину при ошибке легко проглядеть. Логи обязательны.
- Один файл — одна ответственность. Захотелось уведомления в CRM — заводите второй файл на то же событие.
Расписание: реакция на время
Там, где события просто нет, подписчик бессилен. «Корзина висит четыре часа», «через неделю после доставки спросить об отзыве», «каждое утро прислать сводку по остаткам» — территория scheduled jobs. Файл кладём в src/jobs.
import type { MedusaContainer } from "@medusajs/framework/types"
export default async function abandonedCartJob(container: MedusaContainer) {
const query = container.resolve("query")
// выбрать корзины старше N часов без заказа и без отметки об отправке
}
export const config = {
name: "abandoned-cart-reminder",
schedule: "0 * * * *",
}
schedule — обычный cron-синтаксис, name должен быть уникальным. Джоб получает контейнер и дальше работает как обычный код приложения.
Главная ловушка расписаний — повторная отправка. Через час джоб отработает снова, и если признак «письмо ушло» никуда не записан, клиент получит напоминание двенадцать раз за сутки. Отметку ставьте в том же проходе, где отправляете, и фильтруйте по ней при выборке.
Где проходит граница
Правило, которым я пользуюсь:
- Есть событие — берём подписчика.
- Есть только время («прошло столько-то», «наступило девять утра») — берём расписание.
- Есть несколько шагов, которые нужно уметь откатить — оформляем workflow в
src/workflowsи вызываем его из подписчика или из джоба.
Последний пункт спасает от дублирования. Письмо про брошенную корзину и письмо после отмены заказа могут делить общий шаг «отправить шаблон с товарами». Workflow даёт этому шагу имя, повторные попытки и компенсацию: сорвалось списание бонусов — предыдущие шаги откатятся сами.
Что проверяю перед включением
- Идемпотентность. Повторный запуск джоба должен быть безопасным. Отметку об отправке храните в БД; память процесса для этого не годится.
- Тестовый режим провайдера. Первые дни адресат — мой собственный ящик.
- Логи с id. Строка вида
order-placed: sent 01H...в проде экономит часы. - Один инстанс. При нескольких копиях приложения расписание попробует отработать на каждой. Проверьте, что за этим следит очередь.
- Отписка. Ссылка в футере — базовая гигиена. Без неё письма быстро уедут в спам.
Что это меняет
Рассылки в коде становятся частью магазина. Письмо больше не зависит от того, открыт ли ноутбук. Поменялась логика — правится один файл и уезжает в git. И каждый новый сценарий добавляется отдельным файлом рядом, не трогая то, что уже работает.
Автоматизация для бизнеса
Вторая сторона исследования: собираю под бизнес ИИ-агентов и автоматизации, которые работают и с клиентами, и с командой. Система остаётся у вас в работе и под вашим управлением.