From 059f7a5b7daeec619748d567aa6002525b08b88d Mon Sep 17 00:00:00 2001 From: riddim-developer-bot Date: Thu, 11 Jun 2026 05:00:50 -0400 Subject: [PATCH] EPAC-2275 Extract push dispatcher ports --- .../acceptance_test.go | 54 +++---- .../internal/adapter/apns/client.go | 65 +++++++++ .../adapter/postgres/subscriptions.go | 60 ++++++++ .../internal/domain/domain.go | 103 ++++++++++++++ .../usecase/dispatch_push_notification.go | 49 +++++++ .../dispatch_push_notification_test.go | 133 ++++++++++++++++++ backend/push-notification-dispatcher/main.go | 94 +++++++------ docs/architecture/use-case-catalog.md | 29 ++++ scripts/ci/check_go_dependency_rule.sh | 29 ++++ 9 files changed, 547 insertions(+), 69 deletions(-) create mode 100644 backend/push-notification-dispatcher/internal/adapter/apns/client.go create mode 100644 backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go create mode 100644 backend/push-notification-dispatcher/internal/domain/domain.go create mode 100644 backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go create mode 100644 backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification_test.go diff --git a/backend/push-notification-dispatcher/acceptance_test.go b/backend/push-notification-dispatcher/acceptance_test.go index 4674136c..2eea7341 100644 --- a/backend/push-notification-dispatcher/acceptance_test.go +++ b/backend/push-notification-dispatcher/acceptance_test.go @@ -24,7 +24,6 @@ import ( "epac/observability" "github.com/aws/aws-lambda-go/events" - "github.com/jackc/pgx/v5" ) type apnsStub struct { @@ -55,18 +54,24 @@ func (s *apnsStub) snapshot() []map[string]any { } func TestAcceptancePushNotificationDispatcherCallsAPNsThroughCompositionRoot(t *testing.T) { - _testdb.WithTx(t, func(conn *pgx.Conn) { - t.Setenv("DATABASE_URL", os.Getenv("DATABASE_URL")) + conn := _testdb.Connect(t) + token := "test-token-epac-2275" + _, _ = conn.Exec(context.Background(), `DELETE FROM device_subscriptions WHERE token = $1`, token) + t.Cleanup(func() { + _, _ = conn.Exec(context.Background(), `DELETE FROM device_subscriptions WHERE token = $1`, token) + }) + + t.Setenv("DATABASE_URL", os.Getenv("DATABASE_URL")) - _testdb.SeedDeviceSubscription(t, conn, "test-token-123", "", nil, nil) + _testdb.SeedDeviceSubscription(t, conn, token, "", nil, nil) - apns := &apnsStub{} - server := httptest.NewServer(apns) - defer server.Close() + apns := &apnsStub{} + server := httptest.NewServer(apns) + defer server.Close() - t.Setenv("EPAC_APNS_URL", server.URL) + t.Setenv("EPAC_APNS_URL", server.URL) - payload := `{ + payload := `{ "division_id": 42, "parliament": 45, "session": 1, @@ -74,23 +79,22 @@ func TestAcceptancePushNotificationDispatcherCallsAPNsThroughCompositionRoot(t * "status": "concluded" }` - req := events.APIGatewayProxyRequest{ - Body: payload, - } + req := events.APIGatewayProxyRequest{ + Body: payload, + } - wrapped := observability.WrapAPIGateway(pipelineName, HandleRequest) - resp, err := wrapped(context.Background(), req) - if err != nil { - t.Fatalf("wrapped HandleRequest error: %v", err) - } + wrapped := observability.WrapAPIGateway(pipelineName, HandleRequest) + resp, err := wrapped(context.Background(), req) + if err != nil { + t.Fatalf("wrapped HandleRequest error: %v", err) + } - if resp.StatusCode != 202 { - t.Errorf("expected status 202, got %d. body=%v", resp.StatusCode, resp.Body) - } + if resp.StatusCode != 202 { + t.Errorf("expected status 202, got %d. body=%v", resp.StatusCode, resp.Body) + } - hits := apns.snapshot() - if len(hits) != 1 { - t.Fatalf("apns hit count = %d, want 1", len(hits)) - } - }) + hits := apns.snapshot() + if len(hits) != 1 { + t.Fatalf("apns hit count = %d, want 1", len(hits)) + } } diff --git a/backend/push-notification-dispatcher/internal/adapter/apns/client.go b/backend/push-notification-dispatcher/internal/adapter/apns/client.go new file mode 100644 index 00000000..ab8d3e6d --- /dev/null +++ b/backend/push-notification-dispatcher/internal/adapter/apns/client.go @@ -0,0 +1,65 @@ +package apns + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "epac/push-notification-dispatcher/internal/domain" +) + +const DefaultBaseURL = "https://api.push.apple.com" + +type Client struct { + baseURL string + httpClient *http.Client +} + +func NewClient(baseURL string) *Client { + return NewClientWithHTTPClient(baseURL, nil) +} + +func NewClientWithHTTPClient(baseURL string, httpClient *http.Client) *Client { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + baseURL = DefaultBaseURL + } + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{baseURL: strings.TrimRight(baseURL, "/"), httpClient: httpClient} +} + +func (c *Client) Deliver(ctx context.Context, subscription domain.DeviceSubscription, payload domain.PushNotificationPayload) error { + body, err := payload.JSON() + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(subscription.Token), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("apns returned status %d", resp.StatusCode) + } + return nil +} + +func (c *Client) endpoint(token domain.DeviceToken) string { + if !strings.Contains(c.baseURL, "apple") { + return c.baseURL + } + return fmt.Sprintf("%s/3/device/%s", c.baseURL, url.PathEscape(token.String())) +} diff --git a/backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go b/backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go new file mode 100644 index 00000000..06609acc --- /dev/null +++ b/backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go @@ -0,0 +1,60 @@ +package postgres + +import ( + "context" + "errors" + "strings" + + "epac/push-notification-dispatcher/internal/domain" + + "github.com/jackc/pgx/v5" +) + +func Connect(ctx context.Context, connStr string) (*pgx.Conn, error) { + connStr = strings.TrimSpace(connStr) + if connStr == "" { + return nil, errors.New("DATABASE_URL not set") + } + return pgx.Connect(ctx, connStr) +} + +type DeviceSubscriptionRepository struct { + conn *pgx.Conn +} + +func NewDeviceSubscriptionRepository(conn *pgx.Conn) *DeviceSubscriptionRepository { + return &DeviceSubscriptionRepository{conn: conn} +} + +func (r *DeviceSubscriptionRepository) ListDeviceSubscriptions(ctx context.Context) ([]domain.DeviceSubscription, error) { + rows, err := r.conn.Query(ctx, ` + SELECT + token, + COALESCE(my_mp_member_id, ''), + COALESCE(topic_ids, '{}'::text[]), + COALESCE(bill_ids, '{}'::text[]) + FROM device_subscriptions + ORDER BY token`) + if err != nil { + return nil, err + } + defer rows.Close() + + var subscriptions []domain.DeviceSubscription + for rows.Next() { + var ( + token string + myMPMemberID string + topicIDs []string + billIDs []string + ) + if err := rows.Scan(&token, &myMPMemberID, &topicIDs, &billIDs); err != nil { + return nil, err + } + subscriptions = append(subscriptions, domain.NewDeviceSubscription(token, myMPMemberID, topicIDs, billIDs)) + } + if err := rows.Err(); err != nil { + return nil, err + } + return subscriptions, nil +} diff --git a/backend/push-notification-dispatcher/internal/domain/domain.go b/backend/push-notification-dispatcher/internal/domain/domain.go new file mode 100644 index 00000000..5bcf117f --- /dev/null +++ b/backend/push-notification-dispatcher/internal/domain/domain.go @@ -0,0 +1,103 @@ +package domain + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +var ErrInvalidPushNotificationPayload = errors.New("invalid push notification payload") + +type PushNotificationPayload struct { + DivisionID int `json:"division_id,omitempty"` + Parliament int `json:"parliament,omitempty"` + Session int `json:"session,omitempty"` + Result string `json:"result,omitempty"` + Status string `json:"status,omitempty"` + rawDocument json.RawMessage +} + +func ParsePushNotificationPayload(raw []byte) (PushNotificationPayload, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return PushNotificationPayload{}, ErrInvalidPushNotificationPayload + } + + var fields struct { + DivisionID int `json:"division_id"` + Parliament int `json:"parliament"` + Session int `json:"session"` + Result string `json:"result"` + Status string `json:"status"` + } + if err := json.Unmarshal(trimmed, &fields); err != nil { + return PushNotificationPayload{}, ErrInvalidPushNotificationPayload + } + + var compacted bytes.Buffer + if err := json.Compact(&compacted, trimmed); err != nil { + return PushNotificationPayload{}, ErrInvalidPushNotificationPayload + } + + return PushNotificationPayload{ + DivisionID: fields.DivisionID, + Parliament: fields.Parliament, + Session: fields.Session, + Result: fields.Result, + Status: fields.Status, + rawDocument: append(json.RawMessage(nil), compacted.Bytes()...), + }, nil +} + +func (p PushNotificationPayload) Valid() bool { + return len(p.rawDocument) > 0 +} + +func (p PushNotificationPayload) JSON() ([]byte, error) { + if !p.Valid() { + return nil, ErrInvalidPushNotificationPayload + } + return append([]byte(nil), p.rawDocument...), nil +} + +type DeviceToken string + +func NewDeviceToken(value string) DeviceToken { + return DeviceToken(strings.TrimSpace(value)) +} + +func (t DeviceToken) String() string { + return string(t) +} + +type DeviceSubscription struct { + Token DeviceToken + MyMPMemberID string + TopicIDs []string + BillIDs []string +} + +func NewDeviceSubscription(token, myMPMemberID string, topicIDs, billIDs []string) DeviceSubscription { + return DeviceSubscription{ + Token: NewDeviceToken(token), + MyMPMemberID: strings.TrimSpace(myMPMemberID), + TopicIDs: copyStrings(topicIDs), + BillIDs: copyStrings(billIDs), + } +} + +type DispatchResult struct { + Subscriptions int + Delivered int + Failed int +} + +func copyStrings(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, len(values)) + copy(out, values) + return out +} diff --git a/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go b/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go new file mode 100644 index 00000000..b9682568 --- /dev/null +++ b/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go @@ -0,0 +1,49 @@ +package usecase + +import ( + "context" + "errors" + + "epac/push-notification-dispatcher/internal/domain" +) + +var ErrInvalidPayload = errors.New("invalid push notification payload") + +type DeviceSubscriptionRepository interface { + ListDeviceSubscriptions(ctx context.Context) ([]domain.DeviceSubscription, error) +} + +type PushNotificationClient interface { + Deliver(ctx context.Context, subscription domain.DeviceSubscription, payload domain.PushNotificationPayload) error +} + +type DispatchPushNotification struct { + subscriptions DeviceSubscriptionRepository + client PushNotificationClient +} + +func NewDispatchPushNotification(subscriptions DeviceSubscriptionRepository, client PushNotificationClient) *DispatchPushNotification { + return &DispatchPushNotification{subscriptions: subscriptions, client: client} +} + +func (u *DispatchPushNotification) Execute(ctx context.Context, payload domain.PushNotificationPayload) (domain.DispatchResult, error) { + if !payload.Valid() { + return domain.DispatchResult{}, ErrInvalidPayload + } + + subscriptions, err := u.subscriptions.ListDeviceSubscriptions(ctx) + if err != nil { + return domain.DispatchResult{}, err + } + + result := domain.DispatchResult{Subscriptions: len(subscriptions)} + for _, subscription := range subscriptions { + if err := u.client.Deliver(ctx, subscription, payload); err != nil { + result.Failed++ + continue + } + result.Delivered++ + } + + return result, nil +} diff --git a/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification_test.go b/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification_test.go new file mode 100644 index 00000000..2de070f5 --- /dev/null +++ b/backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification_test.go @@ -0,0 +1,133 @@ +package usecase + +import ( + "context" + "errors" + "testing" + + "epac/push-notification-dispatcher/internal/domain" +) + +type fakeSubscriptionRepository struct { + called bool + subscriptions []domain.DeviceSubscription + err error +} + +func (r *fakeSubscriptionRepository) ListDeviceSubscriptions(context.Context) ([]domain.DeviceSubscription, error) { + r.called = true + return r.subscriptions, r.err +} + +type fakePushClient struct { + delivered []domain.DeviceSubscription + failToken string +} + +func (c *fakePushClient) Deliver(_ context.Context, subscription domain.DeviceSubscription, _ domain.PushNotificationPayload) error { + c.delivered = append(c.delivered, subscription) + if subscription.Token.String() == c.failToken { + return errors.New("apns rejected request") + } + return nil +} + +func TestDispatchRejectsInvalidPayloadBeforeSubscriptionLookup(t *testing.T) { + repo := &fakeSubscriptionRepository{} + client := &fakePushClient{} + dispatcher := NewDispatchPushNotification(repo, client) + + _, err := dispatcher.Execute(context.Background(), domain.PushNotificationPayload{}) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("Execute error = %v, want %v", err, ErrInvalidPayload) + } + if repo.called { + t.Fatal("repo should not be called for invalid payload") + } + if len(client.delivered) != 0 { + t.Fatalf("client delivered %d notifications, want 0", len(client.delivered)) + } +} + +func TestDispatchWithZeroSubscriptionsDoesNotCallClient(t *testing.T) { + client := &fakePushClient{} + dispatcher := NewDispatchPushNotification(&fakeSubscriptionRepository{}, client) + + result, err := dispatcher.Execute(context.Background(), validPayload(t)) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result != (domain.DispatchResult{}) { + t.Fatalf("result = %#v, want zero result", result) + } + if len(client.delivered) != 0 { + t.Fatalf("client delivered %d notifications, want 0", len(client.delivered)) + } +} + +func TestDispatchDeliversToMultipleSubscriptions(t *testing.T) { + client := &fakePushClient{} + dispatcher := NewDispatchPushNotification(&fakeSubscriptionRepository{ + subscriptions: []domain.DeviceSubscription{ + domain.NewDeviceSubscription("token-a", "member-1", []string{"housing"}, nil), + domain.NewDeviceSubscription("token-b", "", nil, []string{"C-1"}), + }, + }, client) + + result, err := dispatcher.Execute(context.Background(), validPayload(t)) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result != (domain.DispatchResult{Subscriptions: 2, Delivered: 2}) { + t.Fatalf("result = %#v, want 2 subscriptions delivered", result) + } + if len(client.delivered) != 2 { + t.Fatalf("client delivered %d notifications, want 2", len(client.delivered)) + } +} + +func TestDispatchCountsDeliveryFailuresAndContinues(t *testing.T) { + client := &fakePushClient{failToken: "token-b"} + dispatcher := NewDispatchPushNotification(&fakeSubscriptionRepository{ + subscriptions: []domain.DeviceSubscription{ + domain.NewDeviceSubscription("token-a", "", nil, nil), + domain.NewDeviceSubscription("token-b", "", nil, nil), + domain.NewDeviceSubscription("token-c", "", nil, nil), + }, + }, client) + + result, err := dispatcher.Execute(context.Background(), validPayload(t)) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result != (domain.DispatchResult{Subscriptions: 3, Delivered: 2, Failed: 1}) { + t.Fatalf("result = %#v, want 2 delivered and 1 failed", result) + } + if len(client.delivered) != 3 { + t.Fatalf("client attempted %d notifications, want 3", len(client.delivered)) + } +} + +func TestDispatchReturnsSubscriptionLookupError(t *testing.T) { + want := errors.New("query failed") + dispatcher := NewDispatchPushNotification(&fakeSubscriptionRepository{err: want}, &fakePushClient{}) + + if _, err := dispatcher.Execute(context.Background(), validPayload(t)); !errors.Is(err, want) { + t.Fatalf("Execute error = %v, want %v", err, want) + } +} + +func validPayload(t *testing.T) domain.PushNotificationPayload { + t.Helper() + payload, err := domain.ParsePushNotificationPayload([]byte(`{ + "division_id": 42, + "parliament": 45, + "session": 1, + "result": "carried", + "status": "concluded" + }`)) + if err != nil { + t.Fatalf("ParsePushNotificationPayload: %v", err) + } + return payload +} diff --git a/backend/push-notification-dispatcher/main.go b/backend/push-notification-dispatcher/main.go index d7ee2040..075306db 100644 --- a/backend/push-notification-dispatcher/main.go +++ b/backend/push-notification-dispatcher/main.go @@ -1,81 +1,87 @@ package main import ( - "bytes" "context" - "encoding/json" - "fmt" - "net/http" + "errors" "os" "strings" "epac/observability" + "epac/push-notification-dispatcher/internal/adapter/apns" + "epac/push-notification-dispatcher/internal/adapter/postgres" + "epac/push-notification-dispatcher/internal/domain" + "epac/push-notification-dispatcher/internal/usecase" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" - "github.com/jackc/pgx/v5" ) const pipelineName = "push-notification-dispatcher" +var errDatabaseURLNotSet = errors.New("DATABASE_URL not set") + func main() { lambda.Start(observability.WrapAPIGateway(pipelineName, HandleRequest)) } func HandleRequest(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { - var payload map[string]interface{} - if err := json.Unmarshal([]byte(req.Body), &payload); err != nil { + payload, err := domain.ParsePushNotificationPayload([]byte(req.Body)) + if err != nil { return events.APIGatewayProxyResponse{StatusCode: 400, Body: `{"error": "bad request"}`}, nil } + dispatcher, cleanup, err := buildDispatcher(ctx) + if err != nil { + return mapBuildError(err), nil + } + defer cleanup(ctx) + + if _, err := dispatcher.Execute(ctx, payload); err != nil { + return mapDispatchError(err), nil + } + + return events.APIGatewayProxyResponse{StatusCode: 202, Body: `{"ok":true}`}, nil +} + +func buildDispatcher(ctx context.Context) (*usecase.DispatchPushNotification, func(context.Context), error) { connStr := strings.TrimSpace(os.Getenv("DATABASE_URL")) if connStr == "" { - return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "DATABASE_URL not set"}`}, nil + return nil, nil, errDatabaseURLNotSet } - conn, err := pgx.Connect(ctx, connStr) + conn, err := postgres.Connect(ctx, connStr) if err != nil { - return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "db connect failed"}`}, nil + return nil, nil, err } - defer conn.Close(ctx) - // Fetch all device tokens (in a real scenario, this would filter by topic) - rows, err := conn.Query(ctx, "SELECT token FROM device_subscriptions") - if err != nil { - return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "query failed"}`}, nil - } - defer rows.Close() - - var tokens []string - for rows.Next() { - var token string - if err := rows.Scan(&token); err == nil { - tokens = append(tokens, token) - } + repository := postgres.NewDeviceSubscriptionRepository(conn) + client := apns.NewClient(apnsBaseURLFromEnv()) + dispatcher := usecase.NewDispatchPushNotification(repository, client) + cleanup := func(ctx context.Context) { + conn.Close(ctx) } - apnsURL := strings.TrimSpace(os.Getenv("EPAC_APNS_URL")) - if apnsURL == "" { - apnsURL = "https://api.push.apple.com" + return dispatcher, cleanup, nil +} + +func apnsBaseURLFromEnv() string { + baseURL := strings.TrimSpace(os.Getenv("EPAC_APNS_URL")) + if baseURL == "" { + return apns.DefaultBaseURL } + return baseURL +} - for _, token := range tokens { - body, _ := json.Marshal(payload) - url := fmt.Sprintf("%s/3/device/%s", apnsURL, token) - if apnsURL == os.Getenv("EPAC_APNS_URL") && !strings.Contains(apnsURL, "apple") { - // Acceptance test uses a stub that doesn't expect the path - url = apnsURL - } - - httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) - if err == nil { - httpReq.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(httpReq) - if err == nil { - resp.Body.Close() - } - } +func mapBuildError(err error) events.APIGatewayProxyResponse { + if errors.Is(err, errDatabaseURLNotSet) { + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "DATABASE_URL not set"}`} } + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "db connect failed"}`} +} - return events.APIGatewayProxyResponse{StatusCode: 202, Body: `{"ok":true}`}, nil +func mapDispatchError(err error) events.APIGatewayProxyResponse { + if errors.Is(err, usecase.ErrInvalidPayload) { + return events.APIGatewayProxyResponse{StatusCode: 400, Body: `{"error": "bad request"}`} + } + return events.APIGatewayProxyResponse{StatusCode: 500, Body: `{"error": "query failed"}`} } diff --git a/docs/architecture/use-case-catalog.md b/docs/architecture/use-case-catalog.md index ebdd44e6..52d964de 100644 --- a/docs/architecture/use-case-catalog.md +++ b/docs/architecture/use-case-catalog.md @@ -49,6 +49,8 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/` | `MPLobbyingCohortComparison` | Party and national cohort comparison metrics for an MP exposure summary. | | `LobbyingCohortAverage` | Build-time party or national average communication count for a parliament. | | `DeviceSubscription` | An APNs token plus the topic/bill/member preferences registered for that device. | +| `PushNotificationPayload` | Backend-only internal push payload, currently carrying concluded live-vote division fields plus the original JSON document delivered to APNs. | +| `DispatchResult` | Backend-only push dispatch outcome counts for subscription fan-out, successful deliveries, and failed delivery attempts. | | `LiveParliamentStatus` | A snapshot of whether the House is currently sitting, what business is in progress, and whether a division is active. | | `OnThisDayItem` | A backend-only historical Parliament moment for the same calendar day in prior years. | | `EstimateOrg` | A GC InfoBase organization identifier and display name used to group Main Estimates rows. | @@ -90,6 +92,8 @@ 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. | +| `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. | | `HansardSearchRepository` | backend Go | outbound | Implemented: `backend/hansard-search/internal/usecase/search_hansard.go`; adapter: `backend/hansard-search/internal/adapter/sqlitefts5/repository.go`. | Query the verified SQLite FTS5 Hansard search index. | | `ManifestLoader` | backend Go | outbound | Implemented: `backend/hansard-search/internal/usecase/open_search_index.go`; adapter: `backend/hansard-search/internal/adapter/s3manifest/manifest_loader.go`. | Load the current Hansard search-index manifest. | @@ -762,6 +766,31 @@ Current implementation: --- +### DispatchPushNotification + +``` +Actor: Backend API caller (live-vote-poller Lambda) +Goal: Fan out an internal push notification payload to registered device subscriptions. +Inputs: PushNotificationPayload. +Outputs: DispatchResult with subscription, delivered, and failed-delivery counts. +Entities / values: PushNotificationPayload, DeviceSubscription, DispatchResult. +Ports: backend Go: `DeviceSubscriptionRepository`, `PushNotificationClient`. +Primary adapters: push-notification-dispatcher Lambda, Postgres/pgx device subscription repository, APNs HTTP client. +Current implementation: + backend/push-notification-dispatcher/internal/domain/domain.go + backend/push-notification-dispatcher/internal/usecase/dispatch_push_notification.go + backend/push-notification-dispatcher/internal/adapter/postgres/subscriptions.go + backend/push-notification-dispatcher/internal/adapter/apns/client.go + backend/push-notification-dispatcher/main.go +``` + +> **Boundary rule:** `DispatchPushNotification` owns fan-out policy and depends +> only on the subscription lookup and push delivery ports. API Gateway events, +> environment variables, `pgx`, APNs endpoint construction, HTTP transport, and +> response mapping stay in `main.go` or `internal/adapter/`. + +--- + ### ViewMemberSpeechFeed > Retired in EPAC-1934. The iOS member profile no longer exposes a per-member diff --git a/scripts/ci/check_go_dependency_rule.sh b/scripts/ci/check_go_dependency_rule.sh index af40f924..615c6964 100755 --- a/scripts/ci/check_go_dependency_rule.sh +++ b/scripts/ci/check_go_dependency_rule.sh @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" backend_dir="$repo_root/backend" remediation="Use case packages must not import frameworks. Move framework usage to internal/adapter/. See docs/architecture/use-case-catalog.md." +dispatcher_remediation="Push dispatcher main.go must compose the use case and adapters only. Keep device_subscriptions SQL and APNs HTTP transport in internal/adapter/." status=0 is_standard_library_import() { @@ -50,6 +51,32 @@ extract_imports() { ' "$1" } +check_push_dispatcher_entrypoint() { + local file="$backend_dir/push-notification-dispatcher/main.go" + [[ -f "$file" ]] || return 0 + + local imports + imports="$(extract_imports "$file")" + if ! grep -qx 'epac/push-notification-dispatcher/internal/usecase' <<< "$imports"; then + printf '%s does not import the DispatchPushNotification use case. %s\n' "${file#"$repo_root"/}" "$dispatcher_remediation" >&2 + status=1 + fi + + while IFS= read -r import_path; do + case "$import_path" in + github.com/jackc/pgx/v5|github.com/jackc/pgx/v5/*|net/http) + printf '%s imports forbidden adapter detail %s. %s\n' "${file#"$repo_root"/}" "$import_path" "$dispatcher_remediation" >&2 + status=1 + ;; + esac + done <<< "$imports" + + if grep -qE 'device_subscriptions|/3/device' "$file"; then + printf '%s contains dispatcher adapter details. %s\n' "${file#"$repo_root"/}" "$dispatcher_remediation" >&2 + status=1 + fi +} + while IFS= read -r file; do relative="${file#"$backend_dir"/}" service="${relative%%/*}" @@ -76,4 +103,6 @@ while IFS= read -r file; do done < <(extract_imports "$file") done < <(find "$backend_dir" -path '*/internal/usecase/*.go' -type f | sort) +check_push_dispatcher_entrypoint + exit "$status"