Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/data-ingestion.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ on:
required: true
type: boolean
default: false
run_senators_indexer:
description: 'Senators: fetch and generate senators JSON -> S3'
required: true
type: boolean
default: false
parliament_number:
description: Optional parliament number override for search, lobbying, and bills
required: false
Expand Down Expand Up @@ -101,6 +106,7 @@ jobs:
RUN_MEMBERS: ${{ inputs.run_members }}
RUN_MEMBERS_INDEXER: ${{ inputs.run_members_indexer }}
RUN_VOTES: ${{ inputs.run_votes }}
RUN_SENATORS_INDEXER: ${{ inputs.run_senators_indexer }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -129,6 +135,7 @@ jobs:
case "$EVENT_SCHEDULE" in
"0 12 * * *"|"0 3 * * *")
add_ingestor "search" "Hansard search index" "hansard-search-index" "false"
add_ingestor "senators-indexer" "Senators indexer" "senators-indexer" "false"
;;
"0 12 * * 1"|"0 12 1 */3 *")
add_ingestor "lobbying" "Lobbying index" "lobbying-index" "false"
Expand All @@ -145,6 +152,7 @@ jobs:
[ "$RUN_MEMBERS" = "true" ] && add_ingestor "members" "Members artifacts" "members-publisher" "true"
[ "$RUN_MEMBERS_INDEXER" = "true" ] && add_ingestor "members-indexer" "Members SQLite index" "members-indexer" "false"
[ "$RUN_VOTES" = "true" ] && add_ingestor "votes" "Member votes artifacts" "member-votes-publisher" "true"
[ "$RUN_SENATORS_INDEXER" = "true" ] && add_ingestor "senators-indexer" "Senators indexer" "senators-indexer" "false"
fi

if [ "$(jq 'length' <<< "$items")" -gt 0 ]; then
Expand Down Expand Up @@ -283,6 +291,11 @@ jobs:
votes)
(cd backend/member-votes-publisher && go run .)
;;
senators-indexer)
output_dir="$RUNNER_TEMP/artifacts/senators"
python3 scripts/artifacts/fetch_senators.py --output "$output_dir/v1/all.json"
aws s3 sync "$output_dir" "$(artifact_uri senators)"
;;
*)
echo "Unknown ingestor: $INGESTOR"
exit 1
Expand Down
1 change: 1 addition & 0 deletions backend/go.work
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ use (
./openapi
./push-notification-dispatcher
./riding-boundary
./senators
./sittings
./sittings-publisher
./telemetry
Expand Down
34 changes: 34 additions & 0 deletions backend/manifest/deployment-services.json
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,40 @@
"artifact": false
}
}
},
{
"name": "senators",
"deploy": {
"staging": true,
"production": true
},
"http": {
"payload_format_version": "1.0",
"routes": {
"staging": [
{
"method": "GET",
"path": "/api/v1/senators"
}
],
"production": [
{
"method": "GET",
"path": "/api/v1/senators"
}
]
}
},
"sync": {
"staging": {
"database": false,
"artifact": true
},
"production": {
"database": false,
"artifact": true
}
}
}
]
}
48 changes: 48 additions & 0 deletions backend/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@
}
}
},
"/api/v1/senators": {
"get": {
"tags": ["Senators"],
"summary": "List senators of Canada",
"operationId": "listSenators",
"responses": {
"200": {
"description": "Senator list",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SenatorsResponse" }
}
}
},
"429": { "$ref": "#/components/responses/RateLimit" }
}
}
},
"/api/v1/members": {
"get": {
"tags": ["Members"],
Expand Down Expand Up @@ -1926,6 +1944,36 @@
"source_url": { "type": "string", "format": "uri" }
}
},
"SenatorsResponse": {
"type": "object",
"required": ["items"],
"properties": {
"items": { "type": "array", "items": { "$ref": "#/components/schemas/OpenAPISenator" } }
}
},
"OpenAPISenator": {
"type": "object",
"required": ["PersonOfficialFirstName", "PersonOfficialLastName", "ProvinceNameEn"],
"properties": {
"PersonOfficialFirstName": { "type": "string" },
"PersonOfficialLastName": { "type": "string" },
"ProvinceNameEn": { "type": "string" },
"CaucusAbbreviationEn": { "type": "string" },
"CaucusNameEn": { "type": "string" },
"PersonPageUrl": { "type": "string", "format": "uri" },
"appointment": { "$ref": "#/components/schemas/OpenAPISenateAppointment" }
}
},
"OpenAPISenateAppointment": {
"type": "object",
"required": ["appointment_date", "declared_affiliation", "source_url"],
"properties": {
"appointment_date": { "type": "string", "format": "date" },
"appointing_prime_minister": { "type": "string" },
"declared_affiliation": { "type": "string" },
"source_url": { "type": "string", "format": "uri" }
}
},
"MembersResponse": {
"type": "object",
"required": ["members"],
Expand Down
36 changes: 36 additions & 0 deletions backend/senators/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
module epac/senators

go 1.24.0

require (
epac/observability v0.0.0
epac/shared v0.0.0
github.com/aws/aws-lambda-go v1.54.0
)

require (
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
)

replace epac/observability => ../observability

replace epac/shared => ../_shared
52 changes: 52 additions & 0 deletions backend/senators/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// senators Lambda — GET /api/v1/senators
package main

import (
"context"
"net/http"

"epac/observability"
"epac/shared/artifacts"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)

const senatorsArtifactKey = "senators/v1/all.json"

var newArtifactStore = artifacts.NewFromEnv

func HandleRequest(ctx context.Context, _ events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
store, err := newArtifactStore(ctx)
if err != nil {
return jsonError(http.StatusServiceUnavailable, err.Error()), nil
}
data, err := store.Get(ctx, senatorsArtifactKey)
if err != nil {
status := http.StatusInternalServerError
if artifacts.IsNotFound(err) {
status = http.StatusNotFound
}
return jsonError(status, err.Error()), nil
}

return events.APIGatewayProxyResponse{
StatusCode: http.StatusOK,
Headers: map[string]string{
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
Body: string(data),
}, nil
}

func jsonError(status int, message string) events.APIGatewayProxyResponse {
return events.APIGatewayProxyResponse{
StatusCode: status,
Headers: map[string]string{"Content-Type": "application/json"},
Body: `{"error":"` + message + `"}`,
}
}

func main() {
lambda.Start(observability.WrapAPIGateway("senators", HandleRequest))
}
51 changes: 51 additions & 0 deletions backend/senators/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"context"
"net/http"
"os"
"path/filepath"
"strings"
"testing"

"github.com/aws/aws-lambda-go/events"
)

func TestHandleRequestReadsSenatorsArtifact(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, senatorsArtifactKey, `{"items":[{"PersonOfficialFirstName":"Charles","PersonOfficialLastName":"Adler"}]}`)
t.Setenv("ARTIFACTS_DIR", dir)

resp, err := HandleRequest(context.Background(), events.APIGatewayProxyRequest{})
if err != nil {
t.Fatalf("HandleRequest error: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d body = %s, want 200", resp.StatusCode, resp.Body)
}
if !strings.Contains(resp.Body, `"PersonOfficialFirstName":"Charles"`) {
t.Fatalf("unexpected body: %s", resp.Body)
}
}

