Caerus - прокат автомобилей в Черногории, от отделения до продвижения
Сайт партнёра работал внутри общего агрегатора: на проде оба продукта крутили один бинарник на одной базе и отличались только переменными окружения, разделения данных в схеме не было. Когда у агрегатора остался один партнёр и новых не предвиделось, продукт нужно было отделить - причём так, чтобы не уронить работающий бизнес по дороге.
Разделение
Начал с аудита прода: карта процессов, разница конфигураций, что чем управляется. Снял снимок как точку отката - и только потом разделял. В такой конструкции опасны не столько данные, сколько неявные связи: общий процесс, общие переменные окружения, общий деплой. Пока они не выписаны на бумагу, любое разделение делается вслепую.
Дальше веду продукт один: все 87 коммитов проекта мои. Стек - Next.js 14 с React 18 и TypeScript, Express с Prisma поверх PostgreSQL, шесть языков через префикс в адресе, JWT в куках, деплой через GitHub Actions на PM2.
Что делает продукт
Каталог с фильтрами и расчётом цены, бронирование с проверкой доступности, кабинет партнёра, админка. Договоры собираются в PDF, письма уходят через отдельный сервис. Оплату на онлайн-этапе убрали по решению клиента - интеграции Stripe и PayPal в проекте остались и настроены.
Отдельная часть работы - органика: адреса под людей и поисковые системы, страницы под запросы вроде «car rental in Montenegro», разметка и метатеги, редактируемые из админки без деплоя.
Три бага, по которым видно продакшн
Поисковый робот уронил каталог. TypeError: e.data.map is not a function приходил только с
Baiduspider. Корень был не в парсере: перехватчик axios возвращал error.response вместо того,
чтобы бросить исключение, поэтому при ошибке в data оказывалось что угодно, и следующий .map()
падал. Починил в двух местах сразу - защита на каждом из трёх параллельных запросов и проверка
типа перед обходом, - потому что этот антипаттерн задевает всех потребителей интерсептора, а не
одну страницу.
Бесконечный цикл в фильтрах на мобильном. RangeError при выборе автоматической коробки в
каталоге Будвы, воспроизводился на iPhone. Каскад: setValue внутри эффекта дёргал наблюдателя
формы, тот вызывал отправку, отправка снова писала значения. Лечится не переписыванием формы, а
защитой от повторного входа и флагом инициализации со сбросом таймера в очистке эффекта.
Ваучер с чужим временем. Клиент бронирует на 10:00, в PDF и письме приходит 08:00. Прод-процесс живёт в UTC, а PDF и письма рендерятся на бэкенде и, в отличие от остальных путей, не переводили дату в таймзону города. Добавил конвертацию и тест на переход летнего времени, применил в ваучере, письмах подтверждения и отмены. Фронт при этом не трогал намеренно: он уже показывает верное время через сдвиг на бэкенде, и «исправление» там дало бы двойную конвертацию.
Сайт работает и приносит доход клиенту.