diff --git a/backend/lobbying/application/aggregate.go b/backend/lobbying/application/aggregate.go index 8113052b..5813e221 100644 --- a/backend/lobbying/application/aggregate.go +++ b/backend/lobbying/application/aggregate.go @@ -56,9 +56,11 @@ type AggregateLobbyistOrganizationsInput struct { } type BrowseLobbyistOrganizationsInput struct { - Search string - Limit int - Offset int + Search string + Sector string + Limit int + Offset int + SortDirection string } type AggregateLobbyistOrganizations struct { @@ -255,15 +257,22 @@ func (b *organizationBuilder) applyCommunication( } } for _, dpoh := range communication.DPOHs { + dpoh.MemberID = cleanNullableString(dpoh.MemberID) dpoh.Name = cleanNullableString(dpoh.Name) dpoh.Institution = cleanNullableString(dpoh.Institution) if dpoh.Name == "" { continue } key := NormalizeOrganizationName(dpoh.Name) + "|" + NormalizeOrganizationName(dpoh.Institution) + if dpoh.MemberID != "" { + key = "member:" + dpoh.MemberID + } existing := b.dpohCounts[key] if existing.Count == 0 { - existing = domain.DPOHContact{Name: dpoh.Name, Institution: dpoh.Institution} + existing = domain.DPOHContact{MemberID: dpoh.MemberID, Name: dpoh.Name, Institution: dpoh.Institution} + } + if existing.MemberID == "" { + existing.MemberID = dpoh.MemberID } increment := dpoh.Count if increment <= 0 { diff --git a/backend/lobbying/domain/organization.go b/backend/lobbying/domain/organization.go index ae8938f0..906f8f2e 100644 --- a/backend/lobbying/domain/organization.go +++ b/backend/lobbying/domain/organization.go @@ -29,6 +29,7 @@ type CommunicationCount struct { } type DPOHContact struct { + MemberID string `json:"member_id,omitempty"` Name string `json:"name"` Institution string `json:"institution"` Count int `json:"count"` diff --git a/backend/lobbying/main.go b/backend/lobbying/main.go index d1025aa4..316b6855 100644 --- a/backend/lobbying/main.go +++ b/backend/lobbying/main.go @@ -44,6 +44,9 @@ var newMinisterPortfolioService = newProductionMinisterPortfolioService var newCabinetOverviewService = newProductionCabinetOverviewService func HandleRequest(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + if isOrganizationRequest(req) { + return handleOrganizationRequest(ctx, req) + } if memberID := ministerMemberIDFromRequest(req); memberID != "" { return handleMinisterPortfolio(ctx, req, memberID) } diff --git a/backend/lobbying/organizations_endpoint.go b/backend/lobbying/organizations_endpoint.go new file mode 100644 index 00000000..38b5a95d --- /dev/null +++ b/backend/lobbying/organizations_endpoint.go @@ -0,0 +1,254 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "epac/lobbying/application" + "epac/lobbying/domain" + postgresadapter "epac/lobbying/internal/adapter/postgres" + "epac/lobbying/internal/usecase" + lobbyingrepo "epac/lobbying/repository" + + "github.com/aws/aws-lambda-go/events" +) + +type organizationBrowser interface { + Execute(context.Context, application.BrowseLobbyistOrganizationsInput) ([]domain.LobbyistOrganization, error) +} + +type organizationProfileLoader interface { + Execute(context.Context, string) (domain.LobbyistOrganization, error) +} + +type organizationServices struct { + browser organizationBrowser + profile organizationProfileLoader +} + +var newOrganizationServices = newProductionOrganizationServices + +type organizationDirectoryResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Citation string `json:"citation"` + SourceURL string `json:"source_url"` + Rows []organizationDirectoryRow `json:"rows"` +} + +type organizationDirectoryRow struct { + ID string `json:"id"` + Name string `json:"name"` + Type domain.OrganizationType `json:"type"` + Sector string `json:"sector,omitempty"` + CommunicationVolumeCurrent int `json:"communication_volume_current_parliament"` +} + +type organizationProfileResponse struct { + ID string `json:"id"` + OCLOrganizationID string `json:"ocl_organization_id,omitempty"` + Name string `json:"name"` + Type domain.OrganizationType `json:"type"` + Sector string `json:"sector,omitempty"` + RegisteredLobbyists []domain.RegisteredLobbyist `json:"registered_lobbyists"` + ActiveSubjectMatters []string `json:"active_subject_matters"` + CommunicationVolume domain.CommunicationCount `json:"communication_volume"` + TopDPOHsContacted []domain.DPOHContact `json:"top_dpohs_contacted"` + Citation string `json:"citation"` + SourceURL string `json:"source_url"` +} + +func handleOrganizationRequest(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + if profileID, ok := organizationProfileIDFromRequest(req); ok { + return handleOrganizationProfile(ctx, req, profileID) + } + if isOrganizationDirectoryRequest(req) { + return handleOrganizationDirectory(ctx, req) + } + return jsonError(http.StatusNotFound, "not found"), nil +} + +func handleOrganizationDirectory(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) { + pagination, err := parsePagination(req.QueryStringParameters) + if err != nil { + return jsonError(http.StatusBadRequest, err.Error()), nil + } + sortDirection, err := parseOrganizationSort(req.QueryStringParameters) + if err != nil { + return jsonError(http.StatusBadRequest, err.Error()), nil + } + + services, closeServices, err := newOrganizationServices(ctx) + if err != nil { + slog.Error("organization service initialization failed", "error", err) + return jsonError(http.StatusServiceUnavailable, "lobbying data unavailable"), nil + } + defer closeServices(ctx) + + organizations, err := services.browser.Execute(ctx, application.BrowseLobbyistOrganizationsInput{ + Search: strings.TrimSpace(req.QueryStringParameters["search"]), + Sector: strings.TrimSpace(req.QueryStringParameters["sector"]), + Limit: pagination.PerPage, + Offset: (pagination.Page - 1) * pagination.PerPage, + SortDirection: sortDirection, + }) + if err != nil { + slog.Error("organization directory request failed", "error", err) + return jsonError(http.StatusInternalServerError, "internal error"), nil + } + + rows := make([]organizationDirectoryRow, 0, len(organizations)) + for _, organization := range organizations { + rows = append(rows, organizationDirectoryRow{ + ID: organization.ID, + Name: organization.Name, + Type: organization.Type, + Sector: organization.Sector, + CommunicationVolumeCurrent: organization.CommunicationVolume.CurrentParliament, + }) + } + body, err := json.Marshal(organizationDirectoryResponse{ + Page: pagination.Page, + PerPage: pagination.PerPage, + Citation: usecase.Citation, + SourceURL: usecase.SourceURL, + Rows: rows, + }) + if err != nil { + return jsonError(http.StatusInternalServerError, "marshal error"), nil + } + return jsonResponse(http.StatusOK, body), nil +} + +func handleOrganizationProfile(ctx context.Context, _ events.APIGatewayV2HTTPRequest, organizationID string) (events.APIGatewayV2HTTPResponse, error) { + organizationID = strings.TrimSpace(organizationID) + if organizationID == "" { + return jsonError(http.StatusBadRequest, "missing organization id"), nil + } + + services, closeServices, err := newOrganizationServices(ctx) + if err != nil { + slog.Error("organization service initialization failed", "error", err) + return jsonError(http.StatusServiceUnavailable, "lobbying data unavailable"), nil + } + defer closeServices(ctx) + + organization, err := services.profile.Execute(ctx, organizationID) + if err != nil { + slog.Error("organization profile request failed", "error", err, "organization_id", organizationID) + return jsonError(http.StatusInternalServerError, "internal error"), nil + } + + body, err := json.Marshal(profileResponseFor(organization)) + if err != nil { + return jsonError(http.StatusInternalServerError, "marshal error"), nil + } + return jsonResponse(http.StatusOK, body), nil +} + +func newProductionOrganizationServices(ctx context.Context) (organizationServices, closeFunc, error) { + conn, err := postgresadapter.Connect(ctx) + if err != nil { + return organizationServices{}, noopClose, err + } + repo := lobbyingrepo.NewPostgresLobbyistOrganizationRepository(conn) + browser, err := application.NewBrowseLobbyistOrganizations(repo) + if err != nil { + _ = conn.Close(ctx) + return organizationServices{}, noopClose, err + } + profile, err := application.NewLoadLobbyistOrganizationProfile(repo) + if err != nil { + _ = conn.Close(ctx) + return organizationServices{}, noopClose, err + } + return organizationServices{browser: browser, profile: profile}, func(closeCtx context.Context) { + _ = conn.Close(closeCtx) + }, nil +} + +func parseOrganizationSort(params map[string]string) (string, error) { + sortBy := strings.TrimSpace(params["sort"]) + if sortBy != "" && sortBy != "communication_volume" && sortBy != "communication_volume_current_parliament" { + return "", errors.New("sort must be communication_volume") + } + direction := strings.ToLower(strings.TrimSpace(params["direction"])) + switch direction { + case "", "desc": + return "desc", nil + case "asc": + return "asc", nil + default: + return "", errors.New("direction must be asc or desc") + } +} + +func profileResponseFor(organization domain.LobbyistOrganization) organizationProfileResponse { + return organizationProfileResponse{ + ID: organization.ID, + OCLOrganizationID: organization.OCLOrganizationID, + Name: organization.Name, + Type: organization.Type, + Sector: organization.Sector, + RegisteredLobbyists: nonNilLobbyists(organization.RegisteredLobbyists), + ActiveSubjectMatters: nonNilStrings(organization.ActiveSubjectMatters), + CommunicationVolume: organization.CommunicationVolume, + TopDPOHsContacted: nonNilDPOHs(organization.TopDPOHsContacted), + Citation: usecase.Citation, + SourceURL: usecase.SourceURL, + } +} + +func isOrganizationRequest(req events.APIGatewayV2HTTPRequest) bool { + if isOrganizationDirectoryRequest(req) { + return true + } + _, ok := organizationProfileIDFromRequest(req) + return ok +} + +func isOrganizationDirectoryRequest(req events.APIGatewayV2HTTPRequest) bool { + path := requestPath(req) + return path == "/api/v1/lobbying/organizations" || path == "/lobbying/organizations" +} + +func organizationProfileIDFromRequest(req events.APIGatewayV2HTTPRequest) (string, bool) { + path := requestPath(req) + for _, prefix := range []string{"/api/v1/lobbying/organizations/", "/lobbying/organizations/"} { + if strings.HasPrefix(path, prefix) { + id := strings.TrimSpace(req.PathParameters["id"]) + if id == "" { + id = strings.TrimPrefix(path, prefix) + } + if id != "" && !strings.Contains(id, "/") { + return unescapePathPart(id), true + } + } + } + return "", false +} + +func nonNilLobbyists(values []domain.RegisteredLobbyist) []domain.RegisteredLobbyist { + if values == nil { + return []domain.RegisteredLobbyist{} + } + return values +} + +func nonNilStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func nonNilDPOHs(values []domain.DPOHContact) []domain.DPOHContact { + if values == nil { + return []domain.DPOHContact{} + } + return values +} diff --git a/backend/lobbying/organizations_endpoint_test.go b/backend/lobbying/organizations_endpoint_test.go new file mode 100644 index 00000000..8978cdea --- /dev/null +++ b/backend/lobbying/organizations_endpoint_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "epac/lobbying/application" + "epac/lobbying/domain" + "epac/lobbying/internal/usecase" + + "github.com/aws/aws-lambda-go/events" +) + +type stubOrganizationBrowser struct { + gotInput application.BrowseLobbyistOrganizationsInput + organizations []domain.LobbyistOrganization + err error +} + +func (s *stubOrganizationBrowser) Execute(_ context.Context, input application.BrowseLobbyistOrganizationsInput) ([]domain.LobbyistOrganization, error) { + s.gotInput = input + return s.organizations, s.err +} + +type stubOrganizationProfile struct { + gotID string + organization domain.LobbyistOrganization + err error +} + +func (s *stubOrganizationProfile) Execute(_ context.Context, organizationID string) (domain.LobbyistOrganization, error) { + s.gotID = organizationID + return s.organization, s.err +} + +func TestHandleRequestReturnsOrganizationDirectoryFilteredSearchedAndSorted(t *testing.T) { + browser := &stubOrganizationBrowser{ + organizations: []domain.LobbyistOrganization{ + { + ID: "ocl:200", + Name: "Clean Energy Canada", + Type: domain.OrganizationTypeNonProfit, + Sector: "Energy", + CommunicationVolume: domain.CommunicationCount{ + CurrentParliament: 17, + }, + }, + }, + } + setOrganizationServicesForTest(t, browser, &stubOrganizationProfile{}, nil) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + RawPath: "/api/v1/lobbying/organizations", + QueryStringParameters: map[string]string{ + "search": "energy", + "sector": "Energy", + "sort": "communication_volume", + "direction": "desc", + "page": "2", + "per_page": "10", + }, + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, resp.Body) + } + wantInput := application.BrowseLobbyistOrganizationsInput{ + Search: "energy", + Sector: "Energy", + Limit: 10, + Offset: 10, + SortDirection: "desc", + } + if browser.gotInput != wantInput { + t.Fatalf("browse input = %#v, want %#v", browser.gotInput, wantInput) + } + + var body organizationDirectoryResponse + if err := json.Unmarshal([]byte(resp.Body), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Citation != usecase.Citation || body.SourceURL != usecase.SourceURL { + t.Fatalf("directory response missing source fields: %#v", body) + } + if len(body.Rows) != 1 || body.Rows[0].Name != "Clean Energy Canada" || body.Rows[0].CommunicationVolumeCurrent != 17 { + t.Fatalf("directory rows = %#v", body.Rows) + } +} + +func TestHandleRequestReturnsEmptyOrganizationDirectory(t *testing.T) { + setOrganizationServicesForTest(t, &stubOrganizationBrowser{}, &stubOrganizationProfile{}, nil) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + RawPath: "/lobbying/organizations", + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, resp.Body) + } + + var body organizationDirectoryResponse + if err := json.Unmarshal([]byte(resp.Body), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Page != 1 || body.PerPage != usecase.DefaultPerPage || len(body.Rows) != 0 || body.Citation != usecase.Citation { + t.Fatalf("empty directory body = %#v", body) + } +} + +func TestHandleRequestReturnsPopulatedOrganizationProfile(t *testing.T) { + profile := &stubOrganizationProfile{ + organization: domain.LobbyistOrganization{ + ID: "ocl:42", + OCLOrganizationID: "42", + Name: "Canadian Housing Alliance", + Type: domain.OrganizationTypeAssociation, + Sector: "Housing", + RegisteredLobbyists: []domain.RegisteredLobbyist{ + {Name: "Jane Lobbyist", Kind: domain.LobbyistKindConsultant}, + }, + ActiveSubjectMatters: []string{"Housing", "Infrastructure"}, + CommunicationVolume: domain.CommunicationCount{ + CurrentParliament: 8, + PriorParliament: 5, + }, + TopDPOHsContacted: []domain.DPOHContact{ + {MemberID: "278707", Name: "Example Minister", Institution: "House of Commons", Count: 4}, + }, + }, + } + setOrganizationServicesForTest(t, &stubOrganizationBrowser{}, profile, nil) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + RawPath: "/api/v1/lobbying/organizations/ocl%3A42", + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, resp.Body) + } + if profile.gotID != "ocl:42" { + t.Fatalf("profile id = %q, want ocl:42", profile.gotID) + } + + var body organizationProfileResponse + if err := json.Unmarshal([]byte(resp.Body), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Citation != usecase.Citation || body.CommunicationVolume.CurrentParliament != 8 { + t.Fatalf("profile body = %#v", body) + } + if len(body.TopDPOHsContacted) != 1 || body.TopDPOHsContacted[0].MemberID != "278707" { + t.Fatalf("top dpohs = %#v", body.TopDPOHsContacted) + } +} + +func TestHandleRequestReturnsProfileWithNoCommunications(t *testing.T) { + profile := &stubOrganizationProfile{ + organization: domain.LobbyistOrganization{ + ID: "ocl:empty", + Name: "Quiet Organization", + Type: domain.OrganizationTypeCorporation, + RegisteredLobbyists: nil, + ActiveSubjectMatters: nil, + TopDPOHsContacted: nil, + }, + } + setOrganizationServicesForTest(t, &stubOrganizationBrowser{}, profile, nil) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + PathParameters: map[string]string{"id": "ocl:empty"}, + RawPath: "/api/v1/lobbying/organizations/ocl:empty", + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, resp.Body) + } + + var body organizationProfileResponse + if err := json.Unmarshal([]byte(resp.Body), &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.CommunicationVolume.CurrentParliament != 0 || body.CommunicationVolume.PriorParliament != 0 { + t.Fatalf("communication volume = %#v", body.CommunicationVolume) + } + if len(body.TopDPOHsContacted) != 0 || len(body.RegisteredLobbyists) != 0 || len(body.ActiveSubjectMatters) != 0 { + t.Fatalf("expected empty arrays in no-communications profile: %#v", body) + } +} + +func TestHandleRequestRejectsInvalidOrganizationSort(t *testing.T) { + setOrganizationServicesForTest(t, &stubOrganizationBrowser{}, &stubOrganizationProfile{}, nil) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + RawPath: "/api/v1/lobbying/organizations", + QueryStringParameters: map[string]string{"sort": "name"}, + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", resp.StatusCode) + } +} + +func TestOrganizationRequestDoesNotClaimGenericIDOnOtherRoutes(t *testing.T) { + if isOrganizationRequest(events.APIGatewayV2HTTPRequest{ + PathParameters: map[string]string{"id": "314774"}, + RawPath: "/api/v1/ministers/314774/lobbying-by-portfolio", + }) { + t.Fatal("minister route with generic id path parameter was claimed as organization request") + } +} + +func TestHandleRequestMapsOrganizationServiceInitError(t *testing.T) { + setOrganizationServicesForTest(t, nil, nil, errors.New("not configured")) + + resp, err := HandleRequest(context.Background(), events.APIGatewayV2HTTPRequest{ + RawPath: "/api/v1/lobbying/organizations", + }) + if err != nil { + t.Fatalf("HandleRequest: %v", err) + } + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", resp.StatusCode) + } +} + +func setOrganizationServicesForTest(t *testing.T, browser organizationBrowser, profile organizationProfileLoader, err error) { + t.Helper() + original := newOrganizationServices + newOrganizationServices = func(context.Context) (organizationServices, closeFunc, error) { + return organizationServices{browser: browser, profile: profile}, noopClose, err + } + t.Cleanup(func() { newOrganizationServices = original }) +} diff --git a/backend/lobbying/repository/postgres.go b/backend/lobbying/repository/postgres.go index 9a16fc81..3f8237b3 100644 --- a/backend/lobbying/repository/postgres.go +++ b/backend/lobbying/repository/postgres.go @@ -132,16 +132,23 @@ func (r *PostgresLobbyistOrganizationRepository) BrowseLobbyistOrganizations(ctx limit = 200 } search := strings.TrimSpace(input.Search) - rows, err := r.db.Query(ctx, ` + sector := strings.TrimSpace(input.Sector) + direction := "DESC" + if strings.EqualFold(strings.TrimSpace(input.SortDirection), "asc") { + direction = "ASC" + } + query := ` SELECT organization_id, COALESCE(ocl_organization_id, ''), name, type, COALESCE(sector, ''), registered_lobbyists, active_subject_matters, communication_volume_current_parliament, communication_volume_prior_parliament, top_dpohs, updated_at FROM lobbyist_organizations WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR organization_id ILIKE '%' || $1 || '%') - ORDER BY communication_volume_current_parliament DESC, name ASC - LIMIT $2 OFFSET $3 - `, search, limit, max(input.Offset, 0)) + AND ($2 = '' OR LOWER(sector) = LOWER($2)) + ORDER BY communication_volume_current_parliament ` + direction + `, name ASC + LIMIT $3 OFFSET $4 + ` + rows, err := r.db.Query(ctx, query, search, sector, limit, max(input.Offset, 0)) if err != nil { return nil, fmt.Errorf("browse lobbyist organizations: %w", err) } @@ -240,6 +247,7 @@ func (r *PostgresLobbyistOrganizationRepository) ListOrganizationCommunications( comlog_id::TEXT AS source_id, JSONB_AGG( JSONB_BUILD_OBJECT( + 'member_id', COALESCE(m.person_id, ''), 'name', BTRIM(CONCAT(dpoh_first_nm_prenom_tcpd, ' ', dpoh_last_nm_tcpd)), 'institution', COALESCE(NULLIF(institution, 'null'), ''), 'count', 1 @@ -247,6 +255,8 @@ func (r *PostgresLobbyistOrganizationRepository) ListOrganizationCommunications( ORDER BY dpoh_last_nm_tcpd, dpoh_first_nm_prenom_tcpd, institution ) AS dpohs FROM ocl_communication_dpohs + LEFT JOIN members m + ON LOWER(BTRIM(CONCAT(m.first_name, ' ', m.last_name))) = LOWER(BTRIM(CONCAT(dpoh_first_nm_prenom_tcpd, ' ', dpoh_last_nm_tcpd))) GROUP BY comlog_id::TEXT ) SELECT diff --git a/backend/lobbying/repository/postgres_test.go b/backend/lobbying/repository/postgres_test.go index 42dc02f1..9fdd35ea 100644 --- a/backend/lobbying/repository/postgres_test.go +++ b/backend/lobbying/repository/postgres_test.go @@ -1,6 +1,15 @@ package repository -import "testing" +import ( + "context" + "errors" + "strings" + "testing" + + "epac/lobbying/application" + + "github.com/jackc/pgx/v5" +) func TestNewPostgresLobbyistOrganizationRepositoryStoresQueryer(t *testing.T) { queryer := stubQueryer{} @@ -14,3 +23,50 @@ func TestNewPostgresLobbyistOrganizationRepositoryStoresQueryer(t *testing.T) { type stubQueryer struct { Queryer } + +func TestBrowseLobbyistOrganizationsAppliesSearchSectorAndSort(t *testing.T) { + queryer := &capturingQueryer{err: errors.New("stop after capture")} + repo := NewPostgresLobbyistOrganizationRepository(queryer) + + _, err := repo.BrowseLobbyistOrganizations(context.Background(), application.BrowseLobbyistOrganizationsInput{ + Search: "energy", + Sector: "Energy", + Limit: 25, + Offset: 50, + SortDirection: "asc", + }) + if !errors.Is(err, queryer.err) { + t.Fatalf("BrowseLobbyistOrganizations err = %v, want capture error", err) + } + if !strings.Contains(queryer.sql, "name ILIKE '%' || $1 || '%'") { + t.Fatalf("query missing name search predicate: %s", queryer.sql) + } + if !strings.Contains(queryer.sql, "LOWER(sector) = LOWER($2)") { + t.Fatalf("query missing sector filter: %s", queryer.sql) + } + if !strings.Contains(queryer.sql, "ORDER BY communication_volume_current_parliament ASC") { + t.Fatalf("query missing ascending communication sort: %s", queryer.sql) + } + wantArgs := []any{"energy", "Energy", 25, 50} + if len(queryer.args) != len(wantArgs) { + t.Fatalf("query args = %#v, want %#v", queryer.args, wantArgs) + } + for i := range wantArgs { + if queryer.args[i] != wantArgs[i] { + t.Fatalf("query arg %d = %#v, want %#v", i, queryer.args[i], wantArgs[i]) + } + } +} + +type capturingQueryer struct { + Queryer + sql string + args []any + err error +} + +func (q *capturingQueryer) Query(_ context.Context, sql string, args ...any) (pgx.Rows, error) { + q.sql = sql + q.args = args + return nil, q.err +} diff --git a/backend/manifest/deployment-services.json b/backend/manifest/deployment-services.json index 2a63d800..3da8de76 100644 --- a/backend/manifest/deployment-services.json +++ b/backend/manifest/deployment-services.json @@ -642,6 +642,22 @@ { "method": "GET", "path": "/cabinet/lobbying-overview" + }, + { + "method": "GET", + "path": "/api/v1/lobbying/organizations" + }, + { + "method": "GET", + "path": "/api/v1/lobbying/organizations/{id}" + }, + { + "method": "GET", + "path": "/lobbying/organizations" + }, + { + "method": "GET", + "path": "/lobbying/organizations/{id}" } ], "production": [ @@ -668,6 +684,22 @@ { "method": "GET", "path": "/cabinet/lobbying-overview" + }, + { + "method": "GET", + "path": "/api/v1/lobbying/organizations" + }, + { + "method": "GET", + "path": "/api/v1/lobbying/organizations/{id}" + }, + { + "method": "GET", + "path": "/lobbying/organizations" + }, + { + "method": "GET", + "path": "/lobbying/organizations/{id}" } ] } diff --git a/backend/openapi/main_test.go b/backend/openapi/main_test.go index 5d95c4e2..725790db 100644 --- a/backend/openapi/main_test.go +++ b/backend/openapi/main_test.go @@ -44,6 +44,8 @@ func TestOpenAPISpecEndpoint(t *testing.T) { "/api/v1/lobbying/by-topic/{slug}", "/api/v1/ministers/{member_id}/lobbying-by-portfolio", "/api/v1/cabinet/lobbying-overview", + "/api/v1/lobbying/organizations", + "/api/v1/lobbying/organizations/{id}", "/health", } for _, path := range requiredPaths { @@ -70,6 +72,8 @@ func TestRequiredPathsHaveResponseSchemasAndExamples(t *testing.T) { "/api/v1/lobbying/by-topic/{slug}", "/api/v1/ministers/{member_id}/lobbying-by-portfolio", "/api/v1/cabinet/lobbying-overview", + "/api/v1/lobbying/organizations", + "/api/v1/lobbying/organizations/{id}", "/health", } diff --git a/backend/openapi/openapi.json b/backend/openapi/openapi.json index 7e1d240f..40f951f1 100644 --- a/backend/openapi/openapi.json +++ b/backend/openapi/openapi.json @@ -828,6 +828,160 @@ } } }, + "/api/v1/lobbying/organizations": { + "get": { + "tags": ["Lobbying"], + "summary": "Browse OCL lobbyist organizations", + "description": "Returns a paged directory of canonical Office of the Commissioner of Lobbying organizations for profile discovery.", + "operationId": "browseLobbyistOrganizations", + "parameters": [ + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive organization name or canonical ID search.", + "schema": { "type": "string", "example": "energy" } + }, + { + "name": "sector", + "in": "query", + "required": false, + "description": "Case-insensitive exact sector filter.", + "schema": { "type": "string", "example": "Energy" } + }, + { + "name": "sort", + "in": "query", + "required": false, + "description": "Directory sort key. Defaults to communication volume in the current Parliament.", + "schema": { "type": "string", "enum": ["communication_volume", "communication_volume_current_parliament"], "default": "communication_volume" } + }, + { + "name": "direction", + "in": "query", + "required": false, + "description": "Sort direction for communication volume.", + "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "One-based page number.", + "schema": { "type": "integer", "minimum": 1, "default": 1 } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Items per page. Defaults to 50 and caps at 200.", + "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } + } + ], + "responses": { + "200": { + "description": "Paged lobbyist organization directory", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LobbyistOrganizationDirectoryResponse" }, + "examples": { + "results": { + "value": { + "page": 1, + "per_page": 50, + "citation": "Source: Office of the Commissioner of Lobbying (OCL)", + "source_url": "https://lobbycanada.gc.ca/en/open-data/", + "rows": [ + { + "id": "ocl:42", + "name": "Canadian Housing Alliance", + "type": "association", + "sector": "Housing", + "communication_volume_current_parliament": 17 + } + ] + } + }, + "empty": { + "value": { + "page": 1, + "per_page": 50, + "citation": "Source: Office of the Commissioner of Lobbying (OCL)", + "source_url": "https://lobbycanada.gc.ca/en/open-data/", + "rows": [] + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "429": { "$ref": "#/components/responses/RateLimit" }, + "500": { "$ref": "#/components/responses/Error" }, + "503": { "$ref": "#/components/responses/Error" } + } + } + }, + "/api/v1/lobbying/organizations/{id}": { + "get": { + "tags": ["Lobbying"], + "summary": "Load an OCL lobbyist organization profile", + "description": "Returns a full canonical Office of the Commissioner of Lobbying organization profile, including lobbyists, active subject matters, Parliament communication trend, and top DPOHs contacted.", + "operationId": "loadLobbyistOrganizationProfile", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Canonical organization ID from the directory response.", + "schema": { "type": "string", "example": "ocl:42" } + } + ], + "responses": { + "200": { + "description": "Lobbyist organization profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LobbyistOrganizationProfile" }, + "examples": { + "profile": { + "value": { + "id": "ocl:42", + "ocl_organization_id": "42", + "name": "Canadian Housing Alliance", + "type": "association", + "sector": "Housing", + "registered_lobbyists": [ + { "name": "Jane Lobbyist", "kind": "consultant" } + ], + "active_subject_matters": ["Housing", "Infrastructure"], + "communication_volume": { + "current_parliament": 8, + "prior_parliament": 5 + }, + "top_dpohs_contacted": [ + { + "member_id": "278707", + "name": "Example Minister", + "institution": "House of Commons", + "count": 4 + } + ], + "citation": "Source: Office of the Commissioner of Lobbying (OCL)", + "source_url": "https://lobbycanada.gc.ca/en/open-data/" + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/Error" }, + "429": { "$ref": "#/components/responses/RateLimit" }, + "500": { "$ref": "#/components/responses/Error" }, + "503": { "$ref": "#/components/responses/Error" } + } + } + }, "/api/v1/estimates": { "get": { "tags": ["Fiscal"], @@ -1537,6 +1691,85 @@ "end_date": { "type": "string" } } }, + "LobbyistOrganizationDirectoryResponse": { + "type": "object", + "required": ["page", "per_page", "citation", "source_url", "rows"], + "properties": { + "page": { "type": "integer", "minimum": 1 }, + "per_page": { "type": "integer", "minimum": 1, "maximum": 200 }, + "citation": { "type": "string", "example": "Source: Office of the Commissioner of Lobbying (OCL)" }, + "source_url": { "type": "string", "format": "uri", "example": "https://lobbycanada.gc.ca/en/open-data/" }, + "rows": { "type": "array", "items": { "$ref": "#/components/schemas/LobbyistOrganizationDirectoryRow" } } + } + }, + "LobbyistOrganizationDirectoryRow": { + "type": "object", + "required": ["id", "name", "type", "communication_volume_current_parliament"], + "properties": { + "id": { "type": "string", "example": "ocl:42" }, + "name": { "type": "string" }, + "type": { "$ref": "#/components/schemas/LobbyistOrganizationType" }, + "sector": { "type": "string" }, + "communication_volume_current_parliament": { "type": "integer", "minimum": 0 } + } + }, + "LobbyistOrganizationProfile": { + "type": "object", + "required": [ + "id", + "name", + "type", + "registered_lobbyists", + "active_subject_matters", + "communication_volume", + "top_dpohs_contacted", + "citation", + "source_url" + ], + "properties": { + "id": { "type": "string", "example": "ocl:42" }, + "ocl_organization_id": { "type": "string", "example": "42" }, + "name": { "type": "string" }, + "type": { "$ref": "#/components/schemas/LobbyistOrganizationType" }, + "sector": { "type": "string" }, + "registered_lobbyists": { "type": "array", "items": { "$ref": "#/components/schemas/RegisteredLobbyist" } }, + "active_subject_matters": { "type": "array", "items": { "type": "string" } }, + "communication_volume": { "$ref": "#/components/schemas/LobbyistOrganizationCommunicationVolume" }, + "top_dpohs_contacted": { "type": "array", "maxItems": 5, "items": { "$ref": "#/components/schemas/DPOHContact" } }, + "citation": { "type": "string", "example": "Source: Office of the Commissioner of Lobbying (OCL)" }, + "source_url": { "type": "string", "format": "uri", "example": "https://lobbycanada.gc.ca/en/open-data/" } + } + }, + "LobbyistOrganizationType": { + "type": "string", + "enum": ["corporation", "non_profit", "association", "indigenous_organization"] + }, + "RegisteredLobbyist": { + "type": "object", + "required": ["name", "kind"], + "properties": { + "name": { "type": "string" }, + "kind": { "type": "string", "enum": ["consultant", "in_house"] } + } + }, + "LobbyistOrganizationCommunicationVolume": { + "type": "object", + "required": ["current_parliament", "prior_parliament"], + "properties": { + "current_parliament": { "type": "integer", "minimum": 0 }, + "prior_parliament": { "type": "integer", "minimum": 0 } + } + }, + "DPOHContact": { + "type": "object", + "required": ["name", "institution", "count"], + "properties": { + "member_id": { "type": "string", "description": "House of Commons member/person ID when the contacted DPOH can be linked to an MP or minister profile." }, + "name": { "type": "string" }, + "institution": { "type": "string" }, + "count": { "type": "integer", "minimum": 0 } + } + }, "EstimatesResponse": { "type": "object", "required": ["estimates"], diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index fec3f184..3aa97ee5 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -709,15 +709,16 @@ Current implementation: ### LoadLobbyistOrganizationProfile ``` -Actor: Backend caller (REST endpoint added by a later issue) +Actor: Backend caller Goal: Load one canonical lobbyist organization profile by organization ID. Inputs: Canonical organization ID. Outputs: LobbyistOrganization aggregate with name, type, sector, registered lobbyists, active subject matters, communication trend, and top DPOHs contacted. Entities / values: LobbyistOrganization, OrganizationSector, CommunicationCount. Ports: backend Go: `LobbyistOrganizationRepository`. -Primary adapters: backend/lobbying Postgres repository. +Primary adapters: backend/lobbying Postgres repository, lobbying Lambda (GET /api/v1/lobbying/organizations/{id}). Current implementation: backend/lobbying/application/aggregate.go + backend/lobbying/organizations_endpoint.go backend/lobbying/repository/postgres.go ``` @@ -728,15 +729,16 @@ Current implementation: ### BrowseLobbyistOrganizations ``` -Actor: Backend caller (REST endpoint added by a later issue) +Actor: Backend caller Goal: Browse canonical lobbyist organizations for profile discovery. -Inputs: Search text, limit, offset. +Inputs: Search text, sector filter, communication-volume sort direction, limit, offset. Outputs: Ordered LobbyistOrganization aggregates. Entities / values: LobbyistOrganization, CommunicationCount. Ports: backend Go: `LobbyistOrganizationRepository`. -Primary adapters: backend/lobbying Postgres repository. +Primary adapters: backend/lobbying Postgres repository, lobbying Lambda (GET /api/v1/lobbying/organizations). Current implementation: backend/lobbying/application/aggregate.go + backend/lobbying/organizations_endpoint.go backend/lobbying/repository/postgres.go ```