Skip to content

Commit 60f8666

Browse files
committed
Merge branch 'trunk' into woomob-1240-ui-booking-filters
2 parents a0b57b6 + 1fac92b commit 60f8666

File tree

82 files changed

+2880
-19595
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

82 files changed

+2880
-19595
lines changed

Modules/Sources/Networking/Model/Bookings/Booking.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ public struct Booking: Codable, GeneratedCopiable, Hashable, GeneratedFakeable {
2929
return BookingStatus(rawValue: statusKey) ?? .unknown
3030
}
3131

32-
/// periphery: ignore - will be used in UI in upcoming PRs
3332
public var attendanceStatus: BookingAttendanceStatus {
3433
return BookingAttendanceStatus(rawValue: attendanceStatusKey) ?? .unknown
3534
}
@@ -208,7 +207,6 @@ public enum BookingStatus: String, CaseIterable {
208207
case unknown
209208
}
210209

211-
/// periphery: ignore - will be used in UI in upcoming PRs
212210
public enum BookingAttendanceStatus: String, CaseIterable {
213211
case booked
214212
case checkedIn = "checked-in"

Modules/Sources/Networking/Remote/BookingsRemote.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ public protocol BookingsRemoteProtocol {
1717
func loadBooking(bookingID: Int64,
1818
siteID: Int64) async throws -> Booking?
1919

20+
func updateBooking(
21+
from siteID: Int64,
22+
bookingID: Int64,
23+
attendanceStatus: BookingAttendanceStatus
24+
) async throws -> Booking?
25+
2026
func fetchResource(resourceID: Int64,
2127
siteID: Int64) async throws -> BookingResource?
2228
}
@@ -88,6 +94,28 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol {
8894
return try await enqueue(request, mapper: mapper)
8995
}
9096

97+
public func updateBooking(
98+
from siteID: Int64,
99+
bookingID: Int64,
100+
attendanceStatus: BookingAttendanceStatus
101+
) async throws -> Booking? {
102+
let path = "\(Path.bookings)/\(bookingID)"
103+
let parameters = [
104+
ParameterKey.attendanceStatus: attendanceStatus.rawValue
105+
]
106+
let request = JetpackRequest(
107+
wooApiVersion: .wcBookings,
108+
method: .put,
109+
siteID: siteID,
110+
path: path,
111+
parameters: parameters,
112+
availableAsRESTRequest: true
113+
)
114+
115+
let mapper = BookingMapper(siteID: siteID)
116+
return try await enqueue(request, mapper: mapper)
117+
}
118+
91119
public func fetchResource(
92120
resourceID: Int64,
93121
siteID: Int64
@@ -132,5 +160,6 @@ public extension BookingsRemote {
132160
static let startDateAfter: String = "start_date_after"
133161
static let search: String = "search"
134162
static let order: String = "order"
163+
static let attendanceStatus = "attendance_status"
135164
}
136165
}

Modules/Sources/NetworkingCore/Network/AlamofireNetworkErrorHandler.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import Alamofire
44
/// Thread-safe handler for network error tracking and retry logic
55
final class AlamofireNetworkErrorHandler {
66
private let queue = DispatchQueue(label: "com.networkingcore.errorhandler", attributes: .concurrent)
7+
/// Serial queue for UserDefaults operations to prevent race conditions while avoiding deadlocks
8+
private let userDefaultsQueue = DispatchQueue(label: "com.networkingcore.errorhandler.userdefaults")
79
private let userDefaults: UserDefaults
810
private let credentials: Credentials?
911
private let notificationCenter: NotificationCenter
@@ -162,7 +164,11 @@ final class AlamofireNetworkErrorHandler {
162164
}
163165

164166
func flagSiteAsUnsupported(for siteID: Int64, flow: RequestFlow, cause: AppPasswordFlagCause, error: Error) {
165-
queue.sync(flags: .barrier) {
167+
// Use dedicated serial queue for UserDefaults operations to:
168+
// 1. Prevent race conditions where concurrent writes overwrite each other
169+
// 2. Avoid deadlock by not using the main queue that KVO observers may need
170+
userDefaultsQueue.sync { [weak self] in
171+
guard let self else { return }
166172
var currentList = userDefaults.applicationPasswordUnsupportedList
167173
currentList[String(siteID)] = Date()
168174
userDefaults.applicationPasswordUnsupportedList = currentList
@@ -233,11 +239,16 @@ private extension AlamofireNetworkErrorHandler {
233239
}
234240

235241
func clearUnsupportedFlag(for siteID: Int64) {
236-
queue.sync(flags: .barrier) {
242+
// Use dedicated serial queue for UserDefaults operations to:
243+
// 1. Prevent race conditions where concurrent writes overwrite each other
244+
// 2. Avoid deadlock by not using the main queue that KVO observers may need
245+
userDefaultsQueue.sync { [weak self] in
246+
guard let self else { return }
237247
let currentList = userDefaults.applicationPasswordUnsupportedList
238-
userDefaults.applicationPasswordUnsupportedList = currentList.filter { flag in
248+
let filteredList = currentList.filter { flag in
239249
flag.key != String(siteID)
240250
}
251+
userDefaults.applicationPasswordUnsupportedList = filteredList
241252
}
242253
}
243254

Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ public extension PersistedProduct {
100100
.filter(Columns.downloadable == false)
101101
.order(Columns.name.collating(.localizedCaseInsensitiveCompare))
102102
}
103+
104+
/// Searches for a POS-supported product by global unique ID
105+
/// - Parameters:
106+
/// - siteID: The site ID
107+
/// - globalUniqueID: The global unique ID (barcode) to search for
108+
/// - Returns: A query request that matches products with the given global unique ID
109+
static func posProductByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProduct> {
110+
return PersistedProduct
111+
.filter(Columns.siteID == siteID)
112+
.filter(Columns.globalUniqueID == globalUniqueID)
113+
}
103114
}
104115

105116
// periphery:ignore - TODO: remove ignore when populating database

Modules/Sources/Storage/GRDB/Model/PersistedProductVariation.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,34 @@ extension PersistedProductVariation: FetchableRecord, PersistableRecord {
7878
through: productVariationImage,
7979
using: PersistedProductVariationImage.image,
8080
key: "image")
81+
82+
// Relationship to parent product
83+
public static let parentProduct = belongsTo(PersistedProduct.self,
84+
using: ForeignKey([Columns.siteID, Columns.productID],
85+
to: [PersistedProduct.Columns.siteID, PersistedProduct.Columns.id]))
8186
}
8287

8388
// MARK: - Point of Sale Requests
8489
public extension PersistedProductVariation {
8590
/// Returns a request for non-downloadable variations of a parent product, ordered by ID
8691
static func posVariationsRequest(siteID: Int64, parentProductID: Int64) -> QueryInterfaceRequest<PersistedProductVariation> {
8792
return PersistedProductVariation
88-
.filter(Columns.siteID == siteID && Columns.productID == parentProductID)
93+
.filter(Columns.siteID == siteID)
94+
.filter(Columns.productID == parentProductID)
8995
.filter(Columns.downloadable == false)
9096
.order(Columns.id)
9197
}
98+
99+
/// Searches for a POS-supported variation by global unique ID
100+
/// - Parameters:
101+
/// - siteID: The site ID
102+
/// - globalUniqueID: The global unique ID (barcode) to search for
103+
/// - Returns: A query request that matches variations with the given global unique ID
104+
static func posVariationByGlobalUniqueID(siteID: Int64, globalUniqueID: String) -> QueryInterfaceRequest<PersistedProductVariation> {
105+
return PersistedProductVariation
106+
.filter(Columns.siteID == siteID)
107+
.filter(Columns.globalUniqueID == globalUniqueID)
108+
}
92109
}
93110

94111
// periphery:ignore - TODO: remove ignore when populating database

Modules/Sources/Yosemite/Actions/BookingAction.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,16 @@ public enum BookingAction: Action {
5252
case fetchResource(siteID: Int64,
5353
resourceID: Int64,
5454
onCompletion: (Result<BookingResource, Error>) -> Void)
55+
56+
/// Updates a booking attendance status.
57+
///
58+
/// - Parameter siteID: The site ID of the booking.
59+
/// - Parameter bookingID: The ID of the booking to be updated.
60+
/// - Parameter status: The new attendance status.
61+
/// - Parameter onCompletion: called when update completes, returns an error in case of a failure.
62+
///
63+
case updateBookingAttendanceStatus(siteID: Int64,
64+
bookingID: Int64,
65+
status: BookingAttendanceStatus,
66+
onCompletion: (Error?) -> Void)
5567
}

Modules/Sources/Yosemite/Model/Model.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public typealias BookingCustomerInfo = Networking.BookingCustomerInfo
3333
public typealias BookingPaymentInfo = Networking.BookingPaymentInfo
3434
public typealias BookingProductInfo = Networking.BookingProductInfo
3535
public typealias BookingResource = Networking.BookingResource
36+
public typealias BookingAttendanceStatus = Networking.BookingAttendanceStatus
3637
public typealias CreateBlazeCampaign = Networking.CreateBlazeCampaign
3738
public typealias FallibleCancelable = Hardware.FallibleCancelable
3839
public typealias CommentStatus = Networking.CommentStatus
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Foundation
2+
import protocol Storage.GRDBManagerProtocol
3+
import class WooFoundation.CurrencySettings
4+
5+
/// Service for handling barcode scanning using local GRDB catalog
6+
public final class PointOfSaleLocalBarcodeScanService: PointOfSaleBarcodeScanServiceProtocol {
7+
private let grdbManager: GRDBManagerProtocol
8+
private let siteID: Int64
9+
private let itemMapper: PointOfSaleItemMapperProtocol
10+
11+
public init(siteID: Int64,
12+
grdbManager: GRDBManagerProtocol,
13+
currencySettings: CurrencySettings,
14+
itemMapper: PointOfSaleItemMapperProtocol? = nil) {
15+
self.siteID = siteID
16+
self.grdbManager = grdbManager
17+
self.itemMapper = itemMapper ?? PointOfSaleItemMapper(currencySettings: currencySettings)
18+
}
19+
20+
/// Looks up a POSItem using a barcode scan string from the local GRDB catalog
21+
/// - Parameter barcode: The barcode string from a scan (global unique identifier)
22+
/// - Returns: A POSItem if found, or throws an error
23+
public func getItem(barcode: String) async throws(PointOfSaleBarcodeScanError) -> POSItem {
24+
do {
25+
if let product = try searchProductByGlobalUniqueID(barcode) {
26+
return try convertProductToItem(product, scannedCode: barcode)
27+
}
28+
29+
if let variationAndParent = try searchVariationByGlobalUniqueID(barcode) {
30+
return try convertVariationToItem(variationAndParent.variation, parentProduct: variationAndParent.parentProduct, scannedCode: barcode)
31+
}
32+
33+
throw PointOfSaleBarcodeScanError.notFound(scannedCode: barcode)
34+
} catch let error as PointOfSaleBarcodeScanError {
35+
throw error
36+
} catch {
37+
throw PointOfSaleBarcodeScanError.loadingError(scannedCode: barcode, underlyingError: error)
38+
}
39+
}
40+
41+
// MARK: - Product Search
42+
43+
private func searchProductByGlobalUniqueID(_ globalUniqueID: String) throws -> PersistedProduct? {
44+
try grdbManager.databaseConnection.read { db in
45+
try PersistedProduct.posProductByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db)
46+
}
47+
}
48+
49+
// MARK: - Variation Search
50+
51+
private func searchVariationByGlobalUniqueID(_ globalUniqueID: String) throws -> (variation: PersistedProductVariation, parentProduct: PersistedProduct)? {
52+
try grdbManager.databaseConnection.read { db in
53+
guard let variation = try PersistedProductVariation.posVariationByGlobalUniqueID(siteID: siteID, globalUniqueID: globalUniqueID).fetchOne(db) else {
54+
return nil
55+
}
56+
// Fetch parent product using the relationship
57+
guard let parentProduct = try variation.request(for: PersistedProductVariation.parentProduct).fetchOne(db) else {
58+
throw PointOfSaleBarcodeScanError.noParentProductForVariation(scannedCode: globalUniqueID)
59+
}
60+
return (variation, parentProduct)
61+
}
62+
}
63+
64+
// MARK: - Conversion to POSItem
65+
66+
private func convertProductToItem(_ persistedProduct: PersistedProduct, scannedCode: String) throws(PointOfSaleBarcodeScanError) -> POSItem {
67+
do {
68+
let posProduct = try persistedProduct.toPOSProduct(db: grdbManager.databaseConnection)
69+
70+
guard !posProduct.downloadable else {
71+
throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode, productName: posProduct.name)
72+
}
73+
74+
// Validate product type - only simple products can be scanned directly
75+
// Variable parent products cannot be added to cart (only their variations can)
76+
guard posProduct.productType == .simple else {
77+
throw PointOfSaleBarcodeScanError.unsupportedProductType(
78+
scannedCode: scannedCode,
79+
productName: posProduct.name,
80+
productType: posProduct.productType
81+
)
82+
}
83+
84+
// Convert to POSItem
85+
let items = itemMapper.mapProductsToPOSItems(products: [posProduct])
86+
guard let item = items.first else {
87+
throw PointOfSaleBarcodeScanError.unknown(scannedCode: scannedCode)
88+
}
89+
90+
return item
91+
} catch let error as PointOfSaleBarcodeScanError {
92+
throw error
93+
} catch {
94+
throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error)
95+
}
96+
}
97+
98+
private func convertVariationToItem(_ persistedVariation: PersistedProductVariation,
99+
parentProduct: PersistedProduct,
100+
scannedCode: String) throws(PointOfSaleBarcodeScanError) -> POSItem {
101+
do {
102+
// Convert both variation and parent to POS models
103+
let posVariation = try persistedVariation.toPOSProductVariation(db: grdbManager.databaseConnection)
104+
let parentPOSProduct = try parentProduct.toPOSProduct(db: grdbManager.databaseConnection)
105+
106+
// Map to POSItem
107+
guard let mappedParent = itemMapper.mapProductsToPOSItems(products: [parentPOSProduct]).first,
108+
case .variableParentProduct(let variableParentProduct) = mappedParent,
109+
let item = itemMapper.mapVariationsToPOSItems(variations: [posVariation], parentProduct: variableParentProduct).first else {
110+
throw PointOfSaleBarcodeScanError.variationCouldNotBeConverted(scannedCode: scannedCode)
111+
}
112+
113+
guard !persistedVariation.downloadable else {
114+
throw PointOfSaleBarcodeScanError.downloadableProduct(scannedCode: scannedCode,
115+
productName: variationName(for: item))
116+
}
117+
118+
return item
119+
} catch let error as PointOfSaleBarcodeScanError {
120+
throw error
121+
} catch {
122+
throw PointOfSaleBarcodeScanError.mappingError(scannedCode: scannedCode, underlyingError: error)
123+
}
124+
}
125+
126+
private func variationName(for item: POSItem) -> String {
127+
guard case .variation(let posVariation) = item else {
128+
return Localization.unknownVariationName
129+
}
130+
return posVariation.name
131+
}
132+
}
133+
134+
private extension PointOfSaleLocalBarcodeScanService {
135+
enum Localization {
136+
static let unknownVariationName = NSLocalizedString(
137+
"pointOfSale.barcodeScanning.unresolved.variation.name",
138+
value: "Unknown",
139+
comment: "A placeholder name when we can't determine the name of a variation for an error message")
140+
}
141+
}

Modules/Sources/Yosemite/Stores/BookingStore.swift

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,17 @@ public class BookingStore: Store {
6363
onCompletion: onCompletion)
6464
case let .fetchResource(siteID, resourceID, onCompletion):
6565
fetchResource(siteID: siteID, resourceID: resourceID, onCompletion: onCompletion)
66+
case .updateBookingAttendanceStatus(let siteID, let bookingID, let status, let onCompletion):
67+
performUpdateBookingAttendanceStatus(
68+
siteID: siteID,
69+
bookingID: bookingID,
70+
status: status,
71+
onCompletion: onCompletion
72+
)
6673
}
6774
}
6875
}
6976

