normalize project structure: src/client + src/server + src/shared

- restructure from src/ + server/ to src/client/ + src/server/ + src/shared/
- switch backend runtime from Node (tsx) to Bun
- merge server/package.json into root, remove @hono/node-server + tsx
- convert server @/ imports to relative paths
- standardize biome config (lineWidth 80, quoteStyle double)
- add CLAUDE.md, .env.example at root
- update vite.config, tsconfig, deploy.sh for new structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 22:55:52 +01:00
parent 08029ee68c
commit 053707d96a
119 changed files with 15320 additions and 254 deletions
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from "hono"
import { cors } from "hono/cors"
import { logger } from "hono/logger"
import { politicianRouter } from "./features/politicians"
import { pushRouter } from "./features/push"
const app = new Hono()
app.use("*", logger())
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type"],
}),
)
app.get("/health", (c) => c.json({ status: "ok" }))
app.route("/politicians", politicianRouter)
app.route("/push", pushRouter)
export default app
+1
View File
@@ -0,0 +1 @@
export { politicianRouter } from "./router"
+22
View File
@@ -0,0 +1,22 @@
import { Hono } from "hono"
import { getPoliticianProfile } from "./service"
export const politicianRouter = new Hono()
politicianRouter.get("/:id", async (c) => {
const id = Number(c.req.param("id"))
if (!Number.isFinite(id) || id <= 0) {
return c.json({ error: "invalid politician id" }, 400)
}
try {
const profile = await getPoliticianProfile(id)
if (!profile) {
return c.json({ error: "no mandates found for politician" }, 404)
}
return c.json(profile)
} catch (e) {
console.error(`Failed to fetch politician ${id}:`, e)
return c.json({ error: "failed to fetch politician profile" }, 500)
}
})
+122
View File
@@ -0,0 +1,122 @@
import { eq } from "drizzle-orm"
import { db } from "../../shared/db/client"
import { politicianProfiles } from "../../shared/db/schema/politicians"
import {
type Poll,
fetchMandatesForPolitician,
fetchPollById,
fetchVotesByMandate,
} from "../../shared/lib/aw-api"
interface PoliticianVote {
vote: string
pollId: number
pollLabel: string
pollDate: string | null
pollUrl: string | null
topics: string[]
}
interface PoliticianProfile {
id: number
label: string
party: string | null
fraction: string | null
constituency: string | null
mandateWon: string | null
votes: PoliticianVote[]
}
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
export async function getPoliticianProfile(
politicianId: number,
): Promise<PoliticianProfile | null> {
// check cache
const cached = await db.query.politicianProfiles.findFirst({
where: eq(politicianProfiles.politicianId, politicianId),
})
if (cached && Date.now() - cached.cachedAt.getTime() < CACHE_TTL_MS) {
return cached.data as PoliticianProfile
}
// fetch mandates, pick latest
const mandates = await fetchMandatesForPolitician(politicianId)
if (mandates.length === 0) return null
const mandate = mandates.reduce((a, b) => (a.id > b.id ? a : b))
// extract header
const label = mandate.politician.label
const party = mandate.party?.label ?? null
const currentFraction = mandate.fraction_membership?.find(
(f) => !f.valid_until,
)
const fraction =
currentFraction?.fraction.label.replace(/\s*\([^)]+\)\s*$/, "") ?? null
const constituency = mandate.electoral_data?.constituency?.label ?? null
const mandateWon = mandate.electoral_data?.mandate_won ?? null
// fetch votes
const rawVotes = await fetchVotesByMandate(mandate.id)
// collect unique poll IDs and fetch polls in parallel
const pollIds = [
...new Set(
rawVotes.map((v) => v.poll?.id).filter((id): id is number => id != null),
),
]
const polls = await Promise.all(pollIds.map(fetchPollById))
const pollMap = new Map<number, Poll>()
for (const p of polls) {
if (p) pollMap.set(p.id, p)
}
// assemble votes
const votes: PoliticianVote[] = rawVotes
.flatMap((v) => {
const pollId = v.poll?.id
if (pollId == null) return []
const poll = pollMap.get(pollId)
return [
{
vote: v.vote,
pollId,
pollLabel: poll?.label ?? v.poll?.label ?? "",
pollDate: poll?.field_poll_date ?? null,
pollUrl: poll?.abgeordnetenwatch_url ?? null,
topics:
poll?.field_topics
.map((t) => t.label)
.filter((l): l is string => l != null) ?? [],
},
]
})
.sort((a, b) => {
if (a.pollDate && b.pollDate) return b.pollDate.localeCompare(a.pollDate)
if (a.pollDate) return -1
if (b.pollDate) return 1
return 0
})
const profile: PoliticianProfile = {
id: politicianId,
label,
party,
fraction,
constituency,
mandateWon,
votes,
}
// upsert cache
await db
.insert(politicianProfiles)
.values({ politicianId, data: profile, cachedAt: new Date() })
.onConflictDoUpdate({
target: politicianProfiles.politicianId,
set: { data: profile, cachedAt: new Date() },
})
return profile
}
+1
View File
@@ -0,0 +1 @@
export { pushRouter } from "./router"
+108
View File
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest"
import {
subscribeSchema,
syncFollowsSchema,
testPushSchema,
unsubscribeSchema,
} from "./schema"
describe("push request schemas", () => {
describe("subscribeSchema", () => {
it("accepts valid subscription", () => {
const data = {
device_id: "550e8400-e29b-41d4-a716-446655440000",
subscription: {
endpoint: "https://fcm.googleapis.com/fcm/send/abc123",
keys: {
p256dh:
"BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8p8REqnSw",
auth: "tBHItJI5svbpC7sFnHGA",
},
},
}
const result = subscribeSchema.safeParse(data)
expect(result.success).toBe(true)
})
it("rejects invalid device_id", () => {
const data = {
device_id: "not-a-uuid",
subscription: {
endpoint: "https://example.com",
keys: { p256dh: "abc", auth: "def" },
},
}
const result = subscribeSchema.safeParse(data)
expect(result.success).toBe(false)
})
it("rejects missing keys", () => {
const data = {
device_id: "550e8400-e29b-41d4-a716-446655440000",
subscription: {
endpoint: "https://example.com",
},
}
const result = subscribeSchema.safeParse(data)
expect(result.success).toBe(false)
})
})
describe("syncFollowsSchema", () => {
it("accepts valid follows", () => {
const data = {
device_id: "550e8400-e29b-41d4-a716-446655440000",
follows: [
{ type: "topic", entity_id: 42, label: "Innere Sicherheit" },
{ type: "politician", entity_id: 123, label: "Max Mustermann" },
],
}
const result = syncFollowsSchema.safeParse(data)
expect(result.success).toBe(true)
})
it("accepts empty follows array", () => {
const data = {
device_id: "550e8400-e29b-41d4-a716-446655440000",
follows: [],
}
const result = syncFollowsSchema.safeParse(data)
expect(result.success).toBe(true)
})
it("rejects invalid follow type", () => {
const data = {
device_id: "550e8400-e29b-41d4-a716-446655440000",
follows: [{ type: "invalid", entity_id: 1, label: "test" }],
}
const result = syncFollowsSchema.safeParse(data)
expect(result.success).toBe(false)
})
})
describe("testPushSchema", () => {
it("accepts valid device_id", () => {
const data = { device_id: "550e8400-e29b-41d4-a716-446655440000" }
const result = testPushSchema.safeParse(data)
expect(result.success).toBe(true)
})
it("rejects invalid device_id", () => {
const result = testPushSchema.safeParse({ device_id: "bad" })
expect(result.success).toBe(false)
})
})
describe("unsubscribeSchema", () => {
it("accepts valid device_id", () => {
const data = { device_id: "550e8400-e29b-41d4-a716-446655440000" }
const result = unsubscribeSchema.safeParse(data)
expect(result.success).toBe(true)
})
it("rejects invalid device_id", () => {
const result = unsubscribeSchema.safeParse({ device_id: "bad" })
expect(result.success).toBe(false)
})
})
})
+62
View File
@@ -0,0 +1,62 @@
import { Hono } from "hono"
import {
subscribeSchema,
syncFollowsSchema,
testPushSchema,
unsubscribeSchema,
} from "./schema"
import {
removeSubscription,
sendTestNotification,
syncFollows,
upsertSubscription,
} from "./service"
export const pushRouter = new Hono()
pushRouter.post("/subscribe", async (c) => {
const body = await c.req.json()
const parsed = subscribeSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.flatten() }, 400)
}
await upsertSubscription(parsed.data)
return c.json({ ok: true }, 201)
})
pushRouter.post("/sync", async (c) => {
const body = await c.req.json()
const parsed = syncFollowsSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.flatten() }, 400)
}
await syncFollows(parsed.data)
return c.json({ ok: true })
})
pushRouter.post("/test", async (c) => {
const body = await c.req.json()
const parsed = testPushSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.flatten() }, 400)
}
try {
const success = await sendTestNotification(parsed.data.device_id)
if (!success) {
return c.json({ error: "subscription expired" }, 410)
}
return c.json({ ok: true })
} catch (e) {
return c.json({ error: String(e) }, 404)
}
})
pushRouter.delete("/unsubscribe", async (c) => {
const body = await c.req.json()
const parsed = unsubscribeSchema.safeParse(body)
if (!parsed.success) {
return c.json({ error: parsed.error.flatten() }, 400)
}
await removeSubscription(parsed.data.device_id)
return c.json({ ok: true })
})
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
export const subscribeSchema = z.object({
device_id: z.string().uuid(),
subscription: z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string().min(1),
auth: z.string().min(1),
}),
}),
})
export const syncFollowsSchema = z.object({
device_id: z.string().uuid(),
follows: z.array(
z.object({
type: z.enum(["topic", "politician"]),
entity_id: z.number().int().positive(),
label: z.string(),
}),
),
})
export const unsubscribeSchema = z.object({
device_id: z.string().uuid(),
})
export const testPushSchema = z.object({
device_id: z.string().uuid(),
})
export type SubscribeRequest = z.infer<typeof subscribeSchema>
export type SyncFollowsRequest = z.infer<typeof syncFollowsSchema>
export type UnsubscribeRequest = z.infer<typeof unsubscribeSchema>
export type TestPushRequest = z.infer<typeof testPushSchema>
+66
View File
@@ -0,0 +1,66 @@
import { eq } from "drizzle-orm"
import { db } from "../../shared/db/client"
import { deviceFollows, pushSubscriptions } from "../../shared/db/schema/push"
import { sendPushNotification } from "../../shared/lib/web-push"
import type { SubscribeRequest, SyncFollowsRequest } from "./schema"
export async function upsertSubscription(data: SubscribeRequest) {
await db
.insert(pushSubscriptions)
.values({
deviceId: data.device_id,
endpoint: data.subscription.endpoint,
p256dh: data.subscription.keys.p256dh,
auth: data.subscription.keys.auth,
})
.onConflictDoUpdate({
target: pushSubscriptions.deviceId,
set: {
endpoint: data.subscription.endpoint,
p256dh: data.subscription.keys.p256dh,
auth: data.subscription.keys.auth,
updatedAt: new Date(),
},
})
}
export async function syncFollows(data: SyncFollowsRequest) {
await db.transaction(async (tx) => {
await tx
.delete(deviceFollows)
.where(eq(deviceFollows.deviceId, data.device_id))
if (data.follows.length > 0) {
await tx.insert(deviceFollows).values(
data.follows.map((f) => ({
deviceId: data.device_id,
type: f.type,
entityId: f.entity_id,
label: f.label,
})),
)
}
})
}
export async function removeSubscription(deviceId: string) {
await db
.delete(pushSubscriptions)
.where(eq(pushSubscriptions.deviceId, deviceId))
}
export async function sendTestNotification(deviceId: string): Promise<boolean> {
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.deviceId, deviceId))
.limit(1)
if (subs.length === 0) {
throw new Error("no subscription found for this device")
}
const sub = subs[0]
return sendPushNotification(
{ endpoint: sub.endpoint, p256dh: sub.p256dh, auth: sub.auth },
{ title: "Test-Benachrichtigung", body: "Push funktioniert!", tag: "test" },
)
}
+34
View File
@@ -0,0 +1,34 @@
import app from "./app"
import { getPgBoss } from "./shared/jobs/client"
import { checkForNewPolls } from "./shared/jobs/poll-checker"
import { env } from "./shared/lib/env"
const POLL_CHECK_SCHEDULE = "*/15 * * * *" // every 15 minutes
// start pg-boss
const boss = getPgBoss()
await boss.start()
console.log("[pg-boss] started")
// register the poll-checker cron
await boss.createQueue("poll-checker")
await boss.schedule("poll-checker", POLL_CHECK_SCHEDULE)
await boss.work("poll-checker", async () => {
await checkForNewPolls()
})
console.log("[pg-boss] poll-checker cron registered (every 15 min)")
// graceful shutdown
const shutdown = async () => {
console.log("[server] shutting down…")
await boss.stop()
process.exit(0)
}
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
// start HTTP server (Bun native)
export default {
port: env.PORT,
fetch: app.fetch,
}
+12
View File
@@ -0,0 +1,12 @@
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import { env } from "../lib/env"
import * as politicianSchema from "./schema/politicians"
import * as pushSchema from "./schema/push"
const sql = postgres(env.DATABASE_URL)
export const db = drizzle(sql, {
schema: { ...pushSchema, ...politicianSchema },
})
export type Database = typeof db
@@ -0,0 +1,9 @@
import { integer, jsonb, pgTable, timestamp } from "drizzle-orm/pg-core"
export const politicianProfiles = pgTable("politician_profiles", {
politicianId: integer("politician_id").primaryKey(),
data: jsonb("data").notNull(),
cachedAt: timestamp("cached_at", { withTimezone: true })
.notNull()
.defaultNow(),
})
+53
View File
@@ -0,0 +1,53 @@
import {
integer,
pgTable,
text,
timestamp,
unique,
uuid,
varchar,
} from "drizzle-orm/pg-core"
export const pushSubscriptions = pgTable("push_subscriptions", {
deviceId: uuid("device_id").primaryKey(),
endpoint: text("endpoint").notNull(),
p256dh: text("p256dh").notNull(),
auth: text("auth").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
})
export const deviceFollows = pgTable(
"device_follows",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
deviceId: uuid("device_id")
.notNull()
.references(() => pushSubscriptions.deviceId, { onDelete: "cascade" }),
type: varchar("type", { length: 20 })
.notNull()
.$type<"topic" | "politician">(),
entityId: integer("entity_id").notNull(),
label: text("label").notNull(),
},
(t) => [unique("device_follows_unique").on(t.deviceId, t.type, t.entityId)],
)
export const seenPolls = pgTable("seen_polls", {
pollId: integer("poll_id").primaryKey(),
checkedAt: timestamp("checked_at", { withTimezone: true })
.notNull()
.defaultNow(),
})
export const politicianMandates = pgTable("politician_mandates", {
politicianId: integer("politician_id").primaryKey(),
mandateId: integer("mandate_id").notNull(),
cachedAt: timestamp("cached_at", { withTimezone: true })
.notNull()
.defaultNow(),
})
+11
View File
@@ -0,0 +1,11 @@
import PgBoss from "pg-boss"
import { env } from "../lib/env"
let boss: PgBoss | null = null
export function getPgBoss(): PgBoss {
if (!boss) {
boss = new PgBoss(env.DATABASE_URL)
}
return boss
}
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
// mock modules before importing the module under test
vi.mock("../db/client", () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
onConflictDoNothing: vi.fn().mockResolvedValue(undefined),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
transaction: vi.fn(),
},
}))
vi.mock("../lib/web-push", () => ({
sendPushNotification: vi.fn().mockResolvedValue(true),
}))
vi.mock("../lib/aw-api", () => ({
fetchRecentPolls: vi.fn(),
fetchVotesByPoll: vi.fn(),
fetchCandidacyMandates: vi.fn(),
}))
import { db } from "../db/client"
import { fetchRecentPolls, fetchVotesByPoll } from "../lib/aw-api"
const mockedFetchRecentPolls = vi.mocked(fetchRecentPolls)
describe("poll-checker", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("exits early when no polls are returned", async () => {
mockedFetchRecentPolls.mockResolvedValue([])
const { checkForNewPolls } = await import("./poll-checker")
await checkForNewPolls()
expect(fetchRecentPolls).toHaveBeenCalledWith(50)
expect(fetchVotesByPoll).not.toHaveBeenCalled()
})
it("skips already-seen polls", async () => {
const polls = [
{
id: 1,
label: "Test Poll",
field_poll_date: "2026-01-01",
field_topics: [{ id: 10 }],
abgeordnetenwatch_url: "https://example.com/poll/1",
},
]
mockedFetchRecentPolls.mockResolvedValue(polls)
// mock the select to return the poll as already seen
const mockWhere = vi.fn().mockResolvedValue([{ pollId: 1 }])
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
vi.mocked(db.select).mockReturnValue(mockSelect() as never)
const { checkForNewPolls } = await import("./poll-checker")
await checkForNewPolls()
expect(fetchVotesByPoll).not.toHaveBeenCalled()
})
})
+229
View File
@@ -0,0 +1,229 @@
import { and, eq, inArray } from "drizzle-orm"
import { db } from "../db/client"
import {
deviceFollows,
politicianMandates,
pushSubscriptions,
seenPolls,
} from "../db/schema/push"
import {
type VoteDetail,
fetchCandidacyMandates,
fetchRecentPolls,
fetchVotesByPoll,
} from "../lib/aw-api"
import { type PushPayload, sendPushNotification } from "../lib/web-push"
const VOTE_LABELS: Record<string, string> = {
yes: "Ja",
no: "Nein",
abstain: "Enthaltung",
no_show: "Nicht abgestimmt",
}
const MANDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
async function resolveMandateId(politicianId: number): Promise<number | null> {
// check cache first
const cached = await db
.select()
.from(politicianMandates)
.where(eq(politicianMandates.politicianId, politicianId))
.limit(1)
if (cached.length > 0) {
const age = Date.now() - cached[0].cachedAt.getTime()
if (age < MANDATE_CACHE_TTL_MS) {
return cached[0].mandateId
}
}
// fetch from AW API
const mandates = await fetchCandidacyMandates(politicianId)
if (mandates.length === 0) return null
// use the latest mandate (highest ID)
const mandateId = Math.max(...mandates.map((m) => m.id))
await db
.insert(politicianMandates)
.values({ politicianId, mandateId })
.onConflictDoUpdate({
target: politicianMandates.politicianId,
set: { mandateId, cachedAt: new Date() },
})
return mandateId
}
interface DeviceNotification {
deviceId: string
endpoint: string
p256dh: string
auth: string
votes: VoteDetail[]
}
export async function checkForNewPolls() {
console.log("[poll-checker] checking for new polls…")
// 1. fetch recent polls
const polls = await fetchRecentPolls(50)
if (polls.length === 0) {
console.log("[poll-checker] no polls returned from API")
return
}
// 2. filter out already-seen polls
const pollIds = polls.map((p) => p.id)
const seen = await db
.select({ pollId: seenPolls.pollId })
.from(seenPolls)
.where(inArray(seenPolls.pollId, pollIds))
const seenIds = new Set(seen.map((s) => s.pollId))
const newPolls = polls.filter((p) => !seenIds.has(p.id))
if (newPolls.length === 0) {
console.log("[poll-checker] no new polls")
return
}
console.log(`[poll-checker] found ${newPolls.length} new poll(s)`)
for (const poll of newPolls) {
try {
await processPoll(
poll.id,
poll.label,
poll.field_topics,
poll.abgeordnetenwatch_url,
)
} catch (err) {
console.error(`[poll-checker] error processing poll ${poll.id}:`, err)
}
}
// 9. mark all new polls as seen
await db
.insert(seenPolls)
.values(newPolls.map((p) => ({ pollId: p.id })))
.onConflictDoNothing()
console.log("[poll-checker] done")
}
async function processPoll(
pollId: number,
pollLabel: string,
fieldTopics: { id: number }[],
pollUrl: string | undefined,
) {
const topicIds = fieldTopics.map((t) => t.id)
if (topicIds.length === 0) return
// 34. find devices following any of these topics
const topicFollows = await db
.select({
deviceId: deviceFollows.deviceId,
})
.from(deviceFollows)
.where(
and(
eq(deviceFollows.type, "topic"),
inArray(deviceFollows.entityId, topicIds),
),
)
const deviceIds = [...new Set(topicFollows.map((f) => f.deviceId))]
if (deviceIds.length === 0) return
// fetch all votes for this poll
const allVotes = await fetchVotesByPoll(pollId)
const votesByMandate = new Map<number, VoteDetail>()
for (const v of allVotes) {
votesByMandate.set(v.mandate.id, v)
}
// build per-device notifications
const notifications: DeviceNotification[] = []
for (const deviceId of deviceIds) {
// get this device's followed politicians
const politicianFollows = await db
.select({ entityId: deviceFollows.entityId })
.from(deviceFollows)
.where(
and(
eq(deviceFollows.deviceId, deviceId),
eq(deviceFollows.type, "politician"),
),
)
if (politicianFollows.length === 0) continue
// resolve politician IDs → mandate IDs
const matchedVotes: VoteDetail[] = []
for (const pf of politicianFollows) {
const mandateId = await resolveMandateId(pf.entityId)
if (mandateId === null) continue
const vote = votesByMandate.get(mandateId)
if (vote) matchedVotes.push(vote)
}
if (matchedVotes.length === 0) continue
// get the device's push subscription
const subs = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.deviceId, deviceId))
.limit(1)
if (subs.length === 0) continue
notifications.push({
deviceId,
endpoint: subs[0].endpoint,
p256dh: subs[0].p256dh,
auth: subs[0].auth,
votes: matchedVotes,
})
}
// send notifications
for (const notif of notifications) {
const body = notif.votes
.map((v) => {
const label = VOTE_LABELS[v.vote] ?? v.vote
const fraction = v.fraction?.label ? ` (${v.fraction.label})` : ""
return `${v.mandate.label}${fraction}: ${label}`
})
.join("\n")
const payload: PushPayload = {
title: `Neue Abstimmung: ${pollLabel}`,
body,
url: pollUrl,
tag: `poll-${pollId}`,
}
const success = await sendPushNotification(
{ endpoint: notif.endpoint, p256dh: notif.p256dh, auth: notif.auth },
payload,
)
if (!success) {
// subscription expired — remove it
console.log(
`[poll-checker] removing expired subscription for device ${notif.deviceId}`,
)
await db
.delete(pushSubscriptions)
.where(eq(pushSubscriptions.deviceId, notif.deviceId))
}
}
console.log(
`[poll-checker] poll ${pollId}: sent ${notifications.length} notification(s)`,
)
}
+188
View File
@@ -0,0 +1,188 @@
import { z } from "zod"
const AW_API_BASE = "https://www.abgeordnetenwatch.de/api/v2"
const AW_API_TIMEOUT_MS = 30_000
// --- Zod Schemas ---
const pollTopicSchema = z.object({
id: z.number(),
label: z.string().optional(),
abgeordnetenwatch_url: z.string().optional(),
})
export const pollSchema = z.object({
id: z.number(),
label: z.string(),
abgeordnetenwatch_url: z.string().optional(),
field_poll_date: z.string().nullable(),
field_topics: z.array(pollTopicSchema),
})
export const voteDetailSchema = z.object({
id: z.number(),
vote: z.string(),
mandate: z.object({
id: z.number(),
label: z.string(),
}),
fraction: z
.object({
id: z.number(),
label: z.string(),
})
.nullable()
.optional(),
poll: z
.object({
id: z.number(),
label: z.string(),
abgeordnetenwatch_url: z.string().optional(),
})
.nullable()
.optional(),
})
export const candidacyMandateSchema = z.object({
id: z.number(),
})
export const mandateDetailSchema = z.object({
id: z.number(),
politician: z.object({
id: z.number(),
label: z.string(),
}),
party: z
.object({
id: z.number(),
label: z.string(),
})
.nullable()
.optional(),
fraction_membership: z
.array(
z.object({
fraction: z.object({ label: z.string() }),
valid_until: z.string().nullable().optional(),
}),
)
.optional(),
electoral_data: z
.object({
constituency: z.object({ label: z.string() }).nullable().optional(),
mandate_won: z.string().nullable().optional(),
})
.nullable()
.optional(),
})
// --- Types ---
export type Poll = z.infer<typeof pollSchema>
export type VoteDetail = z.infer<typeof voteDetailSchema>
export type MandateDetail = z.infer<typeof mandateDetailSchema>
// --- Fetch helper ---
async function request<T>(
path: string,
params: Record<string, string>,
schema: z.ZodType<T>,
): Promise<T[]> {
const url = new URL(`${AW_API_BASE}/${path}`)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), AW_API_TIMEOUT_MS)
let res: Response
try {
res = await fetch(url.toString(), {
signal: controller.signal,
headers: { Accept: "application/json" },
})
} finally {
clearTimeout(timer)
}
if (!res.ok) {
const body = await res.text().catch(() => "")
throw new Error(`AW API ${res.status} for ${url}: ${body}`)
}
const json = (await res.json()) as { data: unknown[] }
return z.array(schema).parse(json.data)
}
// --- Public API ---
export function fetchRecentPolls(
count = 50,
legislatureId?: number,
): Promise<Poll[]> {
const params: Record<string, string> = {
range_end: String(count),
sort_by: "field_poll_date",
sort_direction: "desc",
}
if (legislatureId != null) params.field_legislature = String(legislatureId)
return request("polls", params, pollSchema)
}
export function fetchVotesByPoll(pollId: number): Promise<VoteDetail[]> {
return request(
"votes",
{
poll: String(pollId),
range_end: "800",
},
voteDetailSchema,
)
}
export function fetchCandidacyMandates(
politicianId: number,
): Promise<{ id: number }[]> {
return request(
"candidacies-mandates",
{
politician: String(politicianId),
range_end: "200",
},
candidacyMandateSchema,
)
}
export function fetchMandatesForPolitician(
politicianId: number,
): Promise<MandateDetail[]> {
return request(
"candidacies-mandates",
{
politician: String(politicianId),
type: "mandate",
range_end: "10",
},
mandateDetailSchema,
)
}
export function fetchVotesByMandate(mandateId: number): Promise<VoteDetail[]> {
return request(
"votes",
{
mandate: String(mandateId),
range_end: "200",
},
voteDetailSchema,
)
}
export async function fetchPollById(pollId: number): Promise<Poll | null> {
const results = await request(
"polls",
{ id: String(pollId), range_end: "1" },
pollSchema,
)
return results[0] ?? null
}
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod"
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
VAPID_PUBLIC_KEY: z.string().min(1),
VAPID_PRIVATE_KEY: z.string().min(1),
VAPID_SUBJECT: z.string().startsWith("mailto:"),
})
export const env = envSchema.parse(process.env)
export type Env = z.infer<typeof envSchema>
+47
View File
@@ -0,0 +1,47 @@
import webpush from "web-push"
import { env } from "./env"
webpush.setVapidDetails(
env.VAPID_SUBJECT,
env.VAPID_PUBLIC_KEY,
env.VAPID_PRIVATE_KEY,
)
export interface PushPayload {
title: string
body: string
url?: string
tag?: string
}
export interface PushSubscription {
endpoint: string
p256dh: string
auth: string
}
export async function sendPushNotification(
sub: PushSubscription,
payload: PushPayload,
): Promise<boolean> {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
p256dh: sub.p256dh,
auth: sub.auth,
},
},
JSON.stringify(payload),
)
return true
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number }).statusCode
if (statusCode === 410 || statusCode === 404) {
// subscription expired or invalid — caller should remove it
return false
}
throw err
}
}