send push to assignee
Gitea Actions / Deploy (push) Successful in 32s

This commit is contained in:
vadim committed 2026-10-18 20:19:57 +03:00
1 parent 8d02d25da0
commit afc37f8fdb
10 files changed
+144 -369

No files matched your search

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { NotificationsService } from '../../../notification/application/services/notifications.service.js';
import { IssueAssignedEvent } from '../../domain/events/issue-assigned.event.js';
import type { Issue } from '../../domain/entities/issue.entity.js';
/**
* Уведомляет нового исполнителя задачи (в т.ч. при самоназначении)
* по всем включённым у него каналам.
*/
@Injectable()
export class IssueAssignmentNotifierService {
constructor(private readonly notificationsService: NotificationsService) {}
notify(issue: Issue, domainEvents: unknown[]): void {
const assigned = domainEvents.findLast((event) => event instanceof IssueAssignedEvent);
// Последнее назначение могло быть отменено снятием исполнителя в той же операции
if (!assigned || assigned.assignee !== issue.assignee) return;
this.notificationsService.notify({
userId: assigned.assignee,
subject: `You have been assigned to ${issue.key}`,
content: issue.title,
});
}
}
@@ -7,12 +7,15 @@ import { CreateIssueCommand } from './create-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
describe('CreateIssueUseCase', () => {
let repository: IIssueRepository;
let projectRepository: IProjectRepository;
let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let useCase: CreateIssueUseCase;
beforeEach(() => {
@@ -42,7 +45,14 @@ describe('CreateIssueUseCase', () => {
reporter: null,
})),
} as unknown as IssueUserExpanderService;
useCase = new CreateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
useCase = new CreateIssueUseCase(
repository,
projectRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
);
});
it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => {
@@ -119,4 +129,36 @@ describe('CreateIssueUseCase', () => {
expect(received).toEqual([dto]);
});
it('notifies the assignee of a new issue', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-2'),
);
expect(notificationsService.notify).toHaveBeenCalledWith({
userId: 'user-2',
subject: 'You have been assigned to ISSUE-2',
content: 'Improve onboarding',
});
});
it('notifies the reporter who assigns the issue to themselves', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', undefined, undefined, 'user-1'),
);
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
});
it('does not notify when the issue is created unassigned', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
await useCase.execute(new CreateIssueCommand('ISSUE-3', 'Improve onboarding', 'user-1', 'project-1'));
expect(notificationsService.notify).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ import { CreateIssueCommand } from './create-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
@Injectable()
@@ -20,6 +21,7 @@ export class CreateIssueUseCase {
private readonly projectRepository: IProjectRepository,
private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
) {}
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
@@ -42,6 +44,7 @@ export class CreateIssueUseCase {
}
const domainEvents = draft.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents);
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
this.issueEventsService.emitIssueCreated(dto);
}
@@ -8,6 +8,8 @@ import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import type { NotificationsService } from '../../../../notification/application/services/notifications.service.js';
const buildIssue = () =>
Issue.reconstitute({
@@ -30,6 +32,7 @@ describe('UpdateIssueUseCase', () => {
let projectRepository: IProjectRepository;
let issueEventsService: IssueEventsService;
let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let useCase: UpdateIssueUseCase;
beforeEach(() => {
@@ -59,7 +62,14 @@ describe('UpdateIssueUseCase', () => {
reporter: null,
})),
} as unknown as IssueUserExpanderService;
useCase = new UpdateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
notificationsService = { notify: vi.fn() } as unknown as NotificationsService;
useCase = new UpdateIssueUseCase(
repository,
projectRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
);
});
it('applies only the fields provided in the command', async () => {
@@ -115,4 +125,34 @@ describe('UpdateIssueUseCase', () => {
expect(received).toEqual([dto]);
});
it('notifies the new assignee', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-9'));
expect(notificationsService.notify).toHaveBeenCalledWith({
userId: 'user-9',
subject: 'You have been assigned to ISSUE-1',
content: 'Original title',
});
});
it('notifies the user who assigns the issue to themselves', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-1'));
expect(notificationsService.notify).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-1' }));
});
it('does not notify on unassignment or other field changes', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, null));
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
expect(notificationsService.notify).not.toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
@Injectable()
@@ -20,6 +21,7 @@ export class UpdateIssueUseCase {
private readonly projectRepository: IProjectRepository,
private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
) {}
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
@@ -65,6 +67,7 @@ export class UpdateIssueUseCase {
}
const domainEvents = issue.pullDomainEvents();
this.issueAssignmentNotifier.notify(issue, domainEvents);
if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) {
this.issueEventsService.emitIssueUpdated(dto);
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Issue } from './issue.entity.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
describe('Issue', () => {
describe('create', () => {
@@ -147,6 +148,16 @@ describe('Issue', () => {
issue.unassign();
expect(issue.assignee).toBeNull();
});
it('raises IssueAssignedEvent only when the assignee actually changes', () => {
const issue = build();
issue.assignTo('user-9');
issue.assignTo('user-9');
const assigned = issue.pullDomainEvents().filter((event) => event instanceof IssueAssignedEvent);
expect(assigned).toEqual([expect.objectContaining({ issueKey: issue.key, assignee: 'user-9' })]);
});
});
describe('change tracking', () => {
@@ -3,6 +3,7 @@ import { IssueStatus } from '../value-objects/issue-status.vo.js';
import { IssuePriority } from '../value-objects/issue-priority.vo.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
interface IssueProps {
@@ -68,6 +69,9 @@ export class Issue {
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status });
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
if (issue.assignee) {
issue.addDomainEvent(new IssueAssignedEvent(issue.key, issue.assignee));
}
return issue;
}
@@ -175,8 +179,11 @@ export class Issue {
}
assignTo(userId: string): void {
if (this.assignee === userId) return;
this.recordChange('assignee', this.assignee, userId);
this.props.assignee = userId;
this.addDomainEvent(new IssueAssignedEvent(this.key, userId));
}
unassign(): void {
@@ -0,0 +1,7 @@
export class IssueAssignedEvent {
constructor(
public readonly issueKey: string,
public readonly assignee: string,
public readonly occurredAt: Date = new Date(),
) {}
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { ProjectModule } from '../project/project.module.js';
import { UserModule } from '../user/user.module.js';
import { NotificationModule } from '../notification/notification.module.js';
import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
import { IssueController } from './presentation/controllers/issue.controller.js';
@@ -14,11 +15,12 @@ import { IssueResolver } from './presentation/resolvers/issue.resolver.js';
import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
import { IssueEventsService } from './application/services/issue-events.service.js';
import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from './application/services/issue-assignment-notifier.service.js';
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar-issues/list-calendar-issues.use-case.js';
@Module({
imports: [PrismaModule, ProjectModule, UserModule],
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule],
controllers: [IssueController],
providers: [
CreateIssueUseCase,
@@ -32,6 +34,7 @@ import { ListCalendarIssuesUseCase } from './application/use-cases/list-calendar
IssueResolver,
IssueEventsService,
IssueUserExpanderService,
IssueAssignmentNotifierService,
{
// Domain видит только интерфейс IIssueRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
-366
View File
@@ -1,366 +0,0 @@
нужна помощь по архитектуре, есть основной сервис заявок и сервис уведомлений, уведомления должны обладать категориями и типами (пуш, емеил). Связываются сервисы через брокер (nats). Как лучше организовать хранение данных. В каком сервисе ххранить категории, где хранить пуш токены, сами сообщения и тд. Что передавать через брокер в каком виде.
## Общий принцип
Каждый сервис — источник правды только для своих данных (database-per-service). Сервис заявок не должен знать о пуш-токенах, шаблонах писем и т.п. Сервис уведомлений не должен знать бизнес-логику заявок — только достаточно данных, чтобы отрендерить сообщение.
## Где что хранить
**Сервис заявок (source):**
- Сами заявки и их статусы — как обычно.
- Ничего про уведомления не хранит вообще (ни каналы, ни адреса, ни статусы доставки).
**Сервис уведомлений (owner всего, что касается доставки):**
- **Категории уведомлений** (`category`: code, name, важность, дефолтные каналы) — справочник здесь. Категория — это про то, *как доставлять*, а не бизнес-сущность заявок.
- **Типы/каналы** (push, email, позже sms) — тоже справочник/enum здесь.
- **Подписки/настройки пользователя** — какие категории по каким каналам получать (`user_id, category, channel, enabled`).
- **Push-токены устройств** — таблица `device_tokens (user_id, token, platform, updated_at)`. Регистрируются напрямую клиентским приложением через API notification-сервиса, а не через сервис заявок.
- **Email-адреса** — либо дублируются с синхронизацией из identity/user-сервиса (если он есть), либо приходят вместе с событием как поле payload'а (см. ниже). Дублировать с явной ревалидацией — обычно надёжнее, чем ходить синхронно в другой сервис при каждой отправке.
- **Шаблоны сообщений** — тоже здесь.
- **Сами сообщения/лог отправок** — таблица `notifications (id, correlation_id, category, channel, recipient, status, payload, sent_at, delivered_at, error)`. Это и есть история/аудит уведомлений.
## Что передавать через брокер и в каком виде
Через NATS должны идти **доменные события**, а не команды "отправь уведомление". Сервис заявок публикует факт бизнес-события, сервис уведомлений сам решает, нужно ли и как на него реагировать — это держит сервисы независимыми (заявки не знают о существовании уведомлений в принципе).
Рекомендации:
**Subject naming:**
```
orders.request.created
orders.request.status_changed
orders.request.cancelled
```
**Формат события (envelope):**
```json
{
"event_id": "uuid", // для идемпотентности/дедупликации
"event_type": "orders.request.status_changed",
"version": 1,
"occurred_at": "2026-09-22T09:00:00Z",
"aggregate_id": "request-uuid",
"actor_id": "user-uuid",
"payload": {
"status": "approved",
"recipient_user_id": "user-uuid"
// минимально необходимые поля для рендера, не вся заявка целиком
}
}
```
Важные моменты:
- **event_id** обязателен — по нему notification-сервис дедуплицирует (at-least-once доставка в NATS/JetStream — норма, а не баг).
- **"Полу-жирные" события**: не гоняйте туда-сюда синхронные запросы за деталями заявки при каждом уведомлении (это связывает сервисы и роняет доступность notification-сервиса при падении orders). Но и не пихайте в событие всю заявку целиком. Кладите ровно то, что нужно для шаблонов (статус, имя, сумма — по необходимости), остальное — по `aggregate_id` через API orders, если это редкий кейс.
- **Версионирование** payload'а (`version`) — пригодится, когда формат события будет меняться.
- Используйте **JetStream** (persistent streams), а не core NATS, чтобы события не терялись при недоступности notification-сервиса.
## Поток обработки в notification-сервисе
1. Consumer получает событие `orders.request.status_changed`.
2. По `event_type` находит маппинг → категорию уведомления (это конфигурация внутри notification-сервиса, orders о ней не знает).
3. Смотрит подписки пользователя по этой категории → определяет каналы (push/email).
4. Рендерит шаблон, создаёт запись в `notifications` со статусом `queued`.
5. Отправляет через провайдера (FCM/APNs, SMTP/SES и т.д.), обновляет статус (`sent`/`failed`/`delivered`).
6. Опционально публикует обратное событие `notifications.sent` / `notifications.failed` — полезно для аналитики/аудита, но orders-сервису это обычно не нужно подписывать.
## Итого — граница ответственности
| Данные | Где хранятся |
|---|---|
| Заявки | orders-сервис |
| Категории и каналы уведомлений | notification-сервис |
| Push-токены | notification-сервис |
| Настройки подписок пользователя | notification-сервис |
| Шаблоны и текст сообщений | notification-сервис |
| История/статусы отправленных уведомлений | notification-сервис |
| Событие через брокер | orders → notification, "тонкое" доменное событие с event_id |
Если понадобится — могу накидать примерную схему БД (таблицы notification-сервиса) или пример NATS consumer'а с dedup-логикой.
а как пуш токены появляются в сервисе уведомлений?
Клиентское приложение (мобильный/веб) регистрирует токен **напрямую в сервисе уведомлений**, минуя сервис заявок. Это нормальный паттерн — регистрация токена не имеет отношения к бизнес-логике заявок.
## Как это выглядит на практике
1. Клиент получает push-токен от FCM/APNs при старте приложения или логине.
2. Клиент шлёт его в notification-сервис через его собственный API (обычно REST/gRPC endpoint), не через orders-сервис:
```
POST /api/v1/devices
{
"user_id": "...",
"token": "...",
"platform": "ios" | "android" | "web"
}
```
3. Notification-сервис делает upsert в свою таблицу `device_tokens`.
## Нюансы, которые стоит сразу заложить
**Аутентификация запроса.** Notification-сервис должен уметь проверить, что `user_id` в запросе — это действительно тот пользователь, который прислал токен (JWT/сессия), иначе кто угодно сможет зарегистрировать чужой токен на себя. Значит, notification-сервису нужен доступ к тому же механизму аутентификации, что и остальным API (общий auth middleware/gateway, проверка JWT и т.д.) — это отдельный вопрос архитектуры, но обычно решается через API Gateway, который уже провалидировал токен до того, как запрос дошёл до сервиса.
**Один пользователь — несколько токенов.** Человек заходит с телефона и с двух браузеров — три токена, все живые. Ключ уникальности обычно `(user_id, token)`, а не `user_id` — просто добавляете новую запись, если токен новый.
**Протухание токенов.** FCM/APNs могут вернуть ошибку `Unregistered`/`InvalidToken` при попытке отправки — по этому ответу нужно удалять/помечать токен неактивным в `device_tokens`. Это внутренняя логика notification-сервиса, никак не завязанная на orders.
**Ре-регистрация при обновлении токена.** FCM/APNs периодически ротируют токены сами — клиент должен слать обновлённый токен заново тем же способом (upsert по старому+новому или просто upsert по user_id+platform, если храните один активный токен на платформу).
## А что насчёт удаления при логауте?
Тоже прямой вызов клиента в notification-сервис:
```
DELETE /api/v1/devices/{token}
```
чтобы не слать пуши на токен вышедшего пользователя.
## Итого
Путь такой: **клиент → API Gateway (auth) → notification-сервис**, без участия orders-сервиса и без NATS — это синхронная операция регистрации устройства, а не бизнес-событие. NATS остаётся только для доменных событий вроде "заявка изменила статус".
Если у вас пока нет отдельного API Gateway / auth-сервиса — как сейчас происходит аутентификация между клиентом и бэкендом в целом?
есть монолит в котором реализована проверка пользователя
Значит нужно решить, как notification-сервис (и вообще любой новый сервис) будет проверять, что запрос от легитимного пользователя, не дублируя логику авторизации монолита.
## Варианты
**1. JWT, который монолит уже выдаёт клиенту**
Если монолит при логине выдаёт клиенту JWT (даже если сессии где-то ещё используются внутри) — самый простой путь. Notification-сервис получает токен в заголовке, сам проверяет подпись (публичным ключом/секретом монолита) и достаёт `user_id` из claims, без похода в монолит по сети.
```
Client → notification-service
Authorization: Bearer <jwt from monolith>
```
Плюс: notification-сервис не зависит от доступности монолита на каждый запрос.
Минус: нужен способ отзывать токены (revocation list) и следить за синхронизацией ключей.
**2. Если сейчас сессии (cookie), а не JWT**
Тогда варианты:
- Монолит отдаёт эндпоинт **интроспекции**: `POST /internal/auth/verify` с cookie/session-id → возвращает `user_id`. Notification-сервис дергает его синхронно на каждый запрос регистрации токена (это редкая операция — регистрация устройства, не хайлоад, так что синхронный вызов ок).
- Либо ставите перед всеми сервисами (включая монолит) общий **API Gateway/reverse proxy**, который сам проверяет сессию/JWT и прокидывает дальше уже проверенный `X-User-Id` в заголовке. Это правильный долгосрочный путь при переходе на микросервисы, но требует инфраструктурных изменений сейчас.
## Что практичнее на вашем этапе
Раз у вас уже выделяется notification-сервис как первый шаг из монолита, я бы не городил Gateway ради одного сервиса. Практичнее:
1. Уточнить, **что уже выдаёт монолит клиенту** — JWT или cookie-сессию.
2. Если JWT — notification-сервис верифицирует его сам (шарите secret/public key).
3. Если сессия — добавить в монолите один internal-эндпоинт интроспекции токена, notification-сервис ходит туда синхронно только на операции регистрации/удаления устройства (низкая частота, некритично к задержке).
4. NATS-события от orders в этой части не участвуют — это чисто клиент↔notification-сервис путь.
Что у вас сейчас — JWT или серверные сессии?
jwt
С JWT всё просто — notification-сервису вообще не нужно ходить в монолит по сети для проверки.
## Как это работает
1. Монолит подписывает JWT своим ключом при логине (симметричным HMAC-секретом или, лучше, приватным ключом при RS256/ES256).
2. Notification-сервис получает тот же токен от клиента в заголовке `Authorization: Bearer <jwt>`.
3. Notification-сервис **сам** проверяет подпись и достаёт `user_id` (и что нужно) из claims — без обращения к монолиту.
```
POST /api/v1/devices
Authorization: Bearer <jwt>
{
"token": "...",
"platform": "ios"
}
```
`user_id` при этом берётся **из claims токена**, а не из тела запроса — иначе любой авторизованный пользователь сможет зарегистрировать токен на чужой `user_id`, просто подставив его в JSON. Тело запроса содержит только то, что клиент не может подделать в свою пользу (device token, platform), а personal identity всегда из верифицированного JWT.
## Что нужно расшарить между монолитом и notification-сервисом
- **Если HMAC (HS256)** — общий секрет. Хранить в секрет-менеджере (Vault/K8s secret/SSM), не в коде, раздать обоим сервисам.
- **Если RSA/EC (RS256/ES256)** — гораздо лучше для мультисервисной архитектуры: монолит держит приватный ключ и подписывает, notification-сервис знает только **публичный** ключ и может только проверять, но не подделывать токены. Публичный ключ можно даже раздавать через `/.well-known/jwks.json` эндпоинт на монолите — тогда новые сервисы просто подтягивают его при старте/по кэшу, без ручной синхронизации секретов.
Если сейчас HS256 — при росте числа сервисов стоит подумать о переходе на RS256/ES256 именно из соображений "каждому новому сервису для чтения не нужно доверять секрет, которым можно подписывать".
## Отзыв токенов (revocation)
Раз проверка теперь оффлайн (без похода в монолит), классическая проблема JWT — как обработать логаут/бан до истечения `exp`. Варианты, если это важно для вас:
- короткий TTL access-токена (5-15 мин) + refresh-токен — тогда отозванный доступ отваливается быстро сам по себе;
- либо блэклист отозванных `jti` в Redis, который проверяют все сервисы (но это уже добавляет сетевой поход и связанность).
Если у вас логаут не критичен к мгновенности (например, там просто короткий TTL) — можно не усложнять и оставить чисто оффлайн-проверку.
Какой у вас алгоритм подписи сейчас — HS256 или уже RS256/подобный?
а смс отправка, в сервисе уведомлений хранить телефоны
Да, по той же логике, что и с email — если SMS станет одним из каналов, номера телефонов хранятся в notification-сервисе, а не запрашиваются синхронно у монолита при каждой отправке.
## Варианты получения номера
**1. Дублирование с синхронизацией (рекомендую)**
- При регистрации/изменении профиля в монолите публикуется событие `user.profile_updated` (или `user.phone_changed`) в NATS.
- Notification-сервис подписывается, обновляет свою локальную копию `user_contacts (user_id, phone, email, updated_at)`.
- При отправке SMS notification-сервис берёт номер из своей БД — без синхронного похода в монолит.
Плюс: полная развязка, отправка не зависит от доступности монолита.
Минус: нуженource-of-truth-событие на каждое изменение контактных данных в монолите (если такого механизма пока нет — придётся добавить).
**2. Номер приходит вместе с доменным событием**
Если SMS шлётся не "вообще", а в ответ на конкретное действие (например, подтверждение заявки), можно не хранить номер вовсе, а прокидывать его в payload события:
```json
{
"event_type": "orders.request.status_changed",
"payload": {
"recipient_user_id": "...",
"recipient_phone": "+79991234567"
}
}
```
Плюс: не нужен отдельный механизм синхронизации контактов.
Минус: монолит должен знать, что нужно положить телефон в событие (небольшая связанность "по данным", не по вызовам) — но это всё равно значительно лучше, чем синхронный HTTP-запрос за телефоном на каждую отправку.
## Что выбрать
Если контактные данные меняются редко и на них завязано несколько каналов (email, sms, push-уведомления с персонализацией по имени) — вариант 1 чище: заводите таблицу контактов в notification-сервисе и синхронизируете событием при изменении профиля.
Если SMS — разовый узкий кейс (например, только "код подтверждения" или "статус заявки") — вариант 2 проще и не требует лишней инфраструктуры синхронизации.
## Общая таблица получается такая
| Данные | Где хранится | Как приходят |
|---|---|---|
| Push-токены | notification-сервис | напрямую от клиента, по своему API |
| Email/телефон | notification-сервис (копия) | событие `user.profile_updated` из монолита, либо в payload доменного события |
| Категории/каналы/шаблоны | notification-сервис | конфигурация сервиса |
| Заявки | монолит/orders | — |
Одна важная деталь: раз номера и email — персональные данные (ПДн), при дублировании в notification-сервисе стоит сразу продумать их шифрование at rest и политику хранения/удаления (например, по GDPR/152-ФЗ, если это применимо к вашему проекту) — то есть не просто copy-paste, а с тем же уровнем защиты, что и в монолите.
Возникает SMS-канал уже сейчас, или это на перспективу — просто закладываете архитектуру с запасом?
перспектива
Тогда не стоит сейчас городить полноценную синхронизацию контактов ради канала, которого ещё нет.
## Что делать сейчас
Заложите на будущее минимально — не более:
1. **Таблицу `user_contacts` сразу спроектируйте с запасом** (даже если пока заполняете только то, что нужно для push/email):
```sql
user_contacts (
user_id UUID PRIMARY KEY,
email TEXT,
phone TEXT,
updated_at TIMESTAMPTZ
)
```
Так при добавлении SMS не придётся переделывать схему — просто начнёте заполнять `phone`.
2. **Общий механизм синхронизации делайте один — для всех контактных полей сразу**, а не отдельно под email и отдельно под будущий phone. Т.е. событие `user.profile_updated` с обоими полями в payload:
```json
{
"event_type": "user.profile_updated",
"payload": {
"user_id": "...",
"email": "...",
"phone": "..."
}
}
```
Notification-сервис просто апсертит всё, что пришло, не заботясь о том, используется ли `phone` уже сейчас.
3. **Канал `sms` заведите как enum/значение в справочнике каналов уже сейчас**, даже неактивное — не придётся трогать миграции и код маппинга категорий→каналов, когда он реально понадобится.
## Чего делать не стоит
- Не подключайте SMS-провайдера (Twilio/SMS.ru/др.) заранее — это чистый YAGNI, только усложнит деплой и конфиги без пользы.
- Не делайте отдельную инфраструктуру верификации телефонов (подтверждение номера) заранее — это отдельная фича, привязывайте её к моменту, когда SMS реально понадобится.
- Не усложняйте событие `orders.request.status_changed` полем `recipient_phone`, если SMS туда пока не шлётся — лишнее поле без потребителя.
Итог: с минимальными изменениями в схеме и в контракте события `user.profile_updated` вы ничего не переделываете, когда дойдёте до SMS — просто добавляете провайдера и обработчик канала. Остальное — по факту появления реальной необходимости.
получается настройки пользователя по рассылке сообщений тоже хранить в сервисе уведомлений
Да, верно — это тоже часть зоны ответственности notification-сервиса: настройки касаются доставки уведомлений, а не бизнес-логики монолита.
## Схема хранения
```sql
notification_preferences (
user_id UUID,
category TEXT, -- 'request_status_changed', 'request_approved', ...
channel TEXT, -- 'push', 'email', 'sms'
enabled BOOLEAN DEFAULT true,
updated_at TIMESTAMPTZ,
PRIMARY KEY (user_id, category, channel)
)
```
Дефолт — если записи нет, считаете `enabled = true` (или прописываете дефолты явно при создании пользователя/по первому событию — так проще для аналитики "кто что выключил").
## Как пользователь меняет настройки
Тут два пути, зависит от того, как у вас организован фронтенд/UI:
**1. Фронт стучится напрямую в notification-сервис**
```
PUT /api/v1/preferences
Authorization: Bearer <jwt>
{
"category": "request_approved",
"channel": "email",
"enabled": false
}
```
Тот же принцип авторизации, что и с push-токенами — JWT валидируется notification-сервисом самостоятельно, `user_id` берётся из claims.
Плюс: чисто, без лишних хопов.
Минус: если UI настроек живёт внутри монолитного приложения (например, единая страница профиля), фронту нужно знать про два разных бэкенда — обычно решается тем, что API Gateway/reverse-proxy просто роутит `/api/notifications/*` в notification-сервис, а остальное — в монолит. Для фронта это прозрачно, один домен.
**2. Монолит проксирует запрос**
Если пока нет общего гейтвея и не хочется светить notification-сервис наружу отдельным доменом — монолит принимает запрос от фронта и просто форвардит его в notification-сервис (server-to-server, с сервисным токеном или доверенной сетью). Проще для фронта прямо сейчас, но добавляет лишний хоп и точку отказа.
Если планируете API Gateway в перспективе (вы уже говорили, что growth в сторону микросервисов) — я бы сразу шёл по варианту 1, чтобы не переделывать потом.
## Что ещё сюда относится
- **Глобальный opt-out** — отдельная настройка `user_id → all_notifications_disabled` полезна для "отписаться от всего" (часто требуется по закону для email-рассылок — unsubscribe-ссылка).
- **Read-модель для быстрого чтения** — так как эта таблица читается на **каждое** событие из NATS (проверка "слать ли"), убедитесь что там индекс по `user_id`, и в целом это должна быть быстрая точка доступа (можно и в Redis кэшировать активные подписки, если объёмы большие).
Итого зона ответственности notification-сервиса теперь выглядит так:
| Сущность | Таблица |
|---|---|
| Push-токены | `device_tokens` |
| Контакты (email/phone) | `user_contacts` |
| Настройки подписок | `notification_preferences` |
| Категории/каналы | справочник/enum |
| Шаблоны | `templates` |
| Лог отправок | `notifications` |
Всё, что касается "кому, как и хочет ли получать" — здесь, и нигде больше. Монолит/orders об этом не знает вообще, только публикует факт события.