70-
7177
// MARK: - Services
7278
//
7379
private extension BookingStore {
@@ -246,6 +252,50 @@ private extension BookingStore {
246252
}
247253
}
248254
}
255+
256+
func performUpdateBookingAttendanceStatus(
257+
siteID: Int64,
258+
bookingID: Int64,
259+
status: BookingAttendanceStatus,
260+
onCompletion: @escaping (Error?) -> Void
261+
) {
262+
updateBookingAttendanceStatusLocally(
263+
siteID: siteID,
264+
bookingID: bookingID,
265+
statusKey: status
266+
) { _ in
267+
//TODO: - booking status remote update + rollback status in case of error
268+
onCompletion(nil)
269+
}
270+
}
271+
272+
/// Updates local (Storage) Booking attendance status
273+
func updateBookingAttendanceStatusLocally(
274+
siteID: Int64,
275+
bookingID: Int64,
276+
statusKey: BookingAttendanceStatus,
277+
onCompletion: @escaping (BookingAttendanceStatus) -> Void
278+
) {
279+
storageManager.performAndSave({ storage -> BookingAttendanceStatus in
280+
guard let booking = storage.loadBooking(
281+
siteID: siteID,
282+
bookingID: bookingID
283+
) else {
284+
return statusKey
285+
}
286+
287+
let oldStatus = booking.attendanceStatusKey
288+
booking.attendanceStatusKey = statusKey.rawValue
289+
return BookingAttendanceStatus(rawValue: oldStatus ?? "") ?? .unknown
290+
}, completion: { result in
291+
switch result {
292+
case .success(let status):
293+
onCompletion(status)
294+
case .failure:
295+
onCompletion(statusKey)
296+
}
297+
}, on: .main)
298+
}
249299
}
250300

251301

0 commit comments

Comments
 (0)