# Customer API — file reference & test guide Companion to [`customer-app-api.md`](customer-app-api.md), which explains *why*. This one is the flat reference: what each file is for, and how to call every endpoint. **Base URL:** `https://api.doormile.com/api/v1` **Staging:** `https://staging-api.doormile.com/api/v1` --- ## 1. What `seed_customer_app.sql` is for **It is the only thing that makes the booking form work.** The customer app's first four calls read from tables that ship empty, so without this file the app opens to a blank state picker and nothing can be booked. Run it **once, after the Go service has started at least once** — the service runs `AutoMigrate` on boot (`main.go:97`), and this file fills the tables that creates. Running it before the first boot fails: the tables do not exist yet. ```bash psql "postgres://admin:PASSWORD@HOST:5433/logistics" -f seed_customer_app.sql ``` It is **idempotent** — safe to re-run. Every insert is `ON CONFLICT DO UPDATE` or guarded by `WHERE NOT EXISTS`. ### What it inserts, and why each part matters | Rows | Purpose | |---|---| | **5 states** (TN, KL, KA, TG, PY) | `GET /serviceability/states`. Puducherry is seeded serviceable with **no open district** on purpose — it is the only way to exercise the app's "Opening soon" screen. | | **16 districts** | `GET /serviceability/states/{code}/districts`. Madurai, Kozhikode and Dakshina Kannada are seeded **unavailable with a reason**, because the app renders those names in a "coming soon" line. Each carries `centrelatitude`/`centrelongitude` — for most destinations that is the *only* geography the parcel has until the miler corrects it at the door, and it is what the fare estimate is priced against. | | **6 pickup slot templates** | `GET /pickup-slots`. Template `t5` is seeded at **capacity 0** — the only way to reach the "Fully booked" state in design QA without actually filling a window. | | **1 booking-limits row** | `GET /config/booking-limits`. **`maxdestinations` is seeded at 1, not 5** — see §4 below. | | **2 test customers** | `+919999900001`, `+919999900002`. Pair with `CX_STAGING_OTP=1234` so automated tests can sign in without a real handset. | | **A hub-attachment UPDATE** | Links each district to its nearest active hub by proximity, so the destination card can name a serving base. Done by distance rather than hard-coded ids because hub ids differ per environment. | It also **mirrors the client's in-app mock exactly**, because those cases back the app's 18 widget tests and its design QA. Change the seeded availability and a green client test suite stops meaning anything. --- ## 2. Changed files — what each one is for ### New files | File | What it is for | |---|---| | `models/customer_app.go` | The 9 new tables. `BookingDestination` is the one that makes multi-destination expressible. | | `internal/cxstage/stage.go` | Writes the customer's stage timeline from the miler's operational writes. The one place a stage is recorded. | | `internal/sms/sms.go` | The seam for an SMS gateway. **No provider is wired in** — codes go to the log until one is. | | `controllers/cxAuthController.go` | OTP auth: request, signup, verify, refresh, logout, me. | | `controllers/cxCatalogueController.go` | States, districts, pickup slots, booking limits. | | `controllers/cxPlacesController.go` | Geocoder proxy + Redis cache. The app is never handed a map key. | | `controllers/cxFareController.go` | Fare estimate for one pickup visit. | | `controllers/cxBookingController.go` | Create, list, detail, cancel, patch destination, order-by-tracking-id. | | `controllers/cxBookingView.go` | Builds the canonical booking JSON. Every read that returns a booking goes through it. | | `controllers/cxPickupFanout.go` | Splits one pickup into N orders at pickup-complete. | | `controllers/cxConsignmentHooks.go` | Maps a consignment status to a per-order customer stage. | | `controllers/cxDeviceController.go` | Push token registration. | | `controllers/cxIdentifiers.go` | Mints `DM-######` and `DMX########` from Postgres sequences. | | `controllers/cxOpsController.go` | QA-only: force a booking to any stage. Double-gated. | | `utils/response_cx.go` | The customer response envelope. Separate from `utils.OK`/`Fail` on purpose. | | `utils/epoch.go` | IST→epoch-millis conversion. Every customer timestamp goes through it. | | `middlewares/requestid.go` | Echoes `X-Request-Id` on every response. | | `seed_customer_app.sql` | §1 above. | | `docs/openapi-customer.yaml` | The spec — 24 paths, 28 operations. | | `docs/customer-app-api.md` | The change record and reasoning. | | `scratch/cx_readonly_probe.go` | Read-only check of whether the migration is additive against a real DB. Writes nothing. | | `controllers/cxHttp_test.go` | HTTP status-code and envelope tests. | | `controllers/cxCustomerApp_test.go` | Pure-logic and fan-out tests. | | `routes/routes_customer_test.go` | Routing + the guard proving miler/console are untouched. | | `internal/cxstage/stage_test.go`, `utils/epoch_test.go` | Stage rollup and timestamp tests. | ### Modified files | File | What changed | |---|---| | `routes/routes.go` | Customer routes 19 → 28. | | `controllers/customerController.go` | PIN auth + old booking handlers **deleted**; profile and locations kept. | | `controllers/milerController.go` | Pickup-complete now fans out; parcel-confirm takes photos; stage hooks. | | `controllers/milerAppController.go` | Rider queue emits one stop per order; two broken lookups fixed. | | `controllers/logisticsHandoverController.go` | Consignment→booking lookup fixed; `in_transit` recorded. | | `controllers/adminController.go` | Ops cancel now reaches the customer's projection. | | `controllers/booking_assignment_service.go` | Records `assigned` on manual assignment. | | `internal/assignment/crm_assignment.go` | Records `assigned` on auto-assignment. | | `models/booking.go` | 10 new columns on `PickupBooking`, 1 on `BookingParcel`. | | `constants/constants.go` | 9 stage keys, statuses, actor types. | | `migrations/migrate.go` | 9 tables, 2 sequences, 2 indexes. | | `middlewares/idempotency.go` | Anonymous-caller scoping (security fix). | | `middlewares/city_gate.go` | Exported `PincodeInOperatingCity`. | | `middlewares/logger.go` | Logs `client`, `platform`, `requestid`. | | `internal/storage/spaces.go` | `PresignGet` for signed parcel photos. | | `config/config.go` | `GEOCODER_URL`, `GEOCODER_EMAIL`. | | `utils/helper.go` | `GenerateTokenWithTTL`. | | `main.go` | Registers `RequestID()`. | | `dto/auth.go` | Retired PIN DTOs removed. | | `controllers/otpController.go` | **Deleted** — its two routes were customer-only and are superseded. | --- ## 3. Every endpoint, with a request you can paste Every URL below is complete — copy and paste it. Production host shown; for staging swap `api.doormile.com` for `staging-api.doormile.com`, nothing else changes. Authenticated calls need one header, using the `accessToken` returned by `/customer/auth/otp/verify`: ``` Authorization: Bearer ``` ### 3.1 Auth — no token required **1. Request a sign-in code** ``` POST https://api.doormile.com/api/v1/customer/auth/otp/request ``` ```json { "identifier": "+919999900001" } ``` **2. Sign up (creates the account AND sends the code)** ``` POST https://api.doormile.com/api/v1/customer/auth/signup ``` ```json { "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } ``` **3. Verify the code → returns the session** ``` POST https://api.doormile.com/api/v1/customer/auth/otp/verify Header: Idempotency-Key: ``` ```json { "identifier": "+919999900001", "code": "1234" } ``` > On staging `code` is whatever `CX_STAGING_OTP` is set to. Copy `data.accessToken` > from the response — everything below needs it. **4. Rotate the session** ``` POST https://api.doormile.com/api/v1/customer/auth/refresh ``` ```json { "refreshToken": "" } ``` **5. Who am I** — `GET https://api.doormile.com/api/v1/customer/auth/me` (token required, no body) **6. Sign out** ``` POST https://api.doormile.com/api/v1/customer/auth/logout ``` ```json { "refreshToken": "", "deviceToken": "" } ``` ### 3.2 Catalogue — no token required | # | Method | URL | |---|---|---| | 7 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states` | | 8 | GET | `https://api.doormile.com/api/v1/customer/serviceability/states/TN/districts` | | 9 | GET | `https://api.doormile.com/api/v1/customer/pickup-slots?lat=11.0168&lng=76.9558` | | 10 | GET | `https://api.doormile.com/api/v1/customer/config/booking-limits?lat=11.0168&lng=76.9558` | No bodies. Call **9** first in any booking test — you need a real `slotId` from it, and slot ids expire. ### 3.3 Places — token required | # | Method | URL | |---|---|---| | 11 | GET | `https://api.doormile.com/api/v1/customer/places/reverse-geocode?lat=11.0168&lng=76.9558` | | 12 | GET | `https://api.doormile.com/api/v1/customer/places/search?q=brookefields&lat=11.0168&lng=76.9558` | Empty `q` on **12** returns the customer's saved and recent places. ### 3.4 Fare estimate — token required **13.** ``` POST https://api.doormile.com/api/v1/customer/fare/estimate ``` ```json { "pickup": { "lat": 11.0168, "lng": 76.9558 }, "destinations": [ { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 }, { "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } ] } ``` ### 3.5 Bookings — token required **14. Create a pickup** ``` POST https://api.doormile.com/api/v1/customer/bookings Header: Idempotency-Key: ``` ```json { "pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012", "lat": 11.0168, "lng": 76.9558 }, "slotId": "PASTE_A_REAL_ID_FROM_ENDPOINT_9", "destinations": [ { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2, "details": { "street": "12th Main", "building": "3B", "landmark": "Near bus stand", "recipientName": "Meera S", "recipientPhone": "+919884412210", "instructions": "Call before delivery", "codAmount": 1200, "pin": { "lat": 13.0827, "lng": 80.2707 } } } ], "estimate": { "min": 167, "max": 267 } } ``` > ⚠️ **One destination only** while `maxdestinations = 1` (§4). Adding a second > returns `400 "Up to 1 destinations per pickup"`. > Returns `reference` (`DM-######`) and **no tracking number** — those are minted > when the miler completes the pickup. **15. List** — `GET https://api.doormile.com/api/v1/customer/bookings?status=active&limit=20` `status` is `active` | `completed` | `cancelled`. Page with `&cursor=`. **16. Detail** — `GET https://api.doormile.com/api/v1/customer/bookings/DM-482913` The tracking screen and receipt both render from this. Supports `If-None-Match`. **17. Cancel** ``` POST https://api.doormile.com/api/v1/customer/bookings/DM-482913/cancel ``` ```json { "reason": "Package not ready" } ``` > Allowed through `arrived`. From `picked_up` onward returns `409`. **18. Fill in a destination's details after booking** ``` PATCH https://api.doormile.com/api/v1/customer/bookings/DM-482913/destinations/0 ``` ```json { "street": "12th Main", "landmark": "Near bus stand", "recipientName": "Meera S", "recipientPhone": "+919884412210", "instructions": "Call before delivery", "pin": { "lat": 13.08, "lng": 80.27 } } ``` > `0` is the destination's position. Any subset of fields; `null` clears one. **19. One order by tracking number** — `GET https://api.doormile.com/api/v1/customer/orders/DMX10482913` ### 3.6 Devices — token required **20. Register a push token** ``` POST https://api.doormile.com/api/v1/customer/devices ``` ```json { "token": "", "platform": "android", "appVersion": "1.0.0+12" } ``` **21. Unregister** — `DELETE https://api.doormile.com/api/v1/customer/devices/` ### 3.7 Account — token required **22. Profile** — `GET https://api.doormile.com/api/v1/customer/profile` **23. Update profile** ``` PUT https://api.doormile.com/api/v1/customer/profile ``` ```json { "name": "Joe Oommen", "email": "joe@example.com", "defaultPincode": "641012" } ``` **24. Saved addresses** — `GET https://api.doormile.com/api/v1/customer/locations` **25. Save an address** ``` POST https://api.doormile.com/api/v1/customer/locations ``` ```json { "label": "Home", "address": "12 Nehru Street, Gandhipuram", "landmark": "Near the bus stand", "city": "Coimbatore", "state": "Tamil Nadu", "pincode": "641012", "latitude": 11.0168, "longitude": 76.9558, "receivername": "Joe Oommen", "receiverphone": "+919876543210", "isdefault": true } ``` **26. Update an address** — `PUT https://api.doormile.com/api/v1/customer/locations/1` (same body) **27. Delete an address** — `DELETE https://api.doormile.com/api/v1/customer/locations/1` ### 3.8 QA only — staging **28. Force a booking to any stage** ``` POST https://api.doormile.com/api/v1/customer/ops/bookings/DM-482913/stage ``` ```json { "stage": "out_for_delivery", "reason": "design QA" } ``` > Returns `404` unless **both** `ENV != production` **and** > `CX_ALLOW_STAGE_OVERRIDE=true`. Valid stages: `booked`, `assigned`, > `on_the_way`, `arrived`, `picked_up`, `order_created`, `in_transit`, > `out_for_delivery`, `delivered`. --- ## 4. Before you test — two things that will bite you **`maxdestinations` is seeded at 1.** Not a bug. The fan-out works server-side, but the deployed rider app keys its stop list on `orderid`, which is booking-level — all three stops of a three-destination pickup collapse into one in its local store, and two parcels would have no stop and no way to be closed. Raise it only once a rider build keying on `consignmentid` is live: ```sql UPDATE customerbookinglimits SET maxdestinations = 5 WHERE applocationid IS NULL; ``` **There is no SMS provider.** OTP codes are written to the application log and nowhere else. On staging set `CX_STAGING_OTP=1234` and use the seeded test accounts. It is refused when `ENV=production`. --- ## 5. Suggested test order 1. `GET /serviceability/states` — proves the seed ran 2. `GET /serviceability/states/TN/districts` 3. `GET /pickup-slots?lat=&lng=` — **copy a real `slotId`** 4. `GET /config/booking-limits` — confirm `maxDestinations: 1` 5. `POST /auth/otp/request` with `+919999900001` 6. `POST /auth/otp/verify` with the staging code — **copy `accessToken`** 7. `GET /auth/me` 8. `POST /fare/estimate` 9. `POST /bookings` with the slot id from step 3 10. `GET /bookings/{reference}` — the canonical object 11. `PATCH /bookings/{reference}/destinations/0` 12. `POST /ops/bookings/{reference}/stage` → `delivered`, then re-read step 10 13. `POST /bookings/{reference}/cancel` on a fresh booking → expect `409` after `picked_up`, `200` before ## 6. Reading the responses Success: ```json { "success": true, "data": { ... }, "message": "" } ``` List: ```json { "success": true, "data": [ ... ], "total": 9, "nextCursor": "1042", "message": "" } ``` Failure: ```json { "success": false, "message": "Add at least one destination", "error": { "code": "invalid" } } ``` Branch on `error.code`, never on the message text. Codes: `invalid`, `invalid_name`, `invalid_otp`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unserviceable`, `rate_limited`, `server_error`.