← Назад к вопросам

Сервис записи к врачу с напоминаниями

3.0 Senior🔥 221 комментариев
#Архитектура и паттерны#Базы данных и SQL#Брокеры сообщений и очереди

Условие

Спроектируйте и опишите архитектуру сервиса с API для записи пациента на прием к врачу с напоминанием за 2 часа до приема.

Требования:

  1. Создание записи на прием (пациент, врач, дата/время)
  2. Автоматическое напоминание за 2 часа до приема
  3. Отмена записи
  4. Получение списка записей пациента

Вопросы для обсуждения:

  • Как реализовать отложенные напоминания?
  • Какую очередь использовать (Bull, RabbitMQ, Redis)?
  • Как обрабатывать отмену напоминания при отмене записи?
  • Структура базы данных
  • Обработка ошибок при отправке напоминания

Что проверяется

  • Проектирование систем
  • Работа с очередями
  • Cron jobs vs очереди
  • Обработка edge cases

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение: Архитектура сервиса записи к врачу с напоминаниями

1. Архитектура системы

Компоненты:

┌─────────────┐
│   API       │ (Express/Fastify)
│ Endpoints   │
└──────┬──────┘
       │
       ├──► AppointmentService (бизнес-логика)
       │
       ├──► ReminderQueue (Bull/Redis)
       │
       ├──► Database (PostgreSQL)
       │
       └──► NotificationService (SMS/Push/Email)

Основные микросервисы:

  • API Gateway: обработка запросов пациентов и врачей
  • AppointmentService: управление записями
  • ReminderWorker: обработка задач из очереди
  • NotificationService: отправка напоминаний

2. Структура базы данных

