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