TeachTrack: NestJS + Telegram-бот напоминаний + РКН — как я в одиночку собрал CRM для частных репетиторов

от автора

Месяц назад я выложил на Хабр статью про TripTrack — GPS-трекер для машины на iOS, который собрал будучи бэкендером без опыта в Swift. Статья неожиданно набрала 7.4К (на данный момент написания) просмотров. Но, мне посчастливилось поработать по своей специальности, не только под IOS-приложения, а под NestJS бекенд.

Параллельно с TripTrack я писал второй проект — на этот раз ровно в зоне комфорта (NestJS + PostgreSQL), и это позволило развернуться по-серьёзному: транзакционный outbox для идемпотентных отправок в Telegram, single-use invite-токены с защитой от enumeration, timezone-aware scheduler, partial unique indexes — словом, всё то, что для бэкендера интересно само по себе.

Под катом — про то, как устроен Telegram-бот напоминаний в TeachTrack, что я понял про pessimistic_write и FOR UPDATE SKIP LOCKED, зачем pet-проекту с реальными пользователями из РФ нужно уведомление в РКН, и почему холодный аутрич преподавателям английского научил меня важной вещи про русский менталитет.

Зачем это вообще

Моя девушка преподаёт английский. Учеников десяток, занятия несколько раз в неделю, и каждое начало месяца — одна и та же картина: открывается Excel-файл, рядом блокнот, рядом календарь в телефоне. «Так, у Маши было четыре занятия, но одно она переносила… а Иван заплатил за пакет на пять, использовал три, осталось два… а Олегу я не написала, что у нас завтра в 18:00…»

Раз в месяц этот учёт ломался. То ученик «не помнил», что заплатил только за два занятия. То она предлагала перенос — и забывала записать, что оно перенесено. То напоминание ученику не отправлялось вовремя, потому что в это время был занят, и он опаздывал на 15 минут.

Я бэкенд-разработчик. После TripTrack стало интересно: а если взять задачу, в которой у меня есть полный domain-expertise — что получится? Получился TeachTrack: расписание, учёт оплат и пакетов занятий, авто-напоминалки ученикам в Telegram перед уроком. Сейчас в закрытой бете.

Главная страница для преподавателя — счётчики учеников/групп, ближайшие занятия, быстрые действия. Сюда попадаешь после логина

Главная страница для преподавателя — счётчики учеников/групп, ближайшие занятия, быстрые действия. Сюда попадаешь после логина

Стек

Без сюрпризов:

  • Backend: NestJS + TypeORM + PostgreSQL. Один сервер, один инстанс, миграции автоматом при старте через DB_MIGRATE=true.

  • Frontend: React + Vite + React Router 7, Tailwind, react-query для cache, sonner для тостов.

  • Telegram: node-telegram-bot-api в режиме long polling (webhook не нужен — на бесплатном VPS у меня нет статичного IP).

  • Деплой: GitLab CI → SSH в VPS → docker compose pull && up -d.

  • Стоимость инфры: ~350₽/мес VPS + 0₽ Telegram + 0₽ домен (был куплен заранее).

Никакого Kubernetes, managed-сервисов, очередей-брокеров. Один контейнер NestJS, один контейнер Postgres, один Nginx. Если что-то ломается — SSH в VPS и docker compose logs. Принцип такой: пока пользователей меньше тысячи, дополнительные слои инфраструктуры — это оверинжиниринг, замедляющий итерации.

Дальше — про то, что в этой системе действительно интересно с инженерной точки зрения.

Telegram-бот напоминаний

Это сердце сервиса. Препод подключает ученика к боту через персональную одноразовую ссылку, после этого за час (или сколько настроил) до каждого занятия ученик получает в свой Telegram сообщение вида «Напоминание: занятие сегодня в 18:00 — 19:00, английский».

Звучит просто. На деле здесь сидит штук пять разных классов проблем, и я разберу каждую.

1. Single-use invite-токены без enumeration

