feat(shopinbit): implement fetchAllForCustomerKey
What changed, and why it matters
This commit finishes a previously stubbed-out feature that downloads a customer's support/order tickets from an external service (ShopinBit) and stores them locally in the wallet's database. It is a normal feature implementation. There is no direct evidence in the commit that it fixes a security vulnerability, but it touches sensitive data (customer key, ticket messages) and makes network calls, so a defensive review checks for obvious safety issues.
Treat as a routine feature commit rather than a security patch. As a defensive follow-up, reviewers should verify that: (1) the customer key is still held only in secure storage and never logged, (2) API responses (especially message content) are sanitized or safely rendered to avoid injection in the UI, (3) network requests use pinned TLS and validate certificates, and (4) the inferred category regex cannot be manipulated by an attacker-controlled agent message.
Security signals we found
Feature completion of an out-of-band ticket backfill mechanism
Uses a customer key retrieved from secure storage to query an external API
Stores third-party API messages and inferred metadata in local database
No visible input sanitization of message content before local persistence
No visible TLS/certificate pinning changes
No visible rate-limit or concurrency guard beyond comment noting API rate-limit exemption
Evidence from the diff
The diff implements fetchAllForCustomerKey() in lib/services/shopinbit/shopinbit_service.dart. It calls client.getTicketsByCustomer(customerKey), compares returned ticket IDs against the local Drift table, then hydrates each missing ticket by calling getTicketStatus, getMessages, and conditionally getTicketFull. The resulting ShopInBitTicketsCompanion rows are returned for local insertion. The code adds imports for Drift, the shared database, ticket table, order model, and message/ticket models. No input validation, authentication, or encryption changes are visible in this diff.
Changed components
lib/services/shopinbit/shopinbit_service.dartShopInBitService.fetchAllForCustomerKeyShopInBitService._hydrateNewTicketSharedDrift shopInBitTickets tableShopinBit API clientInspect captured patch +130 / −1
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 80f941c..d87b8b2 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -1,8 +1,14 @@
+import 'package:drift/drift.dart';
+
import '../../db/drift/shared_db/shared_database.dart';
+import '../../db/drift/shared_db/tables/shopin_bit_tickets.dart';
import '../../external_api_keys.dart';
+import '../../models/shopinbit/shopinbit_order_model.dart';
import '../../utilities/flutter_secure_storage_interface.dart';
import '../../utilities/logger.dart';
import 'src/client.dart';
+import 'src/models/message.dart';
+import 'src/models/ticket.dart';
const _kShopinBitCustomerKeyKeySecureStore = "shopinBitSecStoreCustomerKeyKey";
@@ -64,9 +70,132 @@ class ShopInBitService {
Logging.instance.i("ShopInBitService: customer key cleared");
}
+ /// Fetch the customer's tickets from the API and build companions for any
+ /// that aren't already in the local database. Used to backfill rows for
+ /// tickets created out-of-band (other devices, web dashboard, etc.).
Future<List<ShopInBitTicketsCompanion>> fetchAllForCustomerKey(
String customerKey,
) async {
- throw UnimplementedError("TODO");
+ final resp = await client.getTicketsByCustomer(customerKey);
+ if (resp.hasError || resp.value == null) {
+ Logging.instance.w(
+ "ShopInBitService.fetchAllForCustomerKey: getTicketsByCustomer failed: "
+ "${resp.exception?.message}",
+ );
+ return const [];
+ }
+
+ final db = SharedDrift.get();
+ final localRows = await db.select(db.shopInBitTickets).get();
+ final knownApiIds = localRows.map((r) => r.apiTicketId).toSet();
+
+ final newRefs = resp.value!
+ .where((r) => !knownApiIds.contains(r.id))
+ .toList();
+ if (newRefs.isEmpty) return const [];
+
+ // Hydrate per-ticket in parallel. status + messages are exempt from the
+ // 60 req/min rate limit per the API spec; getTicketFull is only called
+ // for tickets whose state maps to offerAvailable.
+ final results = await Future.wait(newRefs.map(_hydrateNewTicket));
+ return results.whereType<ShopInBitTicketsCompanion>().toList();
}
+
+ Future<ShopInBitTicketsCompanion?> _hydrateNewTicket(TicketRef ref) async {
+ try {
+ final statusFuture = client.getTicketStatus(ref.id);
+ final messagesFuture = client.getMessages(ref.id);
+ final statusResp = await statusFuture;
+ final messagesResp = await messagesFuture;
+
+ if (statusResp.hasError || statusResp.value == null) {
+ Logging.instance.w(
+ "ShopInBitService.fetchAllForCustomerKey: status failed for "
+ "${ref.id}: ${statusResp.exception?.message}",
+ );
+ return null;
+ }
+
+ final apiMessages = messagesResp.value ?? const <TicketMessage>[];
+
+ final mappedStatus =
+ ShopInBitOrderModel.statusFromTicketState(statusResp.value!.state) ??
+ ShopInBitOrderStatus.pending;
+
+ String? offerProductName;
+ String? offerPrice;
+ if (mappedStatus == ShopInBitOrderStatus.offerAvailable) {
+ final fullResp = await client.getTicketFull(ref.id);
+ if (!fullResp.hasError && fullResp.value != null) {
+ offerProductName = fullResp.value!.productName;
+ offerPrice = fullResp.value!.customerPrice;
+ }
+ }
+
+ final category = _inferCategoryFromMessages(apiMessages);
+ final feeTicketNumber = category == ShopInBitCategory.car
+ ? _extractFeeTicketNumber(apiMessages)
+ : null;
+
+ final messages = apiMessages
+ .map(
+ (m) => ShopInBitTicketMessage(
+ text: m.content,
+ timestamp: m.timestamp,
+ isFromUser: !m.fromAgent,
+ ),
+ )
+ .toList();
+
+ return ShopInBitTicketsCompanion(
+ ticketId: Value(ref.number),
+ displayName: const Value(""),
+ category: Value(category),
+ status: Value(mappedStatus),
+ statusRaw: Value(statusResp.value!.stateRaw),
+ requestDescription: const Value(""),
+ deliveryCountry: const Value(""),
+ offerProductName: Value(offerProductName),
+ offerPrice: Value(offerPrice),
+ shippingName: const Value(""),
+ shippingStreet: const Value(""),
+ shippingCity: const Value(""),
+ shippingPostalCode: const Value(""),
+ shippingCountry: const Value(""),
+ messages: Value(messages),
+ createdAt: Value(DateTime.now()),
+ apiTicketId: Value(ref.id),
+ feeTicketNumber: Value(feeTicketNumber),
+ needsCreateRequest: const Value(false),
+ isPendingPayment: const Value(false),
+ );
+ } catch (e, s) {
+ Logging.instance.e(
+ "ShopInBitService.fetchAllForCustomerKey: hydrate failed for ${ref.id}",
+ error: e,
+ stackTrace: s,
+ );
+ return null;
+ }
+ }
+}
+
+// The API does not return service_type for existing tickets, so we infer
+// category from the first user message. Stack Wallet's car flow always seeds
+// the comment with this exact phrase; travel cannot be distinguished from
+// concierge because Stack Wallet sends travel as service_type="concierge" too.
+final RegExp _kCarResearchFeeRegex = RegExp(r'car research fee \(#([^)]+)\)');
+
+ShopInBitCategory _inferCategoryFromMessages(List<TicketMessage> messages) {
+ final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
+ if (firstUser == null) return ShopInBitCategory.concierge;
+ return _kCarResearchFeeRegex.hasMatch(firstUser.content)
+ ? ShopInBitCategory.car
+ : ShopInBitCategory.concierge;
+}
+
+String? _extractFeeTicketNumber(List<TicketMessage> messages) {
+ final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
+ if (firstUser == null) return null;
+ return _kCarResearchFeeRegex.firstMatch(firstUser.content)?.group(1);
}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.