Sync trash calendar from official Nackenheim/KAW waste collection API

Adds trashCalendarSyncJob.ts, which pulls Restmüll/Biomüll/Gelbe Tonne/
Papiertonne dates for Nackenheim (CityId 35, Verbandsgemeinde Bodenheim)
from the public, keyless API behind lk.kaw-mainz-bingen.de's official
waste calendar, and upserts them into trash_schedule. Adds a unique
(type, date) constraint to prevent duplicate entries on reruns. Run via
`npm run trash-sync:run`, same standalone-script pattern as the other
reminder jobs.
This commit is contained in:
Giuseppe Lombardo 2026-08-13 08:58:21 +00:00
parent 3b505d231a
commit 39220bddf4
3 changed files with 101 additions and 0 deletions

View File

@ -12,6 +12,7 @@
"dunning:run": "ts-node -r dotenv/config src/jobs/dailyDunningJob.ts",
"cleaning-reminder:run": "ts-node -r dotenv/config src/jobs/cleaningReminderJob.ts",
"trash-reminder:run": "ts-node -r dotenv/config src/jobs/trashReminderJob.ts",
"trash-sync:run": "ts-node -r dotenv/config src/jobs/trashCalendarSyncJob.ts",
"notice-deadline-reminder:run": "ts-node -r dotenv/config src/jobs/noticeDeadlineReminderJob.ts",
"test:webhook": "ts-node -r dotenv/config scripts/send-test-webhook.ts",
"prisma:generate": "prisma generate --schema=../prisma/schema.prisma",

View File

@ -0,0 +1,99 @@
import 'dotenv/config';
import { PrismaClient, TrashType } from '@prisma/client';
const prisma = new PrismaClient();
/**
* Öffentliche Schnittstelle der Kommunalen Abfallwirtschaft Mainz und
* Mainz-Bingen AöR (https://lk.kaw-mainz-bingen.de/de/Abfallentsorgung/Abfallkalender),
* kein API-Key/Login nötig. CityId 35 = Nackenheim, DistrictId 5 =
* Verbandsgemeinde Bodenheim (per Browser-Formular ermittelt).
*/
const API_BASE = 'https://abfallkalender-api-lk.kaw-mainz-bingen.de/public/frontend/collectiondates';
const CITY_ID = 35;
const DISTRICT_ID = 5;
const SYNC_MONTHS_AHEAD = 6;
// Deren WasteTypeId -> unser TrashType. Sperrmüll (5) und Problemmüllbus (6)
// kennt unser Schema nicht und werden übersprungen.
const WASTE_TYPE_MAP: Record<number, TrashType> = {
1: 'RESTMUELL',
2: 'BIOMUELL',
3: 'GELBER_SACK',
4: 'PAPIER',
};
interface KawCollectionDate {
WasteTypeId: number;
WasteTypeName: string;
Date: string; // "TT.MM.JJJJ"
}
interface KawResponse {
DataList: KawCollectionDate[];
TotalRecords: number;
}
function parseGermanDate(value: string): Date {
const [day, month, year] = value.split('.').map(Number);
return new Date(Date.UTC(year, month - 1, day));
}
async function fetchAllPages(dateFrom: string, dateTo: string): Promise<KawCollectionDate[]> {
const results: KawCollectionDate[] = [];
let page = 1;
while (true) {
const url =
`${API_BASE}?Filter.CityId=${CITY_ID}&Filter.DistrictId=${DISTRICT_ID}` +
`&Filter.DateFrom=${dateFrom}&Filter.DateTo=${dateTo}&Filter.CurrentPage=${page}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`KAW-API antwortete mit Status ${res.status}`);
const body = (await res.json()) as KawResponse;
results.push(...body.DataList);
if (results.length >= body.TotalRecords || body.DataList.length === 0) break;
page += 1;
}
return results;
}
export async function runTrashCalendarSyncJob(referenceDate: Date = new Date()) {
const dateFrom = referenceDate.toISOString().slice(0, 10);
const dateTo = new Date(referenceDate);
dateTo.setMonth(dateTo.getMonth() + SYNC_MONTHS_AHEAD);
const dateToStr = dateTo.toISOString().slice(0, 10);
const entries = await fetchAllPages(dateFrom, dateToStr);
let created = 0;
let skippedUnknownType = 0;
for (const entry of entries) {
const type = WASTE_TYPE_MAP[entry.WasteTypeId];
if (!type) {
skippedUnknownType += 1;
continue;
}
const date = parseGermanDate(entry.Date);
const existing = await prisma.trashSchedule.findUnique({ where: { type_date: { type, date } } });
if (existing) continue;
await prisma.trashSchedule.create({ data: { type, date } });
created += 1;
}
return { fetched: entries.length, created, skippedUnknownType };
}
if (require.main === module) {
runTrashCalendarSyncJob()
.then((result) => {
console.log(
`[trashCalendarSyncJob] ${result.fetched} Termine von der KAW-API geladen, ` +
`${result.skippedUnknownType} unbekannte Mülltypen übersprungen.`,
);
return prisma.$disconnect();
})
.catch((err) => {
console.error('[trashCalendarSyncJob] Fehlgeschlagen:', err);
process.exit(1);
});
}

View File

@ -590,6 +590,7 @@ model TrashSchedule {
createdAt DateTime @default(now()) @map("created_at")
@@unique([type, date])
@@index([date])
@@map("trash_schedule")
}