Skip to content

Commit 6456963

Browse files
committed
feat: add river levels monitoring and management features
1 parent 9a3cd4f commit 6456963

13 files changed

Lines changed: 788 additions & 0 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ The system interfaces with various official and unofficial sources to provide a
2121
- **Details:** Dynamically retrieves forecast maps for the following day via the Pretemp archive.
2222
- **Notification:** If conditions require it (presence of ongoing alerts), it sends the thunderstorm forecast image to the Telegram channel.
2323

24+
4. **River levels (Allerta Meteo hydrometric stations)**
25+
- **Feature:** Monitoring of water levels at configured hydrometric stations.
26+
- **Details:** Periodically polls the Allerta Meteo time-series endpoint for each registered station and compares the latest reading against operator-defined thresholds (`soglia1`, `soglia2`, `soglia3`). Station metadata and thresholds live in the `rivers` table; each check is appended to `river_levels`.
27+
- **Notification:** Sends a Telegram message only on crossing events — when a reading rises above or falls below a threshold relative to the previous check.
28+
2429
## Scheduled Tasks (Crons)
2530

2631
The application relies on scheduled tasks (crons) to automate the weather monitoring flow:
@@ -31,6 +36,28 @@ The application relies on scheduled tasks (crons) to automate the weather monito
3136
- Verifies and sends the Pretemp map for the following day, provided there is an ongoing alert and the map hasn't been sent yet.
3237
- **Estofex Report Check**
3338
- Verifies and sends the Estofex map for the following day, following the same conditional logic based on ongoing alerts.
39+
- **River Levels Check**
40+
- Every 5 minutes, for each row in the `rivers` table, fetches the latest hydrometric reading and appends it to `river_levels`. Sends a Telegram message only when the reading crosses one of the configured thresholds since the previous check.
41+
42+
## Database bootstrap
43+
44+
Schema is applied manually (no migration tooling). The required tables are in [sql/rivers.sql](sql/rivers.sql):
45+
46+
```bash
47+
psql "$DATABASE_URL" -f sql/rivers.sql
48+
```
49+
50+
## River stations CRUD
51+
52+
Stations are managed via HTTP (port 3000):
53+
54+
- `GET /rivers` — list registered stations
55+
- `POST /rivers` — create; body `{ station_id, river_name, station_name, soglia1?, soglia2?, soglia3? }`
56+
- `PATCH /rivers/:id` — update any of `river_name`, `station_name`, `soglia1`, `soglia2`, `soglia3`
57+
- `DELETE /rivers/:id` — delete (cascades to `river_levels`)
58+
- `POST /river-levels` — trigger an on-demand check (returns `{ checked, crossings, skipped }`)
59+
60+
The `station_id` is the Allerta Meteo `idstazione`; threshold values must be looked up manually from the [Allerta Meteo portal](https://allertameteo.regione.emilia-romagna.it/) and stored alongside the station.
3461

3562
## Configuration and Installation
3663

sql/rivers.sql

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
CREATE TABLE IF NOT EXISTS rivers (
2+
id SERIAL PRIMARY KEY,
3+
station_id TEXT NOT NULL UNIQUE,
4+
river_name TEXT NOT NULL,
5+
station_name TEXT NOT NULL,
6+
soglia1 NUMERIC,
7+
soglia2 NUMERIC,
8+
soglia3 NUMERIC,
9+
created_on TIMESTAMPTZ NOT NULL DEFAULT now(),
10+
updated_on TIMESTAMPTZ NOT NULL DEFAULT now()
11+
);
12+
13+
CREATE TABLE IF NOT EXISTS river_levels (
14+
id SERIAL PRIMARY KEY,
15+
river_id INTEGER NOT NULL REFERENCES rivers(id) ON DELETE CASCADE,
16+
value NUMERIC NOT NULL,
17+
measured_at TIMESTAMPTZ NOT NULL,
18+
soglia1_above BOOLEAN,
19+
soglia2_above BOOLEAN,
20+
soglia3_above BOOLEAN,
21+
created_on TIMESTAMPTZ NOT NULL DEFAULT now()
22+
);
23+
24+
CREATE INDEX IF NOT EXISTS river_levels_river_id_created_on_idx
25+
ON river_levels (river_id, created_on DESC);

src/models/river-level.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { database } from '..'
2+
3+
const tableName = 'river_levels'
4+
5+
export interface RiverLevel {
6+
id: number
7+
river_id: number
8+
value: number
9+
measured_at: string
10+
soglia1_above: boolean | null
11+
soglia2_above: boolean | null
12+
soglia3_above: boolean | null
13+
created_on: string
14+
}
15+
16+
export type CreatableRiverLevel = Omit<RiverLevel, 'id' | 'created_on'>
17+
18+
export const getLatestRiverLevel = async (riverId: number): Promise<RiverLevel | undefined> => {
19+
const query = `SELECT * FROM ${tableName} WHERE river_id = $1 ORDER BY created_on DESC, id DESC LIMIT 1`
20+
21+
const rows = await database.query<RiverLevel>(query, [riverId])
22+
23+
return rows[0]
24+
}
25+
26+
export const createRiverLevel = async (level: CreatableRiverLevel): Promise<RiverLevel> => {
27+
return database.create<RiverLevel>(tableName, { ...level, created_on: new Date().toISOString() })
28+
}

src/models/river.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { database } from '..'
2+
3+
const tableName = 'rivers'
4+
5+
export interface River {
6+
id: number
7+
station_id: string
8+
river_name: string
9+
station_name: string
10+
soglia1: number | null
11+
soglia2: number | null
12+
soglia3: number | null
13+
created_on: string
14+
updated_on: string
15+
}
16+
17+
export type CreatableRiver = Omit<River, 'id' | 'created_on' | 'updated_on'>
18+
export type RiverPatch = Partial<Omit<CreatableRiver, 'station_id'>>
19+
20+
export const getRivers = async (): Promise<River[]> => {
21+
const query = `SELECT * FROM ${tableName} ORDER BY id ASC`
22+
23+
return database.query<River>(query)
24+
}
25+
26+
export const getRiverById = async (id: number): Promise<River | undefined> => {
27+
const query = `SELECT * FROM ${tableName} WHERE id = $1`
28+
29+
const rows = await database.query<River>(query, [id])
30+
31+
return rows[0]
32+
}
33+
34+
export const getRiverByStationId = async (stationId: string): Promise<River | undefined> => {
35+
const query = `SELECT * FROM ${tableName} WHERE station_id = $1`
36+
37+
const rows = await database.query<River>(query, [stationId])
38+
39+
return rows[0]
40+
}
41+
42+
export const createRiver = async (river: CreatableRiver): Promise<River> => {
43+
const now = new Date().toISOString()
44+
45+
return database.create<River>(tableName, { ...river, created_on: now, updated_on: now })
46+
}
47+
48+
export const updateRiver = async (id: number, patch: RiverPatch): Promise<River> => {
49+
return database.edit<River>(tableName, { ...patch, updated_on: new Date().toISOString() }, id)
50+
}
51+
52+
export const deleteRiver = async (id: number): Promise<void> => {
53+
await database.delete(tableName, id)
54+
}

src/routes/rivers.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
2+
import {
3+
createRiver,
4+
deleteRiver,
5+
getRiverById,
6+
getRiverByStationId,
7+
getRivers,
8+
updateRiver,
9+
} from '../models/river'
10+
import { runRiverLevelCheck } from '../tasks/river-levels'
11+
12+
const nullableNumber = { type: ['number', 'null'] }
13+
14+
const createBodySchema = {
15+
type: 'object',
16+
required: ['station_id', 'river_name', 'station_name'],
17+
additionalProperties: false,
18+
properties: {
19+
station_id: { type: 'string', minLength: 1, maxLength: 64 },
20+
river_name: { type: 'string', minLength: 1, maxLength: 128 },
21+
station_name: { type: 'string', minLength: 1, maxLength: 128 },
22+
soglia1: nullableNumber,
23+
soglia2: nullableNumber,
24+
soglia3: nullableNumber,
25+
},
26+
}
27+
28+
const patchBodySchema = {
29+
type: 'object',
30+
additionalProperties: false,
31+
minProperties: 1,
32+
properties: {
33+
river_name: { type: 'string', minLength: 1, maxLength: 128 },
34+
station_name: { type: 'string', minLength: 1, maxLength: 128 },
35+
soglia1: nullableNumber,
36+
soglia2: nullableNumber,
37+
soglia3: nullableNumber,
38+
},
39+
}
40+
41+
const idParamSchema = {
42+
type: 'object',
43+
required: ['id'],
44+
additionalProperties: false,
45+
properties: {
46+
id: { type: 'integer', minimum: 1 },
47+
},
48+
}
49+
50+
interface IdParams {
51+
id: number
52+
}
53+
54+
interface CreateRiverBody {
55+
station_id: string
56+
river_name: string
57+
station_name: string
58+
soglia1?: number | null
59+
soglia2?: number | null
60+
soglia3?: number | null
61+
}
62+
63+
interface PatchRiverBody {
64+
river_name?: string
65+
station_name?: string
66+
soglia1?: number | null
67+
soglia2?: number | null
68+
soglia3?: number | null
69+
}
70+
71+
export const registerRiversRoutes = (fastify: FastifyInstance) => {
72+
fastify.route({
73+
method: 'GET',
74+
url: '/rivers',
75+
handler: async (_request: FastifyRequest, reply: FastifyReply) => {
76+
const rivers = await getRivers()
77+
reply.status(200).send(rivers)
78+
},
79+
})
80+
81+
fastify.route<{ Body: CreateRiverBody }>({
82+
method: 'POST',
83+
url: '/rivers',
84+
schema: { body: createBodySchema },
85+
handler: async (request, reply) => {
86+
const existing = await getRiverByStationId(request.body.station_id)
87+
88+
if (existing) {
89+
return reply.status(409).send({ error: 'Station already registered' })
90+
}
91+
92+
const created = await createRiver({
93+
station_id: request.body.station_id,
94+
river_name: request.body.river_name,
95+
station_name: request.body.station_name,
96+
soglia1: request.body.soglia1 ?? null,
97+
soglia2: request.body.soglia2 ?? null,
98+
soglia3: request.body.soglia3 ?? null,
99+
})
100+
101+
reply.status(201).send(created)
102+
},
103+
})
104+
105+
fastify.route<{ Params: IdParams; Body: PatchRiverBody }>({
106+
method: 'PATCH',
107+
url: '/rivers/:id',
108+
schema: { params: idParamSchema, body: patchBodySchema },
109+
handler: async (request, reply) => {
110+
const river = await getRiverById(request.params.id)
111+
112+
if (!river) {
113+
return reply.status(404).send({ error: 'River not found' })
114+
}
115+
116+
const updated = await updateRiver(request.params.id, request.body)
117+
reply.status(200).send(updated)
118+
},
119+
})
120+
121+
fastify.route<{ Params: IdParams }>({
122+
method: 'DELETE',
123+
url: '/rivers/:id',
124+
schema: { params: idParamSchema },
125+
handler: async (request, reply) => {
126+
const river = await getRiverById(request.params.id)
127+
128+
if (!river) {
129+
return reply.status(404).send({ error: 'River not found' })
130+
}
131+
132+
await deleteRiver(request.params.id)
133+
reply.status(204).send(undefined)
134+
},
135+
})
136+
137+
fastify.route({
138+
method: 'POST',
139+
url: '/river-levels',
140+
schema: {
141+
body: { type: 'object', additionalProperties: false, maxProperties: 0 },
142+
},
143+
handler: async (_request: FastifyRequest, reply: FastifyReply) => {
144+
const summary = await runRiverLevelCheck()
145+
reply.status(200).send(summary)
146+
},
147+
})
148+
}

src/scheduler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Cron } from 'croner'
22
import { runMeteoAlertCheck } from './tasks/meteo-alerts'
33
import { runPretempCheck } from './tasks/pretemp'
44
import { runEstofexCheck } from './tasks/estofex'
5+
import { runRiverLevelCheck } from './tasks/river-levels'
56
import logger from './logger'
67

