сайт в бете
нашли баг? напишите
левин. записаться
весь блог

События магазина вместо ручных рассылок: подписчики и расписания в 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 даёт этому шагу имя, повторные попытки и компенсацию: сорвалось списание бонусов — предыдущие шаги откатятся сами.

Что проверяю перед включением

  1. Идемпотентность. Повторный запуск джоба должен быть безопасным. Отметку об отправке храните в БД; память процесса для этого не годится.
  2. Тестовый режим провайдера. Первые дни адресат — мой собственный ящик.
  3. Логи с id. Строка вида order-placed: sent 01H... в проде экономит часы.
  4. Один инстанс. При нескольких копиях приложения расписание попробует отработать на каждой. Проверьте, что за этим следит очередь.
  5. Отписка. Ссылка в футере — базовая гигиена. Без неё письма быстро уедут в спам.

Что это меняет

Рассылки в коде становятся частью магазина. Письмо больше не зависит от того, открыт ли ноутбук. Поменялась логика — правится один файл и уезжает в git. И каждый новый сценарий добавляется отдельным файлом рядом, не трогая то, что уже работает.

теги #medusa#события#подписчики#автоматизация#рассылки
разберём вашу задачу

Автоматизация для бизнеса

Вторая сторона исследования: собираю под бизнес ИИ-агентов и автоматизации, которые работают и с клиентами, и с командой. Система остаётся у вас в работе и под вашим управлением.

подробнее и записаться цена по запросу

один разговор — и поймём, чем я могу помочь.

В эпоху ИИ человеку нужен человек. Сяду рядом и доведу до результата — встреча длится столько, сколько нужно. Без скрипта продаж и пакетов «за 999 000 ₽». Если пойму, что помочь не смогу, — скажу сразу.