Skip to content

Commit 042fb78

Browse files
author
riddim-developer-bot
committed
[EPAC-2153] add MP lobbying exposure endpoint
1 parent f9d7d7e commit 042fb78

13 files changed

Lines changed: 2032 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
package application
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
"time"
8+
9+
"epac/lobbying/domain"
10+
)
11+
12+
const (
13+
MPLobbyingTimelinePerPage = 50
14+
BillReferenceConfidenceMin = 0.80
15+
)
16+
17+
var (
18+
ErrMPLobbyingRepositoryRequired = errors.New("mp lobbying repository is required")
19+
ErrSubjectDistributionRequired = errors.New("lobbying subject distribution query is required")
20+
ErrInvalidLobbyingWindow = errors.New("window must be one of 30d, 3m, 12m, or all")
21+
ErrInvalidMPLobbyingInput = errors.New("member id, parliament, and page are required")
22+
)
23+
24+
type MPLobbyingRepository interface {
25+
LoadMPLobbyingSummary(ctx context.Context, input LoadMPLobbyingSummaryInput) (domain.MPLobbyingSummary, bool, error)
26+
ListMPLobbyingTimeline(ctx context.Context, input ListMPLobbyingTimelineInput) (domain.LobbyingTimelinePage, error)
27+
}
28+
29+
type LobbyingSubjectDistributionQuery interface {
30+
ListMPLobbyingSubjectDistribution(ctx context.Context, input ListMPLobbyingSubjectDistributionInput) ([]domain.LobbyingSubjectDistribution, error)
31+
}
32+
33+
type MPLobbyingSummaryPrecomputer interface {
34+
RefreshMPLobbyingTimelineEntries(ctx context.Context, input RefreshMPLobbyingTimelineInput) error
35+
RefreshMPLobbyingSummaries(ctx context.Context, input RefreshMPLobbyingSummariesInput) error
36+
}
37+
38+
type LoadMPLobbyingExposureInput struct {
39+
MemberID string
40+
Parliament int
41+
Window domain.LobbyingExposureWindow
42+
Page int
43+
Now time.Time
44+
}
45+
46+
type LoadMPLobbyingSummaryInput struct {
47+
MemberID string
48+
Parliament int
49+
Window domain.LobbyingExposureWindow
50+
}
51+
52+
type ListMPLobbyingTimelineInput struct {
53+
MemberID string
54+
Parliament int
55+
FromDate *time.Time
56+
Page int
57+
PerPage int
58+
}
59+
60+
type ListMPLobbyingSubjectDistributionInput struct {
61+
MemberID string
62+
Parliament int
63+
Window domain.LobbyingExposureWindow
64+
}
65+
66+
type RefreshMPLobbyingSummariesInput struct {
67+
Parliament int
68+
QuarterStart time.Time
69+
QuarterEnd time.Time
70+
UpdatedAt time.Time
71+
}
72+
73+
type RefreshMPLobbyingTimelineInput struct {
74+
Parliament int
75+
FromDate time.Time
76+
ToDate time.Time
77+
UpdatedAt time.Time
78+
}
79+
80+
type MPLobbyingExposureResult struct {
81+
MemberID string `json:"member_id"`
82+
Parliament int `json:"parliament"`
83+
Window domain.LobbyingExposureWindow `json:"window"`
84+
Page int `json:"page"`
85+
PerPage int `json:"per_page"`
86+
Total int `json:"total"`
87+
Pages int `json:"pages"`
88+
Summary domain.MPLobbyingSummary `json:"summary"`
89+
SubjectBreakdown []domain.LobbyingSubjectDistribution `json:"subject_breakdown"`
90+
Timeline []domain.LobbyingTimelineEntry `json:"timeline"`
91+
Citation string `json:"citation"`
92+
SourceURL string `json:"source_url"`
93+
}
94+
95+
type LoadMPLobbyingExposure struct {
96+
repository MPLobbyingRepository
97+
subjects LobbyingSubjectDistributionQuery
98+
}
99+
100+
func NewLoadMPLobbyingExposure(
101+
repository MPLobbyingRepository,
102+
subjects LobbyingSubjectDistributionQuery,
103+
) (*LoadMPLobbyingExposure, error) {
104+
if repository == nil {
105+
return nil, ErrMPLobbyingRepositoryRequired
106+
}
107+
if subjects == nil {
108+
return nil, ErrSubjectDistributionRequired
109+
}
110+
return &LoadMPLobbyingExposure{repository: repository, subjects: subjects}, nil
111+
}
112+
113+
func (u *LoadMPLobbyingExposure) Execute(ctx context.Context, input LoadMPLobbyingExposureInput) (MPLobbyingExposureResult, error) {
114+
input.MemberID = strings.TrimSpace(input.MemberID)
115+
if input.Window == "" {
116+
input.Window = domain.LobbyingExposureWindow3M
117+
}
118+
if input.Page == 0 {
119+
input.Page = 1
120+
}
121+
if input.Now.IsZero() {
122+
input.Now = time.Now().UTC()
123+
}
124+
if input.MemberID == "" || input.Parliament <= 0 || input.Page < 1 {
125+
return MPLobbyingExposureResult{}, ErrInvalidMPLobbyingInput
126+
}
127+
if !IsValidLobbyingWindow(input.Window) {
128+
return MPLobbyingExposureResult{}, ErrInvalidLobbyingWindow
129+
}
130+
131+
summary, found, err := u.repository.LoadMPLobbyingSummary(ctx, LoadMPLobbyingSummaryInput{
132+
MemberID: input.MemberID,
133+
Parliament: input.Parliament,
134+
Window: input.Window,
135+
})
136+
if err != nil {
137+
return MPLobbyingExposureResult{}, err
138+
}
139+
if !found {
140+
summary = emptyMPLobbyingSummary(input)
141+
}
142+
normalizeSummary(&summary)
143+
144+
subjectBreakdown, err := u.subjects.ListMPLobbyingSubjectDistribution(ctx, ListMPLobbyingSubjectDistributionInput{
145+
MemberID: input.MemberID,
146+
Parliament: input.Parliament,
147+
Window: input.Window,
148+
})
149+
if err != nil {
150+
return MPLobbyingExposureResult{}, err
151+
}
152+
if subjectBreakdown == nil {
153+
subjectBreakdown = []domain.LobbyingSubjectDistribution{}
154+
}
155+
156+
timelinePage, err := u.repository.ListMPLobbyingTimeline(ctx, ListMPLobbyingTimelineInput{
157+
MemberID: input.MemberID,
158+
Parliament: input.Parliament,
159+
FromDate: WindowStart(input.Window, input.Now),
160+
Page: input.Page,
161+
PerPage: MPLobbyingTimelinePerPage,
162+
})
163+
if err != nil {
164+
return MPLobbyingExposureResult{}, err
165+
}
166+
timeline := timelinePage.Rows
167+
if timeline == nil {
168+
timeline = []domain.LobbyingTimelineEntry{}
169+
}
170+
for i := range timeline {
171+
normalizeTimelineEntry(&timeline[i])
172+
}
173+
174+
return MPLobbyingExposureResult{
175+
MemberID: input.MemberID,
176+
Parliament: input.Parliament,
177+
Window: input.Window,
178+
Page: input.Page,
179+
PerPage: MPLobbyingTimelinePerPage,
180+
Total: timelinePage.Total,
181+
Pages: pageCount(timelinePage.Total, MPLobbyingTimelinePerPage),
182+
Summary: summary,
183+
SubjectBreakdown: subjectBreakdown,
184+
Timeline: timeline,
185+
Citation: domain.OCLCitation,
186+
SourceURL: domain.OCLSourceURL,
187+
}, nil
188+
}
189+
190+
type RefreshMPLobbyingExposure struct {
191+
precomputer MPLobbyingSummaryPrecomputer
192+
}
193+
194+
func NewRefreshMPLobbyingExposure(precomputer MPLobbyingSummaryPrecomputer) (*RefreshMPLobbyingExposure, error) {
195+
if precomputer == nil {
196+
return nil, ErrMPLobbyingRepositoryRequired
197+
}
198+
return &RefreshMPLobbyingExposure{precomputer: precomputer}, nil
199+
}
200+
201+
func (u *RefreshMPLobbyingExposure) Execute(ctx context.Context, input RefreshMPLobbyingSummariesInput) error {
202+
if input.Parliament <= 0 || input.QuarterStart.IsZero() || input.QuarterEnd.IsZero() {
203+
return ErrInvalidMPLobbyingInput
204+
}
205+
if input.UpdatedAt.IsZero() {
206+
input.UpdatedAt = time.Now().UTC()
207+
}
208+
if err := u.precomputer.RefreshMPLobbyingTimelineEntries(ctx, RefreshMPLobbyingTimelineInput{
209+
Parliament: input.Parliament,
210+
FromDate: input.QuarterStart,
211+
ToDate: input.QuarterEnd,
212+
UpdatedAt: input.UpdatedAt,
213+
}); err != nil {
214+
return err
215+
}
216+
return u.precomputer.RefreshMPLobbyingSummaries(ctx, input)
217+
}
218+
219+
func IsValidLobbyingWindow(window domain.LobbyingExposureWindow) bool {
220+
switch window {
221+
case domain.LobbyingExposureWindow30D,
222+
domain.LobbyingExposureWindow3M,
223+
domain.LobbyingExposureWindow12M,
224+
domain.LobbyingExposureWindowAll:
225+
return true
226+
default:
227+
return false
228+
}
229+
}
230+
231+
func ParseLobbyingWindow(raw string) (domain.LobbyingExposureWindow, error) {
232+
window := domain.LobbyingExposureWindow(strings.ToLower(strings.TrimSpace(raw)))
233+
if window == "" {
234+
window = domain.LobbyingExposureWindow3M
235+
}
236+
if !IsValidLobbyingWindow(window) {
237+
return "", ErrInvalidLobbyingWindow
238+
}
239+
return window, nil
240+
}
241+
242+
func WindowStart(window domain.LobbyingExposureWindow, now time.Time) *time.Time {
243+
if now.IsZero() {
244+
now = time.Now().UTC()
245+
}
246+
now = dateOnly(now)
247+
var start time.Time
248+
switch window {
249+
case domain.LobbyingExposureWindow30D:
250+
start = now.AddDate(0, 0, -30)
251+
case domain.LobbyingExposureWindow3M:
252+
start = now.AddDate(0, -3, 0)
253+
case domain.LobbyingExposureWindow12M:
254+
start = now.AddDate(-1, 0, 0)
255+
case domain.LobbyingExposureWindowAll:
256+
return nil
257+
default:
258+
return nil
259+
}
260+
return &start
261+
}
262+
263+
func QuarterStart(now time.Time) time.Time {
264+
now = dateOnly(now)
265+
month := int(now.Month())
266+
quarterMonth := time.Month(((month-1)/3)*3 + 1)
267+
return time.Date(now.Year(), quarterMonth, 1, 0, 0, 0, 0, time.UTC)
268+
}
269+
270+
func emptyMPLobbyingSummary(input LoadMPLobbyingExposureInput) domain.MPLobbyingSummary {
271+
return domain.MPLobbyingSummary{
272+
MemberID: input.MemberID,
273+
Parliament: input.Parliament,
274+
QuarterStart: QuarterStart(input.Now),
275+
Window: input.Window,
276+
TopOrganizations: []domain.TopLobbyingOrganization{},
277+
Citation: domain.OCLCitation,
278+
UpdatedAt: input.Now.UTC(),
279+
}
280+
}
281+
282+
func normalizeSummary(summary *domain.MPLobbyingSummary) {
283+
if summary.TopOrganizations == nil {
284+
summary.TopOrganizations = []domain.TopLobbyingOrganization{}
285+
}
286+
if summary.Citation == "" {
287+
summary.Citation = domain.OCLCitation
288+
}
289+
}
290+
291+
func normalizeTimelineEntry(entry *domain.LobbyingTimelineEntry) {
292+
if entry.Citation == "" {
293+
entry.Citation = domain.OCLCitation
294+
}
295+
if entry.SourceURL == "" {
296+
entry.SourceURL = domain.OCLSourceURL
297+
}
298+
if entry.Bill != nil && (entry.Bill.Confidence < BillReferenceConfidenceMin || strings.TrimSpace(entry.Bill.URL) == "") {
299+
entry.Bill = nil
300+
}
301+
}
302+
303+
func pageCount(total, perPage int) int {
304+
if total <= 0 || perPage <= 0 {
305+
return 0
306+
}
307+
return (total + perPage - 1) / perPage
308+
}
309+
310+
func dateOnly(t time.Time) time.Time {
311+
y, m, d := t.UTC().Date()
312+
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
313+
}

0 commit comments

Comments
 (0)