Skip to content

Commit 41b1d45

Browse files
committed
fix: send meteo alert before recording it; de-dup by existence not insert
The bulletin report row was created BEFORE the Telegram message was sent, and the 'was it newly inserted' result was used as the send gate. Whenever the row existed without a message actually going out, the alert was de-duplicated and never sent again - and the failure was invisible (caught+logged, tick still ended 'ok'). - meteo-alerts task: look up the report by number (existence check, independent of any UNIQUE constraint), send the message FIRST, and record the report only after a successful send -> a failed send is logged and retried next tick instead of being permanently de-duplicated. Non-critical bulletins are still recorded. - alert-report model: add getAlertReportByNumber. - tests: send-then-record, dedup-skip, non-critical record, send-failure retry, no-alert cases.
1 parent 9a3cd4f commit 41b1d45

3 files changed

Lines changed: 126 additions & 10 deletions

File tree

src/models/alert-report.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export const getLastAlertReport = async (): Promise<AlertReport> => {
2828
return reports[0]
2929
}
3030

31+
export const getAlertReportByNumber = async (reportNumber: string): Promise<AlertReport | undefined> => {
32+
const query = `SELECT * FROM ${tableName} WHERE report_number = $1 ORDER BY id DESC LIMIT 1`
33+
34+
const rows = await database.query<AlertReport>(query, [reportNumber])
35+
36+
return rows[0]
37+
}
38+
3139
export const createAlertReport = async (report: EditableAlertReport): Promise<AlertReport> => {
3240
return database.create<AlertReport>(tableName, report)
3341
}

src/tasks/meteo-alerts.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { sendNewTomorrowAlertMessage } from '../utilities/telegram'
2-
import { createAlertReportIfNew } from '../models/alert-report'
2+
import { createAlertReport, getAlertReportByNumber } from '../models/alert-report'
33
import { getTomorrowMeteoAlert } from '../services/meteo-alerts'
44
import { parseMeteoAlert, ParsedMeteoAlert } from '../utilities/meteo-alerts'
55
import { config } from '../config/config'
@@ -16,7 +16,15 @@ export const runMeteoAlertCheck = async (): Promise<ParsedMeteoAlert | undefined
1616

1717
const parsedAlert = parseMeteoAlert(tomorrowAlert, config.alert_zone)
1818