func TestHandleRequestMissingArtifactReturns404(t *testing.T) {
t.Setenv("ARTIFACTS_DIR", t.TempDir())
resp, err := HandleRequest(context.Background(), events.APIGatewayProxyRequest{})
if err != nil {
t.Fatalf("HandleRequest error: %v", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d body = %s, want 404", resp.StatusCode, resp.Body)
}
}

func writeFixture(t *testing.T, root, key, body string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir fixture: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
}
48 changes: 48 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/`
| `RecordedVote` | A House of Commons division record with date, bill reference, result, and member ballot. |
| `MemberID` | The stable source identifier used to address a ParliamentMember across backend artifacts. |
| `ParliamentMember` | An elected Member of Parliament with riding, party, and contact info. |
| `Senator` | An appointed Senator with province, declared caucus affiliation, Senate profile URL, and appointment facts when available. |
| `SenateAppointment` | Appointment facts for a Senator: date, appointing prime minister, represented province, declared affiliation, and Privy Council Office source URL. |
| `Sitting` | A House sitting date with Parliament/session metadata and source URL. |
| `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, and LEGISinfo source URL. |
| `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. |
Expand Down Expand Up @@ -92,6 +94,7 @@ to the issue that will build the missing artifact.
| `MemberRepository` | backend Go | outbound | Implemented: `backend/members/internal/usecase/members.go`; adapter: `backend/members/internal/adapter/sqlite/repository.go`. | List members and load member-profile attendance rows from the verified members SQLite artifact. |
| `MemberContentRepository` | backend Go | outbound | Implemented: `backend/member-speeches/internal/usecase/usecase.go` with adapter `backend/member-speeches/internal/adapter/artifact/artifact.go`; `backend/member-votes/main.go` has a local vote-feed interface implemented by `S3ArtifactMemberContentRepository`. | Load per-member append-only content feeds such as speeches and recorded votes. There is no iOS Swift protocol with this name today. |
| `TopicPreferenceStore` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/TopicPreferenceStore.swift`; adapter: `ios/epac/Data/Adapters/TopicFollowStoreAdapter.swift`. | Read and persist followed topic IDs as a Domain-layer port. |
| `SenatorAppointmentQueryPort` | iOS Swift | outbound | Implemented by existing `HomeFeedRepository.fetchSenators(for:)`; adapter: `ios/epac/Data/Repositories/HomeFeedSwiftDataRepository.swift` using `ios/epac/Util/SenatorsService.swift`. | Load province-filtered senator appointment context for Home and My MP surfaces. |
| `DeviceSubscriptionRepository` | backend Go | outbound | Implemented: `backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go`; adapter: `backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go`. | List device subscriptions eligible for the current internal notification fan-out. |
| `PushNotificationClient` | backend Go | outbound | Implemented: `backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go`; adapter: `backend/push-notification-dispatcher/internal/adapter/apns/client.go`. | Deliver a typed push payload to a subscribed device through APNs. |
| `HansardSearchProviding` | iOS Swift | outbound | Implemented: `ios/epac/Util/HansardSearchService.swift`; conformer: `BackendHansardSearchService`. | Search Hansard through the backend search endpoint from iOS presentation code. |
Expand Down Expand Up @@ -781,6 +784,51 @@ Current implementation:

---

### TrackSenateAppointments

```
Actor: Backend ingestion job / iOS app
Goal: Keep current Senator rows associated with appointment date, appointing prime minister, province, declared affiliation, and Orders in Council source.
Inputs: Senator roster/appointment payload from backend or existing Senate roster adapter.
Outputs: Senator values with optional SenateAppointment facts.
Entities / values: Senator, SenateAppointment.
Ports: iOS Swift: `SenatorAppointmentQueryPort`.
Primary adapters: SenatorsService, HomeFeedSwiftDataRepository, SenatorCard, MyMPView, HomeFeedView.
Current implementation:
ios/epac/Model/Senator.swift
ios/epac/Util/SenatorsService.swift
ios/epac/Data/Repositories/HomeFeedSwiftDataRepository.swift
ios/epac/Views/Senate/SenatorCard.swift
ios/epac/Views/MyMP/MyMPView.swift
ios/epac/Views/Home/HomeFeedView.swift
```

> **Boundary note:** iOS decodes typed appointment fields when the roster payload includes them and does not scrape PCO or Senate HTML. Backend ingestion/parsing remains outside the iOS adapter.

---

### LoadAppointingPM

```
Actor: User (iOS app, Home / My MP)
Goal: See which prime minister appointed each province Senator and when, with their declared affiliation and PCO Orders in Council citation.
Inputs: User's saved MP province.
Outputs: Province-filtered Senator cards with appointment summary and source link.
Entities / values: Senator, SenateAppointment.
Ports: iOS Swift: `SenatorAppointmentQueryPort`.
Primary adapters: LoadHomeFeed, HomeFeedSwiftDataRepository, SenatorCard, MyMPView, HomeFeedView.
Current implementation:
ios/epac/Domain/UseCases/LoadHomeFeed.swift
ios/epac/Data/Repositories/HomeFeedSwiftDataRepository.swift
ios/epac/Views/Senate/SenatorCard.swift
ios/epac/Views/MyMP/MyMPView.swift
ios/epac/Views/Home/HomeFeedView.swift
```

> **Notification note:** Senate appointment notification eligibility uses the canonical `senate` topic ID added to `shared/topic-taxonomy/parliamentary_topics.json`; existing topic-follow storage carries that ID through device preferences.

---

### FollowTopic

```
Expand Down
1 change: 1 addition & 0 deletions ios/epac/Model/ParliamentaryTopic.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ struct ParliamentaryTopic: Identifiable, Codable, Hashable, Sendable {
ParliamentaryTopic(id: "childcare", nameKey: "topic.childcare", keywords: ["child care", "daycare", "family", "children", "services de garde", "enfant"]),
ParliamentaryTopic(id: "energy", nameKey: "topic.energy", keywords: ["energy", "oil", "gas", "pipeline", "electricity", "LNG", "pétrole"]),
ParliamentaryTopic(id: "naturalresources", nameKey: "topic.naturalResources", keywords: ["natural resources", "forestry", "forest", "lumber", "timber", "mining", "minerals", "mineral production", "potash", "wood harvest", "ressources naturelles", "foresterie", "mines"]),
ParliamentaryTopic(id: "senate", nameKey: "topic.senate", keywords: ["senate", "senator", "senators", "upper chamber", "sénat", "sénateur", "sénatrice"]),
ParliamentaryTopic(id: "pharma", nameKey: "topic.pharma", keywords: ["drug", "pharmaceutical", "medication", "opioid", "naloxone", "médicament"]),
ParliamentaryTopic(id: "digital", nameKey: "topic.digital", keywords: ["digital", "artificial intelligence", "AI", "online harms", "privacy", "cybersecurity", "numérique"]),
ParliamentaryTopic(id: "labour", nameKey: "topic.labour", keywords: ["labour", "labor", "union", "strike", "wage", "employment", "travail", "grève"]),
Expand Down
Loading
Loading