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
55 changes: 53 additions & 2 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/`
| `Sitting` | A House sitting date with Parliament/session metadata and source URL. |
| `EPetition` | An e-petition submitted to the House of Commons with signatures, sponsor, deadline, and optional government response. |
| `PetitionGovernmentResponse` | The government's official written response tabled in the House of Commons for a qualified petition. |
| `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, and LEGISinfo source URL. |
| `Bill` | A Parliament of Canada bill with number, title, stage, sponsor, Royal Assent date when available, and LEGISinfo source URL. |
| `BillVersion` | Backend-only bill publication/version row with source links for text, PDF, and XML artifacts. |
| `BillAmendment` | Backend-only House or committee amendment record associated with a bill and chamber stage. |
| `PBOCosting` | Backend-only Parliamentary Budget Officer costing link associated with a bill. |
Expand Down Expand Up @@ -53,6 +53,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/`
| `DeviceSubscription` | An APNs token plus the topic/bill/member preferences registered for that device. |
| `PushNotificationPayload` | Backend-only internal push payload carrying required live-vote division fields and the compacted original JSON document. |
| `LiveVoteNotification` | Backend-only display-ready push notification value with neutral title/body copy and source division identifiers for APNs delivery. |
| `RoyalAssentNotification` | iOS notification content value used when a followed bill transitions to Royal Assent during app or background refresh. |
| `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. |
Expand Down Expand Up @@ -92,11 +93,13 @@ to the issue that will build the missing artifact.
| `SittingRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/SittingRepository.swift`; adapter: `ios/epac/Data/Adapters/HansardSittingRepositoryAdapter.swift`. | List sitting dates and load transcripts for a sitting date. |
| `BillRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillRepository.swift`; adapter: `ios/epac/Data/Adapters/LEGISinfoBillRepository.swift`. | List current-session bills. |
| `BillRepository` | backend Go | outbound | Implemented: `backend/bills/internal/usecase/bills.go`; adapter: `backend/bills/internal/adapter/sqlite/repository.go`. | List bills and load bill-depth rows from the verified bills SQLite artifact. |
| `RecentLawQueryPort` | iOS Swift | outbound | Implemented by `ios/epac/Application/TrackRoyalAssent.swift`; adapter input is `BillRepository`. | Query current-session bills that received Royal Assent within the recent-law window. |
| `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. |
| `RoyalAssentNotificationPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift`; adapter: `ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift`. | Schedule notification content when a followed bill is observed to have received Royal Assent. |
| `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. |
Expand Down Expand Up @@ -497,6 +500,54 @@ Current implementation:

---

### TrackRoyalAssent

```
Actor: User (iOS app, Home feed / Bills tab)
Goal: Surface current-session bills that recently became law through Royal Assent.
Inputs: Current date, current-session bill records.
Outputs: Bills with Royal Assent in the last 30 days, sorted most recent first.
Entities / values: Bill.
Ports: iOS Swift: `RecentLawQueryPort`, `BillRepository`, `Clock`.
Primary adapters: HomeFeedView RecentlyBecameLawCard, BillsView Became Law filter, LEGISinfoBillRepository wrapping BillsService.
Current implementation:
ios/epac/Application/TrackRoyalAssent.swift
ios/epac/Domain/Ports/RecentLawQueryPort.swift
ios/epac/Domain/Ports/BillRepository.swift
ios/epac/Data/Adapters/LEGISinfoBillRepository.swift
ios/epac/Data/Adapters/BillsService.swift
ios/epac/Views/Home/HomeFeedView.swift
ios/epac/Views/Bills/BillsView.swift
```

> **Boundary note:** The use case filters typed `Bill` values only. LEGISinfo field names, date parsing, sponsor profile URL construction, and source-link construction remain inside `BillsService`.

---

### NotifyFollowedBillRoyalAssent

```
Actor: iOS background refresh / User foreground bill refresh
Goal: Notify the user when a bill they follow is observed transitioning to Royal Assent.
Inputs: Followed bill state, freshly fetched bill records.
Outputs: Notification content for the followed bill and updated last-known follow state.
Entities / values: Bill, BillFollowState, RoyalAssentNotification.
Ports: iOS Swift: `RoyalAssentNotificationPort`, `BillRepository`.
Primary adapters: BillFollowStore, LiveRoyalAssentNotificationAdapter, BackgroundRefreshManager, BillsView refresh path.
Current implementation:
ios/epac/Application/NotifyFollowedBillRoyalAssent.swift
ios/epac/Domain/Entities/RoyalAssentNotification.swift
ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift
ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift
ios/epac/Util/BillFollowStore.swift
ios/epac/Util/BackgroundRefreshManager.swift
ios/epac/Model/Fetch.swift
```

> **Boundary note:** UserNotifications is adapter detail. The use case formats typed notification content and does not import APNs or `UNUserNotificationCenter`; iOS background wake timing remains governed by BGAppRefresh.

---

### GetBillDepth