19-
const insertedReport = await createAlertReportIfNew({
19+
// We already handled this bulletin: its report row exists. The existence check (instead of an
20+
// INSERT ... ON CONFLICT) makes the de-dup independent of any unique constraint on the table.
21+
const existing = await getAlertReportByNumber(parsedAlert.id)
22+
23+
if (existing) {
24+
return parsedAlert
25+
}
26+
27+
const report = {
2028
report_number: parsedAlert.id,
2129
is_critic: parsedAlert.isCritic,
2230
estofex_sent: false,
@@ -25,14 +33,23 @@ export const runMeteoAlertCheck = async (): Promise<ParsedMeteoAlert | undefined
2533
starts_on: parsedAlert.dataInizio,
2634
ends_on: parsedAlert.dataFine,
2735
emitted_on: parsedAlert.dataEmissione,
28-
})
29-
30-
if (insertedReport && parsedAlert.isCritic) {
31-
try {
32-
await sendNewTomorrowAlertMessage(parsedAlert)
33-
} catch (err) {
34-
log.error({ err, alertId: parsedAlert.id }, 'Failed to send Telegram alert')
35-
}
36+
}
37+
38+
// Non-critical bulletin: just record it (so pretemp/estofex see the latest report); no message.
39+
if (!parsedAlert.isCritic) {
40+
await createAlertReport(report)
41+
return parsedAlert
42+
}
43+
44+
// Critical bulletin: send the message FIRST and record it only once the send succeeds. This way
45+
// a failed send is logged AND retried on the next tick, instead of being silently de-duplicated
46+
// forever (the report row would otherwise mark the bulletin as "seen" even though nothing was
47+
// delivered).
48+
try {
49+
await sendNewTomorrowAlertMessage(parsedAlert)
50+
await createAlertReport(report)
51+
} catch (err) {
52+
log.error({ err, alertId: parsedAlert.id }, 'Failed to send meteo alert; will retry next tick')
3653
}
3754

3855
return parsedAlert

tests/tasks/meteo-alerts.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { expect } from 'chai'
2+
import { afterEach, beforeEach, describe, it } from 'mocha'
3+
import sinon, { SinonStub } from 'sinon'
4+
import * as alertService from '../../src/services/meteo-alerts'
5+
import * as alertUtil from '../../src/utilities/meteo-alerts'
6+
import * as alertModel from '../../src/models/alert-report'
7+
import * as telegram from '../../src/utilities/telegram'
8+
import { ParsedMeteoAlert } from '../../src/utilities/meteo-alerts'
9+
import { runMeteoAlertCheck } from '../../src/tasks/meteo-alerts'
10+
11+
const parsed = (overrides: Partial<ParsedMeteoAlert> = {}): ParsedMeteoAlert =>
12+
({
13+
id: '065/2026',
14+
isCritic: true,
15+
dataEmissione: 'Jun 25, 2026 11:45:30 AM',
16+
titolo: 'Allerta',
17+
link: 'https://example/allerta065_2026.pdf',
18+
dataInizio: 'Jun 26, 2026 12:00:00 AM',
19+
dataFine: 'Jun 27, 2026 12:00:00 AM',
20+
descrizionemeteo: 'caldo',
21+
zoneData: {} as ParsedMeteoAlert['zoneData'],
22+
criticZoneData: {},
23+
...overrides,
24+
}) as ParsedMeteoAlert
25+
26+
describe('tests/tasks/meteo-alerts', () => {
27+
let getTomorrow: SinonStub
28+
let parse: SinonStub
29+
let getByNumber: SinonStub
30+
let create: SinonStub
31+
let send: SinonStub
32+
33+
beforeEach(() => {
34+
getTomorrow = sinon.stub(alertService, 'getTomorrowMeteoAlert').resolves({} as never)
35+
parse = sinon.stub(alertUtil, 'parseMeteoAlert')
36+
getByNumber = sinon.stub(alertModel, 'getAlertReportByNumber').resolves(undefined)
37+
create = sinon.stub(alertModel, 'createAlertReport').resolves({} as never)
38+
send = sinon.stub(telegram, 'sendNewTomorrowAlertMessage').resolves()
39+
})
40+
41+
afterEach(() => sinon.restore())
42+
43+
it('sends a new critical bulletin and records it only after a successful send', async () => {
44+
parse.returns(parsed({ isCritic: true }))
45+
46+
await runMeteoAlertCheck()
47+
48+
expect(send.calledOnce).to.equal(true)
49+
expect(create.calledOnce).to.equal(true)
50+
expect(send.calledBefore(create)).to.equal(true)
51+
})
52+
53+
it('does not resend a bulletin whose report row already exists', async () => {
54+
parse.returns(parsed({ isCritic: true }))
55+
getByNumber.resolves({ id: 1 } as never)
56+
57+
await runMeteoAlertCheck()
58+
59+
expect(send.called).to.equal(false)
60+
expect(create.called).to.equal(false)
61+
})
62+
63+
it('records a non-critical bulletin without sending a message', async () => {
64+
parse.returns(parsed({ isCritic: false }))
65+
66+
await runMeteoAlertCheck()
67+
68+
expect(send.called).to.equal(false)
69+
expect(create.calledOnce).to.equal(true)
70+
})
71+
72+
it('does NOT record the report when the send fails, so it retries on the next tick', async () => {
73+
parse.returns(parsed({ isCritic: true }))
74+
send.rejects(new Error('telegram down'))
75+
76+
await runMeteoAlertCheck() // must not throw
77+
78+
expect(send.calledOnce).to.equal(true)
79+
expect(create.called).to.equal(false)
80+
})
81+
82+
it('does nothing when there is no alert for tomorrow', async () => {
83+
getTomorrow.resolves(undefined)
84+
85+
await runMeteoAlertCheck()
86+
87+
expect(parse.called).to.equal(false)
88+
expect(send.called).to.equal(false)
89+
expect(create.called).to.equal(false)
90+
})
91+
})

0 commit comments

Comments
 (0)