commit
ed06b68405
@ -0,0 +1,368 @@ |
||||
// |
||||
// FederalDataService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 09/07/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import CoreLocation |
||||
import LeStorage |
||||
import PadelClubData |
||||
|
||||
struct UmpireContactInfo: Codable { |
||||
let name: String? |
||||
let email: String? |
||||
let phone: String? |
||||
} |
||||
|
||||
/// Response model for the batch umpire data endpoint |
||||
struct UmpireDataResponse: Codable { |
||||
let results: [String: UmpireContactInfo] |
||||
} |
||||
|
||||
// New struct for the response from get_fft_club_tournaments and get_fft_all_tournaments |
||||
struct TournamentsAPIResponse: Codable { |
||||
let success: Bool |
||||
let tournaments: [FederalTournament] |
||||
let totalResults: Int |
||||
let currentCount: Int |
||||
let pagesScraped: Int? // Optional, as it might not always be present or relevant |
||||
let page: Int? // Optional, as it might not always be present or relevant |
||||
let umpireDataIncluded: Bool? // Only for get_fft_club_tournaments_with_umpire_data |
||||
let message: String |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case success |
||||
case tournaments |
||||
case totalResults = "total_results" |
||||
case currentCount = "current_count" |
||||
case pagesScraped = "pages_scraped" |
||||
case page |
||||
case umpireDataIncluded = "umpire_data_included" |
||||
case message |
||||
} |
||||
} |
||||
|
||||
// MARK: - FederalDataService |
||||
|
||||
/// `FederalDataService` handles all API calls related to federal data (clubs, tournaments, umpire info). |
||||
/// All direct interactions with `tenup.fft.fr` are now assumed to be handled by your backend. |
||||
class FederalDataService { |
||||
static let shared: FederalDataService = FederalDataService() |
||||
|
||||
// The 'formId', 'tenupJsonDecoder', 'runTenupTask', and 'getNewBuildForm' |
||||
// from the legacy NetworkFederalService are removed as their logic is now |
||||
// handled server-side. |
||||
|
||||
/// Fetches federal clubs based on geographic criteria. |
||||
/// - Parameters: |
||||
/// - country: The country code (e.g., "fr"). |
||||
/// - city: The city name or address for search. |
||||
/// - radius: The search radius in kilometers. |
||||
/// - location: Optional `CLLocation` for user's precise position to calculate distance. |
||||
/// - Returns: A `FederalClubResponse` object containing a list of clubs and total count. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "country", value: country), |
||||
URLQueryItem(name: "city", value: city), |
||||
URLQueryItem(name: "radius", value: String(Int(radius))) |
||||
] |
||||
|
||||
if let location = location { |
||||
queryItems.append(URLQueryItem(name: "lat", value: location.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us"))))) |
||||
queryItems.append(URLQueryItem(name: "lng", value: location.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us"))))) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for federal clubs: 'fft/federal-clubs/' |
||||
let urlRequest = try service._baseRequest(servicePath: "fft/federal-clubs?\(queryString)", method: .get, requiresToken: false) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) // Keep URLError for generic network issues |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
return try JSONDecoder().decode(FederalClubResponse.self, from: data) |
||||
} catch { |
||||
print("Decoding error for FederalClubResponse: \(error)") |
||||
// Map decoding error to a generic API error |
||||
throw NetworkManagerError.apiError("Failed to decode FederalClubResponse: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches federal tournaments for a specific club. |
||||
/// This function now calls your backend, which in turn handles the `form_build_id` and pagination. |
||||
/// The `tournaments` parameter is maintained for signature compatibility but is not used for server-side fetching. |
||||
/// Client-side accumulation of results from multiple pages should be handled by the caller. |
||||
/// - Parameters: |
||||
/// - page: The current page number for pagination. |
||||
/// - tournaments: An array of already gathered tournaments (for signature compatibility; not used internally for fetching). |
||||
/// - club: The name of the club. |
||||
/// - codeClub: The unique code of the club. |
||||
/// - startDate: Optional start date for filtering tournaments. |
||||
/// - endDate: Optional end date for filtering tournaments. |
||||
/// - Returns: An array of `FederalTournament` objects for the requested page. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> TournamentsAPIResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "club_code", value: codeClub), |
||||
URLQueryItem(name: "club_name", value: club), |
||||
URLQueryItem(name: "page", value: String(page)) |
||||
] |
||||
|
||||
if let startDate = startDate { |
||||
queryItems.append(URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted)) |
||||
} |
||||
if let endDate = endDate { |
||||
queryItems.append(URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted)) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for club tournaments: 'fft/club-tournaments/' |
||||
let urlRequest = try service._baseRequest(servicePath: "fft/club-tournaments?\(queryString)", method: .get, requiresToken: false) |
||||
|
||||
print(urlRequest.url?.absoluteString) |
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
// Your backend should return a direct array of FederalTournament for the requested page |
||||
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data) |
||||
return federalTournaments |
||||
} catch { |
||||
print("Decoding error for FederalTournament array: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches all federal tournaments based on various filtering options. |
||||
/// This function now calls your backend, which handles the complex filtering and data retrieval. |
||||
/// The return type `[HttpCommand]` is maintained for signature compatibility, |
||||
/// wrapping the actual `[FederalTournament]` data within an `HttpCommand` structure. |
||||
/// - Parameters: |
||||
/// - sortingOption: How to sort the results (e.g., "dateDebut asc"). |
||||
/// - page: The current page number for pagination. |
||||
/// - startDate: The start date for the tournament search. |
||||
/// - endDate: The end date for the tournament search. |
||||
/// - city: The city to search within. |
||||
/// - distance: The search distance from the city. |
||||
/// - categories: An array of `TournamentCategory` to filter by. |
||||
/// - levels: An array of `TournamentLevel` to filter by. |
||||
/// - lat: Optional latitude for precise location search. |
||||
/// - lng: Optional longitude for precise location search. |
||||
/// - ages: An array of `FederalTournamentAge` to filter by. |
||||
/// - types: An array of `FederalTournamentType` to filter by. |
||||
/// - nationalCup: A boolean indicating if national cup tournaments should be included. |
||||
/// - Returns: An array of `HttpCommand` objects, containing the `FederalTournament` data. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getAllFederalTournaments( |
||||
sortingOption: String, |
||||
page: Int, |
||||
startDate: Date, |
||||
endDate: Date, |
||||
city: String, |
||||
distance: Double, |
||||
categories: [TournamentCategory], |
||||
levels: [TournamentLevel], |
||||
lat: String?, |
||||
lng: String?, |
||||
ages: [FederalTournamentAge], |
||||
types: [FederalTournamentType], |
||||
nationalCup: Bool |
||||
) async throws -> TournamentsAPIResponse { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Construct query parameters for your backend API |
||||
var queryItems: [URLQueryItem] = [ |
||||
URLQueryItem(name: "sort", value: sortingOption), |
||||
URLQueryItem(name: "page", value: String(page)), |
||||
URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted), |
||||
URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted), |
||||
URLQueryItem(name: "city", value: city), |
||||
URLQueryItem(name: "distance", value: String(Int(distance))), |
||||
URLQueryItem(name: "national_cup", value: nationalCup ? "true" : "false") |
||||
] |
||||
|
||||
if let lat = lat, !lat.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "lat", value: lat)) |
||||
} |
||||
if let lng = lng, !lng.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "lng", value: lng)) |
||||
} |
||||
|
||||
// Add array parameters (assuming your backend can handle comma-separated or multiple query params) |
||||
if !categories.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "categories", value: categories.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
if !levels.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "levels", value: levels.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
if !ages.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "ages", value: ages.map { String($0.rawValue) }.joined(separator: ","))) |
||||
} |
||||
|
||||
if !types.isEmpty { |
||||
queryItems.append(URLQueryItem(name: "types", value: types.map { $0.rawValue }.joined(separator: ","))) |
||||
} |
||||
|
||||
// Build the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
let queryString = urlComponents.query ?? "" |
||||
|
||||
// The servicePath now points to your backend's endpoint for all tournaments: 'fft/all-tournaments/' |
||||
let urlRequest = try service._baseRequest(servicePath: "fft/all-tournaments?\(queryString)", method: .get, requiresToken: true) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
print(urlRequest.url?.absoluteString ?? "No URL") |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
// Your backend should return a direct array of FederalTournament |
||||
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data) |
||||
return federalTournaments |
||||
} catch { |
||||
print("Decoding error for FederalTournament array in getAllFederalTournaments: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
/// Fetches umpire contact data for a given tournament ID. |
||||
/// This function now calls your backend, which performs the HTML scraping. |
||||
/// The return type is maintained for signature compatibility, mapping `UmpireContactInfo` to a tuple. |
||||
/// - Parameter idTournament: The ID of the tournament. |
||||
/// - Returns: A tuple `(name: String?, email: String?, phone: String?)` containing the umpire's contact info. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// The servicePath now points to your backend's endpoint for umpire data: 'fft/umpire/{tournament_id}/' |
||||
let servicePath = "fft/umpire/\(idTournament)/" |
||||
let urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
do { |
||||
let umpireInfo = try JSONDecoder().decode(UmpireContactInfo.self, from: data) |
||||
// Map the decoded struct to the tuple required by the legacy signature |
||||
print(umpireInfo) |
||||
return (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone) |
||||
} catch { |
||||
print("Decoding error for UmpireContactInfo: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode UmpireContactInfo: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
|
||||
/// Fetches umpire contact data for multiple tournament IDs. |
||||
/// This function calls your backend endpoint that handles multiple tournament IDs via query parameters. |
||||
/// - Parameter tournamentIds: An array of tournament ID strings. |
||||
/// - Returns: A dictionary mapping tournament IDs to tuples `(name: String?, email: String?, phone: String?)` containing the umpire's contact info. |
||||
/// - Throws: An error if the network request fails or decoding the response is unsuccessful. |
||||
func getUmpiresData(tournamentIds: [String]) async throws -> [String: (name: String?, email: String?, phone: String?)] { |
||||
let service = try StoreCenter.main.service() |
||||
|
||||
// Validate input |
||||
guard !tournamentIds.isEmpty else { |
||||
throw NetworkManagerError.apiError("Tournament IDs array cannot be empty") |
||||
} |
||||
|
||||
// Create the base service path |
||||
let basePath = "fft/umpires/" |
||||
|
||||
// Build query parameters - join tournament IDs with commas |
||||
let tournamentIdsParam = tournamentIds.joined(separator: ",") |
||||
let queryItems = [URLQueryItem(name: "tournament_ids", value: tournamentIdsParam)] |
||||
|
||||
// Create the URL with query parameters |
||||
var urlComponents = URLComponents() |
||||
urlComponents.queryItems = queryItems |
||||
|
||||
let servicePath = basePath + (urlComponents.url?.query.map { "?\($0)" } ?? "") |
||||
|
||||
let urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false) |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest) |
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else { |
||||
throw URLError(.badServerResponse) |
||||
} |
||||
|
||||
guard !data.isEmpty else { |
||||
throw NetworkManagerError.noDataReceived |
||||
} |
||||
|
||||
// Check for HTTP errors |
||||
guard httpResponse.statusCode == 200 else { |
||||
if let errorData = try? JSONSerialization.jsonObject(with: data) as? [String: Any], |
||||
let message = errorData["message"] as? String { |
||||
throw NetworkManagerError.apiError("Server error: \(message)") |
||||
} |
||||
throw NetworkManagerError.apiError("HTTP error: \(httpResponse.statusCode)") |
||||
} |
||||
|
||||
do { |
||||
let umpireResponse = try JSONDecoder().decode(UmpireDataResponse.self, from: data) |
||||
|
||||
// Convert the results to the expected return format |
||||
var resultDict: [String: (name: String?, email: String?, phone: String?)] = [:] |
||||
|
||||
for (tournamentId, umpireInfo) in umpireResponse.results { |
||||
resultDict[tournamentId] = (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone) |
||||
} |
||||
|
||||
print("Umpire data fetched for \(resultDict.count) tournaments") |
||||
return resultDict |
||||
|
||||
} catch { |
||||
print("Decoding error for UmpireDataResponse: \(error)") |
||||
throw NetworkManagerError.apiError("Failed to decode UmpireDataResponse: \(error.localizedDescription)") |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,59 @@ |
||||
import Foundation |
||||
|
||||
func areFrenchPhoneNumbersSimilar(_ phoneNumber1: String?, _ phoneNumber2: String?) -> Bool { |
||||
|
||||
if phoneNumber1?.canonicalVersion == phoneNumber2?.canonicalVersion { |
||||
return true |
||||
} |
||||
|
||||
// Helper function to normalize a phone number, now returning an optional String |
||||
func normalizePhoneNumber(_ numberString: String?) -> String? { |
||||
// 1. Safely unwrap the input string. If it's nil or empty, return nil immediately. |
||||
guard let numberString = numberString, !numberString.isEmpty else { |
||||
return nil |
||||
} |
||||
|
||||
// 2. Remove all non-digit characters |
||||
let digitsOnly = numberString.filter(\.isNumber) |
||||
|
||||
// If after filtering, there are no digits, return nil. |
||||
guard !digitsOnly.isEmpty else { |
||||
return nil |
||||
} |
||||
|
||||
// 3. Handle French specific prefixes and extract the relevant part |
||||
// We need at least 9 digits to get a meaningful 8-digit comparison from the end |
||||
if digitsOnly.count >= 9 { |
||||
if digitsOnly.hasPrefix("0") { |
||||
return String(digitsOnly.suffix(9)) |
||||
} else if digitsOnly.hasPrefix("33") { |
||||
// Ensure there are enough digits after dropping "33" |
||||
if digitsOnly.count >= 11 { // "33" + 9 digits = 11 |
||||
return String(digitsOnly.dropFirst(2).suffix(9)) |
||||
} else { |
||||
return nil // Not enough digits after dropping "33" |
||||
} |
||||
} else if digitsOnly.count == 9 { // Case like 612341234 |
||||
return digitsOnly |
||||
} else { // More digits but no 0 or 33 prefix, take the last 9 |
||||
return String(digitsOnly.suffix(9)) |
||||
} |
||||
} |
||||
|
||||
return nil // If it doesn't fit the expected patterns or is too short |
||||
} |
||||
|
||||
// Normalize both phone numbers. If either results in nil, we can't compare. |
||||
guard let normalizedNumber1 = normalizePhoneNumber(phoneNumber1), |
||||
let normalizedNumber2 = normalizePhoneNumber(phoneNumber2) else { |
||||
return false |
||||
} |
||||
|
||||
// Ensure both normalized numbers have at least 8 digits before comparing suffixes |
||||
guard normalizedNumber1.count >= 8 && normalizedNumber2.count >= 8 else { |
||||
return false // One or both numbers are too short to have 8 comparable digits |
||||
} |
||||
|
||||
// Compare the last 8 digits |
||||
return normalizedNumber1.suffix(8) == normalizedNumber2.suffix(8) |
||||
} |
||||
Loading…
Reference in new issue