From 0b1a6ad85f5880b9829c9ec64dd058314138ff63 Mon Sep 17 00:00:00 2001 From: Jordi Marzo <22314991+JordiMa@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:54:26 +0200 Subject: [PATCH 1/2] feat: add Sentry sustained-presence and panic alerts Alert the owner when the vehicle stays in Sentry "Aware" (someone lingering around the vehicle) and when Sentry escalates to "Panic" (alarm), using the SentryMode telemetry stream. - Sustained presence: re-alerts every SENTRY_SUSTAINED_AWARE_SECONDS while Aware, capped at SENTRY_SUSTAINED_MAX_REPEATS, with a final "no more alerts for this incident" reminder; resets when the vehicle leaves Aware. - New first-class alert types (sustained_presence, sustained_presence_final, panic) shown distinctly on Telegram, push, and the in-app history (FR/EN). - Keeps the existing immediate Sentry alert on every Aware. --- .env.selfhost.example | 5 + apps/api/.env.example | 7 + .../sentry-alert-handler.service.spec.ts | 182 +++++++++--------- .../sentry/sentry-alert-handler.service.ts | 85 +++++++- .../sentry-presence-tracker.service.spec.ts | 105 ++++++++++ .../sentry/sentry-presence-tracker.service.ts | 56 ++++++ apps/api/src/app/app.module.ts | 2 + .../notifications.service.spec.ts | 52 +++++ .../notifications/notifications.service.ts | 29 ++- apps/api/src/app/telegram/telegram.service.ts | 123 ++++++++++++ apps/api/src/entities/alert-event.entity.ts | 3 + apps/api/src/locales/en/common.json | 14 +- apps/api/src/locales/fr/common.json | 14 +- ...81000000000-AddSentryPresenceAlertTypes.ts | 16 ++ .../src/features/alerts/domain/entities.ts | 3 + apps/mobile/src/locales/en.json | 6 + apps/mobile/src/locales/fr.json | 6 + .../src/screens/alerts/alerts.helpers.test.ts | 48 +++++ 18 files changed, 648 insertions(+), 108 deletions(-) create mode 100644 apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts create mode 100644 apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts create mode 100644 apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts diff --git a/.env.selfhost.example b/.env.selfhost.example index 0763d000..a929a319 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -73,6 +73,11 @@ JWT_EXPIRATION=1d # Tesla Telemetry intervals (seconds) SENTRY_MODE_INTERVAL_SECONDS=30 BREAK_IN_MONITORING_INTERVAL_SECONDS=30 +# "Someone is lingering" critical alert: first sent after this long in Sentry "Aware", +# then repeated every interval while present (seconds, default: 15) +SENTRY_SUSTAINED_AWARE_SECONDS=15 +# Max consecutive "lingering" alerts per Aware episode before stopping (safety cap, default: 3) +SENTRY_SUSTAINED_MAX_REPEATS=3 # Kafka (for self-hosted: kafka:29092 — leave defaults for docker-compose) KAFKA_BROKERS=kafka:29092 diff --git a/apps/api/.env.example b/apps/api/.env.example index 1ce6dcab..17a35d5a 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -41,6 +41,13 @@ TESLA_PUBLIC_KEY_BASE64=your_base64_encoded_public_key_here SENTRY_MODE_INTERVAL_SECONDS=30 BREAK_IN_MONITORING_INTERVAL_SECONDS=30 OFFENSIVE_RESPONSE_LATENCY_THRESHOLD_MS=60000 +# Interval (seconds) for the "someone is lingering around your vehicle" critical alert: +# first sent once the vehicle has stayed in Sentry "Aware" this long, then REPEATED every +# interval while someone remains around the vehicle (default: 15). +SENTRY_SUSTAINED_AWARE_SECONDS=15 +# Max consecutive "lingering" alerts per Aware episode before stopping (safety cap against an +# infinite loop if the vehicle goes offline while Aware; resets when it leaves Aware; default: 3). +SENTRY_SUSTAINED_MAX_REPEATS=3 # Kafka Configuration 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..32011d8f 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,36 @@ 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 { SentryPresenceTrackerService } from './sentry-presence-tracker.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; + let mockPresenceTracker: MockProxy; + beforeEach(async () => { mockTelegramService = mock(); mockKeyboardBuilder = mock(); mockAlertNotifier = mock(); + mockPresenceTracker = mock(); + mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] }); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -25,127 +38,116 @@ describe('The SentryAlertHandlerService class', () => { { provide: TelegramService, useValue: mockTelegramService }, { provide: TelegramKeyboardBuilderService, useValue: mockKeyboardBuilder }, { provide: VehicleAlertNotifierService, useValue: mockAlertNotifier }, - ] + { provide: SentryPresenceTrackerService, useValue: mockPresenceTracker }, + ], }).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); + it('should dispatch the immediate Sentry alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_ALERT', + severity: AlertEventSeverity.Warning, + }) + ); + }); - expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); + it('should also start watching for sustained presence', () => { + expect(mockPresenceTracker.watch).toHaveBeenCalledWith('TEST_VIN_123', expect.any(Function)); }); }); - 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); + describe('When the sustained-presence watch fires (not final)', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Aware)); + const onSustained = mockPresenceTracker.watch.mock.calls[0][1]; + await onSustained(false); + }); - expect(mockAlertNotifier.dispatch).not.toHaveBeenCalled(); + it('should dispatch a critical sustained-presence alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_SUSTAINED_PRESENCE', + severity: AlertEventSeverity.Critical, + type: AlertEventType.SustainedPresence, + }) + ); }); }); - 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'] }); + describe('When the final sustained-presence watch fires', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Aware)); + const onSustained = mockPresenceTracker.watch.mock.calls[0][1]; + await onSustained(true); }); - it('should dispatch alert via alertNotifier', async () => { - await service.handle(baseTelemetryMessage); + it('should dispatch the final sustained-presence alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_SUSTAINED_PRESENCE_FINAL', + severity: AlertEventSeverity.Critical, + type: AlertEventType.SustainedPresenceFinal, + }) + ); + }); + }); - expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith(expect.objectContaining({ - telemetryMessage: baseTelemetryMessage, - alertName: 'SENTRY_ALERT', - latencyLabel: 'SENTRY_LATENCY', - telegramNotifier: expect.any(Function) - })); + describe('When SentryMode is Panic', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Panic)); }); - it('should construct and send telegram message when notifier callback is invoked', async () => { - await service.handle(baseTelemetryMessage); + it('should clear the presence watch', () => { + expect(mockPresenceTracker.clear).toHaveBeenCalledWith('TEST_VIN_123'); + }); - const dispatchCall = mockAlertNotifier.dispatch.mock.calls[0][0]; - const notifierCb = dispatchCall.telegramNotifier; + it('should dispatch a critical panic alert', () => { + expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + alertName: 'SENTRY_PANIC', + severity: AlertEventSeverity.Critical, + type: AlertEventType.Panic, + }) + ); + }); + }); - mockKeyboardBuilder.buildSentryAlertKeyboard.mockReturnValue({ - inline_keyboard: [[{ text: 'Test Button', url: 'http://test.com' }]] - }); + describe('When SentryMode is Armed (neither Aware nor Panic)', () => { + beforeEach(async () => { + await service.handle(buildMessage(SentryModeState.Armed)); + }); - await notifierCb('test-user', { vin: '123', display_name: 'Test Vehicle' }, 'en'); + it('should clear the presence watch', () => { + expect(mockPresenceTracker.clear).toHaveBeenCalledWith('TEST_VIN_123'); + }); - 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..fd813f1e 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 @@ -6,6 +6,7 @@ import { TelemetryEventHandler } from '../../telemetry/interfaces/telemetry-even import { SentryModeState, TelemetryMessage } from '../../telemetry/models/telemetry-message.model'; import { VehicleAlertNotifierService } from '../common/vehicle-alert-notifier.service'; import { AlertEventSeverity, AlertEventType } from '../../../entities/alert-event.entity'; +import { SentryPresenceTrackerService } from './sentry-presence-tracker.service'; @Injectable() export class SentryAlertHandlerService implements TelemetryEventHandler { @@ -15,9 +16,10 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { private readonly telegramService: TelegramService, private readonly keyboardBuilder: TelegramKeyboardBuilderService, private readonly alertNotifier: VehicleAlertNotifierService, + private readonly presenceTracker: SentryPresenceTrackerService, ) { } - 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 +28,86 @@ 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); + this.presenceTracker.watch(telemetryMessage.vin, (isFinal) => { + const dispatch = isFinal + ? this.dispatchSustainedPresenceFinal(telemetryMessage) + : this.dispatchSustainedPresence(telemetryMessage); + dispatch.catch((error: unknown) => { + this.logger.error(`Failed to dispatch sustained-presence alert for VIN ${telemetryMessage.vin}`, error); + }); }); + return; + } + + this.presenceTracker.clear(telemetryMessage.vin); + + 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 dispatchSustainedPresence(telemetryMessage: TelemetryMessage): Promise { + await this.alertNotifier.dispatch({ + telemetryMessage, + alertName: 'SENTRY_SUSTAINED_PRESENCE', + latencyLabel: 'SENTRY_SUSTAINED_LATENCY', + severity: AlertEventSeverity.Critical, + telegramNotifier: this.sustainedPresenceNotifier, + type: AlertEventType.SustainedPresence, + }); + } + + private async dispatchSustainedPresenceFinal(telemetryMessage: TelemetryMessage): Promise { + await this.alertNotifier.dispatch({ + telemetryMessage, + alertName: 'SENTRY_SUSTAINED_PRESENCE_FINAL', + latencyLabel: 'SENTRY_SUSTAINED_LATENCY', + severity: AlertEventSeverity.Critical, + telegramNotifier: this.sustainedPresenceFinalNotifier, + type: AlertEventType.SustainedPresenceFinal, + }); + } + + 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 sustainedPresenceNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { + const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); + await this.telegramService.sendSentryPresenceAlert(userId, alertInfo, userLanguage, keyboard); + }; + + private readonly sustainedPresenceFinalNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { + const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); + await this.telegramService.sendSentryPresenceFinalAlert(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/alerts/sentry/sentry-presence-tracker.service.spec.ts b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts new file mode 100644 index 00000000..1fb4e4b7 --- /dev/null +++ b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts @@ -0,0 +1,105 @@ +import { SentryPresenceTrackerService } from './sentry-presence-tracker.service'; + +describe('The SentryPresenceTrackerService class', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('The watch() method', () => { + describe('When the VIN stays Aware past the threshold', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(15_000); + }); + + it('should fire the sustained-presence callback', () => { + expect(onSustained).toHaveBeenCalledTimes(1); + }); + }); + + describe('When the VIN stays Aware well beyond the cap', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(120_000); + }); + + it('should fire up to the cap, flagging only the last alert as final', () => { + expect(onSustained.mock.calls.map((call) => call[0])).toEqual([false, false, true]); + }); + }); + + describe('When cleared before the threshold', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(10_000); + tracker.clear('VIN1'); + jest.advanceTimersByTime(120_000); + }); + + it('should never fire', () => { + expect(onSustained).not.toHaveBeenCalled(); + }); + }); + + describe('When cleared after firing but before the cap', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(30_000); + tracker.clear('VIN1'); + jest.advanceTimersByTime(120_000); + }); + + it('should stop at the count it had reached', () => { + expect(onSustained).toHaveBeenCalledTimes(2); + }); + }); + + describe('When a new Aware episode starts after the vehicle left Aware', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(120_000); + tracker.clear('VIN1'); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(120_000); + }); + + it('should allow the cap to fire again for the new episode', () => { + expect(onSustained).toHaveBeenCalledTimes(6); + }); + }); + + describe('When watch is called twice for the same VIN', () => { + const onSustained = jest.fn(); + + beforeEach(() => { + const tracker = new SentryPresenceTrackerService(); + tracker.watch('VIN1', onSustained); + tracker.watch('VIN1', onSustained); + jest.advanceTimersByTime(15_000); + }); + + it('should run a single interval', () => { + expect(onSustained).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts new file mode 100644 index 00000000..b5d79a4b --- /dev/null +++ b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class SentryPresenceTrackerService { + private readonly logger = new Logger(SentryPresenceTrackerService.name); + private readonly timers = new Map(); + private readonly counts = new Map(); + private readonly capped = new Set(); + private readonly thresholdMs = parseInt(process.env.SENTRY_SUSTAINED_AWARE_SECONDS ?? '15', 10) * 1000; + private readonly maxRepeats = parseInt(process.env.SENTRY_SUSTAINED_MAX_REPEATS ?? '3', 10); + + public watch(vin: string, onSustained: (isFinal: boolean) => void): void { + if (this.timers.has(vin) || this.capped.has(vin)) { + return; + } + + this.logger.log(`[SENTRY_PRESENCE] Watching VIN ${vin} - re-alerting every ${this.thresholdMs / 1000}s (max ${this.maxRepeats})`); + this.counts.set(vin, 0); + + const timer = setInterval(() => { + const count = (this.counts.get(vin) ?? 0) + 1; + this.counts.set(vin, count); + const isFinal = count >= this.maxRepeats; + this.logger.warn(`[SENTRY_PRESENCE] VIN ${vin} still Aware - dispatching (${count}/${this.maxRepeats})`); + onSustained(isFinal); + + if (isFinal) { + this.stopAtCap(vin); + } + }, this.thresholdMs); + + this.timers.set(vin, timer); + } + + public clear(vin: string): void { + this.stopTimer(vin); + this.capped.delete(vin); + } + + private stopAtCap(vin: string): void { + this.logger.warn(`[SENTRY_PRESENCE] VIN ${vin} reached ${this.maxRepeats} sustained alerts - stopping until it leaves Aware`); + this.stopTimer(vin); + this.capped.add(vin); + } + + private stopTimer(vin: string): void { + const timer = this.timers.get(vin); + + if (timer) { + clearInterval(timer); + this.timers.delete(vin); + } + + this.counts.delete(vin); + } +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 8dbcb152..ce526e33 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -10,6 +10,7 @@ import { KafkaService } from './messaging/kafka/kafka.service'; import { TelemetryMessageHandlerService } from './telemetry/handlers/telemetry-message-handler.service'; import { TelemetryValidationService } from './telemetry/services/telemetry-validation.service'; import { SentryAlertHandlerService } from './alerts/sentry/sentry-alert-handler.service'; +import { SentryPresenceTrackerService } from './alerts/sentry/sentry-presence-tracker.service'; import { BreakInAlertHandlerService } from './alerts/break-in/break-in-alert-handler.service'; import { ChargePortLatchTrackerService } from './alerts/break-in/charge-port-latch-tracker.service'; import { VehicleAlertNotifierService } from './alerts/common/vehicle-alert-notifier.service'; @@ -75,6 +76,7 @@ import { RetryManager } from './shared/retry-manager.service'; TelemetryValidationService, VehicleAlertNotifierService, SentryAlertHandlerService, + SentryPresenceTrackerService, BreakInAlertHandlerService, ChargePortLatchTrackerService, { diff --git a/apps/api/src/app/notifications/notifications.service.spec.ts b/apps/api/src/app/notifications/notifications.service.spec.ts index 791c2eb1..5d0a949a 100644 --- a/apps/api/src/app/notifications/notifications.service.spec.ts +++ b/apps/api/src/app/notifications/notifications.service.spec.ts @@ -86,6 +86,58 @@ describe('The NotificationsService class', () => { }); }); + describe('When an English user receives a sustained-presence alert', () => { + beforeEach(async () => { + await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.SustainedPresence, 'en'); + }); + + it('should send the sustained-presence title', () => { + expect(lastPushPayload().title).toBe('Someone is lingering'); + }); + + it('should send the sustained-presence body', () => { + expect(lastPushPayload().body).toBe('Someone has been around your vehicle for a while.'); + }); + }); + + describe('When an English user receives a final sustained-presence alert', () => { + beforeEach(async () => { + await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.SustainedPresenceFinal, 'en'); + }); + + it('should send the final-reminder title', () => { + expect(lastPushPayload().title).toBe('Last reminder - someone still around'); + }); + + it('should send the final-reminder body', () => { + expect(lastPushPayload().body).toBe('Someone is still around your vehicle. No more alerts for this incident.'); + }); + }); + + 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..261ffd5d 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -83,17 +83,30 @@ 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.SustainedPresence]: { + body: i18n.t('Someone has been around your vehicle for a while.', { lng }), + title: i18n.t('Someone is lingering', { lng }), + }, + [AlertEventType.SustainedPresenceFinal]: { + body: i18n.t('Someone is still around your vehicle. No more alerts for this incident.', { lng }), + title: i18n.t('Last reminder - someone still around', { 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..6231b3e7 100644 --- a/apps/api/src/app/telegram/telegram.service.ts +++ b/apps/api/src/app/telegram/telegram.service.ts @@ -152,6 +152,90 @@ export class TelegramService implements OnModuleDestroy { } } + async sendSentryPresenceAlert( + userId: string, + alertInfo: { vin: string, display_name?: string }, + userLanguage: 'en' | 'fr', + keyboard?: TelegramKeyboard, + ) { + const message = this.formatSentryPresenceMessage(alertInfo, userLanguage); + return this.sendFormattedAlert(userId, alertInfo, userLanguage, message, keyboard); + } + + 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); + } + + async sendSentryPresenceFinalAlert( + userId: string, + alertInfo: { vin: string, display_name?: string }, + userLanguage: 'en' | 'fr', + keyboard?: TelegramKeyboard, + ) { + const message = this.formatSentryPresenceFinalMessage(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 +303,43 @@ export class TelegramService implements OnModuleDestroy { ${i18n.t('Break-in attempt detected. Check your vehicle immediately!', { lng })} `.trim(); } + + private formatSentryPresenceMessage( + { display_name, vin }: { vin: string, display_name?: string }, + lng: 'en' | 'fr' + ): string { + return ` +🚨 ${i18n.t('SOMEONE IS AROUND YOUR TESLA', { lng })} 🚨 + +🚗 ${i18n.t('Vehicle', { lng })}: ${display_name ?? vin} + +${i18n.t('Someone has been lingering around your vehicle for a while. Check your cameras!', { 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(); + } + + private formatSentryPresenceFinalMessage( + { display_name, vin }: { vin: string, display_name?: string }, + lng: 'en' | 'fr' + ): string { + return ` +🚨 ${i18n.t('LAST REMINDER', { lng })} 🚨 + +🚗 ${i18n.t('Vehicle', { lng })}: ${display_name ?? vin} + +${i18n.t('Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.', { lng })} + `.trim(); + } } diff --git a/apps/api/src/entities/alert-event.entity.ts b/apps/api/src/entities/alert-event.entity.ts index 9c23b842..38256d1f 100644 --- a/apps/api/src/entities/alert-event.entity.ts +++ b/apps/api/src/entities/alert-event.entity.ts @@ -8,6 +8,9 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', + SustainedPresence = 'sustained_presence', + SustainedPresenceFinal = 'sustained_presence_final', + 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..d83dfa6e 100644 --- a/apps/api/src/locales/en/common.json +++ b/apps/api/src/locales/en/common.json @@ -8,6 +8,10 @@ "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!", + "SOMEONE IS AROUND YOUR TESLA": "SOMEONE IS AROUND YOUR TESLA", + "Someone has been lingering around your vehicle for a while. Check your cameras!": "Someone has been lingering around your vehicle for a while. Check your cameras!", + "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 +79,13 @@ "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.", + "Someone is lingering": "Someone is lingering", + "Someone has been around your vehicle for a while.": "Someone has been around your vehicle for a while.", + "Alarm triggered": "Alarm triggered", + "Your vehicle alarm may be sounding.": "Your vehicle alarm may be sounding.", + "LAST REMINDER": "LAST REMINDER", + "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.": "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.", + "Last reminder - someone still around": "Last reminder - someone still around", + "Someone is still around your vehicle. No more alerts for this incident.": "Someone is still around your vehicle. No more alerts for this incident." } diff --git a/apps/api/src/locales/fr/common.json b/apps/api/src/locales/fr/common.json index 1875bfaf..5766c2f6 100644 --- a/apps/api/src/locales/fr/common.json +++ b/apps/api/src/locales/fr/common.json @@ -8,6 +8,10 @@ "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 !", + "SOMEONE IS AROUND YOUR TESLA": "QUELQU'UN RÔDE AUTOUR DE VOTRE TESLA", + "Someone has been lingering around your vehicle for a while. Check your cameras!": "Quelqu'un rôde autour de votre véhicule depuis un moment. Vérifiez vos caméras !", + "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 +79,13 @@ "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é.", + "Someone is lingering": "Quelqu'un rôde", + "Someone has been around your vehicle for a while.": "Quelqu'un rôde autour de votre véhicule depuis un moment.", + "Alarm triggered": "Alarme déclenchée", + "Your vehicle alarm may be sounding.": "L'alarme de votre véhicule retentit peut-être.", + "LAST REMINDER": "DERNIER RAPPEL", + "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.": "Quelqu'un est toujours autour de votre véhicule. Vous ne recevrez plus d'alerte pour cet incident - vérifiez vos caméras.", + "Last reminder - someone still around": "Dernier rappel - quelqu'un est toujours là", + "Someone is still around your vehicle. No more alerts for this incident.": "Quelqu'un est toujours autour de votre véhicule. Plus d'alerte pour cet incident." } diff --git a/apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts b/apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts new file mode 100644 index 00000000..a18ee491 --- /dev/null +++ b/apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSentryPresenceAlertTypes1781000000000 implements MigrationInterface { + name = 'AddSentryPresenceAlertTypes1781000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "alert_events_type_enum" ADD VALUE IF NOT EXISTS 'sustained_presence'`); + await queryRunner.query(`ALTER TYPE "alert_events_type_enum" ADD VALUE IF NOT EXISTS 'sustained_presence_final'`); + 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..1d0774b0 100644 --- a/apps/mobile/src/features/alerts/domain/entities.ts +++ b/apps/mobile/src/features/alerts/domain/entities.ts @@ -6,6 +6,9 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', + SustainedPresence = 'sustained_presence', + SustainedPresenceFinal = 'sustained_presence_final', + Panic = 'panic', } export interface AlertEvent { diff --git a/apps/mobile/src/locales/en.json b/apps/mobile/src/locales/en.json index a0b3b441..9ef62eba 100644 --- a/apps/mobile/src/locales/en.json +++ b/apps/mobile/src/locales/en.json @@ -10,6 +10,12 @@ "alerts.event.break_in.title": "Intrusion alert", "alerts.event.sentry.message": "A Sentry event was detected.", "alerts.event.sentry.title": "Sentry alert", + "alerts.event.sustained_presence.message": "Someone has been around your vehicle for a while.", + "alerts.event.sustained_presence.title": "Someone is lingering", + "alerts.event.sustained_presence_final.message": "Someone was still around your vehicle. Alerts were stopped for this incident.", + "alerts.event.sustained_presence_final.title": "Last reminder - someone still around", + "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 ed8c3bba..3bf3bb3c 100644 --- a/apps/mobile/src/locales/fr.json +++ b/apps/mobile/src/locales/fr.json @@ -10,6 +10,12 @@ "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.sustained_presence.message": "Quelqu'un rôde autour de votre véhicule depuis un moment.", + "alerts.event.sustained_presence.title": "Quelqu'un rôde", + "alerts.event.sustained_presence_final.message": "Quelqu'un était toujours autour de votre véhicule. Les alertes ont été arrêtées pour cet incident.", + "alerts.event.sustained_presence_final.title": "Dernier rappel - quelqu'un est toujours là", + "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..2dcba738 100644 --- a/apps/mobile/src/screens/alerts/alerts.helpers.test.ts +++ b/apps/mobile/src/screens/alerts/alerts.helpers.test.ts @@ -49,6 +49,30 @@ describe('The resolveAlertTitleKey() function', () => { ); }); }); + + describe('When the alert is a sustained presence', () => { + it('should return the sustained-presence title key', () => { + expect(resolveAlertTitleKey(createAlert(AlertEventType.SustainedPresence, AlertEventSeverity.Critical))).toBe( + 'alerts.event.sustained_presence.title' + ); + }); + }); + + 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('When the alert is a final sustained presence', () => { + it('should return the final sustained-presence title key', () => { + expect(resolveAlertTitleKey(createAlert(AlertEventType.SustainedPresenceFinal, AlertEventSeverity.Critical))).toBe( + 'alerts.event.sustained_presence_final.title' + ); + }); + }); }); describe('The resolveAlertMessageKey() function', () => { @@ -67,6 +91,30 @@ describe('The resolveAlertMessageKey() function', () => { ); }); }); + + describe('When the alert is a sustained presence', () => { + it('should return the sustained-presence message key', () => { + expect(resolveAlertMessageKey(createAlert(AlertEventType.SustainedPresence, AlertEventSeverity.Critical))).toBe( + 'alerts.event.sustained_presence.message' + ); + }); + }); + + 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('When the alert is a final sustained presence', () => { + it('should return the final sustained-presence message key', () => { + expect(resolveAlertMessageKey(createAlert(AlertEventType.SustainedPresenceFinal, AlertEventSeverity.Critical))).toBe( + 'alerts.event.sustained_presence_final.message' + ); + }); + }); }); describe('The resolveAlertTone() function', () => { From 0afe8388107e9a74421d772271b3551d24f132ea Mon Sep 17 00:00:00 2001 From: Jordi Marzo <22314991+JordiMa@users.noreply.github.com> Date: Wed, 24 Jun 2026 10:52:51 +0200 Subject: [PATCH 2/2] refactor(api): remove Sentry sustained-presence alerts, keep panic Drops the "someone is lingering" layer (presence tracker, alert types, Telegram/push/in-app texts, env vars, migration values) that produced false positives on transient passers-by. The base Sentry "Aware" alert and the "Panic" critical alert are unchanged. Co-Authored-By: Claude Opus 4.8 --- .env.selfhost.example | 5 - apps/api/.env.example | 7 -- .../sentry-alert-handler.service.spec.ts | 53 +-------- .../sentry/sentry-alert-handler.service.ts | 44 -------- .../sentry-presence-tracker.service.spec.ts | 105 ------------------ .../sentry/sentry-presence-tracker.service.ts | 56 ---------- apps/api/src/app/app.module.ts | 2 - .../notifications.service.spec.ts | 28 ----- .../notifications/notifications.service.ts | 8 -- apps/api/src/app/telegram/telegram.service.ts | 46 -------- apps/api/src/entities/alert-event.entity.ts | 2 - apps/api/src/locales/en/common.json | 10 +- apps/api/src/locales/fr/common.json | 10 +- ... 1781000000000-AddSentryPanicAlertType.ts} | 6 +- .../src/features/alerts/domain/entities.ts | 2 - apps/mobile/src/locales/en.json | 4 - apps/mobile/src/locales/fr.json | 4 - .../src/screens/alerts/alerts.helpers.test.ts | 30 ----- 18 files changed, 5 insertions(+), 417 deletions(-) delete mode 100644 apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts delete mode 100644 apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts rename apps/api/src/migrations/{1781000000000-AddSentryPresenceAlertTypes.ts => 1781000000000-AddSentryPanicAlertType.ts} (53%) diff --git a/.env.selfhost.example b/.env.selfhost.example index a929a319..0763d000 100644 --- a/.env.selfhost.example +++ b/.env.selfhost.example @@ -73,11 +73,6 @@ JWT_EXPIRATION=1d # Tesla Telemetry intervals (seconds) SENTRY_MODE_INTERVAL_SECONDS=30 BREAK_IN_MONITORING_INTERVAL_SECONDS=30 -# "Someone is lingering" critical alert: first sent after this long in Sentry "Aware", -# then repeated every interval while present (seconds, default: 15) -SENTRY_SUSTAINED_AWARE_SECONDS=15 -# Max consecutive "lingering" alerts per Aware episode before stopping (safety cap, default: 3) -SENTRY_SUSTAINED_MAX_REPEATS=3 # Kafka (for self-hosted: kafka:29092 — leave defaults for docker-compose) KAFKA_BROKERS=kafka:29092 diff --git a/apps/api/.env.example b/apps/api/.env.example index 17a35d5a..1ce6dcab 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -41,13 +41,6 @@ TESLA_PUBLIC_KEY_BASE64=your_base64_encoded_public_key_here SENTRY_MODE_INTERVAL_SECONDS=30 BREAK_IN_MONITORING_INTERVAL_SECONDS=30 OFFENSIVE_RESPONSE_LATENCY_THRESHOLD_MS=60000 -# Interval (seconds) for the "someone is lingering around your vehicle" critical alert: -# first sent once the vehicle has stayed in Sentry "Aware" this long, then REPEATED every -# interval while someone remains around the vehicle (default: 15). -SENTRY_SUSTAINED_AWARE_SECONDS=15 -# Max consecutive "lingering" alerts per Aware episode before stopping (safety cap against an -# infinite loop if the vehicle goes offline while Aware; resets when it leaves Aware; default: 3). -SENTRY_SUSTAINED_MAX_REPEATS=3 # Kafka Configuration 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 32011d8f..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 @@ -3,7 +3,6 @@ import { plainToInstance } from 'class-transformer'; import { mock, MockProxy } from 'jest-mock-extended'; import { SentryAlertHandlerService } from './sentry-alert-handler.service'; -import { SentryPresenceTrackerService } from './sentry-presence-tracker.service'; import { TelegramService } from '../../telegram/telegram.service'; import { TelegramKeyboardBuilderService } from '../../telegram/telegram-keyboard-builder.service'; import { VehicleAlertNotifierService } from '../common/vehicle-alert-notifier.service'; @@ -23,13 +22,11 @@ describe('The SentryAlertHandlerService class', () => { let mockTelegramService: MockProxy; let mockKeyboardBuilder: MockProxy; let mockAlertNotifier: MockProxy; - let mockPresenceTracker: MockProxy; beforeEach(async () => { mockTelegramService = mock(); mockKeyboardBuilder = mock(); mockAlertNotifier = mock(); - mockPresenceTracker = mock(); mockAlertNotifier.dispatch.mockResolvedValue({ userIds: ['user-1'] }); const module: TestingModule = await Test.createTestingModule({ @@ -38,7 +35,6 @@ describe('The SentryAlertHandlerService class', () => { { provide: TelegramService, useValue: mockTelegramService }, { provide: TelegramKeyboardBuilderService, useValue: mockKeyboardBuilder }, { provide: VehicleAlertNotifierService, useValue: mockAlertNotifier }, - { provide: SentryPresenceTrackerService, useValue: mockPresenceTracker }, ], }).compile(); @@ -72,46 +68,7 @@ describe('The SentryAlertHandlerService class', () => { expect.objectContaining({ alertName: 'SENTRY_ALERT', severity: AlertEventSeverity.Warning, - }) - ); - }); - - it('should also start watching for sustained presence', () => { - expect(mockPresenceTracker.watch).toHaveBeenCalledWith('TEST_VIN_123', expect.any(Function)); - }); - }); - - describe('When the sustained-presence watch fires (not final)', () => { - beforeEach(async () => { - await service.handle(buildMessage(SentryModeState.Aware)); - const onSustained = mockPresenceTracker.watch.mock.calls[0][1]; - await onSustained(false); - }); - - it('should dispatch a critical sustained-presence alert', () => { - expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - alertName: 'SENTRY_SUSTAINED_PRESENCE', - severity: AlertEventSeverity.Critical, - type: AlertEventType.SustainedPresence, - }) - ); - }); - }); - - describe('When the final sustained-presence watch fires', () => { - beforeEach(async () => { - await service.handle(buildMessage(SentryModeState.Aware)); - const onSustained = mockPresenceTracker.watch.mock.calls[0][1]; - await onSustained(true); - }); - - it('should dispatch the final sustained-presence alert', () => { - expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( - expect.objectContaining({ - alertName: 'SENTRY_SUSTAINED_PRESENCE_FINAL', - severity: AlertEventSeverity.Critical, - type: AlertEventType.SustainedPresenceFinal, + type: AlertEventType.Sentry, }) ); }); @@ -122,10 +79,6 @@ describe('The SentryAlertHandlerService class', () => { await service.handle(buildMessage(SentryModeState.Panic)); }); - it('should clear the presence watch', () => { - expect(mockPresenceTracker.clear).toHaveBeenCalledWith('TEST_VIN_123'); - }); - it('should dispatch a critical panic alert', () => { expect(mockAlertNotifier.dispatch).toHaveBeenCalledWith( expect.objectContaining({ @@ -142,10 +95,6 @@ describe('The SentryAlertHandlerService class', () => { await service.handle(buildMessage(SentryModeState.Armed)); }); - it('should clear the presence watch', () => { - expect(mockPresenceTracker.clear).toHaveBeenCalledWith('TEST_VIN_123'); - }); - 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 fd813f1e..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 @@ -6,7 +6,6 @@ import { TelemetryEventHandler } from '../../telemetry/interfaces/telemetry-even import { SentryModeState, TelemetryMessage } from '../../telemetry/models/telemetry-message.model'; import { VehicleAlertNotifierService } from '../common/vehicle-alert-notifier.service'; import { AlertEventSeverity, AlertEventType } from '../../../entities/alert-event.entity'; -import { SentryPresenceTrackerService } from './sentry-presence-tracker.service'; @Injectable() export class SentryAlertHandlerService implements TelemetryEventHandler { @@ -16,7 +15,6 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { private readonly telegramService: TelegramService, private readonly keyboardBuilder: TelegramKeyboardBuilderService, private readonly alertNotifier: VehicleAlertNotifierService, - private readonly presenceTracker: SentryPresenceTrackerService, ) { } public async handle(telemetryMessage: TelemetryMessage): Promise { @@ -29,19 +27,9 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { if (sentryMode === SentryModeState.Aware) { await this.dispatchSentryAlert(telemetryMessage); - this.presenceTracker.watch(telemetryMessage.vin, (isFinal) => { - const dispatch = isFinal - ? this.dispatchSustainedPresenceFinal(telemetryMessage) - : this.dispatchSustainedPresence(telemetryMessage); - dispatch.catch((error: unknown) => { - this.logger.error(`Failed to dispatch sustained-presence alert for VIN ${telemetryMessage.vin}`, error); - }); - }); return; } - this.presenceTracker.clear(telemetryMessage.vin); - if (sentryMode === SentryModeState.Panic) { await this.dispatchPanic(telemetryMessage); } @@ -58,28 +46,6 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { }); } - private async dispatchSustainedPresence(telemetryMessage: TelemetryMessage): Promise { - await this.alertNotifier.dispatch({ - telemetryMessage, - alertName: 'SENTRY_SUSTAINED_PRESENCE', - latencyLabel: 'SENTRY_SUSTAINED_LATENCY', - severity: AlertEventSeverity.Critical, - telegramNotifier: this.sustainedPresenceNotifier, - type: AlertEventType.SustainedPresence, - }); - } - - private async dispatchSustainedPresenceFinal(telemetryMessage: TelemetryMessage): Promise { - await this.alertNotifier.dispatch({ - telemetryMessage, - alertName: 'SENTRY_SUSTAINED_PRESENCE_FINAL', - latencyLabel: 'SENTRY_SUSTAINED_LATENCY', - severity: AlertEventSeverity.Critical, - telegramNotifier: this.sustainedPresenceFinalNotifier, - type: AlertEventType.SustainedPresenceFinal, - }); - } - private async dispatchPanic(telemetryMessage: TelemetryMessage): Promise { await this.alertNotifier.dispatch({ telemetryMessage, @@ -96,16 +62,6 @@ export class SentryAlertHandlerService implements TelemetryEventHandler { await this.telegramService.sendSentryAlert(userId, alertInfo, userLanguage, keyboard); }; - private readonly sustainedPresenceNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { - const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); - await this.telegramService.sendSentryPresenceAlert(userId, alertInfo, userLanguage, keyboard); - }; - - private readonly sustainedPresenceFinalNotifier = async (userId: string, alertInfo: { vin: string; display_name?: string }, userLanguage: 'en' | 'fr') => { - const keyboard = this.keyboardBuilder.buildSentryAlertKeyboard(userId, userLanguage); - await this.telegramService.sendSentryPresenceFinalAlert(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/alerts/sentry/sentry-presence-tracker.service.spec.ts b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts deleted file mode 100644 index 1fb4e4b7..00000000 --- a/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { SentryPresenceTrackerService } from './sentry-presence-tracker.service'; - -describe('The SentryPresenceTrackerService class', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('The watch() method', () => { - describe('When the VIN stays Aware past the threshold', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(15_000); - }); - - it('should fire the sustained-presence callback', () => { - expect(onSustained).toHaveBeenCalledTimes(1); - }); - }); - - describe('When the VIN stays Aware well beyond the cap', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(120_000); - }); - - it('should fire up to the cap, flagging only the last alert as final', () => { - expect(onSustained.mock.calls.map((call) => call[0])).toEqual([false, false, true]); - }); - }); - - describe('When cleared before the threshold', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(10_000); - tracker.clear('VIN1'); - jest.advanceTimersByTime(120_000); - }); - - it('should never fire', () => { - expect(onSustained).not.toHaveBeenCalled(); - }); - }); - - describe('When cleared after firing but before the cap', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(30_000); - tracker.clear('VIN1'); - jest.advanceTimersByTime(120_000); - }); - - it('should stop at the count it had reached', () => { - expect(onSustained).toHaveBeenCalledTimes(2); - }); - }); - - describe('When a new Aware episode starts after the vehicle left Aware', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(120_000); - tracker.clear('VIN1'); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(120_000); - }); - - it('should allow the cap to fire again for the new episode', () => { - expect(onSustained).toHaveBeenCalledTimes(6); - }); - }); - - describe('When watch is called twice for the same VIN', () => { - const onSustained = jest.fn(); - - beforeEach(() => { - const tracker = new SentryPresenceTrackerService(); - tracker.watch('VIN1', onSustained); - tracker.watch('VIN1', onSustained); - jest.advanceTimersByTime(15_000); - }); - - it('should run a single interval', () => { - expect(onSustained).toHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts b/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts deleted file mode 100644 index b5d79a4b..00000000 --- a/apps/api/src/app/alerts/sentry/sentry-presence-tracker.service.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -@Injectable() -export class SentryPresenceTrackerService { - private readonly logger = new Logger(SentryPresenceTrackerService.name); - private readonly timers = new Map(); - private readonly counts = new Map(); - private readonly capped = new Set(); - private readonly thresholdMs = parseInt(process.env.SENTRY_SUSTAINED_AWARE_SECONDS ?? '15', 10) * 1000; - private readonly maxRepeats = parseInt(process.env.SENTRY_SUSTAINED_MAX_REPEATS ?? '3', 10); - - public watch(vin: string, onSustained: (isFinal: boolean) => void): void { - if (this.timers.has(vin) || this.capped.has(vin)) { - return; - } - - this.logger.log(`[SENTRY_PRESENCE] Watching VIN ${vin} - re-alerting every ${this.thresholdMs / 1000}s (max ${this.maxRepeats})`); - this.counts.set(vin, 0); - - const timer = setInterval(() => { - const count = (this.counts.get(vin) ?? 0) + 1; - this.counts.set(vin, count); - const isFinal = count >= this.maxRepeats; - this.logger.warn(`[SENTRY_PRESENCE] VIN ${vin} still Aware - dispatching (${count}/${this.maxRepeats})`); - onSustained(isFinal); - - if (isFinal) { - this.stopAtCap(vin); - } - }, this.thresholdMs); - - this.timers.set(vin, timer); - } - - public clear(vin: string): void { - this.stopTimer(vin); - this.capped.delete(vin); - } - - private stopAtCap(vin: string): void { - this.logger.warn(`[SENTRY_PRESENCE] VIN ${vin} reached ${this.maxRepeats} sustained alerts - stopping until it leaves Aware`); - this.stopTimer(vin); - this.capped.add(vin); - } - - private stopTimer(vin: string): void { - const timer = this.timers.get(vin); - - if (timer) { - clearInterval(timer); - this.timers.delete(vin); - } - - this.counts.delete(vin); - } -} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ce526e33..8dbcb152 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -10,7 +10,6 @@ import { KafkaService } from './messaging/kafka/kafka.service'; import { TelemetryMessageHandlerService } from './telemetry/handlers/telemetry-message-handler.service'; import { TelemetryValidationService } from './telemetry/services/telemetry-validation.service'; import { SentryAlertHandlerService } from './alerts/sentry/sentry-alert-handler.service'; -import { SentryPresenceTrackerService } from './alerts/sentry/sentry-presence-tracker.service'; import { BreakInAlertHandlerService } from './alerts/break-in/break-in-alert-handler.service'; import { ChargePortLatchTrackerService } from './alerts/break-in/charge-port-latch-tracker.service'; import { VehicleAlertNotifierService } from './alerts/common/vehicle-alert-notifier.service'; @@ -76,7 +75,6 @@ import { RetryManager } from './shared/retry-manager.service'; TelemetryValidationService, VehicleAlertNotifierService, SentryAlertHandlerService, - SentryPresenceTrackerService, BreakInAlertHandlerService, ChargePortLatchTrackerService, { diff --git a/apps/api/src/app/notifications/notifications.service.spec.ts b/apps/api/src/app/notifications/notifications.service.spec.ts index 5d0a949a..2fa01906 100644 --- a/apps/api/src/app/notifications/notifications.service.spec.ts +++ b/apps/api/src/app/notifications/notifications.service.spec.ts @@ -86,34 +86,6 @@ describe('The NotificationsService class', () => { }); }); - describe('When an English user receives a sustained-presence alert', () => { - beforeEach(async () => { - await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.SustainedPresence, 'en'); - }); - - it('should send the sustained-presence title', () => { - expect(lastPushPayload().title).toBe('Someone is lingering'); - }); - - it('should send the sustained-presence body', () => { - expect(lastPushPayload().body).toBe('Someone has been around your vehicle for a while.'); - }); - }); - - describe('When an English user receives a final sustained-presence alert', () => { - beforeEach(async () => { - await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.SustainedPresenceFinal, 'en'); - }); - - it('should send the final-reminder title', () => { - expect(lastPushPayload().title).toBe('Last reminder - someone still around'); - }); - - it('should send the final-reminder body', () => { - expect(lastPushPayload().body).toBe('Someone is still around your vehicle. No more alerts for this incident.'); - }); - }); - describe('When an English user receives a panic alert', () => { beforeEach(async () => { await service.sendPushAlert(fakeUserId, AlertEventSeverity.Critical, AlertEventType.Panic, 'en'); diff --git a/apps/api/src/app/notifications/notifications.service.ts b/apps/api/src/app/notifications/notifications.service.ts index 261ffd5d..36580142 100644 --- a/apps/api/src/app/notifications/notifications.service.ts +++ b/apps/api/src/app/notifications/notifications.service.ts @@ -92,14 +92,6 @@ export class NotificationsService { body: i18n.t('A Sentry event was detected.', { lng }), title: i18n.t('Sentry alert', { lng }), }, - [AlertEventType.SustainedPresence]: { - body: i18n.t('Someone has been around your vehicle for a while.', { lng }), - title: i18n.t('Someone is lingering', { lng }), - }, - [AlertEventType.SustainedPresenceFinal]: { - body: i18n.t('Someone is still around your vehicle. No more alerts for this incident.', { lng }), - title: i18n.t('Last reminder - someone still around', { lng }), - }, [AlertEventType.Panic]: { body: i18n.t('Your vehicle alarm may be sounding.', { lng }), title: i18n.t('Alarm triggered', { lng }), diff --git a/apps/api/src/app/telegram/telegram.service.ts b/apps/api/src/app/telegram/telegram.service.ts index 6231b3e7..b3d947b4 100644 --- a/apps/api/src/app/telegram/telegram.service.ts +++ b/apps/api/src/app/telegram/telegram.service.ts @@ -152,16 +152,6 @@ export class TelegramService implements OnModuleDestroy { } } - async sendSentryPresenceAlert( - userId: string, - alertInfo: { vin: string, display_name?: string }, - userLanguage: 'en' | 'fr', - keyboard?: TelegramKeyboard, - ) { - const message = this.formatSentryPresenceMessage(alertInfo, userLanguage); - return this.sendFormattedAlert(userId, alertInfo, userLanguage, message, keyboard); - } - async sendSentryPanicAlert( userId: string, alertInfo: { vin: string, display_name?: string }, @@ -172,16 +162,6 @@ export class TelegramService implements OnModuleDestroy { return this.sendFormattedAlert(userId, alertInfo, userLanguage, message, keyboard); } - async sendSentryPresenceFinalAlert( - userId: string, - alertInfo: { vin: string, display_name?: string }, - userLanguage: 'en' | 'fr', - keyboard?: TelegramKeyboard, - ) { - const message = this.formatSentryPresenceFinalMessage(alertInfo, userLanguage); - return this.sendFormattedAlert(userId, alertInfo, userLanguage, message, keyboard); - } - private async sendFormattedAlert( userId: string, alertInfo: { vin: string, display_name?: string }, @@ -304,19 +284,6 @@ export class TelegramService implements OnModuleDestroy { `.trim(); } - private formatSentryPresenceMessage( - { display_name, vin }: { vin: string, display_name?: string }, - lng: 'en' | 'fr' - ): string { - return ` -🚨 ${i18n.t('SOMEONE IS AROUND YOUR TESLA', { lng })} 🚨 - -🚗 ${i18n.t('Vehicle', { lng })}: ${display_name ?? vin} - -${i18n.t('Someone has been lingering around your vehicle for a while. Check your cameras!', { lng })} - `.trim(); - } - private formatSentryPanicMessage( { display_name, vin }: { vin: string, display_name?: string }, lng: 'en' | 'fr' @@ -329,17 +296,4 @@ export class TelegramService implements OnModuleDestroy { ${i18n.t('Sentry Mode panic - your vehicle alarm may be sounding!', { lng })} `.trim(); } - - private formatSentryPresenceFinalMessage( - { display_name, vin }: { vin: string, display_name?: string }, - lng: 'en' | 'fr' - ): string { - return ` -🚨 ${i18n.t('LAST REMINDER', { lng })} 🚨 - -🚗 ${i18n.t('Vehicle', { lng })}: ${display_name ?? vin} - -${i18n.t('Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.', { lng })} - `.trim(); - } } diff --git a/apps/api/src/entities/alert-event.entity.ts b/apps/api/src/entities/alert-event.entity.ts index 38256d1f..0aa01776 100644 --- a/apps/api/src/entities/alert-event.entity.ts +++ b/apps/api/src/entities/alert-event.entity.ts @@ -8,8 +8,6 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', - SustainedPresence = 'sustained_presence', - SustainedPresenceFinal = 'sustained_presence_final', Panic = 'panic', } diff --git a/apps/api/src/locales/en/common.json b/apps/api/src/locales/en/common.json index d83dfa6e..0fa89e07 100644 --- a/apps/api/src/locales/en/common.json +++ b/apps/api/src/locales/en/common.json @@ -8,8 +8,6 @@ "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!", - "SOMEONE IS AROUND YOUR TESLA": "SOMEONE IS AROUND YOUR TESLA", - "Someone has been lingering around your vehicle for a while. Check your cameras!": "Someone has been lingering around your vehicle for a while. Check your cameras!", "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", @@ -80,12 +78,6 @@ "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.", - "Someone is lingering": "Someone is lingering", - "Someone has been around your vehicle for a while.": "Someone has been around your vehicle for a while.", "Alarm triggered": "Alarm triggered", - "Your vehicle alarm may be sounding.": "Your vehicle alarm may be sounding.", - "LAST REMINDER": "LAST REMINDER", - "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.": "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.", - "Last reminder - someone still around": "Last reminder - someone still around", - "Someone is still around your vehicle. No more alerts for this incident.": "Someone is still around your vehicle. No more alerts for this incident." + "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 5766c2f6..db2c86df 100644 --- a/apps/api/src/locales/fr/common.json +++ b/apps/api/src/locales/fr/common.json @@ -8,8 +8,6 @@ "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 !", - "SOMEONE IS AROUND YOUR TESLA": "QUELQU'UN RÔDE AUTOUR DE VOTRE TESLA", - "Someone has been lingering around your vehicle for a while. Check your cameras!": "Quelqu'un rôde autour de votre véhicule depuis un moment. Vérifiez vos caméras !", "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.", @@ -80,12 +78,6 @@ "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é.", - "Someone is lingering": "Quelqu'un rôde", - "Someone has been around your vehicle for a while.": "Quelqu'un rôde autour de votre véhicule depuis un moment.", "Alarm triggered": "Alarme déclenchée", - "Your vehicle alarm may be sounding.": "L'alarme de votre véhicule retentit peut-être.", - "LAST REMINDER": "DERNIER RAPPEL", - "Someone is still around your vehicle. You will not receive further alerts for this incident - check your cameras.": "Quelqu'un est toujours autour de votre véhicule. Vous ne recevrez plus d'alerte pour cet incident - vérifiez vos caméras.", - "Last reminder - someone still around": "Dernier rappel - quelqu'un est toujours là", - "Someone is still around your vehicle. No more alerts for this incident.": "Quelqu'un est toujours autour de votre véhicule. Plus d'alerte pour cet incident." + "Your vehicle alarm may be sounding.": "L'alarme de votre véhicule retentit peut-être." } diff --git a/apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts b/apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts similarity index 53% rename from apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts rename to apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts index a18ee491..ec3d2c3d 100644 --- a/apps/api/src/migrations/1781000000000-AddSentryPresenceAlertTypes.ts +++ b/apps/api/src/migrations/1781000000000-AddSentryPanicAlertType.ts @@ -1,11 +1,9 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddSentryPresenceAlertTypes1781000000000 implements MigrationInterface { - name = 'AddSentryPresenceAlertTypes1781000000000'; +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 'sustained_presence'`); - await queryRunner.query(`ALTER TYPE "alert_events_type_enum" ADD VALUE IF NOT EXISTS 'sustained_presence_final'`); await queryRunner.query(`ALTER TYPE "alert_events_type_enum" ADD VALUE IF NOT EXISTS 'panic'`); } diff --git a/apps/mobile/src/features/alerts/domain/entities.ts b/apps/mobile/src/features/alerts/domain/entities.ts index 1d0774b0..524c4173 100644 --- a/apps/mobile/src/features/alerts/domain/entities.ts +++ b/apps/mobile/src/features/alerts/domain/entities.ts @@ -6,8 +6,6 @@ export enum AlertEventSeverity { export enum AlertEventType { BreakIn = 'break_in', Sentry = 'sentry', - SustainedPresence = 'sustained_presence', - SustainedPresenceFinal = 'sustained_presence_final', Panic = 'panic', } diff --git a/apps/mobile/src/locales/en.json b/apps/mobile/src/locales/en.json index 2bc2c969..dbd004fd 100644 --- a/apps/mobile/src/locales/en.json +++ b/apps/mobile/src/locales/en.json @@ -10,10 +10,6 @@ "alerts.event.break_in.title": "Intrusion alert", "alerts.event.sentry.message": "A Sentry event was detected.", "alerts.event.sentry.title": "Sentry alert", - "alerts.event.sustained_presence.message": "Someone has been around your vehicle for a while.", - "alerts.event.sustained_presence.title": "Someone is lingering", - "alerts.event.sustained_presence_final.message": "Someone was still around your vehicle. Alerts were stopped for this incident.", - "alerts.event.sustained_presence_final.title": "Last reminder - someone still around", "alerts.event.panic.message": "Your vehicle alarm may be sounding.", "alerts.event.panic.title": "Alarm triggered", "alerts.filter.all": "All", diff --git a/apps/mobile/src/locales/fr.json b/apps/mobile/src/locales/fr.json index 5b93fe74..0346143a 100644 --- a/apps/mobile/src/locales/fr.json +++ b/apps/mobile/src/locales/fr.json @@ -10,10 +10,6 @@ "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.sustained_presence.message": "Quelqu'un rôde autour de votre véhicule depuis un moment.", - "alerts.event.sustained_presence.title": "Quelqu'un rôde", - "alerts.event.sustained_presence_final.message": "Quelqu'un était toujours autour de votre véhicule. Les alertes ont été arrêtées pour cet incident.", - "alerts.event.sustained_presence_final.title": "Dernier rappel - quelqu'un est toujours là", "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", diff --git a/apps/mobile/src/screens/alerts/alerts.helpers.test.ts b/apps/mobile/src/screens/alerts/alerts.helpers.test.ts index 2dcba738..6a82ba63 100644 --- a/apps/mobile/src/screens/alerts/alerts.helpers.test.ts +++ b/apps/mobile/src/screens/alerts/alerts.helpers.test.ts @@ -50,14 +50,6 @@ describe('The resolveAlertTitleKey() function', () => { }); }); - describe('When the alert is a sustained presence', () => { - it('should return the sustained-presence title key', () => { - expect(resolveAlertTitleKey(createAlert(AlertEventType.SustainedPresence, AlertEventSeverity.Critical))).toBe( - 'alerts.event.sustained_presence.title' - ); - }); - }); - describe('When the alert is a panic', () => { it('should return the panic title key', () => { expect(resolveAlertTitleKey(createAlert(AlertEventType.Panic, AlertEventSeverity.Critical))).toBe( @@ -66,13 +58,6 @@ describe('The resolveAlertTitleKey() function', () => { }); }); - describe('When the alert is a final sustained presence', () => { - it('should return the final sustained-presence title key', () => { - expect(resolveAlertTitleKey(createAlert(AlertEventType.SustainedPresenceFinal, AlertEventSeverity.Critical))).toBe( - 'alerts.event.sustained_presence_final.title' - ); - }); - }); }); describe('The resolveAlertMessageKey() function', () => { @@ -92,14 +77,6 @@ describe('The resolveAlertMessageKey() function', () => { }); }); - describe('When the alert is a sustained presence', () => { - it('should return the sustained-presence message key', () => { - expect(resolveAlertMessageKey(createAlert(AlertEventType.SustainedPresence, AlertEventSeverity.Critical))).toBe( - 'alerts.event.sustained_presence.message' - ); - }); - }); - describe('When the alert is a panic', () => { it('should return the panic message key', () => { expect(resolveAlertMessageKey(createAlert(AlertEventType.Panic, AlertEventSeverity.Critical))).toBe( @@ -108,13 +85,6 @@ describe('The resolveAlertMessageKey() function', () => { }); }); - describe('When the alert is a final sustained presence', () => { - it('should return the final sustained-presence message key', () => { - expect(resolveAlertMessageKey(createAlert(AlertEventType.SustainedPresenceFinal, AlertEventSeverity.Critical))).toBe( - 'alerts.event.sustained_presence_final.message' - ); - }); - }); }); describe('The resolveAlertTone() function', () => {