```
Expand Down Expand Up @@ -964,7 +1015,7 @@ Current implementation:

> Retired in EPAC-1921. The backend `live-status` Lambda and `/api/v1/live` route are removed from desired state. The historical architecture note remains in `docs/architecture/live-status-backend-epac165.md`.

---
---

### Lobbying-index builder boundary

Expand Down
25 changes: 25 additions & 0 deletions ios/epac/Application/NotifyFollowedBillRoyalAssent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// NotifyFollowedBillRoyalAssent.swift
// epac
//

import Foundation

@MainActor
struct NotifyFollowedBillRoyalAssent {
private let notificationPort: any RoyalAssentNotificationPort

init(notificationPort: any RoyalAssentNotificationPort) {
self.notificationPort = notificationPort
}

func execute(bill: Bill) async throws {
let title = bill.number
let body = String(
format: NSLocalizedString("notification.royalAssent.body", comment: ""),
bill.title
)
let notification = RoyalAssentNotification(title: title, body: body)
try await notificationPort.sendNotification(notification)
}
}
44 changes: 44 additions & 0 deletions ios/epac/Application/TrackRoyalAssent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// TrackRoyalAssent.swift
// epac
//

import Foundation

@MainActor
struct TrackRoyalAssent: RecentLawQueryPort {
private static let recentWindowDays = 30

private let repository: any BillRepository
private let clock: any Clock
private let calendar: Calendar

init(
repository: any BillRepository,
clock: any Clock = SystemClock(),
calendar: Calendar = .current
) {
self.repository = repository
self.clock = clock
self.calendar = calendar
}

func recentlyBecameLaw() async throws -> [Bill] {
let bills = try await repository.fetchBills()
let today = calendar.startOfDay(for: clock.now)
let earliest = calendar.date(byAdding: .day, value: -Self.recentWindowDays, to: today) ?? today

return bills
.filter { bill in
guard bill.status == .royalAssent,
let date = bill.becameLawDate else {
return false
}
let day = calendar.startOfDay(for: date)
return day >= earliest && day <= today
}
.sorted { lhs, rhs in
(lhs.becameLawDate ?? .distantPast) > (rhs.becameLawDate ?? .distantPast)
}
}
}
19 changes: 19 additions & 0 deletions ios/epac/Data/Adapters/BillsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ struct BillsService {
let readingDates = billReadingDates(from: raw)
let introducedDate = billIntroducedDate(from: readingDates)
let stages = billStages(number: number, raw: raw, dates: readingDates)
let summary = billSummary(from: raw, selectedTitle: title)

let currentStage = raw.LatestCompletedMajorStageEn ?? raw.CurrentStatusEn ?? ""
let billSlug = "\(parliament)-\(session)/\(number.lowercased())"
Expand All @@ -103,6 +104,9 @@ struct BillsService {
status: billStatus(from: raw),
currentStage: currentStage,
introducedDate: introducedDate,
royalAssentDate: readingDates.royalAssent,
summary: summary,
sponsorProfileURL: sponsorProfileURL(from: raw),
stages: stages,
legisInfoURL: legisURL,
type: billType(from: raw),
Expand All @@ -121,6 +125,15 @@ struct BillsService {
return nil
}

private static func billSummary(from raw: LEGISinfoBill, selectedTitle: String) -> String? {
guard let longTitle = raw.LongTitleEn?.trimmingCharacters(in: .whitespacesAndNewlines),
!longTitle.isEmpty,
longTitle != selectedTitle else {
return nil
}
return longTitle
}

private static func billStatus(from raw: LEGISinfoBill) -> BillStatus {
let statusLower = (raw.CurrentStatusEn ?? "").lowercased()
if raw.ReceivedRoyalAssentDateTime != nil || statusLower.contains("royal assent") {
Expand All @@ -137,6 +150,11 @@ struct BillsService {
return billTypeMappings.first { typeLower.contains($0.needle) }?.type ?? .unknown
}

private static func sponsorProfileURL(from raw: LEGISinfoBill) -> URL? {
guard let sponsorID = raw.SponsorId, sponsorID > 0 else { return nil }
return URL(string: "https://www.ourcommons.ca/members/en/\(sponsorID)")
}

private static func billReadingDates(from raw: LEGISinfoBill) -> BillReadingDates {
BillReadingDates(
houseFirst: parseDate(raw.PassedHouseFirstReadingDateTime),
Expand Down Expand Up @@ -225,6 +243,7 @@ private struct LEGISinfoBill: Decodable {
let CurrentStatusEn: String?
let BillTypeEn: String?
let SponsorEn: String?
let SponsorId: Int?
let OriginatingChamberId: Int?
let ParliamentNumber: Int
let SessionNumber: Int
Expand Down
34 changes: 34 additions & 0 deletions ios/epac/Data/Adapters/LiveRoyalAssentNotificationAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// LiveRoyalAssentNotificationAdapter.swift
// epac
//

import Foundation
import UserNotifications

struct LiveRoyalAssentNotificationAdapter: RoyalAssentNotificationPort {
func sendNotification(_ notification: RoyalAssentNotification) async throws {
let center = UNUserNotificationCenter.current()

// Check authorization first; request it if not determined yet.
let settings = await center.notificationSettings()
if settings.authorizationStatus == .notDetermined {
_ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
}

let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default

// Fire the local notification almost immediately (1 second delay).
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: trigger
)

try await center.add(request)
}
}
11 changes: 11 additions & 0 deletions ios/epac/Domain/Entities/RoyalAssentNotification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// RoyalAssentNotification.swift
// epac
//

import Foundation

struct RoyalAssentNotification: Equatable, Sendable {
let title: String
let body: String
}
11 changes: 11 additions & 0 deletions ios/epac/Domain/Ports/RecentLawQueryPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// RecentLawQueryPort.swift
// epac
//

import Foundation

@MainActor
protocol RecentLawQueryPort: Sendable {
func recentlyBecameLaw() async throws -> [Bill]
}
11 changes: 11 additions & 0 deletions ios/epac/Domain/Ports/RoyalAssentNotificationPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// RoyalAssentNotificationPort.swift
// epac
//

import Foundation

@MainActor
protocol RoyalAssentNotificationPort: Sendable {
func sendNotification(_ notification: RoyalAssentNotification) async throws
}
43 changes: 41 additions & 2 deletions ios/epac/Model/Bill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ struct Bill: Identifiable, Codable, Sendable {
let status: BillStatus
let currentStage: String
let introducedDate: Date?
let royalAssentDate: Date?
let summary: String?
let sponsorProfileURL: URL?
let stages: [BillStage]
let legisInfoURL: URL
let type: BillType
let parliament: Int
let session: Int

var becameLawDate: Date? {
royalAssentDate ?? stages.first { $0.name.localizedCaseInsensitiveContains("royal assent") }?.completedDate
}
}

// MARK: - BillStage
Expand Down Expand Up @@ -58,12 +65,27 @@ enum BillStatus: String, Codable, Equatable, Sendable {

init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = BillStatus(rawValue: raw) ?? .unknown
if let exact = BillStatus(rawValue: raw) {
self = exact
return
}

let normalized = raw.lowercased()
if normalized.contains("royal assent") || normalized.contains("royalassent") {
self = .royalAssent
} else if normalized.contains("defeat") {
self = .defeated
} else if normalized.isEmpty {
self = .unknown
} else {
self = .inProgress
}
}
}

// MARK: - BillType

// swiftlint:disable redundant_string_enum_value
enum BillType: String, Codable, Equatable, Sendable {
case government = "government"
case privateMember = "privateMember"
Expand Down Expand Up @@ -94,6 +116,23 @@ enum BillType: String, Codable, Equatable, Sendable {

init(from decoder: Decoder) throws {
let raw = try decoder.singleValueContainer().decode(String.self)
self = BillType(rawValue: raw) ?? .unknown
if let exact = BillType(rawValue: raw) {
self = exact
return
}

let normalized = raw.lowercased()
if normalized.contains("private member") {
self = .privateMember
} else if normalized.contains("senate private") {
self = .senatePrivate
} else if normalized.contains("senate public") {
self = .senatePublic
} else if normalized.contains("government") {
self = .government
} else {
self = .unknown
}
}
}
// swiftlint:enable redundant_string_enum_value
3 changes: 3 additions & 0 deletions ios/epac/Model/Fetch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ actor Fetch: ModelActor, ObservableObject {
try? await downloadFiscalMonitorEntries()
try? loadCabinetPositions()
try? loadMinisterialExpenses()
if let bills = try? await BillsService.fetchBills() {
await BillFollowStore.shared.updateStoredState(in: bills)
}
}

func hansard(_ date: Date) async throws -> PersistentIdentifier {
Expand Down
6 changes: 6 additions & 0 deletions ios/epac/Util/BillFollowStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
var lastKnownStatus: String // BillStatus.rawValue
var lastKnownStage: String // currentStage string
var followedAt: Date
var hasUnreadUpdate: Bool? // status changed since last viewed

Check warning on line 16 in ios/epac/Util/BillFollowStore.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Prefer non-optional booleans over optional booleans (discouraged_optional_boolean)
}

@MainActor
Expand Down Expand Up @@ -102,6 +102,12 @@
let statusChanged = bill.status.rawValue != state.lastKnownStatus
let stageChanged = !bill.currentStage.isEmpty && bill.currentStage != state.lastKnownStage
if statusChanged || stageChanged {
if statusChanged && bill.status == .royalAssent {
Task {
let useCase = NotifyFollowedBillRoyalAssent(notificationPort: LiveRoyalAssentNotificationAdapter())
try? await useCase.execute(bill: bill)
}
}
followed[bill.number] = BillFollowState(
lastKnownStatus: bill.status.rawValue,
lastKnownStage: bill.currentStage,
Expand Down
Loading
Loading