diff --git a/src/modules/issue/application/services/issue-assignment-notifier.service.ts b/src/modules/issue/application/services/issue-assignment-notifier.service.ts new file mode 100644 index 0000000..ce57978 --- /dev/null +++ b/src/modules/issue/application/services/issue-assignment-notifier.service.ts @@ -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, + }); + } +} diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts index d55dee0..939d241 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts @@ -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(); + }); }); diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts index c12d577..a727c6b 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts @@ -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 { @@ -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); } diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts index d4f1560..f7b9a50 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts @@ -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(); + }); }); diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts index bce2cb7..7232aa3 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts @@ -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 { @@ -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); } diff --git a/src/modules/issue/domain/entities/issue.entity.spec.ts b/src/modules/issue/domain/entities/issue.entity.spec.ts index 413f07b..098b4ff 100644 --- a/src/modules/issue/domain/entities/issue.entity.spec.ts +++ b/src/modules/issue/domain/entities/issue.entity.spec.ts @@ -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', () => { diff --git a/src/modules/issue/domain/entities/issue.entity.ts b/src/modules/issue/domain/entities/issue.entity.ts index 08eca47..3ce47eb 100644 --- a/src/modules/issue/domain/entities/issue.entity.ts +++ b/src/modules/issue/domain/entities/issue.entity.ts @@ -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 { diff --git a/src/modules/issue/domain/events/issue-assigned.event.ts b/src/modules/issue/domain/events/issue-assigned.event.ts new file mode 100644 index 0000000..432dda0 --- /dev/null +++ b/src/modules/issue/domain/events/issue-assigned.event.ts @@ -0,0 +1,7 @@ +export class IssueAssignedEvent { + constructor( + public readonly issueKey: string, + public readonly assignee: string, + public readonly occurredAt: Date = new Date(), + ) {} +} diff --git a/src/modules/issue/issue.module.ts b/src/modules/issue/issue.module.ts index 94cd066..e2be5b2 100644 --- a/src/modules/issue/issue.module.ts +++ b/src/modules/issue/issue.module.ts @@ -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-контейнер здесь diff --git a/src/notification/DESC.md b/src/notification/DESC.md deleted file mode 100644 index 1c85f97..0000000 --- a/src/notification/DESC.md +++ /dev/null @@ -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 -``` - -Плюс: 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 `. -3. Notification-сервис **сам** проверяет подпись и достаёт `user_id` (и что нужно) из claims — без обращения к монолиту. - -``` -POST /api/v1/devices -Authorization: Bearer -{ - "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 -{ - "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 об этом не знает вообще, только публикует факт события. \ No newline at end of file