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;
}
}