Точка входа в invite-flow: препод открывает карточку ученика, нажимает "Пригласить в Telegram", бэк генерирует уникальную ссылку — она показывается один раз и больше нигде в открытом виде не лежит

Точка входа в invite-flow: препод открывает карточку ученика, нажимает «Пригласить в Telegram», бэк генерирует уникальную ссылку — она показывается один раз и больше нигде в открытом виде не лежит

Каждый ученик получает уникальную ссылку:

https://t.me/teachtrackbot?start=<token>

<token> — 32 байта URL-safe base64. В БД хранится только SHA-256 от него; plaintext возвращается фронту один раз в ответ на createInvite и больше нигде не остаётся (даже в логах).

const rawToken = randomBytes(32).toString('base64url');const tokenHash = createHash('sha256').update(rawToken).digest('hex');const expiresAt = new Date(Date.now() + INVITE_TTL_MS); // 14 дней

Зачем хэш в БД, а не plaintext? Если злоумышленник получит read-доступ к таблице (резервная копия, дамп, SQL-инъекция в каком-то будущем коде) — он не сможет восстановить рабочую ссылку. Принцип тот же, что с паролями: сравнение через hash(input) === stored_hash, а не через input === stored_plain.

Дальше — partial unique index, который не даёт существовать двум активным инвайтам на одного ученика одновременно:

CREATE UNIQUE INDEX student_telegram_invite_activeON student_telegram_invite (student_id)WHERE consumed_at IS NULL AND revoked_at IS NULL;

Это даёт сильную инвариант на уровне БД, а не приложения. Если препод нажимает «выдать новую ссылку», когда старая ещё активна — мы сначала revoke, потом insert, в одной транзакции:

