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
26 changes: 25 additions & 1 deletion docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/`
| `BillCommitteeStage` | A bill's active committee study stage, carrying the committee, study dates, upcoming meetings, and past meetings. |
| `BillCommitteeMeeting` | A committee meeting tied to a bill study, including meeting number, date, optional evidence URL, and witness count. |
| `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. |
| `BillAmendment` | House or committee amendment record associated with a bill and chamber stage, with number, sponsor name, status, stage, verbatim amendment text, and source link. Surfaced on the iOS bill page via `LoadBillAmendments`. |
| `PBOCosting` | Backend-only Parliamentary Budget Officer costing link associated with a bill. |
| `ParliamentaryCommittee` | A House or Senate committee reference with acronym, name, chamber code, and authoritative source URL. |
| `MemberBiography` | Parliament.ca biography details for an MP profile, including service periods, roles, education, professional background, source URL, and summary text where available. |
Expand Down Expand Up @@ -99,6 +99,7 @@ to the issue that will build the missing artifact.
| `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. |
| `BillCommitteeStageRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillCommitteeStageRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillCommitteeStageRepository.swift`. | Load the committee currently studying a bill, including study dates and meeting rows. |
| `BillAmendmentsRepository` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/BillAmendmentsRepository.swift`; adapter: `ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift`. | Load amendments tabled against a bill (number, mover, stage, status, verbatim text) from the backend bill-depth endpoint. |
| `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. |
Expand Down Expand Up @@ -600,6 +601,29 @@ Current implementation:

---

### LoadBillAmendments

```
Actor: User (iOS app, bill detail)
Goal: See the amendments tabled against a bill — number, mover, stage, disposition (passed / defeated / withdrawn), and the full authoritative amendment text rendered verbatim.
Inputs: LEGISinfo bill ID.
Outputs: Optional [BillAmendment]; nil when the backend has no amendments record for the bill (hide panel), empty array when the bill is tracked but no amendments tabled yet (show empty-state row), non-empty array otherwise.
Entities / values: Bill, BillAmendment, BillAmendmentStatus.
Ports: iOS Swift: `BillAmendmentsRepository`.
Primary adapters: BackendBillAmendmentsRepository (GET /api/v1/bills/{id}), BillDetailView, BillAmendmentsPanel.
Current implementation:
ios/epac/Application/LoadBillAmendments.swift
ios/epac/Domain/Entities/BillAmendment.swift
ios/epac/Domain/Ports/BillAmendmentsRepository.swift
ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift
ios/epac/Views/Bills/BillAmendmentsPanel.swift
ios/epac/Views/Bills/BillDetailView.swift
```

> **Boundary rule:** Amendment text is rendered verbatim on the bill page — no paraphrasing, no summarization. iOS decodes only the backend's typed JSON; LEGISinfo and committee-minute parsing remain backend responsibilities. The bill page hides the panel when the use case returns `nil`, and renders an empty-state row when it returns an empty array.

---

### TagPrivateMembersBill

```
Expand Down
14 changes: 14 additions & 0 deletions ios/epac/Application/LoadBillAmendments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/// Loads the amendments tabled against one bill.
///
/// Returns `nil` when the backend has no amendments record for the bill, so
/// the caller can hide the "Amendments" panel without inspecting wire-format
/// details. An empty array means the bill is tracked but no amendments have
/// been tabled yet — the panel shows an empty-state row in that case (a UI
/// policy, per the feature's boundary rule).
struct LoadBillAmendments: Sendable {
let repository: any BillAmendmentsRepository

func execute(billID: String) async throws -> [BillAmendment]? {
try await repository.loadBillAmendments(billID: billID)
}
}
140 changes: 140 additions & 0 deletions ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation

/// Loads a bill's amendments list from the epac backend.
///
/// Contract — `GET /api/v1/bills/{id}` (bill-depth endpoint):
/// - `200`: bill JSON whose `amendments` field carries the list (see
/// `BillDepthAmendmentsResponse`). The backend has already ingested
/// LEGISinfo and committee-minute amendments into the bill artifact, so the
/// iOS side only decodes and renders.
/// - `204` / `404`: backend has no record for that bill; the panel is hidden.
///
/// The iOS layer never parses LEGISinfo or parl.ca wire formats; this typed
/// JSON shape is the boundary between backend and app.
struct BackendBillAmendmentsRepository: BillAmendmentsRepository {
fileprivate enum Constants {
static let requestTimeout: TimeInterval = 20
static let successStatusLowerBound = 200
static let successStatusUpperBound = 300
static let noContentStatus = 204
static let notFoundStatus = 404
static let pathPrefix = "api/v1/bills"

static var successStatusCodes: Range<Int> {
successStatusLowerBound..<successStatusUpperBound
}
}

private let network: NetworkService
private let baseURL: URL
private let decoder: JSONDecoder

init(
network: NetworkService = .shared,
baseURL: URL = BackendConfig.shared.baseURL,
decoder: JSONDecoder = JSONDecoder()
) {
self.network = network
self.baseURL = baseURL
self.decoder = decoder
}

func loadBillAmendments(billID: String) async throws -> [BillAmendment]? {
let path = "\(Constants.pathPrefix)/\(billID)"
guard let data = try await get(path: path) else {
return nil
}
let response = try decoder.decode(BillDepthAmendmentsResponse.self, from: data)
return response.bill.amendments.map(\.domain)
}

private func get(path: String) async throws -> Data? {
let url = baseURL.appending(path: path)
var request = URLRequest(url: url, timeoutInterval: Constants.requestTimeout)
request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await network.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
if http.statusCode == Constants.noContentStatus || http.statusCode == Constants.notFoundStatus {
return nil
}
guard Constants.successStatusCodes.contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return data
}

fileprivate static func parseDate(_ value: String?) -> Date? {
guard let value, !value.isEmpty else { return nil }
return dateFormatter.date(from: value)
}

private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}