78
const jobs: Cron[] = []
@@ -35,6 +36,7 @@ export const startScheduler = () => {
3536
schedule('meteo-alerts', '*/5 * * * *', runMeteoAlertCheck)
3637
schedule('pretemp', '*/5 * * * *', runPretempCheck)
3738
schedule('estofex', '*/5 * * * *', runEstofexCheck)
39+
schedule('river-levels', '*/5 * * * *', runRiverLevelCheck)
3840

3941
logger.info({ count: jobs.length }, 'Scheduler started')
4042
}

src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import i18next from 'i18next'
44
import italian from './resources/locales/it.json'
55
import { registerTestMessageRoutes } from './routes/test-message'
66
import { registerForecastReportsRoutes } from './routes/forecast-reports'
7+
import { registerRiversRoutes } from './routes/rivers'
78
import logger from './logger'
89

910
const translations = {
@@ -45,6 +46,7 @@ export const startServer = async (): Promise<FastifyInstance> => {
4546
registerTestMessageRoutes(app)
4647
registerMeteoAlertsRoutes(app)
4748
registerForecastReportsRoutes(app)
49+
registerRiversRoutes(app)
4850

4951
await fastify.listen({
5052
host: '127.0.0.1',

src/services/river-sensors.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { http } from './http'
2+
import logger from '../logger'
3+
4+
export interface RiverSensorTimePoint {
5+
t: number
6+
v: number
7+
}
8+
9+
const VARIABILE = '254,0,0/1,-,-,-/B13215'
10+
const TIME_SERIES_BASE_URL = 'https://allertameteo.regione.emilia-romagna.it/o/api/allerta/get-time-series/'
11+
12+
export const getRiverSensorTimeSeries = async (stationId: string): Promise<RiverSensorTimePoint[]> => {
13+
const url = `${TIME_SERIES_BASE_URL}?stazione=${encodeURIComponent(stationId)}&variabile=${VARIABILE}`
14+
15+
try {
16+
const response = await http.get<RiverSensorTimePoint[]>(url).then((res) => res.data)
17+
18+
if (!Array.isArray(response)) {
19+
return []
20+
}
21+
22+
return response.filter(
23+
(point): point is RiverSensorTimePoint =>
24+
point != null && typeof point.t === 'number' && typeof point.v === 'number'
25+
)
26+
} catch (error) {
27+
logger.error({ err: error, stationId }, 'Failed to retrieve river sensor time series')
28+
throw error
29+
}
30+
}
31+
32+
export const getLatestRiverSensorValue = async (stationId: string): Promise<RiverSensorTimePoint | undefined> => {
33+
const series = await getRiverSensorTimeSeries(stationId)
34+
35+
if (series.length === 0) {
36+
return undefined
37+
}
38+
39+
return series.reduce((latest, point) => (point.t > latest.t ? point : latest), series[0])
40+
}

0 commit comments

Comments
 (0)