await this.em.transaction(async (trx) => {const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);// Revoke any currently-active invite for this student so the// partial unique index doesn't reject the insert.await inviteRepo    .createQueryBuilder()    .update()    .set({ revokedAt: () => 'now()' })    .where('student_id = :studentId', { studentId })    .andWhere('consumed_at IS NULL')    .andWhere('revoked_at IS NULL')    .execute();await inviteRepo.save(  new StudentTelegramInviteEntity({        studentId,            teacherId,            tokenHash,            expiresAt,    }),  );});

Самое интересное — на стороне consume(). Когда ученик нажимает в Telegram кнопку Start, бот вызывает этот метод. Тут возможна гонка: двое родителей с одной ссылки кликают одновременно. Без блокировки оба прочитают consumed_at IS NULL, оба создадут binding, оба увидят успех — но binding-row будет два, с разными chat_id.

Решение — pessimistic_write (SELECT ... FOR UPDATE):

public async consume(  rawToken: string,    chatId: string,    from: { username?: string; firstName?: string; lastName?: string },): Promise<ConsumeResult | null> {  const tokenHash = createHash('sha256').update(rawToken).digest('hex');  return this.em.transaction(async (trx) => {    const inviteRepo = trx.getRepository(StudentTelegramInviteEntity);    // Pessimistic lock so concurrent /start attempts don't double-        // consume the same invite. "FOR UPDATE" blocks the second txn        // until the first commits; second then sees consumed_at and bails.        const invite = await inviteRepo      .createQueryBuilder('i')      .where('i.token_hash = :tokenHash', { tokenHash })      .setLock('pessimistic_write')      .getOne();        if (!invite) return null;        if (invite.consumedAt) return null;        if (invite.revokedAt) return null;        if (invite.expiresAt.getTime() < Date.now()) return null;        // ... создаём binding, помечаем consumed_at = now() ...  });}

Первая транзакция блокирует строку. Вторая ждёт коммита первой, после чего читает consumed_at != null и возвращает null. Гонка закрыта на уровне БД, без in-memory mutex’ов.

Возврат null для всех случаев (expired, revoked, unknown, consumed) — намеренный выбор. Бот отвечает универсальным «Ссылка устарела или уже использована». Это закрывает оракул: атакующий, перебирающий токены, не должен по разному ответу понять, какая ссылка была валидна когда-то, а какая никогда не существовала.

2. Anti-enumeration на bare /start

Связанная проблема: Telegram позволяет кому угодно начать чат с ботом без токена — просто /start. По обработке этого случая можно угадывать существующие chat_id‘ы (если бот по-разному отвечает «незнакомцу» и «уже привязанному пользователю» — это side-channel).

В TeachTrack бот отвечает идентичным сообщением и в том, и в другом случае, плюс есть rate-limit:

if (!rawToken) {  // Bare /start — first-time visitor / returning user / attacker probing    // chatIds. Reply with one generic message either way: distinguishing    // "already bound" from "new" was an enumeration oracle. Non-bound chats    // bump the fail counter so bare /start can't be used to keep a    // cool-down alive while guessing with another path.  const existing = await this.em        .getRepository(StudentTelegramBindingEntity)        .findOne({ where: { chatId }, select: ['id'] });    if (!existing) {      if (!this.noteStartFailure(msg.chat.id)) {          await this.bot.sendMessage(msg.chat.id, 'Слишком много попыток. Попробуйте через час.');            return;        }    }    await this.bot.sendMessage(      msg.chat.id,        'Чтобы получать напоминания, попросите у преподавателя персональную ссылку и перейдите по ней.\n/help — список команд.',        );    return;}

noteStartFailure — простой in-memory bucket с TTL: чат, не привязанный ни к кому, может сделать N бесполезных /start в час, дальше ответ заменяется на «слишком много попыток». Привязанные чаты не считаются — иначе настоящий ученик после блокировки бота и возврата получит бан.

3. Atomic claim в scheduler

Тот же outbox-механизм, что обслуживает напоминания, делает и ручные рассылки преподавателя — справа preview именно того сообщения, которое уйдёт в Telegram

Тот же outbox-механизм, что обслуживает напоминания, делает и ручные рассылки преподавателя — справа preview именно того сообщения, которое уйдёт в Telegram

Самая нетривиальная часть — рассылка напоминаний. Cron-тик каждую минуту:

@Cron(CronExpression.EVERY_MINUTE)public async sendUpcomingReminders(): Promise<void> {  if (!this.telegram.isEnabled()) return;    if (this.isRunning) {      this.logger.warn({ event: 'reminder.tick_skipped', reason: 'previous tick still running' });      return;    }    this.isRunning = true;  try {    await this.runTick();  } finally {    this.isRunning = false;    }}

isRunning — in-memory мьютекс. NestJS Schedule не блокирует cron, и если тик длиннее 60 секунд (что бывает: 200 уроков с relations через TypeORM = десятки запросов), следующий тик стартует параллельно. Без флага две итерации читают hasNotificationSent=false и ставят в очередь одну и ту же напоминалку дважды. Для multi-instance деплоя нужен распределённый lock (Redis), но пока деплой single-instance — in-memory достаточно.

Внутри тика — основная логика:

const lessons = await this.em.getRepository(LessonEntity).find({  where: {      status: LessonStatus.Scheduled,        hasNotificationSent: false,        startTime: Between(now, cutoff), // 24-часовое окно    },    relations: {      teacher: true,        participants: { student: { telegramBindings: true } },    },    order: { startTime: 'ASC' },    take: 200,});

Каждый препод задаёт свой reminderLeadMinutes (от 15 минут до суток до начала). Делать N запросов к БД с разными окнами — пустая трата ресурсов. Поэтому сразу тянем все Scheduled-уроки в максимальном окне (24 часа), а per-lesson отсекаем те, которые ещё «слишком рано» для конкретного препода.

Дальше — самое важное. Atomic claim через UPDATE WHERE:

const claim = await this.em.getRepository(LessonEntity).update(  {    id: lesson.id,        status: LessonStatus.Scheduled,        hasNotificationSent: false,  },  { hasNotificationSent: true },);if ((claim.affected ?? 0) === 0) {  // Преподаватель отменил урок между тиком SELECT и нашим UPDATE,    // или конкурентный тик уже разобрал — UPDATE затронул 0 строк,    // мы просто пропускаем enqueue.    this.logger.info({ event: 'reminder.skip_lost_race', lesson_id: lesson.id });    continue;}const text = this.formatReminder(lesson);for (const b of bindings) {  await this.outbox.enqueue({ ... });}

Это compare-and-set на уровне SQL: «поставь hasNotificationSent=true, но только если урок всё ещё Scheduled и флаг ещё не установлен». Если препод успел отменить занятие или другой тик уже разобрал — UPDATE затрагивает 0 строк, и мы пропускаем enqueue. Закрывает гонку с cancelLesson и double-send при overlapping ticks.

После успешного claim’а сообщение enqueue’ится в outbox, но об этом отдельно.

4. Idempotent outbox для отправок

Вот тут начинается главное.

Telegram API не атомарен с твоей БД. Если ты пишешь:

// БАГ: два независимых I/O без транзакционных гарантийawait db.markReminderSent(lessonId);await tg.sendMessage(chatId, text);

И процесс падает между строками: ученик не получит сообщение, но в БД — «отправлено». Если поменять порядок — упадёт после sendMessage до markSent — ученик получит сообщение, при следующем запуске cron пошлёт второе.

Эта проблема классическая, и решается она транзакционным outbox-паттерном: запись «надо отправить такое-то сообщение» создаётся в одной транзакции с бизнес-событием, отдельный воркер читает pending и шлёт. Если воркер падает между «отправил» и «пометил sent» — следующая итерация увидит сообщение всё ещё pending и… отправит второе. Поэтому нужна идемпотентность на стороне получателя или гарантии «at least once».

Telegram API не имеет идемпотентных ключей (нельзя сказать «отправь это сообщение, но если уже отправил с таким же ключом — ничего не делай»). Так что прагматичное решение — at-least-once с честным мониторингом: метим sent_at сразу после успешного sendMessage, и в редких случаях падения между ними — отправим дубль. Один дубль раз в N тысяч сообщений — приемлемо.

Схема outbox-таблицы:

@Entity('telegram_outbox')@Index('idx_telegram_outbox_pending', ['sendAfter'], {  where: '"sent_at" IS NULL AND "dead_at" IS NULL',})@Index('idx_telegram_outbox_context', ['contextType', 'contextId'])export class TelegramOutboxEntity {  @PrimaryGeneratedColumn('uuid')    public readonly id: string;    @Column({ name: 'chat_id', type: 'bigint' })    public chatId: string;    @Column({ name: 'binding_id', type: 'uuid', nullable: true })    public bindingId: string | null;    @Column({ type: 'text' })    public text: string;    @Column({ name: 'image_url', type: 'text', nullable: true })    public imageUrl: string | null;    @Column({ type: 'varchar', length: 16 })    public kind: OutboxKind; // 'reminder' | 'broadcast' | 'manual'    @Column({ name: 'context_type', type: 'varchar', length: 16, nullable: true })    public contextType: string | null;    @Column({ name: 'context_id', type: 'uuid', nullable: true })    public contextId: string | null;    @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })    public readonly createdAt: Date;    @Column({ name: 'send_after', type: 'timestamptz', default: () => 'now()' })    public sendAfter: Date;    @Column({ name: 'sent_at', type: 'timestamptz', nullable: true })    public sentAt: Date | null;    @Column({ type: 'int', default: 0 })    public attempts: number;    @Column({ name: 'last_error', type: 'text', nullable: true })    public lastError: string | null;    @Column({ name: 'dead_at', type: 'timestamptz', nullable: true })    public deadAt: Date | null;    @Column({ name: 'dead_reason', type: 'varchar', length: 32, nullable: true })    public deadReason: OutboxDeadReason | null;}

Воркер дёргается каждые 30 секунд (@Cron(CronExpression.EVERY_30_SECONDS)) и забирает batch’ом 100 строк через FOR UPDATE SKIP LOCKED:

private async lockBatch(): Promise<TelegramOutboxEntity[]> {  return this.em.transaction(async (tx) => {    return tx      .getRepository(TelegramOutboxEntity)      .createQueryBuilder('o')      .setLock('pessimistic_write')      .setOnLocked('skip_locked')      .where('o.sent_at IS NULL')      .andWhere('o.dead_at IS NULL')      .andWhere('o.send_after <= now()')      .orderBy('o.send_after', 'ASC')      .limit(DRAIN_BATCH)      .getMany();  });}

SKIP LOCKED — будущий-proof для multi-instance деплоя. Сейчас инстанс один, но когда пора будет масштабироваться, два воркера спокойно разберут разные строки без распределённых блокировок.

Обработка результата отправки разделена на 4 outcome’а:

if (result.outcome === 'sent') {  row.sentAt = new Date();  // ...} else if (result.outcome === 'blocked') {  // Telegram вернул 403 Forbidden — пользователь заблокировал бота.    // Помечаем dead с reason='blocked' + ставим binding.blockedAt,    // чтобы новые reminder'ы в него даже не enqueue'ились.    row.deadAt = new Date();    row.deadReason = 'blocked';    if (row.bindingId) {      await this.telegram.markBindingBlocked(row.bindingId);  }} else if (result.outcome === 'permanent') {  // 400 от Telegram: длинная caption, мусорный image URL, parse_mode error.    // Retry бессмыслен — фиксируем как dead немедленно.    row.deadAt = new Date();    row.deadReason = 'permanent_error';} else {  // 'failed' — транзиентная ошибка (5xx, 429, network timeout).    // Считаем attempt и смещаем send_after по backoff'у.    row.attempts += 1;    if (row.attempts >= MAX_ATTEMPTS) {      row.deadAt = new Date();        row.deadReason = 'max_retries';    } else {      row.sendAfter = new Date(Date.now() + computeBackoff(row.attempts));    }}

Backoff экспоненциальный, с конкретно подобранными шагами:

function computeBackoff(attempt: number): number {  const steps = [      60_000,     //  1 min        120_000,    //  2 min        300_000,    //  5 min        900_000,    // 15 min        1_800_000,  // 30 min        3_600_000,  //  1 h        10_800_000, //  3 h        21_600_000, //  6 h        43_200_000, // 12 h        86_400_000, // 24 h    ];    return steps[Math.min(attempt - 1, steps.length - 1)];}

10 попыток × шаги до 24 часов = окно ~48 часов до dead-letter. Достаточно, чтобы сеть восстановилась после серьёзного инцидента, но не настолько долго, чтобы сообщение «отправилось» через неделю в момент, когда оно уже не актуально.

Различение transient (failed) vs permanent vs blocked — важная вещь. Если бы я ретраил всё подряд 10 раз — каждое сообщение к заблокировавшему бота пользователю занимало бы 10 слотов в очереди и 10 строк в логах, и spec’ы метрик были бы совершенно бесполезны.

Метрики выставлены через prom-client (Prometheus формат): tgOutboxPending, tgOutboxSentTotal, tgOutboxRetryTotal, tgOutboxDeadTotal{reason}. На дашборде Grafana — pending в реальном времени, retry rate, dead-letter рост по причинам.

5. Timezone-aware форматирование

Препод живёт в Москве. Ученик — в Новосибирске. Бэкенд — в Docker-контейнере с TZ=UTC. Время урока хранится как UTC. Когда ученик получает напоминание, в шаблоне написано «занятие в {time}» — какое время туда подставить?

Очевидный ответ — время в часовом поясе препода. У препода в профиле есть timezone-поле (в формате IANA: Europe/Moscow, Asia/Novosibirsk).

const teacher = lesson.teacher;const timeZone = teacher?.timezone?.trim() || 'Europe/Moscow';const fmt = new Intl.DateTimeFormat('ru-RU', {  hour: '2-digit',    minute: '2-digit',    timeZone,});const start = fmt.format(lesson.startTime);const end = fmt.format(lesson.endTime);const time = ${start} — ${end};

Без явного timeZone в опциях Intl.DateTimeFormat берёт зону процесса — а в Docker-контейнере это UTC. И ученик видел бы «16:50» вместо «19:50». Я ловил это в проде на втором же реальном пользователе — препод в +3, я на dev-машине в +3, всё совпадало, и ничего не предупреждало. На VPS контейнер — UTC, и сразу провал.

Дефолт Europe/Moscow — потому что в PoW таргет РФ, и большая часть преподов в МСК-зоне. Если препод не задал timezone (новый аккаунт, опция в настройках) — лучше показать московское время, чем UTC.

6. Шаблонные переменные

Препод может настраивать текст напоминания. Дефолтный шаблон:

Напоминание о занятии сегодня в {time}.{subject}

Поддерживаемые переменные: {time} (диапазон 18:00 — 19:00), {start} (18:00), {end} (19:00), {subject} (название предмета), {student} (имя ученика), {duration} (1 ч / 45 мин).

Подстановка — простой regex replace:

return template  .replace(/\{time\}/g, time)  .replace(/\{start\}/g, start)  .replace(/\{end\}/g, end)  .replace(/\{subject\}/g, lesson.name ?? '')  .replace(/\{student\}/g, primaryName)  .replace(/\{duration\}/g, duration);

Внешне примитив, но за этим есть продуктовое решение: {time} исторически был диапазоном «18:00 — 19:00». Препод-пилотный пользователь сказал: «слушай, я хочу писать просто ‘занятие с 18:00’, а не ‘с 18:00 — 19:00’». Переименовать {time} в просто-начало — сломать уже сохранённые шаблоны. Поэтому добавил {start} и {end} отдельно, а {time} оставил как range. Backward compatibility ценой одной лишней переменной — бесплатно.

7. Один человек = два student-record (cloned binding)

И отдельный продуктовый кейс, который требовал отдельного API-метода.

У препода есть две student-записи: «Маша индивидуальные занятия» и «Маша групповые занятия». Это один и тот же реальный человек (Маша), но с разной ставкой и разным расписанием. Хочется, чтобы напоминания об обоих типах занятий приходили в один и тот же Telegram-чат — тот, что Маша уже подключила однажды.

Архитектурно это уже было разрешено: UNIQUE(student_id, chat_id) запрещает дубли binding’а на одного ученика, но не запрещает разным ученикам делить один chat_id. Не хватало UI-флоу и API-метода.

Решение — cloneTelegramBinding, который копирует binding с одного student’а на другого того же препода:

public async cloneTelegramBinding(  teacherId: string,    targetStudentId: string,    sourceBindingId: string,): Promise<StudentTelegramBindingEntity> {  await this.getStudentOrThrow(teacherId, targetStudentId);  const sourceBinding = await this.em    .getRepository(StudentTelegramBindingEntity)    .findOne({      where: { id: sourceBindingId },      relations: { student: true },    });  if (!sourceBinding || sourceBinding.student.teacherId !== teacherId) {    throw new NotFoundException('Binding not found');  }  if (sourceBinding.studentId === targetStudentId) {      throw new ConflictException('Этот чат уже привязан к этому ученику');  }  const existing = await this.em    .getRepository(StudentTelegramBindingEntity)    .findOneBy({ studentId: targetStudentId, chatId: sourceBinding.chatId });  if (existing) {    throw new ConflictException('Этот чат уже привязан к этому ученику');  }  return this.em.transaction(async (tx) => {    // Atomic: revoke active invite + create binding в одной транзакции,        // чтобы избежать гонки «binding создан, но активная invite-ссылка        // живая → ученик кликает её и плодится вторая binding на чужой chatId».    await tx.getRepository(StudentTelegramInviteEntity).update(      { studentId: targetStudentId, consumedAt: IsNull(), revokedAt: IsNull() },      { revokedAt: new Date() },    );    return tx.getRepository(StudentTelegramBindingEntity).save(      new StudentTelegramBindingEntity({        studentId: targetStudentId,                chatId: sourceBinding.chatId,                username: sourceBinding.username,                firstName: sourceBinding.firstName,                lastName: sourceBinding.lastName,      }),    );  });}

Двойной ownership-check (target student + source binding оба должны принадлежать вызывающему преподу) — критично. Иначе можно было бы по случайному bindingId чужого препода получить таргетированный спам в чужой чат. UNIQUE-индекс на (student_id, chat_id) гарантирует, что повторные клонирования (вдруг препод нажал дважды) не плодят дубликатов.

В TG-чат при clone никаких уведомлений не отправляется. Это та же реальная Маша; ей уже приходят напоминания за «индивид-Машу», добавление потока «групп-Маша» — внутреннее дело препода.

РКН и 152-ФЗ — что нужно знать pet-проекту в РФ

Что попадает под персональные данные: имя, привязанная сумма, история транзакций. Без уведомления в РКН такая обработка — нарушение 152-ФЗ

Что попадает под персональные данные: имя, привязанная сумма, история транзакций. Без уведомления в РКН такая обработка — нарушение 152-ФЗ

Короткий, но важный раздел для разработчиков, которые думают «ладно, мой проект слишком маленький, до меня никому нет дела».

Если ты собираешь имена и/или телефоны/email пользователей из РФ — ты оператор персональных данных по 152-ФЗ. Не «возможно», а в большинстве случаев именно оператор. И ты обязан:

1. Подать в РКН уведомление о намерении обрабатывать персональные данные rkn.gov.ru (форма онлайн).

2. Иметь Политику конфиденциальности и Соглашение о согласии на обработку, выложенные на сайте.

3. Хранить ПДн на серверах в РФ (242-ФЗ). Если сервер в Германии — это нарушение, штрафы от 100К ₽ для ИП.

4. Назначить ответственного за обработку ПДн (для самозанятого/ИП это обычно сам).

Я открыл самозанятость, подал уведомление в РКН, оформил локализацию ПДн на VPS в РФ, написал Политику + Соглашение. По времени — вместе с ИИ + ресерчем работы суммарно на 1-2 дня, не считая ожидания решения РКН.

Если кто-то из читателей делает SaaS-pet-проект для РФ-аудитории и пропускал этот шаг: рекомендую разобраться. С 2024 года РКН активно рассылает запросы операторам, не подавшим уведомление. По сути это вопрос «когда придёт письмо», а не «придёт ли».

Открытая разработка и feedback loop

TeachTrack — четвёртый сезон моего личного формата «Proof-of-Work»: один проект — один сезон. Все коммиты публичные (репозитории на GitHub: backend, frontend), changelog в CHANGELOG.md, регулярные апдейты в Telegram-канале OneZee.

Формат странный, но он работает: открытость дисциплинирует. Когда коммиты видят живые люди — пишешь чище, не оставляешь TODO в духе «потом разберусь», и боишься опубликовать сырое.

Feedback loop: первые преподаватели в закрытой бете пришли через холодный аутрич в Telegram-чатах репетиторов. Из 30 ответили 3: «спасибо, не нужно» и 2 «ок, интересно. А от меня что нужно-то?».

Этот вопрос подсветил мне дыру в исходном сообщении. Я писал «бесплатно, попробуйте, дайте фидбек». В моей голове это было ясно. С той стороны — нормальный человек прочитал «незнакомый парень бесплатно что-то предлагает» и спросил в лоб: «в чём подвох?»

Переписал сообщение: указал явно, что взамен прошу один отзыв, который потом размещу на главной сайта. Никакого подвоха больше нет, потому что обмен теперь явный.

Заметка на будущее: в любом холодном аутриче в РФ, даже если взамен реально ничего не нужно, придумай явный обмен. Иначе получишь молчание (как у меня — 25 из 30) или вопрос «в чём подвох» (как у двух самых смелых).

AI как соавтор

Как и в TripTrack-статье, не могу пропустить эту тему — без AI-агентов проект бы либо не случился, либо случился через год.

Где Claude Code реально вытащил. Бойлерплейт NestJS-модулей — controller + service + DTO + entity + migration. Если ты знаешь архитектуру, но не помнишь точный синтаксис @Cron декораторов или TypeORM relations — генерация рабочего скелета и потом разбор «что он сделал и почему» работает в разы быстрее, чем гуглить.

TypeORM миграции — отдельный кайф. Описываешь словами «добавь partial unique index на student_telegram_invite по (student_id) при condition consumed_at IS NULL AND revoked_at IS NULL», получаешь рабочий миграционный скрипт. Сам бы я полез в документацию минут на 15.

React-компоненты с правильным state-management через react-query — тут AI вытаскивает на 80%. Optimistic updates, инвалидация cache, обработка ошибок — паттернов уже миллион в датасете, генерация почти безошибочная.

Где AI оказался беспомощным. Продуктовые решения. Что важнее показать в карточке ученика — оплаченные занятия или баланс пакета? Какой UX у формы «отмена занятия» — модалка или slideout? Куда поставить переключатель напоминаний — в карточку TG или в общие настройки? — Claude может предложить варианты, но решает только препод-пользователь и я.

Идемпотентный outbox — ребёнок боли. Я три раза переписывал воркер, пока не дошёл до текущей версии с FOR UPDATE SKIP LOCKED и явным разделением transient/permanent/blocked. Каждый AI-сгенерированный вариант был «технически правильным» — но не решал реальный вопрос «что делать, если Telegram вернул 429 на пятой попытке из десяти». Сел и написал сам, по конкретным проблемам, которые видел в логах.

Локализация ПДн под РКН — Claude знает только то, что в его обучающем датасете на 2024 год. По 152-ФЗ есть свежие поправки 2025-го (376-ФЗ про подписки и обязательную уведомлятельность) — он про них не знает.

По времени. От первого коммита до закрытой беты — около двух с половиной недель вечерами после работы и в выходные. Без AI это было бы все четыре месяца, может пять. С ним — я не превратился в superman, но смог собрать продукт, который реально использую сам и который пользуют живые преподаватели.

Стоимость инструментов. ~$120/мес на Claude и Cursor. Половину этой суммы я и так трачу на рабочие задачи, так что чистый incremental cost проекта — около $60/мес.

Цифры

  • ~20 дней активной разработки (вечера + выходные)

  • ~25 000 строк TypeScript (backend + frontend суммарно, не считая тестов)

  • ~80 миграций TypeORM от первой до текущей

  • 350₽/мес инфраструктура (VPS)

  • $120/мес инструменты разработки (Claude Code + Cursor)

  • 3 преподавателя в закрытой бете на момент публикации (2 ответили на холодный аутрич, 1 — моя девушка)

  • 0 рублей монетизации (это сознательный выбор: пока feedback loop важнее)

Что дальше

Дашборд аналитики препода: выручка, нагрузка по дням, ученики по группам. Сюда на закрытой бете я смотрю, чтобы понять, где приложение реально используется, а где — лежит без активности

Дашборд аналитики препода: выручка, нагрузка по дням, ученики по группам. Сюда на закрытой бете я смотрю, чтобы понять, где приложение реально используется, а где — лежит без активности

Бэклог:

  • Расширение позиционирования — изначально под репетиторов английского, сейчас расширяем на всех частных преподавателей: тренеры, йога-инструкторы, музыкальная школа.

  • Monetization — после РКН и стабилизации. Скорее всего бесплатно до 5 учеников + платный план для активных преподов.

  • Мобильное приложение — критично для preподавателей, которые ведут занятия с телефона. Думаю о React Native (один кодбейс с frontend) против повторения SwiftUI-подхода как в TripTrack.

  • Webhook вместо long-polling для бота — когда вырастем до уровня, когда 5-секундный лаг между сообщением и ответом начнёт ощущаться.

Открытый код и канал

  • Telegram-канал автора: OneZee — про процесс, грабли, и личные наблюдения. Без редполитики.

Если есть вопросы по реализации — пишите в комментариях или в GitHub issues. Если вы преподаватель и хотите попробовать TeachTrack — пишите в Telegram, отвечу лично и помогу с onboarding.

ссылка на оригинал статьи https://habr.com/ru/articles/1032304/