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:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
export { politicianRouter } from "./router"
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { pushRouter } from "./router"
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
// 3–4. 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)`,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user