diff --git a/lib/models/isar/models/shopinbit_ticket.dart b/lib/models/isar/models/shopinbit_ticket.dart index 0a2ac53d7..4ccf62928 100644 --- a/lib/models/isar/models/shopinbit_ticket.dart +++ b/lib/models/isar/models/shopinbit_ticket.dart @@ -16,6 +16,11 @@ class ShopInBitTicket { late ShopInBitCategory category; @enumerated late ShopInBitOrderStatus status; + // Raw API state string (e.g. "OFFER AVAILABLE") preserved alongside the + // mapped enum. If ShopinBit renames a state or adds a new one, the enum + // will read as `pending` but `statusRaw` retains the canonical string so a + // future client update can re-derive the correct status via migration. + String? statusRaw; late String requestDescription; late String deliveryCountry; late String? offerProductName; diff --git a/lib/models/isar/models/shopinbit_ticket.g.dart b/lib/models/isar/models/shopinbit_ticket.g.dart index ecd600a15..0385ab290 100644 --- a/lib/models/isar/models/shopinbit_ticket.g.dart +++ b/lib/models/isar/models/shopinbit_ticket.g.dart @@ -131,8 +131,13 @@ const ShopInBitTicketSchema = CollectionSchema( type: IsarType.byte, enumMap: _ShopInBitTicketstatusEnumValueMap, ), - r'ticketId': PropertySchema( + r'statusRaw': PropertySchema( id: 22, + name: r'statusRaw', + type: IsarType.string, + ), + r'ticketId': PropertySchema( + id: 23, name: r'ticketId', type: IsarType.string, ), @@ -229,6 +234,12 @@ int _shopInBitTicketEstimateSize( bytesCount += 3 + object.shippingName.length * 3; bytesCount += 3 + object.shippingPostalCode.length * 3; bytesCount += 3 + object.shippingStreet.length * 3; + { + final value = object.statusRaw; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } bytesCount += 3 + object.ticketId.length * 3; return bytesCount; } @@ -266,7 +277,8 @@ void _shopInBitTicketSerialize( writer.writeString(offsets[19], object.shippingPostalCode); writer.writeString(offsets[20], object.shippingStreet); writer.writeByte(offsets[21], object.status.index); - writer.writeString(offsets[22], object.ticketId); + writer.writeString(offsets[22], object.statusRaw); + writer.writeString(offsets[23], object.ticketId); } ShopInBitTicket _shopInBitTicketDeserialize( @@ -310,7 +322,8 @@ ShopInBitTicket _shopInBitTicketDeserialize( object.status = _ShopInBitTicketstatusValueEnumMap[reader.readByteOrNull(offsets[21])] ?? ShopInBitOrderStatus.pending; - object.ticketId = reader.readString(offsets[22]); + object.statusRaw = reader.readStringOrNull(offsets[22]); + object.ticketId = reader.readString(offsets[23]); return object; } @@ -381,6 +394,8 @@ P _shopInBitTicketDeserializeProp

( ShopInBitOrderStatus.pending) as P; case 22: + return (reader.readStringOrNull(offset)) as P; + case 23: return (reader.readString(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -3145,6 +3160,165 @@ extension ShopInBitTicketQueryFilter }); } + QueryBuilder + statusRawIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNull(property: r'statusRaw'), + ); + }); + } + + QueryBuilder + statusRawIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + const FilterCondition.isNotNull(property: r'statusRaw'), + ); + }); + } + + QueryBuilder + statusRawEqualTo(String? value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo( + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan( + include: include, + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.lessThan( + include: include, + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.between( + property: r'statusRaw', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawStartsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.startsWith( + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawEndsWith(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.endsWith( + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.contains( + property: r'statusRaw', + value: value, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.matches( + property: r'statusRaw', + wildcard: pattern, + caseSensitive: caseSensitive, + ), + ); + }); + } + + QueryBuilder + statusRawIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.equalTo(property: r'statusRaw', value: ''), + ); + }); + } + + QueryBuilder + statusRawIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition( + FilterCondition.greaterThan(property: r'statusRaw', value: ''), + ); + }); + } + QueryBuilder ticketIdEqualTo(String value, {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -3595,6 +3769,20 @@ extension ShopInBitTicketQuerySortBy }); } + QueryBuilder + sortByStatusRaw() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'statusRaw', Sort.asc); + }); + } + + QueryBuilder + sortByStatusRawDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'statusRaw', Sort.desc); + }); + } + QueryBuilder sortByTicketId() { return QueryBuilder.apply(this, (query) { @@ -3917,6 +4105,20 @@ extension ShopInBitTicketQuerySortThenBy }); } + QueryBuilder + thenByStatusRaw() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'statusRaw', Sort.asc); + }); + } + + QueryBuilder + thenByStatusRawDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'statusRaw', Sort.desc); + }); + } + QueryBuilder thenByTicketId() { return QueryBuilder.apply(this, (query) { @@ -4110,6 +4312,13 @@ extension ShopInBitTicketQueryWhereDistinct }); } + QueryBuilder + distinctByStatusRaw({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'statusRaw', caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByTicketId({ bool caseSensitive = true, }) { @@ -4280,6 +4489,12 @@ extension ShopInBitTicketQueryProperty }); } + QueryBuilder statusRawProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'statusRaw'); + }); + } + QueryBuilder ticketIdProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'ticketId'); diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart index f41aa49e3..aab436aeb 100644 --- a/lib/models/shopinbit/shopinbit_order_model.dart +++ b/lib/models/shopinbit/shopinbit_order_model.dart @@ -113,6 +113,19 @@ class ShopInBitOrderModel extends ChangeNotifier { } } + // The most recent raw API state string, persisted alongside _status so that + // we can recover from contract drift (renames / new states) without losing + // history. _status is the parsed/mapped value; _statusRaw is the source of + // truth straight from the API. + String? _statusRaw; + String? get statusRaw => _statusRaw; + set statusRaw(String? value) { + if (_statusRaw != value) { + _statusRaw = value; + notifyListeners(); + } + } + String? _offerProductName; String? get offerProductName => _offerProductName; @@ -236,6 +249,7 @@ class ShopInBitOrderModel extends ChangeNotifier { ..displayName = _displayName ..category = _category ?? ShopInBitCategory.concierge ..status = _status + ..statusRaw = _statusRaw ..requestDescription = _requestDescription ..deliveryCountry = _deliveryCountry ..offerProductName = _offerProductName @@ -271,6 +285,7 @@ class ShopInBitOrderModel extends ChangeNotifier { .._apiTicketId = ticket.apiTicketId .._ticketId = ticket.ticketId .._status = ticket.status + .._statusRaw = ticket.statusRaw .._requestDescription = ticket.requestDescription .._deliveryCountry = ticket.deliveryCountry .._offerProductName = ticket.offerProductName @@ -298,7 +313,11 @@ class ShopInBitOrderModel extends ChangeNotifier { .toList(); } - static ShopInBitOrderStatus statusFromTicketState(TicketState state) { + // Returns null when the API state cannot be mapped (TicketState.unknown). + // Callers MUST treat null as "do not overwrite the locally stored status": + // silently coercing an unknown API state to a default (e.g. pending) + // would mask contract drift and look like data regression to the user. + static ShopInBitOrderStatus? statusFromTicketState(TicketState state) { switch (state) { case TicketState.newTicket: return ShopInBitOrderStatus.pending; @@ -323,6 +342,8 @@ class ShopInBitOrderModel extends ChangeNotifier { return ShopInBitOrderStatus.cancelled; case TicketState.refunded: return ShopInBitOrderStatus.refunded; + case TicketState.unknown: + return null; } } } diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart index 85ceb97cf..1ec904856 100644 --- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart +++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart @@ -134,9 +134,13 @@ class _ShopInBitTicketDetailState extends State { } if (!statusResp.hasError && statusResp.value != null) { - widget.model.status = ShopInBitOrderModel.statusFromTicketState( + final mapped = ShopInBitOrderModel.statusFromTicketState( statusResp.value!.state, ); + // Always preserve the raw API string, even when mapping fails, so + // it can be recovered later. + widget.model.statusRaw = statusResp.value!.stateRaw; + if (mapped != null) widget.model.status = mapped; } if (widget.model.status == ShopInBitOrderStatus.offerAvailable && diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart index ce62d3be3..208e382b9 100644 --- a/lib/pages/shopinbit/shopinbit_tickets_view.dart +++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart @@ -127,9 +127,11 @@ class _ShopInBitTicketsViewState extends State { final statusResp = await service.client.getTicketStatus(ref.id); if (statusResp.hasError || statusResp.value == null) continue; - _tickets[localIdx].status = ShopInBitOrderModel.statusFromTicketState( + final mapped = ShopInBitOrderModel.statusFromTicketState( statusResp.value!.state, ); + _tickets[localIdx].statusRaw = statusResp.value!.stateRaw; + if (mapped != null) _tickets[localIdx].status = mapped; if (_tickets[localIdx].status == ShopInBitOrderStatus.offerAvailable && (_tickets[localIdx].offerProductName == null || diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index eec6dd360..9476da6e9 100644 --- a/lib/services/shopinbit/src/models/ticket.dart +++ b/lib/services/shopinbit/src/models/ticket.dart @@ -1,3 +1,5 @@ +import '../../../../utilities/logger.dart'; + enum TicketState { newTicket('NEW'), checking('CHECKING'), @@ -11,16 +13,25 @@ enum TicketState { replyNeeded('REPLY NEEDED'), closed('CLOSED'), closedCancelled('CLOSED/CANCELLED'), - merged('MERGED'); + merged('MERGED'), + // Sentinel for any state string the API returns that this client does not + // recognise (e.g. the API added a new state, or renamed an existing one). + // Callers must handle this explicitly: treat as "do not trust", do not + // overwrite previously known good state with it. + unknown('UNKNOWN'); final String value; const TicketState(this.value); static TicketState fromString(String s) { - return TicketState.values.firstWhere( - (e) => e.value == s, - orElse: () => TicketState.newTicket, + for (final e in TicketState.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised TicketState '$s' from API: " + "mapping to TicketState.unknown", ); + return TicketState.unknown; } } @@ -38,6 +49,10 @@ class TicketRef { class TicketStatus { final int ticketId; final TicketState state; + // The raw 'state' string returned by the API. Preserved verbatim so that + // unknown / renamed states can be re-derived later via a client update, + // rather than being lost to TicketState.unknown. + final String stateRaw; final DateTime updatedAt; final DateTime? lastAgentMessageAt; final String? paymentInvoiceStatus; @@ -46,6 +61,7 @@ class TicketStatus { TicketStatus({ required this.ticketId, required this.state, + required this.stateRaw, required this.updatedAt, this.lastAgentMessageAt, this.paymentInvoiceStatus, @@ -53,9 +69,11 @@ class TicketStatus { }); factory TicketStatus.fromJson(Map json) { + final rawState = json['state'] as String; return TicketStatus( ticketId: _toInt(json['ticket_id']), - state: TicketState.fromString(json['state'] as String), + state: TicketState.fromString(rawState), + stateRaw: rawState, updatedAt: DateTime.parse(json['updated_at'] as String), lastAgentMessageAt: json['last_agent_message_at'] != null ? DateTime.parse(json['last_agent_message_at'] as String) diff --git a/lib/services/shopinbit/src/models/webhook_event.dart b/lib/services/shopinbit/src/models/webhook_event.dart index 7bf41694e..e1ff040f3 100644 --- a/lib/services/shopinbit/src/models/webhook_event.dart +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -1,15 +1,25 @@ +import '../../../../utilities/logger.dart'; + enum WebhookEventType { ticketStateChanged('ticket.state_changed'), - ticketMessageCreated('ticket.message_created'); + ticketMessageCreated('ticket.message_created'), + // Sentinel for any webhook event_type the API sends that this client does + // not recognise. Callers MUST drop these events rather than dispatch them: + // coercing an unknown event onto a known handler is worse than ignoring it. + unknown('UNKNOWN'); final String value; const WebhookEventType(this.value); static WebhookEventType fromString(String s) { - return WebhookEventType.values.firstWhere( - (e) => e.value == s, - orElse: () => WebhookEventType.ticketStateChanged, + for (final e in WebhookEventType.values) { + if (e.value == s) return e; + } + Logging.instance.w( + "ShopInBit: unrecognised WebhookEventType '$s' from API: " + "mapping to WebhookEventType.unknown (event will be dropped)", ); + return WebhookEventType.unknown; } }