Skip to content

Commit 1f92bba

Browse files
committed
fix: send same-day meteo alert updates, not only tomorrow's forecast
The meteo-alerts task only queried the next-day view of the bulletin, so an escalation for the current day was never delivered: the same bulletin can be green tomorrow yet orange today (e.g. zona D1 upgraded from yellow to orange for temporali). Now both today and tomorrow are evaluated each tick. De-dup keys on (bulletin, referenced date) rather than the bulletin number alone, so each (bulletin, day) is sent once and an escalation (a new bulletin number for the same date) re-sends. Today's message uses an "Aggiornamento allerta di oggi" heading; tomorrow's is unchanged.
1 parent c3dc977 commit 1f92bba

4 files changed

Lines changed: 78 additions & 35 deletions

File tree

src/services/meteo-alerts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ export type MeteoAlertZoneData = {
5757

5858
export type MeteoAlert = BaseMeteoAlert & MeteoAlertZoneData
5959

60+
export const getTodayMeteoAlert = async (): Promise<MeteoAlert | undefined> => {
61+
const today = customMoment().format('YYYY-MM-DD HH:mm')
62+
63+
return getMeteoAlert(today)
64+
}
65+
6066
export const getTomorrowMeteoAlert = async (): Promise<MeteoAlert | undefined> => {
6167
const tomorrow = customMoment().add(1, 'day').format('YYYY-MM-DD HH:mm')
6268

src/tasks/meteo-alerts.ts

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,32 @@
1-
import { sendNewTomorrowAlertMessage } from '../utilities/telegram'
1+
import { AlertDay, sendMeteoAlertMessage } from '../utilities/telegram'
22
import { createAlertReport, getAlertReportByNumber } from '../models/alert-report'
3-
import { getTomorrowMeteoAlert } from '../services/meteo-alerts'
3+
import { getTodayMeteoAlert, getTomorrowMeteoAlert, MeteoAlert } from '../services/meteo-alerts'
44
import { parseMeteoAlert, ParsedMeteoAlert } from '../utilities/meteo-alerts'
5+
import customMoment from '../custom-components/custom-moment'
56
import { config } from '../config/config'
67
import logger from '../logger'
78

89
const log = logger.child({ task: 'meteo-alerts' })
910

10-
export const runMeteoAlertCheck = async (): Promise<ParsedMeteoAlert | undefined> => {
11-
const tomorrowAlert = await getTomorrowMeteoAlert()
12-
13-
if (!tomorrowAlert) {
14-
log.info({ event: 'no-tomorrow-alert' }, 'No alert published for tomorrow')
15-
return undefined
16-
}
11+
// A bulletin covers a multi-day window and carries a FIXED criticality per day, so the same
12+
// document (e.g. allerta075/2026) is green tomorrow yet orange today. De-dup therefore has to key
13+
// on (bulletin, referenced date), not the bulletin number alone. An escalation arrives as a NEW
14+
// bulletin number for the same date, so it re-sends; re-observing the same bulletin does not.
15+
const reportKey = (id: string, date: string): string => `${id}@${date}`
1716

18-
const parsedAlert = parseMeteoAlert(tomorrowAlert, config.alert_zone)
17+
const handleDayAlert = async (raw: MeteoAlert, day: AlertDay, date: string): Promise<ParsedMeteoAlert> => {
18+
const parsedAlert = parseMeteoAlert(raw, config.alert_zone)
19+
const key = reportKey(parsedAlert.id, date)
1920

20-
// We already handled this bulletin: its report row exists. The existence check (instead of an
21-
// INSERT ... ON CONFLICT) makes the de-dup independent of any unique constraint on the table.
22-
const existing = await getAlertReportByNumber(parsedAlert.id)
21+
const existing = await getAlertReportByNumber(key)
2322

2423
if (existing) {
25-
log.info(
26-
{ event: 'already-handled', reportNumber: parsedAlert.id, critic: parsedAlert.isCritic },
27-
'Bulletin already handled'
28-
)
24+
log.info({ event: 'already-handled', reportNumber: key, critic: parsedAlert.isCritic }, 'Bulletin already handled')
2925
return parsedAlert
3026
}
3127

3228
const report = {
33-
report_number: parsedAlert.id,
29+
report_number: key,
3430
is_critic: parsedAlert.isCritic,
3531
estofex_sent: false,
3632
pretemp_sent: false,
@@ -43,21 +39,40 @@ export const runMeteoAlertCheck = async (): Promise<ParsedMeteoAlert | undefined
4339
// Non-critical bulletin: just record it (so pretemp/estofex see the latest report); no message.
4440
if (!parsedAlert.isCritic) {
4541
await createAlertReport(report)
46-
log.info({ event: 'recorded-non-critical', reportNumber: parsedAlert.id }, 'Recorded non-critical bulletin')
42+
log.info({ event: 'recorded-non-critical', reportNumber: key }, 'Recorded non-critical bulletin')
4743
return parsedAlert
4844
}
4945

50-
// Critical bulletin: send the message FIRST and record it only once the send succeeds. This way
51-
// a failed send is logged AND retried on the next tick, instead of being silently de-duplicated
52-
// forever (the report row would otherwise mark the bulletin as "seen" even though nothing was
53-
// delivered).
46+
// Critical bulletin: send FIRST and record only once the send succeeds, so a failed send is
47+
// retried on the next tick instead of being silently de-duplicated forever.
5448
try {
55-
await sendNewTomorrowAlertMessage(parsedAlert)
49+
await sendMeteoAlertMessage(parsedAlert, day)
5650
await createAlertReport(report)
57-
log.info({ event: 'sent', reportNumber: parsedAlert.id }, 'Meteo alert sent')
51+
log.info({ event: 'sent', reportNumber: key, day }, 'Meteo alert sent')
5852
} catch (err) {
59-
log.error({ err, alertId: parsedAlert.id }, 'Failed to send meteo alert; will retry next tick')
53+
log.error({ err, alertId: key }, 'Failed to send meteo alert; will retry next tick')
6054
}
6155

6256
return parsedAlert
6357
}
58+
59+
export const runMeteoAlertCheck = async (): Promise<ParsedMeteoAlert | undefined> => {
60+
const todayDate = customMoment().format('YYYY-MM-DD')
61+
const tomorrowDate = customMoment().add(1, 'day').format('YYYY-MM-DD')
62+
63+
// Process today first so the tomorrow row is the most recent alert_reports row: pretemp/estofex
64+
// key off getLastAlertReport and are inherently next-day outlooks.
65+
const todayRaw = await getTodayMeteoAlert()
66+
const todayAlert = todayRaw ? await handleDayAlert(todayRaw, 'today', todayDate) : undefined
67+
if (!todayRaw) {
68+
log.info({ event: 'no-today-alert' }, 'No alert published for today')
69+
}
70+
71+
const tomorrowRaw = await getTomorrowMeteoAlert()
72+
const tomorrowAlert = tomorrowRaw ? await handleDayAlert(tomorrowRaw, 'tomorrow', tomorrowDate) : undefined
73+
if (!tomorrowRaw) {
74+
log.info({ event: 'no-tomorrow-alert' }, 'No alert published for tomorrow')
75+
}
76+
77+
return tomorrowAlert ?? todayAlert
78+
}

src/utilities/telegram.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ const thresholdLabel: Record<ThresholdCrossing['threshold']['key'], string> = {
1414
soglia3: 'soglia 3',
1515
}
1616

17-
export const sendNewTomorrowAlertMessage = async (alert: ParsedMeteoAlert) => {
17+
export type AlertDay = 'today' | 'tomorrow'
18+
19+
export const sendMeteoAlertMessage = async (alert: ParsedMeteoAlert, day: AlertDay = 'tomorrow') => {
1820
const criticDataMessage = Object.keys(alert.criticZoneData)
1921
.map((key) => {
2022
const color = alert.criticZoneData[key]
@@ -30,7 +32,10 @@ export const sendNewTomorrowAlertMessage = async (alert: ParsedMeteoAlert) => {
3032
})
3133
.join('\n')
3234

33-
const textMessage = `⚠️ Nuova allerta meteo per domani!
35+
const heading =
36+
day === 'today' ? '⚠️ Aggiornamento allerta meteo di oggi!' : '⚠️ Nuova allerta meteo per domani!'
37+
38+
const textMessage = `${heading}
3439
📅 Data inizio: ${alert.dataInizio}
3540
📅 Data fine: ${alert.dataFine}
3641
${separator}

tests/tasks/meteo-alerts.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,30 +24,47 @@ const parsed = (overrides: Partial<ParsedMeteoAlert> = {}): ParsedMeteoAlert =>
2424
}) as ParsedMeteoAlert
2525

2626
describe('tests/tasks/meteo-alerts', () => {
27+
let getToday: SinonStub
2728
let getTomorrow: SinonStub
2829
let parse: SinonStub
2930
let getByNumber: SinonStub
3031
let create: SinonStub
3132
let send: SinonStub
3233

3334
beforeEach(() => {
35+
getToday = sinon.stub(alertService, 'getTodayMeteoAlert').resolves({} as never)
3436
getTomorrow = sinon.stub(alertService, 'getTomorrowMeteoAlert').resolves({} as never)
3537
parse = sinon.stub(alertUtil, 'parseMeteoAlert')
3638
getByNumber = sinon.stub(alertModel, 'getAlertReportByNumber').resolves(undefined)
3739
create = sinon.stub(alertModel, 'createAlertReport').resolves({} as never)
38-
send = sinon.stub(telegram, 'sendNewTomorrowAlertMessage').resolves()
40+
send = sinon.stub(telegram, 'sendMeteoAlertMessage').resolves()
3941
})
4042

4143
afterEach(() => sinon.restore())
4244

43-
it('sends a new critical bulletin and records it only after a successful send', async () => {
45+
it('sends today AND tomorrow when both are critical, recording each only after its send', async () => {
4446
parse.returns(parsed({ isCritic: true }))
4547

4648
await runMeteoAlertCheck()
4749

48-
expect(send.calledOnce).to.equal(true)
49-
expect(create.calledOnce).to.equal(true)
50+
// One send + one record per day.
51+
expect(send.calledTwice).to.equal(true)
52+
expect(create.calledTwice).to.equal(true)
5053
expect(send.calledBefore(create)).to.equal(true)
54+
// Today is passed 'today', tomorrow 'tomorrow'.
55+
expect(send.getCall(0).args[1]).to.equal('today')
56+
expect(send.getCall(1).args[1]).to.equal('tomorrow')
57+
})
58+
59+
it('de-dups per (bulletin, date): the same bulletin keyed by today vs tomorrow yields distinct keys', async () => {
60+
parse.returns(parsed({ isCritic: true }))
61+
62+
await runMeteoAlertCheck()
63+
64+
const keys = getByNumber.getCalls().map((c) => c.args[0])
65+
expect(keys.length).to.equal(2)
66+
expect(keys[0]).to.not.equal(keys[1])
67+
expect(keys.every((k) => k.startsWith('065/2026@'))).to.equal(true)
5168
})
5269

5370
it('does not resend a bulletin whose report row already exists', async () => {
@@ -66,7 +83,7 @@ describe('tests/tasks/meteo-alerts', () => {
6683
await runMeteoAlertCheck()
6784

6885
expect(send.called).to.equal(false)
69-
expect(create.calledOnce).to.equal(true)
86+
expect(create.calledTwice).to.equal(true)
7087
})
7188

7289
it('does NOT record the report when the send fails, so it retries on the next tick', async () => {
@@ -75,11 +92,11 @@ describe('tests/tasks/meteo-alerts', () => {
7592

7693
await runMeteoAlertCheck() // must not throw
7794

78-
expect(send.calledOnce).to.equal(true)
7995
expect(create.called).to.equal(false)
8096
})
8197

82-
it('does nothing when there is no alert for tomorrow', async () => {
98+
it('does nothing when neither today nor tomorrow has an alert', async () => {
99+
getToday.resolves(undefined)
83100
getTomorrow.resolves(undefined)
84101

85102
await runMeteoAlertCheck()

0 commit comments

Comments
 (0)