-- Таблица пациентов
CREATE TABLE patients (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  phone VARCHAR(20) NOT NULL UNIQUE,
  email VARCHAR(255),
  name VARCHAR(255) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Таблица врачей
CREATE TABLE doctors (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  specialty VARCHAR(100),
  phone VARCHAR(20),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Таблица записей на приём
CREATE TABLE appointments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  patient_id UUID NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
  doctor_id UUID NOT NULL REFERENCES doctors(id),
  appointment_at TIMESTAMPTZ NOT NULL,
  status VARCHAR(50) DEFAULT 'scheduled', -- scheduled, completed, cancelled
  reminder_sent BOOLEAN DEFAULT FALSE,
  reminder_sent_at TIMESTAMPTZ,
  cancellation_reason TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  
  UNIQUE(doctor_id, appointment_at),
  INDEX(patient_id),
  INDEX(appointment_at),
  INDEX(status)
);

-- Таблица истории напоминаний
CREATE TABLE reminder_history (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  appointment_id UUID NOT NULL REFERENCES appointments(id) ON DELETE CASCADE,
  sent_at TIMESTAMPTZ,
  status VARCHAR(50), -- pending, sent, failed, cancelled
  error_message TEXT,
  retry_count INT DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

3. Реализация на Node.js

API Endpoints

// routes/appointments.ts
import { Router, Request, Response } from 'express';
import { AppointmentService } from '../services/AppointmentService';
import { ReminderQueue } from '../queue/ReminderQueue';

const router = Router();
const appointmentService = new AppointmentService();
const reminderQueue = new ReminderQueue();

// Создание записи
router.post('/appointments', async (req: Request, res: Response) => {
  try {
    const { patientId, doctorId, appointmentTime } = req.body;
    
    // Валидация
    if (new Date(appointmentTime) <= new Date()) {
      return res.status(400).json({ error: 'Appointment time must be in future' });
    }
    
    const appointment = await appointmentService.createAppointment({
      patientId,
      doctorId,
      appointmentAt: appointmentTime
    });
    
    // Добавляем задачу напоминания в очередь
    const reminderTime = new Date(appointmentTime).getTime() - 2 * 60 * 60 * 1000;
    await reminderQueue.addReminder(appointment.id, reminderTime);
    
    res.status(201).json(appointment);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Получение записей пациента
router.get('/patients/:patientId/appointments', async (req: Request, res: Response) => {
  try {
    const { patientId } = req.params;
    const appointments = await appointmentService.getPatientAppointments(patientId);
    
    res.json(appointments);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Отмена записи
router.delete('/appointments/:appointmentId', async (req: Request, res: Response) => {
  try {
    const { appointmentId } = req.params;
    const { reason } = req.body;
    
    // Отменяем запись
    await appointmentService.cancelAppointment(appointmentId, reason);
    
    // Удаляем задачу напоминания из очереди
    await reminderQueue.cancelReminder(appointmentId);
    
    res.json({ message: 'Appointment cancelled' });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

export default router;

AppointmentService

// services/AppointmentService.ts
import { db } from '../database';
import { v4 as uuidv4 } from 'uuid';

export class AppointmentService {
  async createAppointment(data: {
    patientId: string;
    doctorId: string;
    appointmentAt: Date;
  }) {
    const appointmentId = uuidv4();
    
    const result = await db.query(`
      INSERT INTO appointments (id, patient_id, doctor_id, appointment_at, status)
      VALUES ($1, $2, $3, $4, 'scheduled')
      RETURNING *
    `, [appointmentId, data.patientId, data.doctorId, data.appointmentAt]);
    
    return result.rows[0];
  }
  
  async getPatientAppointments(patientId: string) {
    const result = await db.query(`
      SELECT a.*, d.name as doctor_name, d.specialty
      FROM appointments a
      JOIN doctors d ON a.doctor_id = d.id
      WHERE a.patient_id = $1 AND a.status = 'scheduled'
      ORDER BY a.appointment_at ASC
    `, [patientId]);
    
    return result.rows;
  }
  
  async cancelAppointment(appointmentId: string, reason: string) {
    await db.query(`
      UPDATE appointments
      SET status = 'cancelled', cancellation_reason = $2, updated_at = NOW()
      WHERE id = $1
    `, [appointmentId, reason]);
  }
  
  async markReminderSent(appointmentId: string) {
    await db.query(`
      UPDATE appointments
      SET reminder_sent = TRUE, reminder_sent_at = NOW()
      WHERE id = $1
    `, [appointmentId]);
  }
}

Очередь напоминаний (Bull + Redis)

// queue/ReminderQueue.ts
import Queue from 'bull';
import { NotificationService } from '../services/NotificationService';
import { AppointmentService } from '../services/AppointmentService';

const reminderQueue = new Queue('appointment-reminders', {
  redis: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.REDIS_PORT || '6379')
  },
  defaultJobOptions: {
    attempts: 3, // 3 попытки отправки
    backoff: {
      type: 'exponential',
      delay: 2000 // 2 сек, 4 сек, 8 сек
    },
    removeOnComplete: true
  }
});

const notificationService = new NotificationService();
const appointmentService = new AppointmentService();

// Обработчик задач из очереди
reminderQueue.process(async (job) => {
  const { appointmentId } = job.data;
  
  try {
    // Получаем данные записи
    const appointment = await getAppointment(appointmentId);
    
    if (!appointment || appointment.status !== 'scheduled') {
      console.log(`Skipping reminder for cancelled appointment: ${appointmentId}`);
      return;
    }
    
    // Отправляем напоминание
    await notificationService.sendReminder({
      patientId: appointment.patient_id,
      phone: appointment.patient_phone,
      doctorName: appointment.doctor_name,
      appointmentTime: appointment.appointment_at
    });
    
    // Отмечаем, что напоминание отправлено
    await appointmentService.markReminderSent(appointmentId);
    
    console.log(`Reminder sent for appointment: ${appointmentId}`);
  } catch (error) {
    console.error(`Failed to send reminder for appointment ${appointmentId}:`, error);
    throw error; // Bull перепопытается
  }
});

// Обработка ошибок
reminderQueue.on('failed', async (job, error) => {
  console.error(`Job ${job.id} failed after ${job.attemptsMade} attempts:`, error);
  
  // Логируем в БД для анализа
  await db.query(`
    INSERT INTO reminder_history (appointment_id, status, error_message, retry_count)
    VALUES ($1, 'failed', $2, $3)
  `, [job.data.appointmentId, error.message, job.attemptsMade]);
});

export class ReminderQueue {
  async addReminder(appointmentId: string, reminderTime: number) {
    const delay = reminderTime - Date.now();
    
    if (delay <= 0) {
      throw new Error('Reminder time must be in the future');
    }
    
    // Добавляем задачу с отсроченным выполнением
    await reminderQueue.add(
      { appointmentId },
      {
        delay,
        jobId: `reminder-${appointmentId}` // уникальный ID для отмены
      }
    );
  }
  
  async cancelReminder(appointmentId: string) {
    const job = await reminderQueue.getJob(`reminder-${appointmentId}`);
    
    if (job) {
      await job.remove();
    }
  }
}

export default reminderQueue;

NotificationService

// services/NotificationService.ts
import twilio from 'twilio';

export class NotificationService {
  private twilioClient = twilio(
    process.env.TWILIO_ACCOUNT_SID,
    process.env.TWILIO_AUTH_TOKEN
  );
  
  async sendReminder(data: {
    patientId: string;
    phone: string;
    doctorName: string;
    appointmentTime: Date;
  }) {
    const time = data.appointmentTime.toLocaleString('ru-RU');
    const message = `Напоминание: у вас прием у врача ${data.doctorName} в ${time}. До приема осталось 2 часа.`;
    
    try {
      // Отправляем SMS
      await this.twilioClient.messages.create({
        body: message,
        from: process.env.TWILIO_PHONE_NUMBER,
        to: data.phone
      });
      
      console.log(`SMS sent to ${data.phone}`);
    } catch (error) {
      console.error(`Failed to send SMS to ${data.phone}:`, error);
      
      // Пытаемся отправить email как fallback
      try {
        await this.sendEmailReminder(data, message);
      } catch (emailError) {
        throw new Error(`Both SMS and email failed: ${error}`);
      }
    }
  }
  
  private async sendEmailReminder(data: any, message: string) {
    // Реализация отправки email через nodemailer, SendGrid и т.д.
    console.log(`Email sent to patient ${data.patientId}`);
  }
}

4. Решение ключевых вопросов

Как реализовать отложенные напоминания?

Вариант 1: Bull Queue (рекомендуется)

  • Задачи хранятся в Redis с точным временем выполнения
  • Автоматически повторяет при ошибках
  • Масштабируется горизонтально
  • Гарантирует выполнение

Вариант 2: Cron jobs (НЕ рекомендуется)

  • Запускается каждую минуту/час
  • Требует логики для проверки времени
  • Неточный timing
  • Сложнее с масштабированием
// Плохой пример с cron:
schedule.scheduleJob('* * * * *', async () => {
  const appointments = await db.query(`
    SELECT * FROM appointments
    WHERE status = 'scheduled'
    AND appointment_at <= NOW() + INTERVAL '2 hours'
    AND reminder_sent = FALSE
  `);
  
  // Проблема: что если сервис упадет? Напоминания потеряны
});

Какую очередь использовать?

ОчередьПлюсыМинусыКогда использовать
BullПростой API, Redis, надеженТребует Redis✅ Стартапы, MVP
RabbitMQНадежный, feature-richСложнее настраиватьEnterprise системы
AWS SQSМасштабируется, managedСтоит денегCloud-only решения
KafkaHigh throughputOverkill для напоминанийHigh-load системы

Вывод: для этой задачи Bull + Redis идеален.

Обработка отмены при отмене записи

// Критично: использовать транзакции
async cancelAppointment(appointmentId: string, reason: string) {
  const client = await db.connect();
  
  try {
    await client.query('BEGIN');
    
    // Шаг 1: Отмечаем запись как отменённую
    await client.query(`
      UPDATE appointments
      SET status = 'cancelled', cancellation_reason = $2
      WHERE id = $1
    `, [appointmentId, reason]);
    
    // Шаг 2: Удаляем задачу из очереди
    await reminderQueue.cancelReminder(appointmentId);
    
    // Шаг 3: Логируем отмену напоминания
    await client.query(`
      INSERT INTO reminder_history (appointment_id, status)
      VALUES ($1, 'cancelled')
    `, [appointmentId]);
    
    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

Обработка ошибок при отправке

// Стратегия retry с exponential backoff
reminderQueue.on('failed', async (job, error) => {
  const { appointmentId } = job.data;
  const maxAttempts = 3;
  
  if (job.attemptsMade >= maxAttempts) {
    // Финальный отказ: уведомляем админ
    await notificationService.alertAdmin({
      appointmentId,
      error: error.message,
      attempts: job.attemptsMade
    });
    
    // Записываем в БД для ручной обработки
    await db.query(`
      UPDATE appointments
      SET status = 'reminder_failed'
      WHERE id = $1
    `, [appointmentId]);
  }
});

5. Полный flow

1. Пациент создает запись → POST /appointments
2. API создает запись в БД → status = 'scheduled'
3. API добавляет задачу в Bull (задержка = за 2 часа)
4. Bull выполняет задачу в нужное время
5. Worker отправляет SMS/Email
6. Отмечаем reminder_sent = TRUE

Если пациент отменяет запись:
7. DELETE /appointments/{id}
8. API отмечает status = 'cancelled'
9. API удаляет задачу из очереди
10. Напоминание не отправляется

6. Дополнительные оптимизации

Batch processing:

// Если много одновременных напоминаний
reminderQueue.process(100, async (job) => {
  // обрабатываем параллельно до 100 задач
});

Мониторинг:

// Отслеживаем задачи в Redis
const jobCounts = await reminderQueue.getJobCounts();
console.log(jobCounts); // { active, completed, failed, delayed }

Dead Letter Queue:

reminderQueue.on('failed', async (job) => {
  if (job.attemptsMade >= 3) {
    // Перемещаем в DLQ для анализа
    await deadLetterQueue.add(job.data);
  }
});