From c272a33fa684586161e9424544c6c85e86525148 Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 5 Aug 2026 12:22:39 +0530 Subject: [PATCH] feat: 14 new endpoints closing jupiter->Doormile API gaps, plus two tenant-scoping bug fixes New endpoints: - Admin: partner CRUD (GET/POST /admin/partners, GET/PUT/DELETE /admin/partners/:id), bulk express booking create (POST /admin/expressbooking/bulk), bulk cancel (POST /admin/bookings/bulk-cancel), reports (GET /admin/reports), password change (PUT /admin/profile/password), miler notify (POST /admin/milers/:id/notify) - Miler: PIN reset (POST /miler/reset-pin), cancel assignment (POST /miler/bookings/:bookingid/cancel), skip delivery (POST /miler/consignments/:id/skip) - Hub: batch assign (POST /hub/bookings/batch-assign) - greedy nearest-rider queue clearing, capped per rider Bug fixes: - BookingPickupComplete now sets Consignment.Tenantid from the booking's tenant instead of the completing miler's own tenant (fixes cross-tenant shipment mis-attribution) - GetHubUnassignedBookings/GetHubBookingsRange now scoped via scopeBookingsToOwnTenant (fixes partner hub staff seeing other tenants' bookings) Also: CRM booking routes renamed to expressbooking to end the naming collision with the separate CRM clients feature; PickupBooking gains nullable Tenantid; adds CLAUDE.md project memory. Verified: go build ./... and go vet ./... both clean. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 457 ++++++++++++++++++++ controllers/adminController.go | 588 ++++++++++++++++++++++++-- controllers/hubController.go | 142 +++++++ controllers/milerAppController.go | 85 ++++ controllers/milerController.go | 133 +++++- dto/admin.go | 7 + dto/auth.go | 6 + internal/assignment/crm_assignment.go | 2 +- models/booking.go | 8 + routes/routes.go | 19 +- 10 files changed, 1404 insertions(+), 43 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..59ee7ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,457 @@ +# Doormile — Full Project Memory + +Portable context for a fresh Claude session working on this project on any +machine or account. Covers the **whole** Doormile system, not just one slice +of it. Read fully before touching the codebase. + +A note on provenance: sections marked **[verified this session]** were +confirmed by directly reading this repo's code. Sections marked **[carried +forward]** come from Suriya's own prior-session notes and have NOT been +independently re-verified against source — treat them as reported state, not +confirmed state, until checked. + +--- + +## 1. What Doormile is + +Suriya is building **Doormile**, a real commercial logistics platform for the +Indian market (Coimbatore, Hyderabad, Bengaluru, Chennai) — hub-based parcel +courier/delivery, not a prototype. Treat everything as production-grade from +the start. Stakeholders: Doormile's own ops team, partner tenants (client +logistics/travel companies), hub staff, milers (delivery riders), and end +customers. + +**Suriya's standing preferences** (apply without being asked): +- Direct, honest technical assessments over diplomatic framing. If something + isn't done, say so plainly. Don't declare victory early. +- Production-grade code from the start, not "refactor later." +- **Warn before any consequential server/schema change** before making it. +- Concise answers — don't over-explain. +- Once a decision is made, don't keep re-asking for confirmation on the same + scope — proceed and report back. But *do* ask when a decision is genuinely + ambiguous or touches money/data-correctness in a way that can't be safely + guessed. +- Minimal-effort, highest-leverage fixes over big rewrites, until he + explicitly asks for the big rewrite. +- Works across multiple Claude sessions/machines simultaneously, treating + Claude as a co-architect across the full stack — this file exists so any + of those sessions can pick up full context. + +--- + +## 2. System architecture — the whole stack + +**[carried forward, substantial infra reported as built and verified in +prior sessions]** + +- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`. + 200 registered routes **[verified this session, exact count]** — see §7. + This is the primary booking/assignment API and the primary trigger for + miler assignment, calling the AI decision layer with a 5-second timeout + fallback so a slow AI response never blocks a booking. +- **AI dispatch layer**: a Python autonomous agent swarm (8 agents, **JARVIS** + as master orchestrator) on a dedicated server, connected to NATS + JetStream. The `DispatchAgent` operates as a NATS *watcher*, not the + assignment trigger — the Go backend triggers assignment directly (see §4); + the agent swarm reasons about it, doesn't gate it. +- **AI decision engine**: `routemate.workolik.com` — runs Claude Sonnet for + miler-assignment reasoning, scoring candidates using hub load, on-time + rate, and hub capacity. Uses pgvector-backed RAG memory with local + embeddings (`all-MiniLM-L6-v2`, 384-dim) specifically to avoid ongoing + per-call API cost. **[verified this session]**: the Go side of this is + `models.AgentDecision` (`agentdecisions` table) with a + `context_embedding vector(1536)` column and an ivfflat index (raw SQL in + `migrations/migrate.go`, not GORM AutoMigrate) — note the dimension + mismatch worth flagging (1536 in the Go model/migration vs 384 for + MiniLM); worth confirming which embedding model is actually populating + that column before trusting vector search quality. +- **Event bus**: NATS JetStream, 6 persistent streams, dedicated server. + **[verified this session]**: `db.Js` is the package-level JetStream handle; + convention across the codebase is `if db.Js != nil { ... }` and warn-log + on publish failure rather than failing the request — publishing is + best-effort, never blocking. Confirmed publish call sites include + `booking.assigned` (from `internal/assignment`) and NATS publishes inside + `AssignMilerToBooking` (`controllers/booking_assignment_service.go`). +- **Data stores**: PostgreSQL + pgvector 0.7.4, dedicated server, 37 tables + under GORM AutoMigrate **[verified this session, exact count from + `migrations/migrate.go`]**. Redis (`go-redis/v9`) for high-frequency + ephemeral state — GPS pings, live miler status/location (GEO-indexed at + `milers:locations`, used by `queryNearbyMilers` for assignment radius + search), booking cache. Durable business state always lands in Postgres; + Redis is never the system of record for anything that needs to survive a + flush. +- **Ingress/infra**: Kubernetes, Traefik, nginx. + +--- + +## 3. The client-facing surfaces + +Five distinct front doors into this system. Only the backend API surface for +each has been directly inspected this session (via `routes.go`); the actual +client codebases (Flutter, React) have not been opened in this session. + +1. **Customer app (B2C)** — Flutter. Auth via Firebase OTP (phone). Backend + surface: 19 customer routes **[verified this session]** + (`customer`/`customerAuth` groups) — booking creation, tracking, + `AppCustomer`/`AppCustomerLocation`. **[carried forward]**: reported built + and verified in prior sessions; the last live end-to-end test was + blocked here — see §9. +2. **Miler app** — Flutter, for delivery riders. Backend surface: 38 routes + **[verified this session]** (`miler`/`milerAuth` groups) — duty + start/stop, GPS pings, assignment accept/reject/cancel, delivery + complete/skip, break logs, support tickets. This session added + `ResetMilerPin`, `MilerCancelAssignment`, `MilerSkipDelivery` to close + gaps found against the old Nearle rider app (§8). +3. **Admin console** — React, converted from the old `NearlExpress` + /`doormile_express_console` codebase. Backend surface: 89 routes + **[verified this session]** (`admin`/`adminAuth` groups) — bookings, + partner management, reports, pricing, express (formerly "CRM") bookings. + This session added partner CRUD, bulk booking create/cancel, reports, + password change, miler notify (§8). +4. **Hub console** — React, separate from the admin console. Backend + surface: 31 routes **[verified this session]** (`hub`/`hubAuth` groups) + — per-hub booking queues, tripsheet building, manual/batch assignment, + hub messaging. Auth is separate from admin (`middlewares.HubStaffAuth`, + sets `c.Locals("hubid")`). This session added tenant-scoping (closed a + real cross-tenant leak) and `HubBatchAssign` (§8). +5. **CRM (field sales)** — a genuinely separate feature from "CRM bookings" + (see §8 naming note). `POST/GET /crm/clients`, `GET/PUT/DELETE + /crm/clients/:id` — 5 routes, all open/no-auth by design: field reps + register leads from the Flutter side, the web console reads without a + separate CRM login. Models: `DoormileClient`, `DoormileAuth`. **Not + deeply audited** — only the route registration was read this session. + +Total: 200 routes = miler 38 + admin 89 + hub 31 + customer 19 + crm 5 + +internal 5 + redisUsers 4 + bookingCache 3 + 2 top-level pricing routes + +websocket routes. **[verified this session]** + +--- + +## 4. AI dispatch & assignment — how a booking actually gets a miler + +**[verified this session]** — read directly from +`internal/assignment/crm_assignment.go` and `booking_assignment_service.go`. + +- Two entry points, same core: `AssignCustomerMiler` (B2C bookings) and + `AssignCRMMiler` (express/console-originated bookings — name predates this + session's "CRM"→"express" rename, deliberately left unrenamed since it's a + working assignment engine, not just a label). Both are fire-and-forget + goroutines called after transaction commit, retrying up to 5 times, 2 + minutes apart (~10 minutes total), before giving up and publishing a + failure event (`publishAssignmentFailed`, `reasonNoMilerAvailable`) to NATS + so the failure isn't silently invisible. +- `TryAssignOnce` is the synchronous single-attempt variant — used when a + hub staff member manually triggers "auto-assign" from the console and + needs an immediate answer rather than the ~10-minute retry loop. Returns a + rich `AutoAssignResult` (assigned/escalated, miler name, distance, AI + reasoning text, candidates found) for the UI to show. +- Core flow (`tryAssign`): Redis `GEOSEARCH` on `milers:locations` (10km + radius, top 10, sorted nearest-first) → `selectMilerWithAI` (the actual + Claude Sonnet call to `routemate.workolik.com`, logs an `AgentDecision` + row with reasoning + optional embedding) → `commitAssignment` (single DB + transaction: create `BookingAssignment`, update `PickupBooking.status` to + `BookingMilerAssigned`, flip `MilerProfile.availabilitystatus`) → publish + `booking.assigned` to NATS (best-effort) → push notifications to miler and + customer. +- `HubBatchAssign` (**built this session**, `controllers/hubController.go`) + is a separate, simpler path: a greedy nearest-available-rider heuristic + (haversine distance, no AI call, no retry loop) for clearing a hub's + pending-pickup queue in one batch call, capped per rider (default 5), + reusing `AssignMilerToBooking` for the actual transactional assignment. + **Explicitly not** a replacement for `selectMilerWithAI`'s reasoning, and + **not** a multi-stop route/VRP solver — see the routing gap below. + +**Logistics/routing — what exists vs what doesn't:** +- What exists: hub network (`Hub` model, `originhubid`/`currenthubid`/ + `destinationhubid` on bookings/consignments for tracking a parcel's hub + path), `Tripsheet`/`TripsheetItem` for hub-to-hub batch transport, the + nearest-rider assignment logic above, `HubBatchAssign`'s greedy queue + clearer. +- What does **not** exist in Doormile yet: any multi-stop route sequencing / + vehicle-routing-problem solver. Nearle used external paid services + (`routes.workolik.com`, and `routemate.workolik.com` was itself originally + a Nearle-side concept) for that; nothing in Doormile replaces true + stop-sequencing optimization today. `HubBatchAssign` only decides *who* + gets which single booking, not *what order* a rider should run multiple + stops in. Flag this clearly if asked whether routing is "done" — it isn't, + by design, pending a real conversation about whether it's needed yet. + +--- + +## 5. Data model reference (`models/*.go`) + +**[verified this session]** + +- `PickupBooking` (`pickupbookings`) — customer's pickup request, pre-hub. + Has `Tenantid *int` (**added this session**, see §8). `Bookingsource`: + `"Customer_App"` (B2C) or `"CRM_Console"` (console-created — deliberately + left as `"CRM_Console"` even though the outward API/route naming was + changed to "express", see §8). +- `BookingParcel`, `BookingServiceOption`, `BookingPayment`, + `BookingAssignment` (status: Assigned/Accepted/Rejected/Reassigned/ + Completed/Cancelled, carries `AgentDecisionID *uint64` linking back to the + AI reasoning that produced it), `BookingVehicleRequirement`. +- `Consignment` (`consignments`) — the shipment once picked up. Has its own + `Tenantid int` (not nullable, pre-existing). `Attemptcount int` (used by + `MilerSkipDelivery`, added this session). `ConsignmentHistory` (event + log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/ + Missing_Contents/Undeliverable). +- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. +- `AppUser` (`appusers`) — shared login table for staff/miler/admin roles + (`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a + separate `HubStaffAccount` table). `MilerProfile` — actual rider profile + (availability status, current lat/lng, rating). `MilerDutyLog`, + `MilerBreakLog`, `MilerSupportTicket`. +- `AppCustomer`/`AppCustomerLocation` — B2C app customers (separate from + legacy `Customer`/`CustomerLocation` kept for backward compat — flagged as + a future duplicate-data risk, not resolved). +- `HubStaffAccount` — `Tenantid *int`: **nil = Doormile staff (sees + everything)**, **set = partner-tenant staff (own tenant's data only)**. + Load-bearing distinction — see the tenant-leak fix in §8. +- `PartnerInfo` (`partnerinfo`) — fleet/vehicle-supplying partner companies. + **Distinct concept from `Tenant`** (`tenants` — client companies Doormile + delivers *for*). Confusingly close names, flagged, not acted on. +- `DoormileClient`/`DoormileAuth` — the real, separate CRM feature (§3.5), + not to be confused with "CRM bookings" (renamed to "express bookings" this + session specifically to end that naming collision). +- `Pricing`, `DoormilePricing`, `CompetitorBranch`, `CarrierPricing` — + pricing engine + competitive intel. +- `AgentDecision` — AI dispatch-reasoning log (§4), pgvector + `context_embedding` column. +- Redis-only ephemeral structs (`models/redis.go`): `MilerLog`, + `MilerStatus`, `ConsignmentLog`, `CachedUser` — never durable state. + +--- + +## 6. Current environment / seeded data state + +**[carried forward, not re-verified this session]** + +- Database seeded with 16 hubs (4 per city across the 4 target cities), 10+ + milers, 10 tenants (4 Doormile-ops-owned + 6 named partner tenants), 5 hub + staff accounts, 55+ pricing rules. +- Credentials on file from prior sessions (values not repeated here — + confirm current values rather than assuming): admin login at + `suriya@doormile.com`; hub staff accounts pattern `hub.[city]@doormile.in` + plus partner variants; miler test phone numbers + PINs. +- Auth: Firebase OTP for customer login — this cannot be scripted/bypassed + from the command line, which is why the last live E2E test stalled (§9). + +--- + +## 7. Codebase conventions (read before writing any Go here) + +**[verified this session]** + +- **Response helpers** (`utils` package): `utils.OK(c, data)`, + `utils.Created(c, data)`, `utils.Message(c, "text")`, + `utils.List(c, slice, total)`, `utils.Paginated(c, slice, total, page)`, + `utils.BadRequest/NotFound/Internal/Unauthorized/Forbidden/Conflict(c, + msg)`. Always use these, never hand-roll `c.JSON`. +- **DB**: `db.DB` is the package-level `*gorm.DB`. `db.Rdb` is Redis + (`*redis.Client`, `go-redis/v9`). `db.Js` is NATS JetStream (nil-check + before publishing, warn-log on failure, never fail the request over it). + `db.Ctx` for Redis calls needing a context. +- **Auth**: `c.Locals("userid")` (int), `c.Locals("tenantid")` (int, only + set via `middlewares.AuthMiddleware` — the *requesting user's own* + tenant, not necessarily the resource's tenant; conflating these two was + exactly the bug fixed this session, §8). Hub staff auth is separate + (`middlewares.HubStaffAuth`), sets `c.Locals("hubid")` and + `c.Locals("userid")` = `hubstaffaccountid`. +- **DTOs**: `dto/*.go` (`admin.go`, `auth.go`, `booking.go`, `client.go`), + one struct per request shape. +- **Constants** (`constants/constants.go`): every status enum lives here as + typed string constants. Always use these, never inline string literals. +- **Soft delete**: only some models have `Deletedat *time.Time` (`Hub`, + `Vehicle`, `Consignment`, `Tripsheet`, `TripsheetItem`, + `ConsignmentException`, `DoormilePricing`, `CarrierPricing`). Others + (`PartnerInfo`, `Tenant`) have no soft-delete column — hard `Delete`. + Check the struct before assuming either way. +- **Assignment logic reuse**: `AssignMilerToBooking(bookingID, + milerUserID int, assignedByUserID *int)` in + `controllers/booking_assignment_service.go` is the one transactional path + for "assign this miler to this booking." Reuse it, don't reimplement — + both `HubAssignMiler`/`AdminAssignMiler` and `HubBatchAssign` call it. The + AI-driven `commitAssignment` in `internal/assignment` is a separate + transactional writer used by the auto-assignment retry path — don't + conflate the two; know which one a given call site needs. +- **Distance calc**: `haversineKM(lat1, lon1, lat2, lon2)` defined once in + `hubController.go`, used package-wide. Don't redefine it. +- **Hub tenant scoping**: `scopeBookingsToOwnTenant(c, query)` in + `hubController.go` (**added this session**) — restricts a bookings query + to the requesting hub staff's own tenant if partner-scoped, no-ops for + Doormile staff. Use on any new hub-console bookings query. +- **No Go toolchain was available in the prior sandbox session.** Every + change was verified by hand (field/column names cross-checked against + real structs, brace-balance via `grep -o "{" | wc -l` vs `}`) but **never + actually compiled**. Run `go build ./...` before trusting any of it — the + single most important unfinished step from that session. + +--- + +## 8. The Jupiter → Doormile migration (this session's scoped work) + +This section is what one specific session did: closing API/schema gaps +between the legacy Nearle ("jupiter") system and the new Doormile backend, +for the **rider app and console specifically** (hub console gap-closure was +also done incidentally while fixing the tenant leak, but wasn't the primary +target). + +### 8.1 Why jupiter/Nearle was being replaced +Legacy Go+Fiber backend (`backend_jupiter`, module `nearle`), Postgres, +Redis, base URL `jupiter.nearle.app` (+ a separate write path +`queue.workolik.com` with TLS verification disabled and a hardcoded IP +pin). Consumed by a Flutter rider app and a React admin console +(`doormile_express_console` — literal ancestor of the current Doormile admin +console). Found to be structurally broken on live-DB analysis: +- `riderlogs`: 1.17M rows, 643MB, **zero indexes**, 68M lifetime UPDATEs vs + 762K inserts, caused by an unindexed `UPDATE ... WHERE userid=?` rewriting + ~97K rows per call — the real root cause of read timeouts previously + blamed on Redis. +- Every table had only its primary key indexed; `orders`/`deliveries` never + autovacuumed. +- `getdeliveries` returned every row **21×** (unconstrained `LEFT JOIN + tenantpricing`, `DISTINCT` over 87 columns that didn't dedupe anything). +- `createdeliveries` had a quadratic insert bug (slice declared outside a + loop kept accumulating) — confirmed live: 66,446 deliveries → 132,826 + `deliveryqueues` rows (~2×) with duplicate `deliveryid`s. +- v2 endpoints wrote **only to Redis**, invisible to v1/v3 Postgres reads — + genuine split-brain, with a Redis `INCR` ID space independent of the + Postgres sequence (collision risk). +- `orders` had 75 columns (~20 never populated once across 137K rows). + `deliveries` had 92 columns, six lat/lng pairs for 3 real points, status + spread across 6 separate text+timestamp columns instead of an events + table. +- `PUT /deliveries/updatedelivery` was overloaded for **11 different real + actions** (8 rider status transitions + 3 unrelated console actions), + distinguished only by which JSON fields happened to be non-empty. +- The "exhaustive" API docs undercounted real usage by ~8 endpoints + (including the login endpoint itself and a whole `/v1/substitutions` CRUD + feature), found only by cross-checking against actual console source. + +Doormile's new schema was already solving most of this structurally before +this session (Redis-only telemetry, real event-log tables, 37 normalized +tables with real FKs and typed columns, parcel/courier domain model instead +of retail/food-delivery shaped). + +### 8.2 What was built this session (14 new endpoints) + +| Method | Path | Handler | +|---|---|---| +| POST | `/miler/reset-pin` | `ResetMilerPin` | +| POST | `/miler/bookings/:bookingid/cancel` | `MilerCancelAssignment` | +| POST | `/miler/consignments/:id/skip` | `MilerSkipDelivery` | +| GET | `/admin/reports` | `GetAdminReports` | +| PUT | `/admin/profile/password` | `AdminChangePassword` | +| GET | `/admin/partners` | `GetPartners` | +| POST | `/admin/partners` | `CreatePartner` | +| GET | `/admin/partners/:id` | `GetPartnerDetails` | +| PUT | `/admin/partners/:id` | `UpdatePartner` | +| DELETE | `/admin/partners/:id` | `DeletePartner` | +| POST | `/admin/milers/:id/notify` | `AdminNotifyMiler` | +| POST | `/admin/expressbooking/bulk` | `AdminBulkCreateBookings` | +| POST | `/admin/bookings/bulk-cancel` | `AdminBulkCancelBookings` | +| POST | `/hub/bookings/batch-assign` | `HubBatchAssign` | + +Plus two real bugs found and fixed incidentally while doing tenant- +separation work (not requested, found along the way): +1. **Data mis-attribution**: `BookingPickupComplete` set the resulting + `Consignment.Tenantid` from the *completing miler's own* tenant rather + than the booking's actual tenant — silently mis-attributed shipments for + any miler carrying parcels across tenants. Fixed to use + `booking.Tenantid` when set. +2. **Cross-tenant data leak**: `GetHubUnassignedBookings`/ + `GetHubBookingsRange` had no tenant scoping — a partner tenant's hub + staff could see every other tenant's bookings at the same hub. Fixed via + `scopeBookingsToOwnTenant`. + +Naming cleanup: `CreateCRMBooking`→`CreateExpressBooking`, +`/admin/crmbooking`→`/admin/expressbooking` (+ `/bulk`), because "CRM +bookings" collided with the genuinely separate CRM feature (§3.5). +Deliberately **not** renamed: the stored `Bookingsource: "CRM_Console"` +value and `internal/assignment/crm_assignment.go`'s `AssignCRMMiler` — a +working assignment engine and an already-written DB value, not just a +label; renaming those is a deeper change than was asked for. + +Deliberately skipped: rider substitutions (`/v1/substitutions` in Nearle) — +Suriya's own call, looked low-traffic in the old system. + +### 8.3 Verified vs not, for this migration slice +**Verified**: every new handler's field/column names checked by hand +against real structs; brace-balance confirmed after every edit; no +duplicate symbol definitions. +**Not verified**: +1. `go build ./...` — never run, no Go toolchain in that sandbox. +2. No integration test has hit any of the 14 new endpoints. +3. The `Tenantid` migration hasn't executed against a real DB yet — will + run automatically via `AutoMigrate` next deploy (additive, nullable, + safe). +4. **Nothing on the client side has changed.** The rider Flutter app and + `doormile_express_console` still call `jupiter.nearle.app`. API coverage + existing on Doormile does not mean traffic is using it. Not a + flip-a-URL cutover either — response shapes are completely different + (flat 87-column Nearle rows vs nested Doormile JSON) — every screen that + parses a response needs rewriting, not just repointing. + +--- + +## 9. Current blockers & open work (whole-project level) + +**[carried forward]** +- Last live end-to-end system test stalled on **customer JWT acquisition** + — customer login requires Firebase OTP on a real phone, can't be scripted + from the command line. Needs either a real-phone run or a load-test + workaround. +- A **load test** targeting high concurrent bookings (capacity check before + go-live) was in progress at the end of the last relevant session, not + finished. +- Go-live preparation across the 4 target cities is still ahead. + +**Migration-slice-specific (§8.3)**: `go build` verification, integration +testing of the 14 new endpoints, running the `Tenantid` migration, and the +client-app rewrites are all still pending. + +--- + +## 10. Deliberately skipped / open decisions + +- **Rider substitutions** — skipped, low old-system traffic. Revisit if it + turns out to matter. +- **B2C tenant attribution** — `PickupBooking.Tenantid` stays nil for B2C + bookings; whether direct-to-consumer traffic should be attributed to one + of the 4 Doormile-ops tenants (per city) is a business decision, not + something to guess at. +- **Batch route optimization** — `HubBatchAssign` is a single-booking + nearest-rider heuristic, not a multi-stop VRP solver (§4). Untested + against real volume vs. whatever the old paid external services provided. +- **`PartnerInfo` vs `Tenant` naming confusion** — flagged, not acted on. +- **`Customer`/`CustomerLocation` (legacy) vs `AppCustomer` (new B2C)** — + two customer-shaped tables coexisting, flagged as a future duplicate-data + risk, not resolved. +- **`AgentDecision.context_embedding` dimension (1536) vs the reported + MiniLM embedding size (384)** — flagged this session (§2), not + investigated further; worth resolving before relying on vector search + quality from that column. + +--- + +## 11. Suggested next steps, in order + +1. Resolve the customer-JWT E2E test blocker (real phone or load-test + workaround) and finish the load test. +2. `go build ./...` in `DoormileBackend`, fix anything that doesn't + compile — nothing from the migration session has ever been compiled. +3. Stand up a test/staging DB, let `AutoMigrate` run, smoke-test the 14 new + migration-session endpoints with real requests. +4. Pick one low-risk console slice (e.g. Reports or Partner management — + net-new UI, not replacing something live) and wire it to Doormile + instead of jupiter — first real proof the cutover works end to end. +5. Only after that: rewrite the higher-traffic screens (orders/deliveries + list, rider status updates) and the rider app's request/response + handling. +6. Resolve the B2C tenant-attribution question before it's load-bearing for + real revenue reporting. +7. Confirm the `AgentDecision` embedding-dimension question. +8. Decide on substitutions and batch-optimization sophistication once real + usage data says whether they're actually needed. +9. Go-live preparation across the 4 target cities. diff --git a/controllers/adminController.go b/controllers/adminController.go index 0309dbc..54c10bb 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -112,6 +112,157 @@ func GetAdminDashboard(c *fiber.Ctx) error { }) } +// -------------------- +// REPORTS +// -------------------- + +// GetAdminReports gives operations dashboard-style aggregates over a date +// range (defaults to today via parseHubDateRange, same helper GetHubReport +// uses), optionally scoped to one tenant/hub, broken down by hub, tenant, and +// rider. Replaces the old system's separate getreportsummary / +// getriderlocationsummary / getridersummary endpoints with one +// parameterized view instead of three fixed ones. +func GetAdminReports(c *fiber.Ctx) error { + from, to, err := parseHubDateRange(c) + if err != nil { + return utils.BadRequest(c, err.Error()) + } + + tenantID := c.Query("tenantid") + hubID := c.Query("hubid") + + var totalBookings int64 + db.DB.Model(&models.PickupBooking{}).Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings) + + var delivered int64 + db.DB.Model(&models.PickupBooking{}). + Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to). + Count(&delivered) + + var cancelled int64 + db.DB.Model(&models.PickupBooking{}). + Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to). + Count(&cancelled) + + consignmentQuery := db.DB.Model(&models.Consignment{}).Where("createdat BETWEEN ? AND ?", from, to) + if tenantID != "" { + consignmentQuery = consignmentQuery.Where("tenantid = ?", tenantID) + } + if hubID != "" { + consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID) + } + var totalConsignments int64 + consignmentQuery.Count(&totalConsignments) + + var codCollected float64 + db.DB.Model(&models.BookingPayment{}). + Where("paymentstatus = ? AND createdat BETWEEN ? AND ?", constants.PaymentStatusPaid, from, to). + Select("COALESCE(SUM(amount), 0)").Scan(&codCollected) + + var openExceptions int64 + db.DB.Model(&models.ConsignmentException{}). + Where("status != ? AND createdat BETWEEN ? AND ?", constants.ExceptionClosed, from, to). + Count(&openExceptions) + + completionRate := 0.0 + if totalBookings > 0 { + completionRate = float64(delivered) / float64(totalBookings) * 100 + } + + // ---- by hub: parcels delivered through each hub in range ---- + type hubRow struct { + Hubid int `gorm:"column:hubid"` + Hubname string `gorm:"column:hubname"` + Delivered int64 `gorm:"column:delivered"` + } + var hubRows []hubRow + db.DB.Raw(` + SELECT h.hubid AS hubid, h.hubname AS hubname, COUNT(c.consignmentid) AS delivered + FROM hubs h + LEFT JOIN consignments c ON c.currenthubid = h.hubid AND c.status = ? AND c.updatedat BETWEEN ? AND ? + WHERE h.deletedat IS NULL + GROUP BY h.hubid, h.hubname + ORDER BY delivered DESC + `, constants.ConsignmentDelivered, from, to).Scan(&hubRows) + + byHub := make([]fiber.Map, 0, len(hubRows)) + for _, r := range hubRows { + byHub = append(byHub, fiber.Map{"hubid": r.Hubid, "hubname": r.Hubname, "delivered": r.Delivered}) + } + + // ---- by tenant: consignments shipped for each tenant in range ---- + type tenantRow struct { + Tenantid int `gorm:"column:tenantid"` + Tenantname string `gorm:"column:tenantname"` + Bookings int64 `gorm:"column:bookings"` + } + var tenantRows []tenantRow + db.DB.Raw(` + SELECT t.tenantid AS tenantid, t.tenantname AS tenantname, COUNT(c.consignmentid) AS bookings + FROM tenants t + LEFT JOIN consignments c ON c.tenantid = t.tenantid AND c.createdat BETWEEN ? AND ? + GROUP BY t.tenantid, t.tenantname + ORDER BY bookings DESC + `, from, to).Scan(&tenantRows) + + byTenant := make([]fiber.Map, 0, len(tenantRows)) + for _, r := range tenantRows { + byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings}) + } + + // ---- by rider: completed stops/kms/earnings per rider in range, + // optionally scoped to one hub ---- + type riderRow struct { + Userid int `gorm:"column:userid"` + Displayname string `gorm:"column:displayname"` + CompletedStops int64 `gorm:"column:completed_stops"` + TotalKms float64 `gorm:"column:total_kms"` + TotalEarnings float64 `gorm:"column:total_earnings"` + } + var riderRows []riderRow + riderQuery := ` + SELECT mp.userid AS userid, mp.displayname AS displayname, + COUNT(ba.bookingassignmentid) AS completed_stops, + COALESCE(SUM(ba.riderkms),0) AS total_kms, + COALESCE(SUM(ba.ridercharges),0) AS total_earnings + FROM milerprofiles mp + JOIN bookingassignments ba ON ba.mileruserid = mp.userid + AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ? + ` + args := []interface{}{constants.AssignmentCompleted, from, to} + if hubID != "" { + riderQuery += " WHERE mp.hubid = ? " + args = append(args, hubID) + } + riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50" + db.DB.Raw(riderQuery, args...).Scan(&riderRows) + + byRider := make([]fiber.Map, 0, len(riderRows)) + for _, r := range riderRows { + byRider = append(byRider, fiber.Map{ + "userid": r.Userid, "displayname": r.Displayname, + "completed_stops": r.CompletedStops, "total_kms": r.TotalKms, "total_earnings": r.TotalEarnings, + }) + } + + return utils.OK(c, fiber.Map{ + "from": from.Format("2006-01-02"), + "to": to.Format("2006-01-02"), + "summary": fiber.Map{ + "total_bookings": totalBookings, + "delivered": delivered, + "cancelled": cancelled, + "total_consignments": totalConsignments, + "cod_collected": codCollected, + "open_exceptions": openExceptions, + "completion_rate": completionRate, + }, + "by_hub": byHub, + "by_tenant": byTenant, + "by_rider": byRider, + }) +} + // -------------------- // APP USERS MANAGEMENT // -------------------- @@ -665,6 +816,120 @@ func DeleteTenantCustomer(c *fiber.Ctx) error { return utils.Message(c, "customer deleted successfully") } +// -------------------- +// PARTNERS CRUD +// -------------------- +// PartnerInfo has no Deletedat column (unlike Hub/Vehicle), so delete here is +// a hard delete, matching DeleteTenant's pattern for the same reason. + +func GetPartners(c *fiber.Ctx) error { + query := db.DB.Model(&models.PartnerInfo{}) + + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + if keyword := c.Query("keyword"); keyword != "" { + query = query.Where("partnername ILIKE ?", "%"+keyword+"%") + } + + var partners []models.PartnerInfo + if err := query.Find(&partners).Error; err != nil { + return utils.Internal(c, "failed to fetch partners") + } + return utils.List(c, partners, int64(len(partners))) +} + +func CreatePartner(c *fiber.Ctx) error { + req := new(dto.PartnerCreateRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Partnername == "" { + return utils.BadRequest(c, "partnername is required") + } + + partner := models.PartnerInfo{ + Partnername: req.Partnername, + Partnertypeid: req.Partnertypeid, + Contactno: req.Contactno, + Status: req.Status, + } + if partner.Status == "" { + partner.Status = "Active" + } + + if err := db.DB.Create(&partner).Error; err != nil { + return utils.Internal(c, "failed to create partner") + } + return utils.Created(c, partner) +} + +func GetPartnerDetails(c *fiber.Ctx) error { + id, _ := strconv.Atoi(c.Params("id")) + var partner models.PartnerInfo + if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil { + return utils.NotFound(c, "partner not found") + } + + var vehicleCount int64 + db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount) + + return utils.OK(c, fiber.Map{ + "partner": partner, + "vehicle_count": vehicleCount, + }) +} + +func UpdatePartner(c *fiber.Ctx) error { + id, _ := strconv.Atoi(c.Params("id")) + var partner models.PartnerInfo + if err := db.DB.Where("partnerid = ?", id).First(&partner).Error; err != nil { + return utils.NotFound(c, "partner not found") + } + + req := new(dto.PartnerCreateRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Partnername != "" { + partner.Partnername = req.Partnername + } + if req.Partnertypeid != 0 { + partner.Partnertypeid = req.Partnertypeid + } + if req.Contactno != "" { + partner.Contactno = req.Contactno + } + if req.Status != "" { + partner.Status = req.Status + } + partner.Updatedat = time.Now() + + db.DB.Save(&partner) + return utils.OK(c, partner) +} + +func DeletePartner(c *fiber.Ctx) error { + id, _ := strconv.Atoi(c.Params("id")) + var partner models.PartnerInfo + if err := db.DB.First(&partner, id).Error; err != nil { + return utils.NotFound(c, "partner not found") + } + + var vehicleCount int64 + db.DB.Model(&models.Vehicle{}).Where("partnerid = ? AND deletedat IS NULL", id).Count(&vehicleCount) + if vehicleCount > 0 { + return utils.BadRequest(c, "cannot delete a partner with vehicles still assigned to them") + } + + if err := db.DB.Delete(&partner).Error; err != nil { + return utils.Internal(c, "failed to delete partner") + } + return utils.Message(c, "partner deleted successfully") +} + // -------------------- // HUBS CRUD // -------------------- @@ -957,6 +1222,44 @@ func GetMilerDetails(c *fiber.Ctx) error { return utils.OK(c, profile) } +// AdminNotifyMiler lets console staff push a one-off notification to a +// specific miler directly (not tied to a booking, unlike InternalNotify +// which is machine-to-machine and booking-scoped). :id is the milerprofileid, +// matching every other /admin/milers/:id route. +func AdminNotifyMiler(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid miler ID") + } + + var req struct { + Title string `json:"title"` + Message string `json:"message"` + Data map[string]string `json:"data"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Title == "" || req.Message == "" { + return utils.BadRequest(c, "title and message are required") + } + + var profile models.MilerProfile + if err := db.DB.Where("milerprofileid = ?", id).First(&profile).Error; err != nil { + return utils.NotFound(c, "miler not found") + } + + if profile.Devicetoken == "" { + return utils.BadRequest(c, "this miler has no registered device to notify") + } + + if err := notify.SendToDevice(profile.Devicetoken, req.Title, req.Message, req.Data); err != nil { + return utils.Internal(c, "failed to send notification") + } + + return utils.Message(c, "notification sent") +} + func UpdateMiler(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var profile models.MilerProfile @@ -1053,13 +1356,18 @@ func GetAdminBookings(c *fiber.Ctx) error { pagesize := min(100, max(1, c.QueryInt("pagesize", 20))) offset := (pageno - 1) * pagesize + query := db.DB.Model(&models.PickupBooking{}) + if tenantID := c.Query("tenantid"); tenantID != "" { + query = query.Where("tenantid = ?", tenantID) + } + var total int64 - if err := db.DB.Model(&models.PickupBooking{}).Count(&total).Error; err != nil { + if err := query.Count(&total).Error; err != nil { return utils.Internal(c, "failed to count bookings") } var bookings []models.PickupBooking - if err := db.DB.Preload("Parcels").Preload("ServiceOptions"). + if err := query.Preload("Parcels").Preload("ServiceOptions"). Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil { return utils.Internal(c, "failed to fetch bookings") } @@ -1076,42 +1384,60 @@ func GetAdminBookings(c *fiber.Ctx) error { }) } -func CreateCRMBooking(c *fiber.Ctx) error { - type AdminBookingRequest struct { - Appcustomerid int `json:"appcustomerid"` - CustomerPhone string `json:"customer_phone"` - CustomerName string `json:"customer_name"` - Pickupaddress string `json:"pickupaddress"` - Pickuppincode string `json:"pickuppincode"` - Pickuplatitude float64 `json:"pickuplatitude"` - Pickuplongitude float64 `json:"pickuplongitude"` - Deliveryaddress string `json:"deliveryaddress"` - Deliverypincode string `json:"deliverypincode"` - Deliverycity string `json:"deliverycity"` - Deliverylatitude float64 `json:"deliverylatitude"` - Deliverylongitude float64 `json:"deliverylongitude"` - Providercompany string `json:"providercompany"` - Providerlocation string `json:"providerlocation"` - Notes string `json:"notes"` - ServiceOption string `json:"service_option"` - Finalprice float64 `json:"finalprice"` - Insuranceamount float64 `json:"insuranceamount"` - Preferredpickupfrom *time.Time `json:"preferredpickupfrom"` - Preferredpickupto *time.Time `json:"preferredpickupto"` - Parcels []dto.ParcelRequest `json:"parcels"` - } +// AdminBookingRequest is the express-console booking payload, shared by CreateExpressBooking +// (one booking) and AdminBulkCreateBookings (many) — was previously a type +// local to CreateExpressBooking, promoted to package level so both can use it. +type AdminBookingRequest struct { + Tenantid int `json:"tenantid"` + Appcustomerid int `json:"appcustomerid"` + CustomerPhone string `json:"customer_phone"` + CustomerName string `json:"customer_name"` + Pickupaddress string `json:"pickupaddress"` + Pickuppincode string `json:"pickuppincode"` + Pickuplatitude float64 `json:"pickuplatitude"` + Pickuplongitude float64 `json:"pickuplongitude"` + Deliveryaddress string `json:"deliveryaddress"` + Deliverypincode string `json:"deliverypincode"` + Deliverycity string `json:"deliverycity"` + Deliverylatitude float64 `json:"deliverylatitude"` + Deliverylongitude float64 `json:"deliverylongitude"` + Providercompany string `json:"providercompany"` + Providerlocation string `json:"providerlocation"` + Notes string `json:"notes"` + ServiceOption string `json:"service_option"` + Finalprice float64 `json:"finalprice"` + Insuranceamount float64 `json:"insuranceamount"` + Preferredpickupfrom *time.Time `json:"preferredpickupfrom"` + Preferredpickupto *time.Time `json:"preferredpickupto"` + Parcels []dto.ParcelRequest `json:"parcels"` +} - req := new(AdminBookingRequest) - if err := c.BodyParser(req); err != nil { - return utils.BadRequest(c, "invalid request body") - } +// expressBookingValidationError marks a createExpressBooking failure as a bad +// request (missing/invalid input) rather than a server-side failure, so +// CreateExpressBooking can still return the right HTTP status after the +// validation logic moved into the shared helper below. +type expressBookingValidationError struct{ msg string } +func (e *expressBookingValidationError) Error() string { return e.msg } + +// createExpressBooking holds the actual booking-creation logic, shared by +// CreateExpressBooking (one booking, used by the "New Booking" form) and +// AdminBulkCreateBookings (many, used by CSV/bulk import). Takes no +// *fiber.Ctx — the original function never touched c after BodyParser, so +// both callers can use this identically. +func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error) { if req.Pickupaddress == "" || req.Pickuppincode == "" { - return utils.BadRequest(c, "pickup address and pincode are required") + return nil, &expressBookingValidationError{"pickup address and pincode are required"} } - if len(req.Parcels) == 0 { - return utils.BadRequest(c, "at least one parcel is required") + return nil, &expressBookingValidationError{"at least one parcel is required"} + } + if req.Tenantid == 0 { + return nil, &expressBookingValidationError{"tenantid is required for express-console bookings"} + } + var tenant models.Tenant + if err := db.DB.Where("tenantid = ?", req.Tenantid).First(&tenant).Error; err != nil { + return nil, &expressBookingValidationError{"tenantid does not match a known tenant"} } tx := db.DB.Begin() @@ -1135,14 +1461,16 @@ func CreateCRMBooking(c *fiber.Ctx) error { } if err := tx.Create(&newCustomer).Error; err != nil { tx.Rollback() - return utils.Internal(c, "failed to create customer record") + return nil, fmt.Errorf("failed to create customer record") } customerID = newCustomer.Appcustomerid } } + tenantID := req.Tenantid booking := models.PickupBooking{ Bookingno: generateBookingNo(), + Tenantid: &tenantID, Appcustomerid: customerID, Pickupaddress: req.Pickupaddress, Pickuppincode: req.Pickuppincode, @@ -1164,7 +1492,7 @@ func CreateCRMBooking(c *fiber.Ctx) error { if err := tx.Create(&booking).Error; err != nil { tx.Rollback() - return utils.Internal(c, "failed to create booking") + return nil, fmt.Errorf("failed to create booking") } var totalWeight float64 @@ -1197,7 +1525,7 @@ func CreateCRMBooking(c *fiber.Ctx) error { parcel.Insuranceamount = p.Declaredvalue * 0.01 } - // If explicit insurance amount is provided from CRM, apply it to the first parcel + // If explicit insurance amount is provided in the request, apply it to the first parcel if req.Insuranceamount > 0 && totalWeight == math.Max(p.Weight, calculateVolumetricWeight(p.Length, p.Width, p.Height)) { parcel.Insuranceamount = req.Insuranceamount parcel.Needsinsurance = true @@ -1205,7 +1533,7 @@ func CreateCRMBooking(c *fiber.Ctx) error { if err := tx.Create(&parcel).Error; err != nil { tx.Rollback() - return utils.Internal(c, "failed to save parcel details") + return nil, fmt.Errorf("failed to save parcel details") } } @@ -1265,7 +1593,7 @@ func CreateCRMBooking(c *fiber.Ctx) error { if err := tx.Create(&srvOption).Error; err != nil { tx.Rollback() - return utils.Internal(c, "failed to save service option") + return nil, fmt.Errorf("failed to save service option") } if requiresLargeVehicle || totalWeight > 20.0 { @@ -1277,12 +1605,12 @@ func CreateCRMBooking(c *fiber.Ctx) error { } if err := tx.Create(&reqVeh).Error; err != nil { tx.Rollback() - return utils.Internal(c, "failed to save vehicle requirement") + return nil, fmt.Errorf("failed to save vehicle requirement") } } if err := tx.Commit().Error; err != nil { - return utils.Internal(c, "failed to create booking") + return nil, fmt.Errorf("failed to create booking") } go assignment.AssignCRMMiler(booking.Bookingid) @@ -1304,9 +1632,65 @@ func CreateCRMBooking(c *fiber.Ctx) error { db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, booking.Bookingid) + return &booking, nil +} + +func CreateExpressBooking(c *fiber.Ctx) error { + req := new(AdminBookingRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + booking, err := createExpressBooking(*req) + if err != nil { + if _, ok := err.(*expressBookingValidationError); ok { + return utils.BadRequest(c, err.Error()) + } + return utils.Internal(c, err.Error()) + } + return utils.Created(c, booking) } +// AdminBulkCreateBookings creates several express-console bookings in one call — the +// console's CSV/bulk-import flow. Each item is processed independently, same +// per-item-result shape as AdminBulkCancelBookings, so one bad row (missing +// address, unknown tenantid) doesn't block the rest of the batch. +func AdminBulkCreateBookings(c *fiber.Ctx) error { + var req struct { + Bookings []AdminBookingRequest `json:"bookings"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if len(req.Bookings) == 0 { + return utils.BadRequest(c, "bookings is required and must not be empty") + } + if len(req.Bookings) > 200 { + return utils.BadRequest(c, "maximum 200 bookings per bulk request") + } + + type result struct { + Index int `json:"index"` + Success bool `json:"success"` + Bookingid int `json:"bookingid,omitempty"` + Bookingno string `json:"bookingno,omitempty"` + Error string `json:"error,omitempty"` + } + results := make([]result, 0, len(req.Bookings)) + + for i, item := range req.Bookings { + booking, err := createExpressBooking(item) + if err != nil { + results = append(results, result{Index: i, Success: false, Error: err.Error()}) + continue + } + results = append(results, result{Index: i, Success: true, Bookingid: booking.Bookingid, Bookingno: booking.Bookingno}) + } + + return utils.OK(c, fiber.Map{"results": results}) +} + func GetAdminBookingDetails(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var booking models.PickupBooking @@ -1443,6 +1827,76 @@ func AdminCancelBooking(c *fiber.Ctx) error { return utils.Message(c, "booking cancelled") } +// AdminBulkCancelBookings cancels several bookings in one call — the console's +// multi-select "cancel selected" action. Each id is processed independently +// so one bad id (already shipped, already cancelled, not found) doesn't block +// the rest; the response reports success/failure per id rather than failing +// the whole batch on the first error. +func AdminBulkCancelBookings(c *fiber.Ctx) error { + var req struct { + Bookingids []int `json:"bookingids"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if len(req.Bookingids) == 0 { + return utils.BadRequest(c, "bookingids is required and must not be empty") + } + + type result struct { + Bookingid int `json:"bookingid"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + } + results := make([]result, 0, len(req.Bookingids)) + + for _, id := range req.Bookingids { + var booking models.PickupBooking + if err := db.DB.First(&booking, id).Error; err != nil { + results = append(results, result{Bookingid: id, Success: false, Error: "booking not found"}) + continue + } + + if booking.Status == constants.BookingConvertedConsignment || booking.Status == constants.BookingCancelled { + results = append(results, result{Bookingid: id, Success: false, Error: "cannot cancel a delivered or already cancelled booking"}) + continue + } + + booking.Status = constants.BookingCancelled + booking.Updatedat = time.Now() + if err := db.DB.Save(&booking).Error; err != nil { + results = append(results, result{Bookingid: id, Success: false, Error: "failed to save"}) + continue + } + + if booking.Assignedmileruserid != nil { + db.DB.Model(&models.MilerProfile{}). + Where("userid = ?", *booking.Assignedmileruserid). + Update("availabilitystatus", constants.MilerAvailable) + } + + if db.Js != nil { + payload := map[string]interface{}{"bookingid": booking.Bookingid, "reason": "admin_bulk_cancelled"} + if data, err := json.Marshal(payload); err == nil { + if _, err := db.Js.Publish("booking.cancelled", data); err != nil { + utils.Warn("AdminBulkCancelBookings: NATS publish failed", "booking_id", booking.Bookingid, "error", err) + } + } + } + + var customer models.AppCustomer + if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" { + if err := notify.SendToDevice(customer.Devicetoken, "Booking Cancelled", "Your booking has been cancelled by operations", nil); err != nil { + utils.Warn("AdminBulkCancelBookings: failed to notify customer", "booking_id", booking.Bookingid, "error", err) + } + } + + results = append(results, result{Bookingid: id, Success: true}) + } + + return utils.OK(c, fiber.Map{"results": results}) +} + // -------------------- // CONSIGNMENTS // -------------------- @@ -2227,6 +2681,60 @@ func GetAdminProfile(c *fiber.Ctx) error { return utils.OK(c, user) } +// AdminChangePassword lets a logged-in admin console user change their own +// password. Unlike ResetCustomerPin/ResetMilerPin — open, phone-only reset +// endpoints matching what those two apps already do — this requires an +// active session and the current password. Admin accounts touch tenant, +// pricing, and financial data, so an open "reset by email" endpoint here +// would be a much bigger blast radius than a customer or miler PIN reset; +// intentionally not mirroring that pattern for this one. +func AdminChangePassword(c *fiber.Ctx) error { + userID, ok := c.Locals("userid").(int) + if !ok { + return utils.Unauthorized(c, "authentication required") + } + + var req struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.CurrentPassword == "" || req.NewPassword == "" { + return utils.BadRequest(c, "current_password and new_password are required") + } + if len(req.NewPassword) < 8 { + return utils.BadRequest(c, "new_password must be at least 8 characters") + } + + var user models.AppUser + if err := db.DB.Where("userid = ?", userID).First(&user).Error; err != nil { + return utils.NotFound(c, "user not found") + } + + var auth models.DoormileAuth + if err := db.DB.Where("email = ?", user.Email).First(&auth).Error; err != nil { + return utils.NotFound(c, "admin credentials not found for this account") + } + + if !utils.CheckPasswordHash(req.CurrentPassword, auth.PasswordHash) { + return utils.Unauthorized(c, "current password is incorrect") + } + + newHash, err := utils.HashPassword(req.NewPassword) + if err != nil { + return utils.Internal(c, "failed to process password change") + } + + auth.PasswordHash = newHash + if err := db.DB.Save(&auth).Error; err != nil { + return utils.Internal(c, "failed to update password") + } + + return utils.Message(c, "password changed successfully") +} + // InternalNotify sends FCM push notifications on behalf of the Python agent system. // The caller specifies target = "customer", "miler", or "both". // Auth: X-Internal-Key header (see InternalKeyAuth middleware). diff --git a/controllers/hubController.go b/controllers/hubController.go index edffe7c..08b1228 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -17,6 +17,7 @@ import ( "doormile/utils" "github.com/gofiber/fiber/v2" + "gorm.io/gorm" ) // hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for @@ -211,6 +212,23 @@ func isDoormileStaff(staff *models.HubStaffAccount) bool { return staff.Tenantid == nil } +// scopeBookingsToOwnTenant restricts a bookings query to the requesting hub +// staff's own tenant when they're partner staff (HubStaffAccount.Tenantid set) +// — without this, a partner tenant's own hub staff could see every other +// tenant's bookings passing through the same hub, which is exactly the kind +// of cross-client leak a multi-tenant flow can't have. Doormile staff +// (Tenantid nil) are unrestricted, same as everywhere else in this file. +// Bookings created before Tenantid existed (nil) are only visible to +// Doormile staff, never to partner staff, since they can't be proven to +// belong to that partner. +func scopeBookingsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB { + staff, err := getCurrentHubStaff(c) + if err != nil || isDoormileStaff(staff) { + return query + } + return query.Where("tenantid = ?", *staff.Tenantid) +} + func GetHubDashboardStats(c *fiber.Ctx) error { hubID := c.Locals("hubid").(int) prefix := hubPincodePrefix(hubID) @@ -279,6 +297,7 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error { if prefix != "" { query = query.Where("pickuppincode LIKE ?", prefix+"%") } + query = scopeBookingsToOwnTenant(c, query) var bookings []models.PickupBooking if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil { @@ -343,6 +362,7 @@ func GetHubBookingsRange(c *fiber.Ctx) error { if prefix != "" { query = query.Where("pickuppincode LIKE ?", prefix+"%") } + query = scopeBookingsToOwnTenant(c, query) var bookings []models.PickupBooking if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil { @@ -1878,6 +1898,128 @@ func HubAutoAssign(c *fiber.Ctx) error { } } +// defaultBatchAssignCapPerRider caps how many bookings one rider can receive +// in a single HubBatchAssign run, so the greedy pass doesn't pile every +// pending pickup onto whichever rider happens to be closest to the first one. +const defaultBatchAssignCapPerRider = 5 + +// batchRiderCandidate is a rider available for HubBatchAssign to consider, +// tracked with a per-run assigned count so the cap can be enforced without a +// DB round trip per booking. +type batchRiderCandidate struct { + userid int + lat, lon float64 + assigned int +} + +// HubBatchAssign is a greedy nearest-available-rider batch dispatcher: for +// every pending, unassigned booking at this hub (oldest first), it assigns +// the closest rider who hasn't hit defaultBatchAssignCapPerRider yet in this +// run, using the exact same transactional AssignMilerToBooking path +// HubAssignMiler and AdminAssignMiler already use for one-at-a-time +// assignment. This is a nearest-neighbor heuristic, not full multi-stop +// route optimization (no route sequencing within a rider's stops, no +// distance-matrix solver) — it answers "who's closest and free," not +// "what's the optimal round for every rider." Good enough to clear a queue +// of pending pickups without a human clicking through them one by one; +// upgrade later if stop-sequencing quality becomes the actual bottleneck. +func HubBatchAssign(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + staffID := c.Locals("userid").(int) + prefix := hubPincodePrefix(hubID) + + var req struct { + Bookingids []int `json:"bookingids"` + MaxPerRider int `json:"max_per_rider"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + capPerRider := req.MaxPerRider + if capPerRider <= 0 { + capPerRider = defaultBatchAssignCapPerRider + } + + bookingQuery := db.DB.Where("assignedmileruserid IS NULL AND status = ?", constants.BookingPendingPickup) + if len(req.Bookingids) > 0 { + bookingQuery = bookingQuery.Where("bookingid IN ?", req.Bookingids) + } else if prefix != "" { + bookingQuery = bookingQuery.Where("pickuppincode LIKE ?", prefix+"%") + } + bookingQuery = scopeBookingsToOwnTenant(c, bookingQuery) + + var bookings []models.PickupBooking + if err := bookingQuery.Order("createdat ASC").Find(&bookings).Error; err != nil { + return utils.Internal(c, "failed to fetch pending bookings") + } + if len(bookings) == 0 { + return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}}) + } + + var riderProfiles []models.MilerProfile + if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable). + Find(&riderProfiles).Error; err != nil { + return utils.Internal(c, "failed to fetch available riders") + } + + candidates := make([]*batchRiderCandidate, 0, len(riderProfiles)) + for _, mp := range riderProfiles { + candidates = append(candidates, &batchRiderCandidate{ + userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude, + }) + } + + results := make([]fiber.Map, 0, len(bookings)) + assignedCount, skippedCount := 0, 0 + + for _, b := range bookings { + var nearest *batchRiderCandidate + nearestDist := math.MaxFloat64 + + for _, cand := range candidates { + if cand.assigned >= capPerRider { + continue + } + d := haversineKM(b.Pickuplatitude, b.Pickuplongitude, cand.lat, cand.lon) + if d < nearestDist { + nearestDist = d + nearest = cand + } + } + + if nearest == nil { + results = append(results, fiber.Map{ + "bookingid": b.Bookingid, "bookingno": b.Bookingno, + "assigned": false, "reason": "no available rider under capacity", + }) + skippedCount++ + continue + } + + if _, err := AssignMilerToBooking(b.Bookingid, nearest.userid, &staffID); err != nil { + results = append(results, fiber.Map{ + "bookingid": b.Bookingid, "bookingno": b.Bookingno, + "assigned": false, "reason": err.Error(), + }) + skippedCount++ + continue + } + + nearest.assigned++ + results = append(results, fiber.Map{ + "bookingid": b.Bookingid, "bookingno": b.Bookingno, + "assigned": true, "mileruserid": nearest.userid, "distance_km": nearestDist, + }) + assignedCount++ + } + + return utils.OK(c, fiber.Map{ + "assigned": assignedCount, + "skipped": skippedCount, + "results": results, + }) +} + // -------------------- // HUB REPORT EXPORT // -------------------- diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 02ab5fe..eb31a61 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -376,6 +376,91 @@ func MilerDeliverConsignment(c *fiber.Ctx) error { }) } +// MilerSkipDelivery records a failed/incomplete final-mile delivery attempt +// (customer unavailable, gate locked, wrong address, etc.) without closing +// out the consignment — the miler still holds the parcel, the consignment +// stays Out_for_Delivery, and the attempt is logged for a retry. This is the +// "skipped" half of the old system's 8-in-1 status endpoint; MilerDeliverConsignment +// above is the "delivered" half, and MilerCancelAssignment (milerController.go) +// is the pre-pickup "cancelled"/"rejected" half. +func MilerSkipDelivery(c *fiber.Ctx) error { + milerUserID := c.Locals("userid").(int) + consignmentID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid consignment ID") + } + + var req struct { + Reason string `json:"reason"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Reason == "" { + return utils.BadRequest(c, "reason is required") + } + + var consignment models.Consignment + if err := db.DB.First(&consignment, consignmentID).Error; err != nil { + return utils.NotFound(c, "consignment not found") + } + + var booking models.PickupBooking + if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID). + First(&booking).Error; err != nil { + return utils.NotFound(c, "assigned consignment not found") + } + + if consignment.Status != constants.ConsignmentOutForDelivery { + return utils.BadRequest(c, "consignment is not out for delivery") + } + + tx := db.DB.Begin() + + consignment.Attemptcount += 1 + consignment.Updatedat = time.Now() + if err := tx.Save(&consignment).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record skipped attempt") + } + + history := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Userid: &milerUserID, + Eventstatus: "Delivery_Skipped", + Remarks: fmt.Sprintf("Attempt %d skipped at (%.5f, %.5f): %s", consignment.Attemptcount, req.Lat, req.Lon, req.Reason), + } + if err := tx.Create(&history).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to log skipped attempt") + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to commit skipped attempt") + } + + // After 3 failed attempts, flag it for hub attention rather than leaving + // it silently retrying forever. + if consignment.Attemptcount >= 3 { + exception := models.ConsignmentException{ + Consignmentid: consignment.Consignmentid, + Reportedbyuserid: &milerUserID, + Exceptiontype: constants.ExceptionUndeliverable, + Severity: "Medium", + Description: fmt.Sprintf("3 delivery attempts failed. Last reason: %s", req.Reason), + } + db.DB.Create(&exception) + } + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "attemptcount": consignment.Attemptcount, + "status": consignment.Status, + }) +} + // -------------------- // EARNINGS // -------------------- diff --git a/controllers/milerController.go b/controllers/milerController.go index 735c28d..fe4a674 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -134,6 +134,48 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler { } } +// ResetMilerPin lets a miler who forgot their PIN set a new one from just +// their phone number, matching ResetCustomerPin's flow exactly (protected +// only by authThrottle at the route level, same as the customer version — +// no OTP verification wired in here either, consistent with the existing +// pattern rather than a change to it). +func ResetMilerPin(c *fiber.Ctx) error { + req := new(dto.MilerResetPinRequest) + if err := c.BodyParser(req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + + if req.Phone == "" || req.NewPin == "" { + return utils.BadRequest(c, "phone and new_pin are required") + } + + configID := req.Configid + if configID == 0 { + configID = 1001 + } + + var user models.AppUser + if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil { + return utils.NotFound(c, "no miler account found for this phone number") + } + + if user.Roleid != 5 { + return utils.Forbidden(c, "this endpoint is restricted to miler accounts") + } + + pinHash, err := utils.HashPassword(req.NewPin) + if err != nil { + return utils.Internal(c, "failed to process PIN reset") + } + + user.Password = pinHash + if err := db.DB.Save(&user).Error; err != nil { + return utils.Internal(c, "failed to reset PIN") + } + + return utils.Message(c, "PIN reset successfully") +} + func GetMilerProfile(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) @@ -443,6 +485,84 @@ func RejectMilerAssignment(c *fiber.Ctx) error { return utils.Message(c, "assignment rejected") } +// MilerCancelAssignment lets a miler back out of a booking they've already +// accepted but not yet picked up (vehicle breakdown, can't reach the +// address, etc.). Distinct from RejectMilerAssignment, which only applies +// before acceptance — once the parcel is picked up the booking has become a +// consignment and this no longer applies (use MilerSkipDelivery instead for +// a failed delivery attempt on an in-flight consignment). Releases the +// booking for reassignment the same way RejectMilerAssignment does. +func MilerCancelAssignment(c *fiber.Ctx) error { + milerUserID := c.Locals("userid").(int) + bookingID, err := strconv.Atoi(c.Params("bookingid")) + if err != nil { + return utils.BadRequest(c, "invalid booking ID") + } + + var req struct { + Reason string `json:"reason"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Reason == "" { + req.Reason = "Cancelled by miler" + } + + tx := db.DB.Begin() + + var booking models.PickupBooking + if err := tx.Where("bookingid = ? AND assignedmileruserid = ?", bookingID, milerUserID).First(&booking).Error; err != nil { + tx.Rollback() + return utils.NotFound(c, "assigned booking not found") + } + + if booking.Status == constants.BookingPickedUp || booking.Status == constants.BookingConvertedConsignment { + tx.Rollback() + return utils.BadRequest(c, "booking cannot be cancelled after pickup — the parcel is already in the network") + } + + var ba models.BookingAssignment + if err := tx.Where("bookingid = ? AND mileruserid = ? AND assignmentstatus = ?", + bookingID, milerUserID, constants.AssignmentAccepted).First(&ba).Error; err != nil { + tx.Rollback() + return utils.BadRequest(c, "no accepted assignment found for this booking") + } + + ba.Assignmentstatus = constants.AssignmentCancelled + ba.Remarks = req.Reason + if err := tx.Save(&ba).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to cancel assignment") + } + + booking.Status = constants.BookingCreated + booking.Assignedmileruserid = nil + booking.Updatedat = time.Now() + if err := tx.Save(&booking).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to release booking") + } + + if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to update miler availability") + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to commit cancellation") + } + + if booking.Bookingsource == "CRM_Console" { + go assignment.AssignCRMMiler(booking.Bookingid) + } else { + go assignment.AssignCustomerMiler(booking.Bookingid) + } + + return utils.Message(c, "assignment cancelled and released for reassignment") +} + func BookingReachedCustomer(c *fiber.Ctx) error { milerUserID := c.Locals("userid").(int) bookingID, err := strconv.Atoi(c.Params("bookingid")) @@ -665,9 +785,20 @@ func BookingPickupComplete(c *fiber.Ctx) error { consignmentStatus = constants.ConsignmentOutForDelivery } + // The consignment's tenant is the booking's own tenant (set explicitly at + // CreateExpressBooking time), not the completing miler's tenantid claim — a + // miler can carry parcels for tenants other than their own, and using + // their JWT tenantid here mis-attributed every such consignment. Falls + // back to the miler's own tenantid only for B2C bookings that don't carry + // one yet, matching the previous behavior for that case. + consignmentTenantID := c.Locals("tenantid").(int) + if booking.Tenantid != nil { + consignmentTenantID = *booking.Tenantid + } + consignment := models.Consignment{ Trackingno: trackingNo, - Tenantid: c.Locals("tenantid").(int), + Tenantid: consignmentTenantID, Pickuplatitude: booking.Pickuplatitude, Pickuplongitude: booking.Pickuplongitude, Deliverylatitude: booking.Deliverylatitude, diff --git a/dto/admin.go b/dto/admin.go index 1642365..e91189b 100644 --- a/dto/admin.go +++ b/dto/admin.go @@ -27,6 +27,13 @@ type TenantCustomerCreateRequest struct { Email string `json:"email"` } +type PartnerCreateRequest struct { + Partnername string `json:"partnername"` + Partnertypeid int `json:"partnertypeid"` + Contactno string `json:"contactno"` + Status string `json:"status"` +} + type HubCreateRequest struct { Hubname string `json:"hubname"` Hubtype string `json:"hubtype"` // sorting_center, delivery_hub diff --git a/dto/auth.go b/dto/auth.go index 945a167..1d910f7 100644 --- a/dto/auth.go +++ b/dto/auth.go @@ -49,6 +49,12 @@ type MilerPinVerifyRequest struct { DeviceToken string `json:"device_token"` } +type MilerResetPinRequest struct { + Phone string `json:"phone" xml:"phone" form:"phone"` + NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"` + Configid int `json:"configid"` +} + type AdminLoginRequest struct { Email string `json:"email" xml:"email" form:"email"` Password string `json:"password" xml:"password" form:"password"` diff --git a/internal/assignment/crm_assignment.go b/internal/assignment/crm_assignment.go index 0ae98c3..3a3252d 100644 --- a/internal/assignment/crm_assignment.go +++ b/internal/assignment/crm_assignment.go @@ -30,7 +30,7 @@ type milerCandidate struct { // AssignCRMMiler finds the best available nearby miler for a CRM booking and assigns them. // It retries up to maxRetries times (retryDelay apart) before logging NO_MILER_AVAILABLE. -// Must be called as a goroutine after tx.Commit() in CreateCRMBooking. +// Must be called as a goroutine after tx.Commit() in CreateExpressBooking. func AssignCRMMiler(bookingID int) { defer func() { if r := recover(); r != nil { diff --git a/models/booking.go b/models/booking.go index c1a6537..25dd316 100644 --- a/models/booking.go +++ b/models/booking.go @@ -7,6 +7,14 @@ import ( type PickupBooking struct { Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"` Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"` + // Tenantid identifies which client company this booking is for. Nil for + // direct B2C bookings (Bookingsource "Customer_App") that aren't attributed + // to a tenant yet — see CreateCustomerBooking. Required for CRM bookings + // (Bookingsource "CRM_Console"), since those are always made on behalf of + // a specific tenant. Propagated onto the resulting Consignment at pickup + // time in BookingPickupComplete, instead of inferring it from whichever + // miler happens to complete the pickup. + Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"` Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"` Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"` Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"` diff --git a/routes/routes.go b/routes/routes.go index 90c0671..b4512e3 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -113,6 +113,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { miler := api.Group("/miler") miler.Post("/login", authThrottle, controllers.LoginMiler(cfg)) miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg)) + miler.Post("/reset-pin", authThrottle, controllers.ResetMilerPin) // Authenticated Miler App routes milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5)) @@ -134,6 +135,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { milerAuth.Post("/bookings/:bookingid/payment", controllers.BookingPaymentCollect) milerAuth.Post("/bookings/:bookingid/pickup-complete", controllers.BookingPickupComplete) milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate) + milerAuth.Post("/bookings/:bookingid/cancel", controllers.MilerCancelAssignment) // Redis periodic telemetry and status logs milerAuth.Post("/logs", controllers.CreateMilerPeriodicLog) @@ -160,6 +162,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Delivery confirmation milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment) + milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery) // Earnings milerAuth.Get("/earnings", controllers.MilerGetEarnings) @@ -183,8 +186,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth := admin.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4)) adminAuth.Get("/dashboard", controllers.GetAdminDashboard) + adminAuth.Get("/reports", controllers.GetAdminReports) adminAuth.Get("/profile", controllers.GetAdminProfile) adminAuth.Get("/me", controllers.GetAdminProfile) + adminAuth.Put("/profile/password", controllers.AdminChangePassword) // App Users Management adminAuth.Get("/users", controllers.GetAppUsers) @@ -192,6 +197,14 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Put("/users/:id", controllers.UpdateAppUser) adminAuth.Delete("/users/:id", controllers.DeleteAppUser) + // Partner management (fleet/rider suppliers — distinct from tenants, + // which are the client companies Doormile delivers for) + adminAuth.Get("/partners", controllers.GetPartners) + adminAuth.Post("/partners", controllers.CreatePartner) + adminAuth.Get("/partners/:id", controllers.GetPartnerDetails) + adminAuth.Put("/partners/:id", controllers.UpdatePartner) + adminAuth.Delete("/partners/:id", controllers.DeletePartner) + // Tenant management adminAuth.Get("/tenants", controllers.GetTenants) adminAuth.Post("/tenants", controllers.CreateTenant) @@ -234,15 +247,18 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { adminAuth.Put("/milers/:id", controllers.UpdateMiler) adminAuth.Put("/milers/:id/block", controllers.BlockMiler) adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle) + adminAuth.Post("/milers/:id/notify", controllers.AdminNotifyMiler) // Bookings adminAuth.Get("/bookings", controllers.GetAdminBookings) - adminAuth.Post("/crmbooking", middlewares.CityGateMiddleware, controllers.CreateCRMBooking) + adminAuth.Post("/expressbooking", middlewares.CityGateMiddleware, controllers.CreateExpressBooking) + adminAuth.Post("/expressbooking/bulk", middlewares.CityGateMiddleware, controllers.AdminBulkCreateBookings) adminAuth.Get("/bookings/:id", controllers.GetAdminBookingDetails) adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler) adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle) adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus) adminAuth.Post("/bookings/:id/cancel", controllers.AdminCancelBooking) + adminAuth.Post("/bookings/bulk-cancel", controllers.AdminBulkCancelBookings) // Consignments adminAuth.Get("/consignments", controllers.GetAdminConsignments) @@ -307,6 +323,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan) hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler) hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign) + hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign) hubAuth.Get("/batches", controllers.GetHubBatches) hubAuth.Post("/batches", controllers.CreateHubBatch) hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)