add legislation service with CRUD for legislation + user votes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 16:48:37 +01:00
parent ff1fc64ca8
commit 03b704e483
3 changed files with 212 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("../../shared/db/client", () => {
const mockDb = {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
}
// chain builder pattern
const chain = (result: unknown) => ({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(result),
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(result),
}),
}),
leftJoin: vi.fn().mockReturnValue({
orderBy: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(result),
}),
}),
}),
})
mockDb.select.mockReturnValue(chain([]))
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
returning: vi.fn().mockResolvedValue([{ id: 1 }]),
}),
})
return { db: mockDb }
})
const { getLegislation, castVote, getUserVote } = await import("./service")
const { db } = await import("../../shared/db/client")
describe("legislation service", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("getLegislation", () => {
it("returns null when legislation not found", async () => {
const result = await getLegislation(999)
expect(result).toBeNull()
expect(db.select).toHaveBeenCalled()
})
})
describe("castVote", () => {
it("upserts a user vote", async () => {
await castVote(1, {
deviceId: "550e8400-e29b-41d4-a716-446655440000",
vote: "ja",
})
expect(db.insert).toHaveBeenCalled()
})
})
describe("getUserVote", () => {
it("returns null when no vote exists", async () => {
const result = await getUserVote(
1,
"550e8400-e29b-41d4-a716-446655440000",
)
expect(result).toBeNull()
})
it("returns vote when it exists", async () => {
const mockVote = {
id: 1,
deviceId: "550e8400-e29b-41d4-a716-446655440000",
legislationId: 1,
vote: "ja",
votedAt: new Date("2026-03-10"),
}
const mockChain = {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([mockVote]),
}),
}),
}
vi.mocked(db.select).mockReturnValueOnce(
mockChain as ReturnType<typeof db.select>,
)
const result = await getUserVote(
1,
"550e8400-e29b-41d4-a716-446655440000",
)
expect(result).not.toBeNull()
expect(result?.vote).toBe("ja")
})
})
})

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const castVoteSchema = z.object({
deviceId: z.string().uuid(),
vote: z.enum(["ja", "nein", "enthaltung"]),
})
export type CastVoteRequest = z.infer<typeof castVoteSchema>

View File

@@ -0,0 +1,107 @@
import { and, desc, eq } from "drizzle-orm"
import { db } from "../../shared/db/client"
import {
legislationSummaries,
legislationTexts,
userVotes,
} from "../../shared/db/schema/legislation"
import type { CastVoteRequest } from "./schema"
export async function getUpcomingLegislation() {
const rows = await db
.select({
id: legislationTexts.id,
title: legislationTexts.title,
beratungsstand: legislationTexts.beratungsstand,
summary: legislationSummaries.summary,
})
.from(legislationTexts)
.leftJoin(
legislationSummaries,
eq(legislationSummaries.legislationId, legislationTexts.id),
)
.orderBy(desc(legislationTexts.fetchedAt))
.limit(20)
return rows.map((r) => ({
id: r.id,
title: r.title,
beratungsstand: r.beratungsstand,
summary: r.summary ?? null,
}))
}
export async function getLegislation(id: number) {
const rows = await db
.select()
.from(legislationTexts)
.where(eq(legislationTexts.id, id))
.limit(1)
if (rows.length === 0) return null
const leg = rows[0]
// fetch summary if available
const summaries = await db
.select()
.from(legislationSummaries)
.where(eq(legislationSummaries.legislationId, id))
.limit(1)
return {
id: leg.id,
dipVorgangsId: leg.dipVorgangsId,
title: leg.title,
abstract: leg.abstract,
fullText: leg.fullText,
drucksacheUrl: leg.drucksacheUrl,
beratungsstand: leg.beratungsstand,
summary: summaries[0]?.summary ?? null,
fetchedAt: leg.fetchedAt.toISOString(),
}
}
export async function getLegislationText(id: number) {
const rows = await db
.select({ fullText: legislationTexts.fullText })
.from(legislationTexts)
.where(eq(legislationTexts.id, id))
.limit(1)
return rows[0]?.fullText ?? null
}
export async function castVote(legislationId: number, data: CastVoteRequest) {
await db
.insert(userVotes)
.values({
deviceId: data.deviceId,
legislationId,
vote: data.vote,
})
.onConflictDoUpdate({
target: [userVotes.deviceId, userVotes.legislationId],
set: { vote: data.vote, votedAt: new Date() },
})
}
export async function getUserVote(legislationId: number, deviceId: string) {
const rows = await db
.select()
.from(userVotes)
.where(
and(
eq(userVotes.legislationId, legislationId),
eq(userVotes.deviceId, deviceId),
),
)
.limit(1)
if (rows.length === 0) return null
return {
legislationId: rows[0].legislationId,
vote: rows[0].vote,
votedAt: rows[0].votedAt.toISOString(),
}
}