// MARK: - Backend JSON contract

private struct BillDepthAmendmentsResponse: Decodable {
let bill: BillDTO

struct BillDTO: Decodable {
let amendments: [AmendmentDTO]

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
amendments = try container.decodeIfPresent([AmendmentDTO].self, forKey: .amendments) ?? []
}

enum CodingKeys: String, CodingKey {

Check warning on line 96 in ios/epac/Data/Repositories/BackendBillAmendmentsRepository.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Types should be nested at most 1 level deep (nesting)
case amendments
}
}
}

private struct AmendmentDTO: Decodable {
let id: String?
let number: String?
let title: String?
let status: String?
let stage: String?
let sponsorName: String?
let proposedOn: String?
let text: String?
let sourceURL: URL?

enum CodingKeys: String, CodingKey {
case id
case number
case title
case status
case stage
case sponsorName = "sponsor_name"
case proposedOn = "proposed_on"
case text
case sourceURL = "source_url"
}

var domain: BillAmendment {
let rawStatus = status ?? ""
return BillAmendment(
id: id ?? number ?? UUID().uuidString,
number: number ?? "",
title: title?.isEmpty == false ? title : nil,
sponsorName: sponsorName ?? "",
proposedOn: BackendBillAmendmentsRepository.parseDate(proposedOn),
stage: stage ?? "",
status: BillAmendmentStatus.from(rawStatus),
statusLabel: rawStatus,
text: text ?? "",
sourceURL: sourceURL
)
}
}
74 changes: 74 additions & 0 deletions ios/epac/Domain/Entities/BillAmendment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation

/// One amendment tabled against a bill at committee, report, or third-reading
/// stage. Sourced from LEGISinfo and committee minutes via the epac backend's
/// bill-depth endpoint; the iOS layer never parses parl.ca markup directly.
///
/// Civic-content rule: `text` is the authoritative amendment text as published
/// by Parliament. The bill page renders this verbatim — no paraphrasing, no
/// summarization — per the feature's boundary rule.
struct BillAmendment: Identifiable, Equatable, Sendable {
/// Stable identifier from the backend artifact.
let id: String

/// Sponsor-prefixed amendment label as published (e.g. "LIB-1", "NDP-3").
let number: String

/// Short title or descriptor when the backend has one. Optional because the
/// upstream record is uneven — some amendments only carry a number.
let title: String?

/// The mover's display name as recorded by Parliament. The backend stores
/// only a name string today, so this is not yet linkable to an MP profile.
let sponsorName: String

/// When the amendment was proposed, if known.
let proposedOn: Date?

/// Legislative stage the amendment was moved at, verbatim from the backend
/// (e.g. "Committee", "Report Stage", "Third Reading").
let stage: String

/// Disposition reported by Parliament. `unknown` covers records the backend
/// has not classified — render the raw `statusLabel` for those.
let status: BillAmendmentStatus

/// Verbatim status label as returned by the backend, used both as the
/// fallback display when `status == .unknown` and for diagnostics.
let statusLabel: String

/// Authoritative amendment text, rendered verbatim on tap.
let text: String

/// Source link on parl.ca / LEGISinfo when the backend has one.
let sourceURL: URL?
}

/// Disposition of a bill amendment as reported by Parliament.
///
/// The backend ingests the raw status string; this enum normalizes the
/// recurring values so the UI can colour them consistently. Anything the
/// backend has not classified maps to `unknown`, and the raw label is shown
/// instead.
enum BillAmendmentStatus: String, Equatable, Sendable {
case passed
case defeated
case withdrawn
case unknown

/// Map a raw backend status string onto a known case. Unknown values
/// collapse to `.unknown`, and callers should show `BillAmendment.statusLabel`
/// verbatim in that case.
static func from(_ rawValue: String) -> BillAmendmentStatus {
switch rawValue.lowercased() {
case "passed", "adopted", "agreed", "agreed_to", "agreed to", "carried":
return .passed
case "defeated", "rejected", "negatived":
return .defeated
case "withdrawn", "not_moved", "not moved":
return .withdrawn
default:
return .unknown
}
}
}
9 changes: 9 additions & 0 deletions ios/epac/Domain/Ports/BillAmendmentsRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/// Reads amendments tabled against a bill.
///
/// `nil` means the backend has no amendments record for the bill at all (404
/// or 204) — the panel hides itself. An empty array means "bill is tracked but
/// no amendments tabled yet" — the panel shows an empty-state row. Throws only
/// on transport or decoding failures.
protocol BillAmendmentsRepository: Sendable {
func loadBillAmendments(billID: String) async throws -> [BillAmendment]?
}
Loading
Loading