diff --git a/lib/db/drift/shared_db/shared_database.g.dart b/lib/db/drift/shared_db/shared_database.g.dart index 24a3c8351..28c4c3991 100644 --- a/lib/db/drift/shared_db/shared_database.g.dart +++ b/lib/db/drift/shared_db/shared_database.g.dart @@ -538,6 +538,17 @@ class $ShopInBitTicketsTable extends ShopInBitTickets ).withConverter( $ShopInBitTicketsTable.$converterstatus, ); + static const VerificationMeta _statusRawMeta = const VerificationMeta( + 'statusRaw', + ); + @override + late final GeneratedColumn statusRaw = GeneratedColumn( + 'status_raw', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _requestDescriptionMeta = const VerificationMeta('requestDescription'); @override @@ -762,6 +773,7 @@ class $ShopInBitTicketsTable extends ShopInBitTickets displayName, category, status, + statusRaw, requestDescription, deliveryCountry, offerProductName, @@ -813,6 +825,12 @@ class $ShopInBitTicketsTable extends ShopInBitTickets } else if (isInserting) { context.missing(_displayNameMeta); } + if (data.containsKey('status_raw')) { + context.handle( + _statusRawMeta, + statusRaw.isAcceptableOrUnknown(data['status_raw']!, _statusRawMeta), + ); + } if (data.containsKey('request_description')) { context.handle( _requestDescriptionMeta, @@ -1020,6 +1038,10 @@ class $ShopInBitTicketsTable extends ShopInBitTickets data['${effectivePrefix}status'], )!, ), + statusRaw: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status_raw'], + ), requestDescription: attachedDatabase.typeMapping.read( DriftSqlType.string, data['${effectivePrefix}request_description'], @@ -1121,6 +1143,7 @@ class ShopInBitTicket extends DataClass implements Insertable { final String displayName; final ShopInBitCategory category; final ShopInBitOrderStatus status; + final String? statusRaw; final String requestDescription; final String deliveryCountry; final String? offerProductName; @@ -1145,6 +1168,7 @@ class ShopInBitTicket extends DataClass implements Insertable { required this.displayName, required this.category, required this.status, + this.statusRaw, required this.requestDescription, required this.deliveryCountry, this.offerProductName, @@ -1180,6 +1204,9 @@ class ShopInBitTicket extends DataClass implements Insertable { $ShopInBitTicketsTable.$converterstatus.toSql(status), ); } + if (!nullToAbsent || statusRaw != null) { + map['status_raw'] = Variable(statusRaw); + } map['request_description'] = Variable(requestDescription); map['delivery_country'] = Variable(deliveryCountry); if (!nullToAbsent || offerProductName != null) { @@ -1228,6 +1255,9 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName: Value(displayName), category: Value(category), status: Value(status), + statusRaw: statusRaw == null && nullToAbsent + ? const Value.absent() + : Value(statusRaw), requestDescription: Value(requestDescription), deliveryCountry: Value(deliveryCountry), offerProductName: offerProductName == null && nullToAbsent @@ -1278,6 +1308,7 @@ class ShopInBitTicket extends DataClass implements Insertable { status: $ShopInBitTicketsTable.$converterstatus.fromJson( serializer.fromJson(json['status']), ), + statusRaw: serializer.fromJson(json['statusRaw']), requestDescription: serializer.fromJson( json['requestDescription'], ), @@ -1323,6 +1354,7 @@ class ShopInBitTicket extends DataClass implements Insertable { 'status': serializer.toJson( $ShopInBitTicketsTable.$converterstatus.toJson(status), ), + 'statusRaw': serializer.toJson(statusRaw), 'requestDescription': serializer.toJson(requestDescription), 'deliveryCountry': serializer.toJson(deliveryCountry), 'offerProductName': serializer.toJson(offerProductName), @@ -1356,6 +1388,7 @@ class ShopInBitTicket extends DataClass implements Insertable { String? displayName, ShopInBitCategory? category, ShopInBitOrderStatus? status, + Value statusRaw = const Value.absent(), String? requestDescription, String? deliveryCountry, Value offerProductName = const Value.absent(), @@ -1380,6 +1413,7 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName: displayName ?? this.displayName, category: category ?? this.category, status: status ?? this.status, + statusRaw: statusRaw.present ? statusRaw.value : this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, offerProductName: offerProductName.present @@ -1420,6 +1454,7 @@ class ShopInBitTicket extends DataClass implements Insertable { : this.displayName, category: data.category.present ? data.category.value : this.category, status: data.status.present ? data.status.value : this.status, + statusRaw: data.statusRaw.present ? data.statusRaw.value : this.statusRaw, requestDescription: data.requestDescription.present ? data.requestDescription.value : this.requestDescription, @@ -1483,6 +1518,7 @@ class ShopInBitTicket extends DataClass implements Insertable { ..write('displayName: $displayName, ') ..write('category: $category, ') ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') ..write('offerProductName: $offerProductName, ') @@ -1512,6 +1548,7 @@ class ShopInBitTicket extends DataClass implements Insertable { displayName, category, status, + statusRaw, requestDescription, deliveryCountry, offerProductName, @@ -1540,6 +1577,7 @@ class ShopInBitTicket extends DataClass implements Insertable { other.displayName == this.displayName && other.category == this.category && other.status == this.status && + other.statusRaw == this.statusRaw && other.requestDescription == this.requestDescription && other.deliveryCountry == this.deliveryCountry && other.offerProductName == this.offerProductName && @@ -1566,6 +1604,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { final Value displayName; final Value category; final Value status; + final Value statusRaw; final Value requestDescription; final Value deliveryCountry; final Value offerProductName; @@ -1591,6 +1630,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { this.displayName = const Value.absent(), this.category = const Value.absent(), this.status = const Value.absent(), + this.statusRaw = const Value.absent(), this.requestDescription = const Value.absent(), this.deliveryCountry = const Value.absent(), this.offerProductName = const Value.absent(), @@ -1617,6 +1657,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + this.statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, this.offerProductName = const Value.absent(), @@ -1658,6 +1699,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { Expression? displayName, Expression? category, Expression? status, + Expression? statusRaw, Expression? requestDescription, Expression? deliveryCountry, Expression? offerProductName, @@ -1684,6 +1726,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { if (displayName != null) 'display_name': displayName, if (category != null) 'category': category, if (status != null) 'status': status, + if (statusRaw != null) 'status_raw': statusRaw, if (requestDescription != null) 'request_description': requestDescription, if (deliveryCountry != null) 'delivery_country': deliveryCountry, if (offerProductName != null) 'offer_product_name': offerProductName, @@ -1717,6 +1760,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { Value? displayName, Value? category, Value? status, + Value? statusRaw, Value? requestDescription, Value? deliveryCountry, Value? offerProductName, @@ -1743,6 +1787,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { displayName: displayName ?? this.displayName, category: category ?? this.category, status: status ?? this.status, + statusRaw: statusRaw ?? this.statusRaw, requestDescription: requestDescription ?? this.requestDescription, deliveryCountry: deliveryCountry ?? this.deliveryCountry, offerProductName: offerProductName ?? this.offerProductName, @@ -1786,6 +1831,9 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { $ShopInBitTicketsTable.$converterstatus.toSql(status.value), ); } + if (statusRaw.present) { + map['status_raw'] = Variable(statusRaw.value); + } if (requestDescription.present) { map['request_description'] = Variable(requestDescription.value); } @@ -1864,6 +1912,7 @@ class ShopInBitTicketsCompanion extends UpdateCompanion { ..write('displayName: $displayName, ') ..write('category: $category, ') ..write('status: $status, ') + ..write('statusRaw: $statusRaw, ') ..write('requestDescription: $requestDescription, ') ..write('deliveryCountry: $deliveryCountry, ') ..write('offerProductName: $offerProductName, ') @@ -2230,6 +2279,7 @@ typedef $$ShopInBitTicketsTableCreateCompanionBuilder = required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + Value statusRaw, required String requestDescription, required String deliveryCountry, Value offerProductName, @@ -2257,6 +2307,7 @@ typedef $$ShopInBitTicketsTableUpdateCompanionBuilder = Value displayName, Value category, Value status, + Value statusRaw, Value requestDescription, Value deliveryCountry, Value offerProductName, @@ -2314,6 +2365,11 @@ class $$ShopInBitTicketsTableFilterComposer builder: (column) => ColumnWithTypeConverterFilters(column), ); + ColumnFilters get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => ColumnFilters(column), @@ -2444,6 +2500,11 @@ class $$ShopInBitTicketsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get statusRaw => $composableBuilder( + column: $table.statusRaw, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => ColumnOrderings(column), @@ -2563,6 +2624,9 @@ class $$ShopInBitTicketsTableAnnotationComposer GeneratedColumnWithTypeConverter get status => $composableBuilder(column: $table.status, builder: (column) => column); + GeneratedColumn get statusRaw => + $composableBuilder(column: $table.statusRaw, builder: (column) => column); + GeneratedColumn get requestDescription => $composableBuilder( column: $table.requestDescription, builder: (column) => column, @@ -2697,6 +2761,7 @@ class $$ShopInBitTicketsTableTableManager Value displayName = const Value.absent(), Value category = const Value.absent(), Value status = const Value.absent(), + Value statusRaw = const Value.absent(), Value requestDescription = const Value.absent(), Value deliveryCountry = const Value.absent(), Value offerProductName = const Value.absent(), @@ -2723,6 +2788,7 @@ class $$ShopInBitTicketsTableTableManager displayName: displayName, category: category, status: status, + statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, offerProductName: offerProductName, @@ -2750,6 +2816,7 @@ class $$ShopInBitTicketsTableTableManager required String displayName, required ShopInBitCategory category, required ShopInBitOrderStatus status, + Value statusRaw = const Value.absent(), required String requestDescription, required String deliveryCountry, Value offerProductName = const Value.absent(), @@ -2775,6 +2842,7 @@ class $$ShopInBitTicketsTableTableManager displayName: displayName, category: category, status: status, + statusRaw: statusRaw, requestDescription: requestDescription, deliveryCountry: deliveryCountry, offerProductName: offerProductName, diff --git a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart index b8afcc969..450053a20 100644 --- a/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart +++ b/lib/db/drift/shared_db/tables/shopin_bit_tickets.dart @@ -12,6 +12,7 @@ class ShopInBitTickets extends Table { IntColumn get category => intEnum()(); IntColumn get status => intEnum()(); + TextColumn get statusRaw => text().nullable()(); TextColumn get requestDescription => text()(); TextColumn get deliveryCountry => text()(); diff --git a/lib/models/shopinbit/shopinbit_order_model.dart b/lib/models/shopinbit/shopinbit_order_model.dart index c7d4e4df2..14b530475 100644 --- a/lib/models/shopinbit/shopinbit_order_model.dart +++ b/lib/models/shopinbit/shopinbit_order_model.dart @@ -142,6 +142,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; @@ -277,6 +290,7 @@ class ShopInBitOrderModel extends ChangeNotifier { displayName: Value(_displayName), category: Value(_category ?? ShopInBitCategory.concierge), status: Value(_status), + statusRaw: Value(_statusRaw), requestDescription: Value(_requestDescription), deliveryCountry: Value(_deliveryCountry), offerProductName: Value(_offerProductName), @@ -316,6 +330,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 @@ -335,7 +350,7 @@ class ShopInBitOrderModel extends ChangeNotifier { .._messages = messages; } - static ShopInBitOrderStatus statusFromTicketState(TicketState state) { + static ShopInBitOrderStatus? statusFromTicketState(TicketState state) { switch (state) { case TicketState.newTicket: return ShopInBitOrderStatus.pending; @@ -360,6 +375,8 @@ class ShopInBitOrderModel extends ChangeNotifier { return ShopInBitOrderStatus.cancelled; case TicketState.refunded: return ShopInBitOrderStatus.refunded; + case TicketState.unknown: + return null; } } } diff --git a/lib/services/shopinbit/shopinbit_orders_service.dart b/lib/services/shopinbit/shopinbit_orders_service.dart index 8651b4a2c..204bb25c3 100644 --- a/lib/services/shopinbit/shopinbit_orders_service.dart +++ b/lib/services/shopinbit/shopinbit_orders_service.dart @@ -82,7 +82,8 @@ class ShopInBitOrdersService extends ChangeNotifier { final newStatus = ShopInBitOrderModel.statusFromTicketState( statusResp.value!.state, ); - if (model.status != newStatus) { + model.statusRaw = statusResp.value!.stateRaw; + if (model.status != newStatus && newStatus != null) { model.status = newStatus; changed = true; } diff --git a/lib/services/shopinbit/src/models/ticket.dart b/lib/services/shopinbit/src/models/ticket.dart index 05a20d15b..1313d6032 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 value) { - return TicketState.values.firstWhere( - (e) => e.value == value, - orElse: () => throw Exception("Unknown TicketState string found: $value"), + static TicketState fromString(String s) { + 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; } } @@ -45,6 +56,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; @@ -53,6 +68,7 @@ class TicketStatus { TicketStatus({ required this.ticketId, required this.state, + required this.stateRaw, required this.updatedAt, this.lastAgentMessageAt, this.paymentInvoiceStatus, @@ -60,9 +76,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 67a160b2c..e1ff040f3 100644 --- a/lib/services/shopinbit/src/models/webhook_event.dart +++ b/lib/services/shopinbit/src/models/webhook_event.dart @@ -1,16 +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 value) { - return WebhookEventType.values.firstWhere( - (e) => e.value == value, - orElse: () => - throw Exception("Unknown WebhookEventType string found: $value"), + static WebhookEventType fromString(String s) { + 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; } }