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
24 changes: 24 additions & 0 deletions docs/architecture/use-case-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ For the Clean Architecture shape this catalog assumes, see [`docs/architecture/`
| `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. |
| `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. |
| `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. |
Expand Down Expand Up @@ -145,6 +147,7 @@ to the issue that will build the missing artifact.
| `DivisionsFetching` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/ourcommons/divisions_client.go`. | Fetch live parliamentary divisions from ourcommons.ca. |
| `ArtifactRepository` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/artifacts/repository.go`. | Check existence and persist completed vote payload artifacts. |
| `PushDispatching` | backend Go | outbound | Implemented: `backend/live-vote-poller/internal/usecase/poll_live_divisions.go`; adapter: `backend/live-vote-poller/internal/adapter/push/dispatcher.go`. | Forward concluded division payloads to the push-notification dispatcher. |
| `PetitionGovernmentResponseQueryPort` | iOS Swift | outbound | Implemented: `ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift`; adapter: `ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift`. | Load petition government responses from the backend endpoint. |

## Use Cases

Expand Down Expand Up @@ -1321,6 +1324,27 @@ Current implementation:

---

### LoadPetitionGovernmentResponse

```
Actor: User (iOS app, foreground)
Goal: Surface the government's tabled written response for a petition in the detail view.
Inputs: Petition ID.
Outputs: PetitionGovernmentResponse value object (text, date tabled, responding minister) or nil.
Entities / values: EPetition, PetitionGovernmentResponse.
Ports: iOS Swift: `PetitionGovernmentResponseQueryPort`.
Primary adapters: BackendPetitionGovernmentResponseQueryPort, PetitionDetailView.
Current implementation:
ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift
ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift
ios/epac/Data/Repositories/BackendPetitionGovernmentResponseQueryPort.swift
ios/epac/Views/Petitions/PetitionDetailView.swift
```

> Boundary rule: HTML/wire-format parsing of the petitions source lives only in the backend ingestion adapter; iOS consumes a typed JSON shape.

---

## Boundary Check

Run locally to verify inward files do not import framework types:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// BackendPetitionGovernmentResponseQueryPort.swift
// epac
//

import Foundation

@MainActor
struct BackendPetitionGovernmentResponseQueryPort: PetitionGovernmentResponseQueryPort {
fileprivate enum Constants {
static let requestTimeout: TimeInterval = 20
static let successStatusLowerBound = 200
static let successStatusUpperBound = 300
static let notFoundStatusCode = 404
static let pathPrefix = "api/v1/petitions"
static let pathSuffix = "response"

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 fetchGovernmentResponse(for petitionID: String) async throws -> PetitionGovernmentResponse? {
let path = "\(Constants.pathPrefix)/\(petitionID)/\(Constants.pathSuffix)"
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.notFoundStatusCode {
return nil
}

guard Constants.successStatusCodes.contains(http.statusCode) else {
throw URLError(.badServerResponse)
}

let dto = try decoder.decode(PetitionGovernmentResponseDTO.self, from: data)
return dto.domain
}
}

private struct PetitionGovernmentResponseDTO: Decodable {
let text: String
let tabledOn: String
let respondingMinister: String?

enum CodingKeys: String, CodingKey {
case text
case tabledOn = "tabled_on"
case respondingMinister = "responding_minister"
}

var domain: PetitionGovernmentResponse {
let date = Self.parseDate(tabledOn) ?? Date()
return PetitionGovernmentResponse(
text: text,
tabledOn: date,
respondingMinister: respondingMinister
)
}

private 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
}()
}
11 changes: 11 additions & 0 deletions ios/epac/Domain/Ports/PetitionGovernmentResponseQueryPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
//
// PetitionGovernmentResponseQueryPort.swift
// epac
//

import Foundation

@MainActor
protocol PetitionGovernmentResponseQueryPort: Sendable {
func fetchGovernmentResponse(for petitionID: String) async throws -> PetitionGovernmentResponse?
}
19 changes: 19 additions & 0 deletions ios/epac/Domain/UseCases/LoadPetitionGovernmentResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// LoadPetitionGovernmentResponse.swift
// epac
//

import Foundation

@MainActor
struct LoadPetitionGovernmentResponse {
private let queryPort: PetitionGovernmentResponseQueryPort

init(queryPort: PetitionGovernmentResponseQueryPort) {
self.queryPort = queryPort
}

func execute(petitionID: String) async throws -> PetitionGovernmentResponse? {
try await queryPort.fetchGovernmentResponse(for: petitionID)
}
}
29 changes: 29 additions & 0 deletions ios/epac/Model/EPetition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
import Foundation
import SwiftUI

struct PetitionGovernmentResponse: Codable, Equatable {
let text: String
let tabledOn: Date
let respondingMinister: String?
}

struct EPetition: Identifiable {
let id: String // petition number, e.g. "e-7344"
let subject: String // category / topic (e.g. "Transportation")
Expand All @@ -17,6 +23,29 @@ struct EPetition: Identifiable {
let deadline: Date?
let status: PetitionStatus
let petitionURL: URL // official page on petitions.ourcommons.ca
let governmentResponse: PetitionGovernmentResponse?

init(
id: String,
subject: String,
keywords: [String],
sponsorName: String,
signatureCount: Int,
deadline: Date?,
status: PetitionStatus,
petitionURL: URL,
governmentResponse: PetitionGovernmentResponse? = nil
) {
self.id = id
self.subject = subject
self.keywords = keywords
self.sponsorName = sponsorName
self.signatureCount = signatureCount
self.deadline = deadline
self.status = status
self.petitionURL = petitionURL
self.governmentResponse = governmentResponse
}
}

enum PetitionStatus: String {
Expand Down
111 changes: 111 additions & 0 deletions ios/epac/Views/Petitions/PetitionDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ private enum PetitionDetailLayout {

struct PetitionDetailView: View {
let petition: EPetition
private let queryPort: any PetitionGovernmentResponseQueryPort

@State private var response: PetitionGovernmentResponse?
@State private var isLoading = false
@State private var loadFailed = false

init(
petition: EPetition,
queryPort: any PetitionGovernmentResponseQueryPort = BackendPetitionGovernmentResponseQueryPort()
) {
self.petition = petition
self.queryPort = queryPort
}

var body: some View {
List {
Expand Down Expand Up @@ -84,6 +97,9 @@ struct PetitionDetailView: View {
}
}

// MARK: - Government Response Section
responseSection

// MARK: - Sign link
Section {
Link(
Expand All @@ -100,6 +116,101 @@ struct PetitionDetailView: View {
.adaptiveReadingWidth()
.navigationTitle(petition.id)
.navigationBarTitleDisplayMode(.inline)
.task {
await loadResponse()
}
}

// MARK: - Government Response UI

@ViewBuilder
private var responseSection: some View {
if isLoading {
Section(NSLocalizedString("petitions.response.section", comment: "")) {
HStack {
Spacer()
ProgressView()
Spacer()
}
}
} else if let response = response {
Section(NSLocalizedString("petitions.response.section", comment: "")) {
VStack(alignment: .leading, spacing: EpacSpacing.xs) {
if let minister = response.respondingMinister, !minister.isEmpty {
Text(NSLocalizedString("petitions.response.minister", comment: ""))
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
Text(minister)
.font(.subheadline)
}

Text(String(format: NSLocalizedString("petitions.response.tabled", comment: ""), response.tabledOn.formatted(date: .abbreviated, time: .omitted)))
.font(.caption2)
.foregroundStyle(.secondary)

Divider()
.padding(.vertical, EpacSpacing.xxs)

Text(response.text)
.font(.body)
}
}
} else if loadFailed {
Section(NSLocalizedString("petitions.response.section", comment: "")) {
HStack {
Text(NSLocalizedString("petitions.error.description", comment: ""))
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button(NSLocalizedString("Retry", comment: "")) {
Task {
await loadResponse()
}
}
.buttonStyle(.borderless)
}
}
} else {
// Check if it qualified
if petition.signatureCount >= 500 {
Section(NSLocalizedString("petitions.response.section", comment: "")) {
HStack(spacing: EpacSpacing.xs) {
Image(systemName: "clock.fill")
.foregroundStyle(.orange)
Text(NSLocalizedString("petitions.response.awaiting", comment: ""))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
} else if petition.status == .closed || petition.status == .certified || petition.status == .responseReceived {
// Closed or certified and did not reach 500 signatures: did not qualify
Section(NSLocalizedString("petitions.response.section", comment: "")) {
HStack(spacing: EpacSpacing.xs) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
Text(NSLocalizedString("petitions.response.notQualified", comment: ""))
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
}

private func loadResponse() async {
if let existing = petition.governmentResponse {
self.response = existing
return
}
isLoading = true
loadFailed = false
defer { isLoading = false }
do {
let useCase = LoadPetitionGovernmentResponse(queryPort: queryPort)
self.response = try await useCase.execute(petitionID: petition.id)
} catch {
loadFailed = true
}
}
}

Expand Down
Loading
Loading