Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──
`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.
Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.
The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.
── Who the Miler rings ──
One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.
Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.
── Account's rows led nowhere ──
Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.
Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.
── ONE TOUCH is one sheet ──
It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.
Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.
── Sending a second parcel ──
`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.
Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.
── Bundle ──
pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
This commit is contained in:
@@ -276,6 +276,37 @@ class AppConfig {
|
||||
|
||||
static String get clientHeader => 'doormile-cx/$appVersion';
|
||||
|
||||
// ------------------------------------------------------------------- contact
|
||||
|
||||
/// ── Empty on purpose ──
|
||||
///
|
||||
/// Account's support and policy rows used to fire a toast reading "Opening
|
||||
/// doormile.com…" and open nothing. Replacing a fake toast with a fake phone
|
||||
/// number is not an improvement, and a support line that rings nowhere is
|
||||
/// worse than no support line — so these default to empty and every block
|
||||
/// that needs one is simply absent until it is filled in.
|
||||
///
|
||||
/// Pass them at build time, the same way [appVersion] is passed:
|
||||
///
|
||||
/// --dart-define=DM_SUPPORT_PHONE=+911234567890
|
||||
/// --dart-define=DM_SUPPORT_EMAIL=help@doormile.com
|
||||
/// --dart-define=DM_TERMS_URL=https://doormile.com/terms
|
||||
/// --dart-define=DM_PRIVACY_URL=https://doormile.com/privacy
|
||||
/// --dart-define=DM_SITE_URL=https://doormile.com
|
||||
static const String supportPhone = String.fromEnvironment(
|
||||
'DM_SUPPORT_PHONE',
|
||||
);
|
||||
static const String supportEmail = String.fromEnvironment(
|
||||
'DM_SUPPORT_EMAIL',
|
||||
);
|
||||
static const String termsUrl = String.fromEnvironment('DM_TERMS_URL');
|
||||
static const String privacyUrl = String.fromEnvironment('DM_PRIVACY_URL');
|
||||
static const String siteUrl = String.fromEnvironment('DM_SITE_URL');
|
||||
|
||||
/// True when there is at least one way for a customer to reach a person.
|
||||
static bool get hasSupportContact =>
|
||||
supportPhone.isNotEmpty || supportEmail.isNotEmpty;
|
||||
|
||||
static String get platformHeader {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
|
||||
@@ -443,6 +443,7 @@ class DevDoormileApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) => _respond(() {
|
||||
if (destinations.isEmpty ||
|
||||
|
||||
@@ -134,6 +134,7 @@ abstract class DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
});
|
||||
|
||||
|
||||
@@ -401,50 +401,53 @@ class LiveDoormileApi extends DoormileApi {
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? contactPhone,
|
||||
String? contactName,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
if (slotId == null || slotId.isEmpty) {
|
||||
throw ApiException(ApiException.invalid, 'Pick a pickup slot');
|
||||
}
|
||||
// [fare] is deliberately not sent. The contract's create request has no
|
||||
// field for what the customer was quoted, and an undocumented extra on the
|
||||
// one call that must not be rejected is not worth the audit trail. The
|
||||
// server prices the booking itself; the shown band lives in the estimate
|
||||
// call's own log.
|
||||
// ── The pickup carries no contact, because nothing reads one ──
|
||||
//
|
||||
// Who the Miler asks for at the door. The contract asks for it on the
|
||||
// pickup, and the signed-in customer is the only answer this app has.
|
||||
final customer = (await client.currentSession())?.customer;
|
||||
|
||||
// This used to send `contactName` and `contactPhone` on the pickup object.
|
||||
// The create contract's pickup is `{title, sub, lat, lng}` and nothing
|
||||
// else, and extra keys are dropped without a word — so those two were
|
||||
// written, accepted, and discarded on every booking.
|
||||
//
|
||||
// The rider's number comes from the **account**: `GET /miler/bookings`
|
||||
// returns one phone field, `customerphone`, derived by the backend from
|
||||
// this booking's customer. A second number cannot reach them through this
|
||||
// call at all, so the app stops pretending it can and puts the handover
|
||||
// person in `remarks`, which is stored and shown.
|
||||
final response = await client.post(
|
||||
'/bookings',
|
||||
idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(),
|
||||
body: {
|
||||
'slotId': slotId,
|
||||
'pickup': pickup.toBookingJson(
|
||||
contactName: customer?.name,
|
||||
// The customer's own number unless they said somebody else is
|
||||
// handing the parcel over. Same field either way — the contract has
|
||||
// always carried it; it simply had one possible source.
|
||||
contactPhone: contactPhone ?? customer?.phone,
|
||||
),
|
||||
'pickup': pickup.toJson(),
|
||||
'destinations': [for (final d in destinations) d.toBookingJson()],
|
||||
// The delivery instructions the customer typed per destination. The
|
||||
// contract carries one `remarks` line for the whole visit, which is
|
||||
// what the Miler reads, so several are joined rather than dropped.
|
||||
'remarks': ?_remarksFrom(destinations),
|
||||
// The visit's one free-text line. Only the handover person goes here
|
||||
// now — per-destination instructions have their own field inside
|
||||
// `details` and were being duplicated into this one.
|
||||
'remarks': ?_handoverNote(name: contactName, phone: contactPhone),
|
||||
},
|
||||
);
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
static String? _remarksFrom(List<DestinationGroup> destinations) {
|
||||
final lines = [
|
||||
for (final d in destinations)
|
||||
if (d.details.instructions?.trim().isNotEmpty ?? false)
|
||||
d.details.instructions!.trim(),
|
||||
];
|
||||
return lines.isEmpty ? null : lines.join(' · ');
|
||||
/// The one place a different handover person can be recorded.
|
||||
///
|
||||
/// There is no contact field on the create request — the rider's number is
|
||||
/// derived by the backend from the booking's account — so this goes in the
|
||||
/// visit's `remarks`, which is stored and shown. Labelled, so whoever reads
|
||||
/// it knows it is a person to ring and not a note about the parcel.
|
||||
static String? _handoverNote({String? name, String? phone}) {
|
||||
final number = phone?.trim() ?? '';
|
||||
if (number.isEmpty) return null;
|
||||
final who = name?.trim() ?? '';
|
||||
return who.isEmpty
|
||||
? 'Handover contact: $number'
|
||||
: 'Handover contact: $who, $number';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -480,14 +480,6 @@ class Place {
|
||||
/// Carries who the Miler asks for at the door. That is the signed-in
|
||||
/// customer unless the caller names somebody else, and it is sent rather
|
||||
/// than left to the server to look up, because the contract asks for it.
|
||||
Map<String, dynamic> toBookingJson({
|
||||
String? contactName,
|
||||
String? contactPhone,
|
||||
}) => {
|
||||
...toJson(),
|
||||
'contactName': ?contactName,
|
||||
'contactPhone': ?contactPhone,
|
||||
};
|
||||
}
|
||||
|
||||
/// The only required destination information: a serviceable state + district.
|
||||
@@ -567,7 +559,12 @@ class MapPin {
|
||||
return MapPin(lat, lng);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {'latitude': lat, 'longitude': lng};
|
||||
/// `{lat, lng}` — the contract's spelling, and the one that has already
|
||||
/// cost this app a production outage once: `latitude`/`longitude` on a
|
||||
/// pickup made the server see a booking with no coordinates and answer
|
||||
/// **422 unserviceable** for months. The destination pin was left on the old
|
||||
/// spelling when the pickup was fixed.
|
||||
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lng};
|
||||
}
|
||||
|
||||
/// Everything here is optional at booking time. The Miler fills the gaps
|
||||
@@ -671,38 +668,49 @@ class DeliveryDetails {
|
||||
instructions == null &&
|
||||
pin == null;
|
||||
|
||||
/// The recipient and address fields exactly as a booking's destination
|
||||
/// carries them.
|
||||
/// The contents of a destination's `details` object on booking create.
|
||||
///
|
||||
/// [instructions] is deliberately absent: the contract has no per-destination
|
||||
/// note, it has one `remarks` line for the whole visit, and that is where
|
||||
/// [LiveDoormileApi] sends it. Repeating it here would put the same sentence
|
||||
/// on the wire twice under a key the server does not read.
|
||||
/// ── This used to be spread flat onto the destination ──
|
||||
///
|
||||
/// It read well and it was silently discarded. The create contract nests
|
||||
/// these under `details`, and extra keys on a destination are dropped
|
||||
/// without an error — so every building number, street, landmark, recipient
|
||||
/// and pin a customer typed on the full-address path went to the server,
|
||||
/// was accepted with a 201, and never reached the Miler.
|
||||
///
|
||||
/// [instructions] belongs here too. It was being folded into the visit's one
|
||||
/// `remarks` line on the belief that the contract had no per-destination
|
||||
/// note. It has one.
|
||||
Map<String, dynamic> toJson() => {
|
||||
'recipientName': ?recipientName,
|
||||
'recipientPhone': ?recipientPhone,
|
||||
'building': ?building,
|
||||
'street': ?street,
|
||||
'landmark': ?landmark,
|
||||
'latitude': ?pin?.lat,
|
||||
'longitude': ?pin?.lng,
|
||||
'instructions': ?instructions,
|
||||
'pin': ?pin?.toJson(),
|
||||
};
|
||||
|
||||
/// `PATCH /customer/bookings/{reference}/destinations/{index}`.
|
||||
///
|
||||
/// Flat and in the destination's own vocabulary, like every other place the
|
||||
/// contract carries a recipient. A `null` **clears** the field rather than
|
||||
/// being omitted, so every key is sent whether or not it has a value —
|
||||
/// otherwise a customer could add a landmark but never remove one.
|
||||
/// The body *is* the details object, so this one is flat by design — unlike
|
||||
/// create, where it nests.
|
||||
///
|
||||
/// ── Empty string clears; null does not ──
|
||||
///
|
||||
/// This sent `null` to clear a field, and said so in a comment. The server
|
||||
/// writes only non-nil values, so a `null` means "leave it alone" — which
|
||||
/// made removing a landmark or an instruction impossible. Every key is still
|
||||
/// sent, but an unset field goes as `""`, and an unset pin as `{0,0}`, which
|
||||
/// is what the contract documents as clearing them.
|
||||
Map<String, dynamic> toPatchJson() => {
|
||||
'recipientName': recipientName,
|
||||
'recipientPhone': recipientPhone,
|
||||
'building': building,
|
||||
'street': street,
|
||||
'landmark': landmark,
|
||||
'instructions': instructions,
|
||||
'latitude': pin?.lat,
|
||||
'longitude': pin?.lng,
|
||||
'recipientName': recipientName ?? '',
|
||||
'recipientPhone': recipientPhone ?? '',
|
||||
'building': building ?? '',
|
||||
'street': street ?? '',
|
||||
'landmark': landmark ?? '',
|
||||
'instructions': instructions ?? '',
|
||||
'pin': pin?.toJson() ?? const {'lat': 0, 'lng': 0},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,12 +971,16 @@ class DestinationGroup {
|
||||
/// `latitude`/`longitude`. Anything the customer left blank is omitted —
|
||||
/// that is the "Not added" state the Miler completes at the door, and it is
|
||||
/// not the same as sending an empty string.
|
||||
Map<String, dynamic> toBookingJson() => {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
...details.toJson(),
|
||||
'codAmount': ?codAmount,
|
||||
};
|
||||
Map<String, dynamic> toBookingJson() {
|
||||
final detail = {...details.toJson(), 'codAmount': ?codAmount};
|
||||
return {
|
||||
...destination.toJson(),
|
||||
'packageCount': packageCount,
|
||||
// Omitted rather than sent empty: One Touch fills none of this in, and
|
||||
// `details: {}` is a key that says nothing.
|
||||
if (detail.isNotEmpty) 'details': detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicative price for a booking, confirmed at pickup once the Miler weighs
|
||||
|
||||
Reference in New Issue
Block a user