Skip to content

Commit 0ca47b4

Browse files
committed
add basic analysis in etl (#80)
* add basic analysis in etl providing coalition info, deviations, motion topic counts and party votes * add frontend
1 parent 33ddca6 commit 0ca47b4

25 files changed

Lines changed: 1670 additions & 61 deletions

File tree

app/backend/eslint.config.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@ export default tseslint.config(
2424
types: {
2525
json: "unknown",
2626
jsonb: "unknown",
27-
uuid: "UUID",
27+
uuid: "`${string}-${string}-${string}-${string}-${string}`",
28+
bytea: "unknown",
2829
},
2930
},
3031
}),
32+
{
33+
rules: {
34+
"@ts-safeql/check-sql": "off",
35+
},
36+
},
3137
);

app/backend/src/contracts/index.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const VoteSchema = z.object({
7171
politician: PoliticianSchema.optional(),
7272
});
7373

74-
const DecisionSchema = z.object({
74+
export const DecisionSchema = z.object({
7575
id: z.string(),
7676
agendaPointId: z.string().nullable(),
7777
caseId: z.string().nullable(),
@@ -325,6 +325,63 @@ const statisticsGetPartyCategoryLikenessContract = oc
325325
.input(z.object({ partyId: z.string() }).merge(dateFilterSchema))
326326
.output(z.array(PartyCategoryLikenessSchema));
327327

328+
// Analysis schemas
329+
export const CoalitionAlignmentSchema = z.object({
330+
fractie1Id: z.string(),
331+
fractie2Id: z.string(),
332+
fractie1Name: z.string(),
333+
fractie2Name: z.string(),
334+
alignmentPct: z.number(),
335+
sameVotes: z.number(),
336+
totalVotes: z.number(),
337+
});
338+
339+
export const MPDeviationSchema = z.object({
340+
persoonId: z.string(),
341+
fractieId: z.string(),
342+
persoonNaam: z.string(),
343+
fractieNaam: z.string(),
344+
deviationPct: z.number(),
345+
deviationCount: z.number(),
346+
totalVotes: z.number(),
347+
});
348+
349+
export const TopicTrendSchema = z.object({
350+
categoryId: z.string(),
351+
categoryName: z.string(),
352+
motionCount: z.number(),
353+
acceptedCount: z.number(),
354+
rejectedCount: z.number(),
355+
});
356+
357+
export const PartyTopicVotingSchema = z.object({
358+
fractieId: z.string(),
359+
categoryId: z.string(),
360+
fractieNaam: z.string(),
361+
categoryName: z.string(),
362+
votesFor: z.number(),
363+
votesAgainst: z.number(),
364+
totalVotes: z.number(),
365+
forPct: z.number(),
366+
});
367+
368+
// Analysis contracts
369+
const analysisGetCoalitionAlignmentContract = oc
370+
.input(z.object({ period: z.string().default("all") }).optional())
371+
.output(z.array(CoalitionAlignmentSchema));
372+
373+
const analysisGetMPDeviationsContract = oc
374+
.input(z.object({ period: z.string().default("all"), limit: z.number().default(50) }).optional())
375+
.output(z.array(MPDeviationSchema));
376+
377+
const analysisGetTopicTrendsContract = oc
378+
.input(z.object({ period: z.string().default("all") }).optional())
379+
.output(z.array(TopicTrendSchema));
380+
381+
const analysisGetPartyTopicVotingContract = oc
382+
.input(z.object({ fractieId: z.string().optional(), period: z.string().default("all") }).optional())
383+
.output(z.array(PartyTopicVotingSchema));
384+
328385
export const apiContract = {
329386
motions: {
330387
getAll: motionGetAllContract,
@@ -352,6 +409,12 @@ export const apiContract = {
352409
getPartyFocus: statisticsGetPartyFocusContract,
353410
getPartyCategoryLikeness: statisticsGetPartyCategoryLikenessContract,
354411
},
412+
analysis: {
413+
getCoalitionAlignment: analysisGetCoalitionAlignmentContract,
414+
getMPDeviations: analysisGetMPDeviationsContract,
415+
getTopicTrends: analysisGetTopicTrendsContract,
416+
getPartyTopicVoting: analysisGetPartyTopicVotingContract,
417+
},
355418
};
356419

357420
// Type exports
@@ -372,3 +435,7 @@ export type PartyFocus = z.infer<typeof PartyFocusSchema>;
372435
export type PartyFocusCategory = z.infer<typeof PartyFocusCategorySchema>;
373436
export type PartyCategoryLikeness = z.infer<typeof PartyCategoryLikenessSchema>;
374437
export type UserSession = z.infer<typeof UserSessionSchema>;
438+
export type CoalitionAlignment = z.infer<typeof CoalitionAlignmentSchema>;
439+
export type MPDeviation = z.infer<typeof MPDeviationSchema>;
440+
export type TopicTrend = z.infer<typeof TopicTrendSchema>;
441+
export type PartyTopicVoting = z.infer<typeof PartyTopicVotingSchema>;

app/backend/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RPCHandler } from "@orpc/server/node";
44
import cors from "cors";
55
import express from "express";
66
import { handleUriError } from "./middleware/handleUriError.js";
7+
import { analysisRouter } from "./routers/analysis.js";
78
import { compassRouter } from "./routers/compass.js";
89
import { motionRouter } from "./routers/motions.js";
910
import { partyRouter } from "./routers/parties.js";
@@ -27,6 +28,7 @@ export const router = os.router({
2728
parties: partyRouter,
2829
compass: compassRouter,
2930
statistics: statisticsRouter,
31+
analysis: analysisRouter,
3032
});
3133

3234
const handler = new RPCHandler(router, {
@@ -88,7 +90,6 @@ app.use(
8890
err: Error,
8991
_req: express.Request,
9092
res: express.Response,
91-
_next: express.NextFunction,
9293
) => {
9394
console.error("Server error:", err);
9495
res.status(500).json({
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { implement } from "@orpc/server";
2+
import { apiContract } from "../contracts/index.js";
3+
import { sql } from "../services/db/sql-tag.js";
4+
5+
const os = implement(apiContract);
6+
7+
export const analysisRouter = {
8+
getCoalitionAlignment: os.analysis.getCoalitionAlignment.handler(
9+
async ({ input }) => {
10+
const period = input?.period ?? "all";
11+
12+
const results = await sql<{ 'fractie1Id': string | null; 'fractie2Id': string | null; 'fractie1Name': string | null; 'fractie2Name': string | null; alignmentPct: string; sameVotes: string | null; totalVotes: string }>`
13+
SELECT
14+
plpm.fractie1_id AS "fractie1Id",
15+
plpm.fractie2_id AS "fractie2Id",
16+
f1.afkorting AS "fractie1Name",
17+
f2.afkorting AS "fractie2Name",
18+
ROUND(AVG(CASE WHEN same_vote THEN 1.0 ELSE 0.0 END) * 100, 2) AS "alignmentPct",
19+
SUM(CASE WHEN same_vote THEN 1 ELSE 0 END) AS "sameVotes",
20+
COUNT(*) AS "totalVotes"
21+
FROM party_likeness_per_motion plpm
22+
JOIN fracties f1 ON plpm.fractie1_id = f1.id
23+
JOIN fracties f2 ON plpm.fractie2_id = f2.id
24+
WHERE (${period} = 'all' OR EXTRACT(YEAR FROM plpm.gestart_op)::text = ${period})
25+
GROUP BY plpm.fractie1_id, plpm.fractie2_id, f1.afkorting, f2.afkorting
26+
HAVING COUNT(*) >= 10
27+
ORDER BY "alignmentPct" DESC
28+
`;
29+
30+
return results
31+
.filter((r) => r.fractie1Id && r.fractie2Id && r.fractie1Name && r.fractie2Name)
32+
.map((r) => ({
33+
fractie1Id: r.fractie1Id!,
34+
fractie2Id: r.fractie2Id!,
35+
fractie1Name: r.fractie1Name!,
36+
fractie2Name: r.fractie2Name!,
37+
alignmentPct: Number(r.alignmentPct),
38+
sameVotes: Number(r.sameVotes),
39+
totalVotes: Number(r.totalVotes),
40+
}));
41+
},
42+
),
43+
44+
getMPDeviations: os.analysis.getMPDeviations.handler(async ({ input }) => {
45+
const period = input?.period ?? "all";
46+
const limit = input?.limit ?? 50;
47+
48+
const results = await sql<{ persoonId: string | null; fractieId: string | null; persoonNaam: string | null; fractieNaam: string | null; deviationPct: string; deviationCount: string | null; totalVotes: string }>`
49+
WITH party_majority AS (
50+
SELECT
51+
b.id as besluit_id,
52+
s.fractie_id,
53+
s.soort as majority_vote
54+
FROM stemmingen s
55+
JOIN besluiten b ON s.besluit_id = b.id
56+
JOIN zaken z ON b.zaak_id = z.id
57+
WHERE s.fractie_id IS NOT NULL
58+
AND s.soort IN ('Voor', 'Tegen')
59+
AND z.soort = 'Motie'
60+
AND (${period} = 'all' OR EXTRACT(YEAR FROM z.gestart_op)::text = ${period})
61+
),
62+
individual_votes AS (
63+
SELECT
64+
s.persoon_id,
65+
s.fractie_id,
66+
b.id as besluit_id,
67+
s.soort as vote
68+
FROM stemmingen s
69+
JOIN besluiten b ON s.besluit_id = b.id
70+
JOIN zaken z ON b.zaak_id = z.id
71+
WHERE s.persoon_id IS NOT NULL
72+
AND s.fractie_id IS NOT NULL
73+
AND s.soort IN ('Voor', 'Tegen')
74+
AND z.soort = 'Motie'
75+
AND (${period} = 'all' OR EXTRACT(YEAR FROM z.gestart_op)::text = ${period})
76+
)
77+
SELECT
78+
iv.persoon_id AS "persoonId",
79+
iv.fractie_id AS "fractieId",
80+
COALESCE(p.roepnaam, p.voornamen) || ' ' || COALESCE(p.tussenvoegsel || ' ', '') || p.achternaam AS "persoonNaam",
81+
f.afkorting AS "fractieNaam",
82+
ROUND(AVG(CASE WHEN iv.vote != pm.majority_vote THEN 1.0 ELSE 0.0 END) * 100, 2) AS "deviationPct",
83+
SUM(CASE WHEN iv.vote != pm.majority_vote THEN 1 ELSE 0 END) AS "deviationCount",
84+
COUNT(*) AS "totalVotes"
85+
FROM individual_votes iv
86+
JOIN party_majority pm ON iv.besluit_id = pm.besluit_id AND iv.fractie_id = pm.fractie_id
87+
JOIN personen p ON iv.persoon_id = p.id
88+
JOIN fracties f ON iv.fractie_id = f.id
89+
GROUP BY iv.persoon_id, iv.fractie_id, p.roepnaam, p.voornamen, p.tussenvoegsel, p.achternaam, f.afkorting
90+
HAVING COUNT(*) >= 20
91+
ORDER BY "deviationPct" DESC
92+
LIMIT ${limit}
93+
`;
94+
95+
return results
96+
.filter((r) => r.persoonId && r.fractieId && r.persoonNaam && r.fractieNaam)
97+
.map((r) => ({
98+
persoonId: r.persoonId!,
99+
fractieId: r.fractieId!,
100+
persoonNaam: r.persoonNaam!,
101+
fractieNaam: r.fractieNaam!,
102+
deviationPct: Number(r.deviationPct),
103+
deviationCount: Number(r.deviationCount),
104+
totalVotes: Number(r.totalVotes),
105+
}));
106+
}),
107+
108+
getTopicTrends: os.analysis.getTopicTrends.handler(async ({ input }) => {
109+
const period = input?.period ?? "all";
110+
111+
const results = await sql<{
112+
categoryId: string;
113+
categoryName: string;
114+
motionCount: string;
115+
acceptedCount: string;
116+
rejectedCount: string;
117+
}>`
118+
SELECT
119+
zc.category_id AS "categoryId",
120+
mc.name AS "categoryName",
121+
COUNT(DISTINCT z.id) AS "motionCount",
122+
COUNT(DISTINCT CASE WHEN b.status = 'Aangenomen' THEN z.id END) AS "acceptedCount",
123+
COUNT(DISTINCT CASE WHEN b.status = 'Verworpen' THEN z.id END) AS "rejectedCount"
124+
FROM zaak_categories zc
125+
JOIN zaken z ON zc.zaak_id = z.id
126+
JOIN motion_categories mc ON zc.category_id = mc.id
127+
LEFT JOIN besluiten b ON b.zaak_id = z.id
128+
WHERE z.soort = 'Motie'
129+
AND (${period} = 'all' OR EXTRACT(YEAR FROM z.gestart_op)::text = ${period})
130+
GROUP BY zc.category_id, mc.name
131+
ORDER BY "motionCount" DESC
132+
`;
133+
134+
return results.map((r) => ({
135+
categoryId: r.categoryId,
136+
categoryName: r.categoryName,
137+
motionCount: Number(r.motionCount),
138+
acceptedCount: Number(r.acceptedCount),
139+
rejectedCount: Number(r.rejectedCount),
140+
}));
141+
}),
142+
143+
getPartyTopicVoting: os.analysis.getPartyTopicVoting.handler(
144+
async ({ input }) => {
145+
const period = input?.period ?? "all";
146+
const fractieId = input?.fractieId ?? "";
147+
148+
const results = await sql<{ fractieId: string | null; categoryId: string; fractieNaam: string | null; categoryName: string; votesFor: string | null; votesAgainst: string | null; totalVotes: string; forPct: string }>`
149+
SELECT
150+
mv.fractie_id AS "fractieId",
151+
zc.category_id AS "categoryId",
152+
f.afkorting AS "fractieNaam",
153+
mc.name AS "categoryName",
154+
SUM(CASE WHEN mv.vote_type = 'Voor' THEN 1 ELSE 0 END) AS "votesFor",
155+
SUM(CASE WHEN mv.vote_type = 'Tegen' THEN 1 ELSE 0 END) AS "votesAgainst",
156+
COUNT(*) AS "totalVotes",
157+
ROUND(AVG(CASE WHEN mv.vote_type = 'Voor' THEN 1.0 ELSE 0.0 END) * 100, 2) AS "forPct"
158+
FROM majority_party_votes mv
159+
JOIN zaak_categories zc ON mv.zaak_id = zc.zaak_id
160+
JOIN motion_categories mc ON zc.category_id = mc.id
161+
JOIN fracties f ON mv.fractie_id = f.id
162+
WHERE (${fractieId} = '' OR mv.fractie_id = ${fractieId})
163+
AND (${period} = 'all' OR EXTRACT(YEAR FROM mv.gestart_op)::text = ${period})
164+
GROUP BY mv.fractie_id, zc.category_id, f.afkorting, mc.name
165+
HAVING COUNT(*) >= 5
166+
ORDER BY "totalVotes" DESC
167+
`;
168+
169+
return results
170+
.filter((r) => r.fractieId && r.fractieNaam)
171+
.map((r) => ({
172+
fractieId: r.fractieId!,
173+
categoryId: r.categoryId,
174+
fractieNaam: r.fractieNaam!,
175+
categoryName: r.categoryName,
176+
votesFor: Number(r.votesFor),
177+
votesAgainst: Number(r.votesAgainst),
178+
totalVotes: Number(r.totalVotes),
179+
forPct: Number(r.forPct),
180+
}));
181+
},
182+
),
183+
};

app/backend/src/routers/compass.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
mapVoteToContract,
1010
} from "../utils/mappers.js";
1111

12+
13+
1214
const os = implement(apiContract);
1315

1416
function mapVoteType(dutchVoteType: string | null): VoteType {
@@ -32,6 +34,7 @@ export const compassRouter = {
3234
const id = randomUUID();
3335
const now = new Date();
3436

37+
3538
await sql`
3639
INSERT INTO user_sessions (id, answers, results, "createdAt", "updatedAt")
3740
VALUES (
@@ -228,7 +231,7 @@ export const compassRouter = {
228231
contentLength: string | null;
229232
updatedAt: Date | null;
230233
apiUpdatedAt: Date | null;
231-
logoData: any | null;
234+
logoData: string | null;
232235
removed: boolean | null;
233236
};
234237

@@ -256,7 +259,7 @@ export const compassRouter = {
256259
contentLength: string | null;
257260
updatedAt: Date | null;
258261
apiUpdatedAt: Date | null;
259-
logoData: any | null;
262+
logoData: string | null;
260263
removed: boolean | null;
261264
}>`
262265
SELECT
@@ -383,7 +386,7 @@ async function calculatePartyAlignment(answers: UserAnswer[]) {
383386
contentLength: string | null;
384387
updatedAt: Date | null;
385388
apiUpdatedAt: Date | null;
386-
logoData: any | null;
389+
logoData: string | null;
387390
removed: boolean | null;
388391
}>`
389392
SELECT
@@ -648,7 +651,7 @@ async function getMotionVoteDetails(answers: UserAnswer[]) {
648651
contentLength: string | null;
649652
updatedAt: Date | null;
650653
apiUpdatedAt: Date | null;
651-
logoData: any | null;
654+
logoData: string | null;
652655
removed: boolean | null;
653656
}>`
654657
SELECT
@@ -687,7 +690,7 @@ async function getMotionVoteDetails(answers: UserAnswer[]) {
687690
contentLength: string | null;
688691
updatedAt: Date | null;
689692
apiUpdatedAt: Date | null;
690-
logoData: any | null;
693+
logoData: string | null;
691694
removed: boolean | null;
692695
};
693696

@@ -735,7 +738,7 @@ async function getMotionVoteDetails(answers: UserAnswer[]) {
735738
}
736739
});
737740

738-
partyVotes.forEach((partyData, _partyId) => {
741+
partyVotes.forEach((partyData) => {
739742
const voteCounts = partyData.votes.reduce(
740743
(acc, vote) => {
741744
acc[vote as VoteType] = (acc[vote as VoteType] || 0) + 1;

app/backend/src/routers/motions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export const motionRouter = {
201201
contentLength: string | null;
202202
updatedAt: Date | null;
203203
apiUpdatedAt: Date | null;
204-
logoData: any | null;
204+
logoData: string | null;
205205
removed: boolean | null;
206206
}>`
207207
SELECT

0 commit comments

Comments
 (0)