diff --git a/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.spec.ts b/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.spec.ts index b56b441a..ba7bb6bc 100644 --- a/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.spec.ts +++ b/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.spec.ts @@ -1,23 +1,33 @@ import { Test, TestingModule } from '@nestjs/testing'; import { plainToInstance } from 'class-transformer'; import { mock, MockProxy } from 'jest-mock-extended'; + import { SentryAlertHandlerService } from './sentry-alert-handler.service'; import { TelegramService } from '../../telegram/telegram.service'; import { TelegramKeyboardBuilderService } from '../../telegram/telegram-keyboard-builder.service'; import { VehicleAlertNotifierService } from '../common/vehicle-alert-notifier.service'; - -import { TelemetryMessage, SentryModeState } from '../../telemetry/models/telemetry-message.model'; +import { AlertEventSeverity, AlertEventType } from '../../../entities/alert-event.entity'; +import { SentryModeState, TelemetryMessage } from '../../telemetry/models/telemetry-message.model'; + +const buildMessage = (state: string): TelemetryMessage => + plainToInstance(TelemetryMessage, { + data: [{ key: 'SentryMode', value: { sentryModeStateValue: state } }], + createdAt: '2025-01-21T10:00:00.000Z', + vin: 'TEST_VIN_123', + isResend: false, + }); describe('The SentryAlertHandlerService class', () => { let service: SentryAlertHandlerService; - let mockTelegramService: MockProxy; let mockKeyboardBuilder: MockProxy; let mockAlertNotifier: MockProxy; + beforeEach(async () => { mockTelegramService = mock(); mockKeyboardBuilder = mock(); mockAlertNotifier = mock(); + mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] }); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -25,127 +35,68 @@ describe('The SentryAlertHandlerService class', () => { { provide: TelegramService, useValue: mockTelegramService }, { provide: TelegramKeyboardBuilderService, useValue: mockKeyboardBuilder }, { provide: VehicleAlertNotifierService, useValue: mockAlertNotifier }, - ] + ], }).compile(); service = module.get(SentryAlertHandlerService); jest.clearAllMocks(); }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('The handle method', () => { - describe('when message does not contain valid SentryMode', () => { - it('should skip message without SentryMode', async () => { - const invalidMessage = plainToInstance(TelemetryMessage, { + describe('The handle() method', () => { + describe('When the message has no valid SentryMode', () => { + it('should not dispatch any alert', async () => { + const message = plainToInstance(TelemetryMessage, { data: [{ key: 'OtherField', value: { stringValue: 'value' } }], createdAt: '2025-01-21T10:00:00.000Z', vin: 'TEST_VIN_123', - isResend: false + isResend: false, }); - await service.handle(invalidMessage); + await service.handle(message); expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); }); + }); - it('should skip message with null SentryMode value', async () => { - const invalidMessage = plainToInstance(TelemetryMessage, { - data: [{ key: 'SentryMode', value: { sentryModeStateValue: null } }], - createdAt: '2025-01-21T10:00:00.000Z', - vin: 'TEST_VIN_123', - isResend: false - }); - - await service.handle(invalidMessage); - - expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); + describe('When SentryMode is Aware', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Aware)); }); - it('should skip message with invalid sentryModeStateValue', async () => { - const invalidMessage = plainToInstance(TelemetryMessage, { - data: [{ key: 'SentryMode', value: { sentryModeStateValue: 'InvalidState' } }], - createdAt: '2025-01-21T10:00:00.000Z', - vin: 'TEST_VIN_123', - isResend: false - }); - - await service.handle(invalidMessage); - - expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); + it('should dispatch the immediate Sentry alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_ALERT', + severity: AlertEventSeverity.Warning, + type: AlertEventType.Sentry, + }) + ); }); }); - describe('when SentryMode is not Aware', () => { - it('should not dispatch alert', async () => { - const message = plainToInstance(TelemetryMessage, { - data: [ - { - key: 'SentryMode', - value: { sentryModeStateValue: SentryModeState.Off } - } - ], - createdAt: '2025-01-21T10:00:00.000Z', - vin: 'TEST_VIN_123', - isResend: false - }); - - await service.handle(message); - - expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); + describe('When SentryMode is Panic', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Panic)); }); - }); - describe('when SentryMode is Aware', () => { - let baseTelemetryMessage: TelemetryMessage; - - beforeEach(() => { - baseTelemetryMessage = plainToInstance(TelemetryMessage, { - data: [ - { - key: 'SentryMode', - value: { sentryModeStateValue: 'SentryModeStateAware' } - } - ], - createdAt: '2025-01-21T10:00:00.000Z', - vin: 'TEST_VIN_123', - isResend: false - }); - mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] }); + it('should dispatch a critical panic alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_PANIC', + severity: AlertEventSeverity.Critical, + type: AlertEventType.Panic, + }) + ); }); + }); - it('should dispatch alert via alertNotifier', async () => { - await service.handle(baseTelemetryMessage); - - expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith(expect.objectContaining({ - telemetryMessage: baseTelemetryMessage, - alertName: 'SENTRY_ALERT', - latencyLabel: 'SENTRY_LATENCY', - telegramNotifier: expect.any(Function) - })); + describe('When SentryMode is Armed (neither Aware nor Panic)', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Armed)); }); - it('should construct and send telegram message when notifier callback is invoked', async () => { - await service.handle(baseTelemetryMessage); - - const dispatchCall = mockAlertNotifier.dispatch.mock.calls[0][0]; - const notifierCb = dispatchCall.telegramNotifier; - - mockKeyboardBuilder.buildSentryAlertKeyboard.mockReturnValue({ - inline_keyboard: [[{ text: 'Test Button', url: 'http://test.com' }]] - }); - - await notifierCb('test-user', { vin: '123', display_name: 'Test Vehicle' }, 'en'); - - expect(mockKeyboardBuilder.buildSentryAlertKeyboard).toHaveBeenCalledWith('test-user', 'en'); - expect(mockTelegramService.sendSentryAlert).toHaveBeenCalledWith( - 'test-user', - { vin: '123', display_name: 'Test Vehicle' }, - 'en', - { inline_keyboard: [[{ text: 'Test Button', url: 'http://test.com' }]] } - ); + it('should not dispatch any alert', () => { + expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.ts b/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.ts index 34d73e67..5e807883 100644 --- a/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.ts +++ b/apps/api/src/app/alerts/sentry/sentry-alert-handler.service.ts @@ -17,7 +17,7 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { private readonly alertNotifier: VehicleAlertNotifierService, ) { } - async handle(telemetryMessage: TelemetryMessage): Promise { + public async handle(telemetryMessage: TelemetryMessage): Promise { if (!telemetryMessage.validateContainsSentryMode() || !telemetryMessage.validateSentryModeValue()) { this.logger.warn('Telemetry message does not contain SentryMode data', telemetryMessage); return; @@ -26,19 +26,44 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { const sentryMode = telemetryMessage.getSentryModeState(); if (sentryMode === SentryModeState.Aware) { - await this.alertNotifier.dispatch({ - telemetryMessage, - alertName: 'SENTRY_ALERT', - latencyLabel: 'SENTRY_LATENCY', - severity: AlertEventSeverity.Warning, - telegramNotifier: this.telegramNotifier, - type: AlertEventType.Sentry, - }); + await this.dispatchSentryAlert(telemetryMessage); + return; + } + + if (sentryMode === SentryModeState.Panic) { + await this.dispatchPanic(telemetryMessage); } } + private async dispatchSentryAlert(telemetryMessage: TelemetryMessage): Promise { + await this.alertNotifier.dispatch({ + telemetryMessage, + alertName: 'SENTRY_ALERT', + latencyLabel: 'SENTRY_LATENCY', + severity: AlertEventSeverity.Warning, + telegramNotifier: this.telegramNotifier, + type: AlertEventType.Sentry, + }); + } + + private async dispatchPanic(telemetryMessage: TelemetryMessage): Promise { + await this.alertNotifier.dispatch({ + telemetryMessage, + alertName: 'SENTRY_PANIC', + latencyLabel: 'SENTRY_PANIC_LATENCY', + severity: AlertEventSeverity.Critical, + telegramNotifier: this.panicNotifier, + type: AlertEventType.Panic, + }); + } + private readonly telegramNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); await this.telegramService.sendSentryAlert(userId, alertInfo, userLanguage, keyboard); }; + + private readonly panicNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { + const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); + await this.telegramService.sendSentryPanicAlert(userId, alertInfo, userLanguage, keyboard); + }; } diff --git a/apps/api/src/app/notifications/notifications.service.spec.ts b/apps/api/src/app/notifications/notifications.service.spec.ts index 791c2eb1..2fa01906 100644 --- a/apps/api/src/app/notifications/notifications.service.spec.ts +++ b/apps/api/src/app/notifications/notifications.service.spec.ts @@ -86,6 +86,30 @@ describe('The NotificationsService class', () => { }); }); + describe('When an English user receives a panic alert', () => { + beforeEach(async () => { + await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.Panic, 'en'); + }); + + it('should send the panic title', () => { + expect(lastPushPayload().title).toBe('Alarm triggered'); + }); + + it('should send the panic body', () => { + expect(lastPushPayload().body).toBe('Your vehicle alarm may be sounding.'); + }); + }); + + describe('When a French user receives a panic alert', () => { + beforeEach(async () => { + await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.Panic, 'fr'); + }); + + it('should send the localized French title', () => { + expect(lastPushPayload().title).toBe('Alarme déclenchée'); + }); + }); + describe('When the user has no eligible device', () => { beforeEach(async () => { mockPushDeviceTokenRepository.find.mockResolvedValue([]); diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index c497a3ef..36580142 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -83,17 +83,22 @@ export class NotificationsService { } private resolveAlertTexts(type: AlertEventType, lng: 'en' | 'fr'): { body: string; title: string } { - if (type === AlertEventType.BreakIn) { - return { + const textsByType: Record = { + [AlertEventType.BreakIn]: { body: i18n.t('A break-in attempt was detected.', { lng }), title: i18n.t('Intrusion alert', { lng }), - }; - } - - return { - body: i18n.t('A Sentry event was detected.', { lng }), - title: i18n.t('Sentry alert', { lng }), + }, + [AlertEventType.Sentry]: { + body: i18n.t('A Sentry event was detected.', { lng }), + title: i18n.t('Sentry alert', { lng }), + }, + [AlertEventType.Panic]: { + body: i18n.t('Your vehicle alarm may be sounding.', { lng }), + title: i18n.t('Alarm triggered', { lng }), + }, }; + + return textsByType[type]; } private async findOrCreatePreferences(userId: string): Promise { diff --git a/apps/api/src/app/telegram/telegram.service.ts b/apps/api/src/app/telegram/telegram.service.ts index 6c331bda..b3d947b4 100644 --- a/apps/api/src/app/telegram/telegram.service.ts +++ b/apps/api/src/app/telegram/telegram.service.ts @@ -152,6 +152,70 @@ export class TelegramService implements OnModuleDestroy { } } + async sendSentryPanicAlert( + userId: string, + alertInfo: { vin: string, display_name?: string }, + userLanguage: 'en' | 'fr', + keyboard?: TelegramKeyboard, + ) { + const message = this.formatSentryPanicMessage(alertInfo, userLanguage); + return this.sendFormattedAlert(userId, alertInfo, userLanguage, message, keyboard); + } + + private async sendFormattedAlert( + userId: string, + alertInfo: { vin: string, display_name?: string }, + userLanguage: 'en' | 'fr', + message: string, + keyboard?: TelegramKeyboard, + ): Promise { + if (await this.telegramMuteService.checkIsNotificationMuted(userId)) { + this.logger.log(`🔕 Alert suppressed for muted user ${userId}`); + return false; + } + + const chatId = await this.telegramContextService.getChatIdFromUserId(userId); + + if (!chatId) { + this.logger.warn(`⚠️ No chat_id found for user: ${userId}`); + return false; + } + + await this.telegramBotUpdateService.ensureUserIsUpToDate(userId, chatId, userLanguage); + + if (this.shouldSimulateMessage(alertInfo.vin)) { + return await this.simulateMessage(userId, 'alert', alertInfo.vin); + } + + const options = keyboard ? { keyboard } : undefined; + + try { + return await this.telegramBotService.sendMessage(chatId, message, options); + } catch (error) { + if (this.failureHandler.canHandle(error as Error)) { + await this.failureHandler.handleFailure(error as Error, userId); + return false; + } + + if (this.isRetryableTelegramError(error)) { + const correlationId = `telegram-alert-${userId}-${Date.now()}`; + this.retryManager.addToRetry( + async () => { + await this.telegramBotService.sendMessage(chatId, message, options); + }, + error as Error, + correlationId + ); + + return false; + } + + this.logError(userId, 'alert', error); + + throw error; + } + } + onModuleDestroy() { this.retryManager.stop(); } @@ -219,4 +283,17 @@ export class TelegramService implements OnModuleDestroy { ${i18n.t('Break-in attempt detected. Check your vehicle immediately!', { lng })} `.trim(); } + + private formatSentryPanicMessage( + { display_name, vin }: { vin: string, display_name?: string }, + lng: 'en' | 'fr' + ): string { + return ` +🚨 ${i18n.t('TESLA ALARM TRIGGERED', { lng })} 🚨 + +🚗 ${i18n.t('Vehicle', { lng })}: ${display_name ?? vin} + +${i18n.t('Sentry Mode panic - your vehicle alarm may be sounding!', { lng })} + `.trim(); + } } diff --git a/apps/api/src/entities/alert-event.entity.ts b/apps/api/src/entities/alert-event.entity.ts index 9c23b842..0aa01776 100644 --- a/apps/api/src/entities/alert-event.entity.ts +++ b/apps/api/src/entities/alert-event.entity.ts @@ -8,6 +8,7 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', + Panic = 'panic', } @Entity('alert_events') diff --git a/apps/api/src/locales/en/common.json b/apps/api/src/locales/en/common.json index 81d62c84..0fa89e07 100644 --- a/apps/api/src/locales/en/common.json +++ b/apps/api/src/locales/en/common.json @@ -8,6 +8,8 @@ "TESLA SENTRY ALERT": "TESLA SENTRY ALERT", "TESLA BREAK-IN ALERT": "TESLA BREAK-IN ALERT", "Break-in attempt detected. Check your vehicle immediately!": "Break-in attempt detected. Check your vehicle immediately!", + "TESLA ALARM TRIGGERED": "TESLA ALARM TRIGGERED", + "Sentry Mode panic - your vehicle alarm may be sounding!": "Sentry Mode panic - your vehicle alarm may be sounding!", "This token has expired": "This token has expired", "Vehicle": "Vehicle", "Welcome to SentryGuard Bot": "Welcome to SentryGuard Bot", @@ -75,5 +77,7 @@ "Intrusion alert": "Intrusion alert", "A break-in attempt was detected.": "A break-in attempt was detected.", "Sentry alert": "Sentry alert", - "A Sentry event was detected.": "A Sentry event was detected." + "A Sentry event was detected.": "A Sentry event was detected.", + "Alarm triggered": "Alarm triggered", + "Your vehicle alarm may be sounding.": "Your vehicle alarm may be sounding." } diff --git a/apps/api/src/locales/fr/common.json b/apps/api/src/locales/fr/common.json index 1875bfaf..db2c86df 100644 --- a/apps/api/src/locales/fr/common.json +++ b/apps/api/src/locales/fr/common.json @@ -8,6 +8,8 @@ "TESLA SENTRY ALERT": "ALERTE SENTINELLE TESLA", "TESLA BREAK-IN ALERT": "ALERTE EFFRACTION TESLA", "Break-in attempt detected. Check your vehicle immediately!": "Tentative d'effraction détectée. Vérifiez votre véhicule immédiatement !", + "TESLA ALARM TRIGGERED": "ALARME TESLA DÉCLENCHÉE", + "Sentry Mode panic - your vehicle alarm may be sounding!": "Sentinelle en panique - l'alarme de votre véhicule retentit peut-être !", "This token has expired": "⏰ Ce token a expiré. Veuillez générer un nouveau lien depuis l'application.", "Vehicle": "Véhicule", "Welcome to SentryGuard Bot": "🚗 Bienvenue sur le bot SentryGuard !\n\nPour lier votre compte, utilisez le lien fourni dans l'application web.", @@ -75,5 +77,7 @@ "Intrusion alert": "Alerte intrusion", "A break-in attempt was detected.": "Une tentative d’intrusion a été détectée.", "Sentry alert": "Alerte Sentinelle", - "A Sentry event was detected.": "Un événement Sentinelle a été détecté." + "A Sentry event was detected.": "Un événement Sentinelle a été détecté.", + "Alarm triggered": "Alarme déclenchée", + "Your vehicle alarm may be sounding.": "L'alarme de votre véhicule retentit peut-être." } diff --git a/apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts b/apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts new file mode 100644 index 00000000..ec3d2c3d --- /dev/null +++ b/apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSentryPanicAlertType1781000000000 implements MigrationInterface { + name = 'AddSentryPanicAlertType1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "alert_events_type_enum" ADD VALUE IF NOT EXISTS 'panic'`); + } + + public async down(): Promise { + // PostgreSQL does not support removing enum values without recreating the type. + // Left as a no-op to avoid disrupting the live alert_events.type column. + } +} diff --git a/apps/mobile/src/features/alerts/domain/entities.ts b/apps/mobile/src/features/alerts/domain/entities.ts index e9be5a24..524c4173 100644 --- a/apps/mobile/src/features/alerts/domain/entities.ts +++ b/apps/mobile/src/features/alerts/domain/entities.ts @@ -6,6 +6,7 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', + Panic = 'panic', } export interface AlertEvent { diff --git a/apps/mobile/src/locales/en.json b/apps/mobile/src/locales/en.json index 98ae6980..dbd004fd 100644 --- a/apps/mobile/src/locales/en.json +++ b/apps/mobile/src/locales/en.json @@ -10,6 +10,8 @@ "alerts.event.break_in.title": "Intrusion alert", "alerts.event.sentry.message": "A Sentry event was detected.", "alerts.event.sentry.title": "Sentry alert", + "alerts.event.panic.message": "Your vehicle alarm may be sounding.", + "alerts.event.panic.title": "Alarm triggered", "alerts.filter.all": "All", "alerts.filter.critical": "Critical", "alerts.filter.warning": "Warning", diff --git a/apps/mobile/src/locales/fr.json b/apps/mobile/src/locales/fr.json index 0ac3700d..0346143a 100644 --- a/apps/mobile/src/locales/fr.json +++ b/apps/mobile/src/locales/fr.json @@ -10,6 +10,8 @@ "alerts.event.break_in.title": "Alerte intrusion", "alerts.event.sentry.message": "Un événement Sentinelle a été détecté.", "alerts.event.sentry.title": "Alerte Sentinelle", + "alerts.event.panic.message": "L'alarme de votre véhicule retentit peut-être.", + "alerts.event.panic.title": "Alarme déclenchée", "alerts.filter.all": "Tout", "alerts.filter.critical": "Critique", "alerts.filter.warning": "Attention", diff --git a/apps/mobile/src/screens/alerts/alerts.helpers.test.ts b/apps/mobile/src/screens/alerts/alerts.helpers.test.ts index 8b8e9322..6a82ba63 100644 --- a/apps/mobile/src/screens/alerts/alerts.helpers.test.ts +++ b/apps/mobile/src/screens/alerts/alerts.helpers.test.ts @@ -49,6 +49,15 @@ describe('The resolveAlertTitleKey() function', () => { ); }); }); + + describe('When the alert is a panic', () => { + it('should return the panic title key', () => { + expect(resolveAlertTitleKey(createAlert(AlertEventType.Panic, AlertEventSeverity.Critical))).toBe( + 'alerts.event.panic.title' + ); + }); + }); + }); describe('The resolveAlertMessageKey() function', () => { @@ -67,6 +76,15 @@ describe('The resolveAlertMessageKey() function', () => { ); }); }); + + describe('When the alert is a panic', () => { + it('should return the panic message key', () => { + expect(resolveAlertMessageKey(createAlert(AlertEventType.Panic, AlertEventSeverity.Critical))).toBe( + 'alerts.event.panic.message' + ); + }); + }); + }); describe('The resolveAlertTone() function', () => {