Skip to content

Commit f662da2

Browse files
apply changes before export and improve export tables
1 parent b25aaf9 commit f662da2

File tree

3 files changed

+549
-142
lines changed

3 files changed

+549
-142
lines changed
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import { query } from "@/lib/db/db";
2+
import type { PPBRecord, CitationInfo, ManualMetadata } from "@/types";
3+
import { fetchPPBRecords } from "@/features/mandates/services/data-service";
4+
import {
5+
fetchDocumentMetadata,
6+
cleanTitle as cleanMetadataTitle,
7+
} from "@/features/mandates/services/documents/metadata";
8+
9+
export interface AppliedMandateRow {
10+
symbol: string;
11+
title: string;
12+
body: string;
13+
year: number | null;
14+
link: string | null;
15+
entity: string;
16+
entityLong: string | null;
17+
subprogramme: string | null;
18+
part: string | null;
19+
}
20+
21+
export interface LatestDecisionRow {
22+
id: string;
23+
documentSymbol: string;
24+
entity: string;
25+
subprogramme: string | null;
26+
decision: string;
27+
newSymbol: string | null;
28+
manualMetadata: ManualMetadata | null;
29+
decisionReason: string | null;
30+
otherReason: string | null;
31+
userEmail: string;
32+
userEntity: string | null;
33+
createdAt: string;
34+
approvedBy: string | null;
35+
approvedByEntity: string | null;
36+
approvedAt: string | null;
37+
}
38+
39+
interface DecisionRow {
40+
id: string;
41+
document_symbol: string;
42+
entity: string;
43+
subprogramme: string | null;
44+
decision: string;
45+
new_symbol: string | null;
46+
manual_metadata: ManualMetadata | null;
47+
decision_reason: string | null;
48+
other_reason: string | null;
49+
user_email: string;
50+
user_entity: string | null;
51+
created_at: string;
52+
approved_by: string | null;
53+
approved_by_entity: string | null;
54+
approved_at: string | null;
55+
}
56+
57+
interface ResolvedDecisionMetadata {
58+
title: string;
59+
body: string;
60+
year: number | null;
61+
link: string | null;
62+
}
63+
64+
function getSubprogramme(ci: CitationInfo): string | null {
65+
return ci["sub-programme"] || ci.component || null;
66+
}
67+
68+
function isLegislative(part: string | null): boolean {
69+
return part === "Legislative mandates";
70+
}
71+
72+
function decisionKey(
73+
entity: string,
74+
symbol: string,
75+
subprogramme: string | null,
76+
): string {
77+
return `${entity}:${symbol}:${subprogramme || ""}`;
78+
}
79+
80+
function applyManualOverrides(
81+
base: ResolvedDecisionMetadata,
82+
manual: ManualMetadata | null | undefined,
83+
): ResolvedDecisionMetadata {
84+
if (!manual) return base;
85+
return {
86+
title: manual.title ?? base.title,
87+
body: manual.body ?? base.body,
88+
year: manual.year ?? base.year,
89+
link: manual.link ?? base.link,
90+
};
91+
}
92+
93+
function cleanTitle(title: string | null | undefined): string {
94+
if (!title) return "";
95+
return cleanMetadataTitle(title) || "";
96+
}
97+
98+
function getSubprogrammeSortKey(subprogramme: string | null): string {
99+
return subprogramme?.trim() || "All Subprogrammes";
100+
}
101+
102+
function compareSymbolsNatural(a: string, b: string): number {
103+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
104+
}
105+
106+
function compareAppliedRows(a: AppliedMandateRow, b: AppliedMandateRow): number {
107+
const aSub = getSubprogrammeSortKey(a.subprogramme).toLowerCase();
108+
const bSub = getSubprogrammeSortKey(b.subprogramme).toLowerCase();
109+
const aIsAll = aSub.includes("all subprogramme");
110+
const bIsAll = bSub.includes("all subprogramme");
111+
if (aIsAll && !bIsAll) return -1;
112+
if (!aIsAll && bIsAll) return 1;
113+
if (aSub !== bSub) {
114+
return aSub.localeCompare(bSub, undefined, { sensitivity: "base" });
115+
}
116+
return compareSymbolsNatural(a.symbol, b.symbol);
117+
}
118+
119+
async function fetchLatestDecisions(
120+
entity?: string,
121+
): Promise<LatestDecisionRow[]> {
122+
const rows = await query<DecisionRow>(
123+
`
124+
SELECT DISTINCT ON (d.entity, d.document_symbol, d.subprogramme)
125+
d.id,
126+
d.document_symbol,
127+
d.entity,
128+
d.subprogramme,
129+
d.decision,
130+
d.new_symbol,
131+
d.manual_metadata,
132+
d.decision_reason,
133+
d.other_reason,
134+
d.user_email,
135+
u.entity as user_entity,
136+
d.created_at,
137+
d.approved_by,
138+
approver.entity as approved_by_entity,
139+
d.approved_at
140+
FROM mandates_housekeeping.mandate_decisions d
141+
LEFT JOIN mandates_housekeeping.users u ON d.user_email = u.email
142+
LEFT JOIN mandates_housekeeping.users approver ON d.approved_by = approver.email
143+
WHERE ($1::text IS NULL OR d.entity = $1)
144+
ORDER BY d.entity, d.document_symbol, d.subprogramme, d.created_at DESC, d.id DESC
145+
`,
146+
[entity || null],
147+
);
148+
149+
return rows.map((row) => ({
150+
id: row.id,
151+
documentSymbol: row.document_symbol,
152+
entity: row.entity,
153+
subprogramme: row.subprogramme,
154+
decision: row.decision,
155+
newSymbol: row.new_symbol,
156+
manualMetadata: row.manual_metadata,
157+
decisionReason: row.decision_reason,
158+
otherReason: row.other_reason,
159+
userEmail: row.user_email,
160+
userEntity: row.user_entity,
161+
createdAt: row.created_at,
162+
approvedBy: row.approved_by,
163+
approvedByEntity: row.approved_by_entity,
164+
approvedAt: row.approved_at,
165+
}));
166+
}
167+
168+
function resolveDecisionMetadata(
169+
symbol: string,
170+
manualMetadata: ManualMetadata | null,
171+
metadataLookup: Record<string, ResolvedDecisionMetadata | null>,
172+
): ResolvedDecisionMetadata {
173+
const base = metadataLookup[symbol] || {
174+
title: "",
175+
body: "",
176+
year: null,
177+
link: null,
178+
};
179+
return applyManualOverrides(base, manualMetadata);
180+
}
181+
182+
function buildEntityLongMap(records: PPBRecord[]): Map<string, string | null> {
183+
const map = new Map<string, string | null>();
184+
for (const rec of records) {
185+
for (const ci of rec.citation_info) {
186+
if (ci.entity && !map.has(ci.entity)) {
187+
map.set(ci.entity, ci.entity_long || null);
188+
}
189+
}
190+
}
191+
return map;
192+
}
193+
194+
export async function getAppliedExportData(entity?: string): Promise<{
195+
rows: AppliedMandateRow[];
196+
decisions: LatestDecisionRow[];
197+
}> {
198+
const records = await fetchPPBRecords();
199+
const decisions = await fetchLatestDecisions(entity);
200+
const decisionsMap = new Map<string, LatestDecisionRow>();
201+
const symbolsToResolve = new Set<string>();
202+
203+
for (const decision of decisions) {
204+
decisionsMap.set(
205+
decisionKey(decision.entity, decision.documentSymbol, decision.subprogramme),
206+
decision,
207+
);
208+
if (decision.decision === "update" && decision.newSymbol) {
209+
symbolsToResolve.add(decision.newSymbol);
210+
}
211+
if (decision.decision === "add") {
212+
symbolsToResolve.add(decision.documentSymbol);
213+
}
214+
}
215+
216+
const metadata = await fetchDocumentMetadata([...symbolsToResolve]);
217+
const metadataLookup: Record<string, ResolvedDecisionMetadata | null> = {};
218+
for (const [symbol, meta] of Object.entries(metadata)) {
219+
metadataLookup[symbol] = meta
220+
? {
221+
title: cleanTitle(meta.title),
222+
body: meta.body || "",
223+
year: meta.year ?? null,
224+
link: meta.link || null,
225+
}
226+
: null;
227+
}
228+
229+
const entityLongMap = buildEntityLongMap(records);
230+
const rows: AppliedMandateRow[] = [];
231+
const seen = new Set<string>();
232+
233+
for (const rec of records) {
234+
for (const ci of rec.citation_info) {
235+
if (!ci.entity) continue;
236+
if (entity && ci.entity !== entity) continue;
237+
if (!isLegislative(ci.part_in_document)) continue;
238+
239+
const subprogramme = getSubprogramme(ci);
240+
const key = decisionKey(ci.entity, rec.full_document_symbol, subprogramme);
241+
const decision = decisionsMap.get(key);
242+
243+
if (decision?.decision === "remove") continue;
244+
245+
if (decision?.decision === "update" && decision.newSymbol) {
246+
const resolved = resolveDecisionMetadata(
247+
decision.newSymbol,
248+
decision.manualMetadata || null,
249+
metadataLookup,
250+
);
251+
const appliedKey = decisionKey(
252+
decision.entity,
253+
decision.newSymbol,
254+
decision.subprogramme,
255+
);
256+
if (!seen.has(appliedKey)) {
257+
seen.add(appliedKey);
258+
rows.push({
259+
symbol: decision.newSymbol,
260+
title: cleanTitle(resolved.title),
261+
body: resolved.body || "",
262+
year: resolved.year ?? null,
263+
link: resolved.link || null,
264+
entity: decision.entity,
265+
entityLong: ci.entity_long || null,
266+
subprogramme,
267+
part: "Legislative mandates",
268+
});
269+
}
270+
continue;
271+
}
272+
273+
const appliedKey = decisionKey(ci.entity, rec.full_document_symbol, subprogramme);
274+
if (seen.has(appliedKey)) continue;
275+
seen.add(appliedKey);
276+
277+
rows.push({
278+
symbol: rec.full_document_symbol,
279+
title: cleanTitle(rec.description || rec.uniform_title || ""),
280+
body: rec.body || "",
281+
year: rec.year,
282+
link: rec.link,
283+
entity: ci.entity,
284+
entityLong: ci.entity_long || null,
285+
subprogramme,
286+
part: ci.part_in_document,
287+
});
288+
}
289+
}
290+
291+
for (const decision of decisions) {
292+
if (decision.decision !== "add") continue;
293+
if (entity && decision.entity !== entity) continue;
294+
295+
const appliedKey = decisionKey(
296+
decision.entity,
297+
decision.documentSymbol,
298+
decision.subprogramme,
299+
);
300+
if (seen.has(appliedKey)) continue;
301+
302+
const resolved = resolveDecisionMetadata(
303+
decision.documentSymbol,
304+
decision.manualMetadata || null,
305+
metadataLookup,
306+
);
307+
rows.push({
308+
symbol: decision.documentSymbol,
309+
title: cleanTitle(resolved.title),
310+
body: resolved.body || "",
311+
year: resolved.year ?? null,
312+
link: resolved.link || null,
313+
entity: decision.entity,
314+
entityLong: entityLongMap.get(decision.entity) || null,
315+
subprogramme: decision.subprogramme,
316+
part: "Legislative mandates",
317+
});
318+
}
319+
320+
return {
321+
rows: rows.sort(compareAppliedRows),
322+
decisions,
323+
};
324+
}

0 commit comments

Comments
 (0)