Skip to content

Commit e7073cb

Browse files
committed
feat(shopinbit): implement fetchAllForCustomerKey
1 parent e93af4a commit e7073cb

1 file changed

Lines changed: 130 additions & 1 deletion

File tree

lib/services/shopinbit/shopinbit_service.dart

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import 'package:drift/drift.dart';
2+
13
import '../../db/drift/shared_db/shared_database.dart';
4+
import '../../db/drift/shared_db/tables/shopin_bit_tickets.dart';
25
import '../../external_api_keys.dart';
6+
import '../../models/shopinbit/shopinbit_order_model.dart';
37
import '../../utilities/flutter_secure_storage_interface.dart';
48
import '../../utilities/logger.dart';
59
import 'src/client.dart';
10+
import 'src/models/message.dart';
11+
import 'src/models/ticket.dart';
612

713
const _kShopinBitCustomerKeyKeySecureStore = "shopinBitSecStoreCustomerKeyKey";
814

@@ -64,9 +70,132 @@ class ShopInBitService {
6470
Logging.instance.i("ShopInBitService: customer key cleared");
6571
}
6672

73+
/// Fetch the customer's tickets from the API and build companions for any
74+
/// that aren't already in the local database. Used to backfill rows for
75+
/// tickets created out-of-band (other devices, web dashboard, etc.).
6776
Future<List<ShopInBitTicketsCompanion>> fetchAllForCustomerKey(
6877
String customerKey,
6978
) async {
70-
throw UnimplementedError("TODO");
79+
final resp = await client.getTicketsByCustomer(customerKey);
80+
if (resp.hasError || resp.value == null) {
81+
Logging.instance.w(
82+
"ShopInBitService.fetchAllForCustomerKey: getTicketsByCustomer failed: "
83+
"${resp.exception?.message}",
84+
);
85+
return const [];
86+
}
87+
88+
final db = SharedDrift.get();
89+
final localRows = await db.select(db.shopInBitTickets).get();
90+
final knownApiIds = localRows.map((r) => r.apiTicketId).toSet();
91+
92+
final newRefs = resp.value!
93+
.where((r) => !knownApiIds.contains(r.id))
94+
.toList();
95+
if (newRefs.isEmpty) return const [];
96+
97+
// Hydrate per-ticket in parallel. status + messages are exempt from the
98+
// 60 req/min rate limit per the API spec; getTicketFull is only called
99+
// for tickets whose state maps to offerAvailable.
100+
final results = await Future.wait(newRefs.map(_hydrateNewTicket));
101+
return results.whereType<ShopInBitTicketsCompanion>().toList();
71102
}
103+
104+
Future<ShopInBitTicketsCompanion?> _hydrateNewTicket(TicketRef ref) async {
105+
try {
106+
final statusFuture = client.getTicketStatus(ref.id);
107+
final messagesFuture = client.getMessages(ref.id);
108+
final statusResp = await statusFuture;
109+
final messagesResp = await messagesFuture;
110+
111+
if (statusResp.hasError || statusResp.value == null) {
112+
Logging.instance.w(
113+
"ShopInBitService.fetchAllForCustomerKey: status failed for "
114+
"${ref.id}: ${statusResp.exception?.message}",
115+
);
116+
return null;
117+
}
118+
119+
final apiMessages = messagesResp.value ?? const <TicketMessage>[];
120+
121+
final mappedStatus =
122+
ShopInBitOrderModel.statusFromTicketState(statusResp.value!.state) ??
123+
ShopInBitOrderStatus.pending;
124+
125+
String? offerProductName;
126+
String? offerPrice;
127+
if (mappedStatus == ShopInBitOrderStatus.offerAvailable) {
128+
final fullResp = await client.getTicketFull(ref.id);
129+
if (!fullResp.hasError && fullResp.value != null) {
130+
offerProductName = fullResp.value!.productName;
131+
offerPrice = fullResp.value!.customerPrice;
132+
}
133+
}
134+
135+
final category = _inferCategoryFromMessages(apiMessages);
136+
final feeTicketNumber = category == ShopInBitCategory.car
137+
? _extractFeeTicketNumber(apiMessages)
138+
: null;
139+
140+
final messages = apiMessages
141+
.map(
142+
(m) => ShopInBitTicketMessage(
143+
text: m.content,
144+
timestamp: m.timestamp,
145+
isFromUser: !m.fromAgent,
146+
),
147+
)
148+
.toList();
149+
150+
return ShopInBitTicketsCompanion(
151+
ticketId: Value(ref.number),
152+
displayName: const Value(""),
153+
category: Value(category),
154+
status: Value(mappedStatus),
155+
statusRaw: Value(statusResp.value!.stateRaw),
156+
requestDescription: const Value(""),
157+
deliveryCountry: const Value(""),
158+
offerProductName: Value(offerProductName),
159+
offerPrice: Value(offerPrice),
160+
shippingName: const Value(""),
161+
shippingStreet: const Value(""),
162+
shippingCity: const Value(""),
163+
shippingPostalCode: const Value(""),
164+
shippingCountry: const Value(""),
165+
messages: Value(messages),
166+
createdAt: Value(DateTime.now()),
167+
apiTicketId: Value(ref.id),
168+
feeTicketNumber: Value(feeTicketNumber),
169+
needsCreateRequest: const Value(false),
170+
isPendingPayment: const Value(false),
171+
);
172+
} catch (e, s) {
173+
Logging.instance.e(
174+
"ShopInBitService.fetchAllForCustomerKey: hydrate failed for ${ref.id}",
175+
error: e,
176+
stackTrace: s,
177+
);
178+
return null;
179+
}
180+
}
181+
}
182+
183+
// The API does not return service_type for existing tickets, so we infer
184+
// category from the first user message. Stack Wallet's car flow always seeds
185+
// the comment with this exact phrase; travel cannot be distinguished from
186+
// concierge because Stack Wallet sends travel as service_type="concierge" too.
187+
final RegExp _kCarResearchFeeRegex = RegExp(r'car research fee \(#([^)]+)\)');
188+
189+
ShopInBitCategory _inferCategoryFromMessages(List<TicketMessage> messages) {
190+
final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
191+
if (firstUser == null) return ShopInBitCategory.concierge;
192+
return _kCarResearchFeeRegex.hasMatch(firstUser.content)
193+
? ShopInBitCategory.car
194+
: ShopInBitCategory.concierge;
195+
}
196+
197+
String? _extractFeeTicketNumber(List<TicketMessage> messages) {
198+
final firstUser = messages.where((m) => !m.fromAgent).firstOrNull;
199+
if (firstUser == null) return null;
200+
return _kCarResearchFeeRegex.firstMatch(firstUser.content)?.group(1);
72201
}

0 commit comments

Comments
 (0)