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 <noreply@anthropic.com>
This commit is contained in:
457
CLAUDE.md
Normal file
457
CLAUDE.md
Normal file
@@ -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.
|
||||||
@@ -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
|
// APP USERS MANAGEMENT
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -665,6 +816,120 @@ func DeleteTenantCustomer(c *fiber.Ctx) error {
|
|||||||
return utils.Message(c, "customer deleted successfully")
|
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
|
// HUBS CRUD
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -957,6 +1222,44 @@ func GetMilerDetails(c *fiber.Ctx) error {
|
|||||||
return utils.OK(c, profile)
|
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 {
|
func UpdateMiler(c *fiber.Ctx) error {
|
||||||
id, _ := strconv.Atoi(c.Params("id"))
|
id, _ := strconv.Atoi(c.Params("id"))
|
||||||
var profile models.MilerProfile
|
var profile models.MilerProfile
|
||||||
@@ -1053,13 +1356,18 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
|||||||
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
pagesize := min(100, max(1, c.QueryInt("pagesize", 20)))
|
||||||
offset := (pageno - 1) * pagesize
|
offset := (pageno - 1) * pagesize
|
||||||
|
|
||||||
|
query := db.DB.Model(&models.PickupBooking{})
|
||||||
|
if tenantID := c.Query("tenantid"); tenantID != "" {
|
||||||
|
query = query.Where("tenantid = ?", tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
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")
|
return utils.Internal(c, "failed to count bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
var bookings []models.PickupBooking
|
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 {
|
Offset(offset).Limit(pagesize).Find(&bookings).Error; err != nil {
|
||||||
return utils.Internal(c, "failed to fetch bookings")
|
return utils.Internal(c, "failed to fetch bookings")
|
||||||
}
|
}
|
||||||
@@ -1076,42 +1384,60 @@ func GetAdminBookings(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateCRMBooking(c *fiber.Ctx) error {
|
// AdminBookingRequest is the express-console booking payload, shared by CreateExpressBooking
|
||||||
type AdminBookingRequest struct {
|
// (one booking) and AdminBulkCreateBookings (many) — was previously a type
|
||||||
Appcustomerid int `json:"appcustomerid"`
|
// local to CreateExpressBooking, promoted to package level so both can use it.
|
||||||
CustomerPhone string `json:"customer_phone"`
|
type AdminBookingRequest struct {
|
||||||
CustomerName string `json:"customer_name"`
|
Tenantid int `json:"tenantid"`
|
||||||
Pickupaddress string `json:"pickupaddress"`
|
Appcustomerid int `json:"appcustomerid"`
|
||||||
Pickuppincode string `json:"pickuppincode"`
|
CustomerPhone string `json:"customer_phone"`
|
||||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
CustomerName string `json:"customer_name"`
|
||||||
Pickuplongitude float64 `json:"pickuplongitude"`
|
Pickupaddress string `json:"pickupaddress"`
|
||||||
Deliveryaddress string `json:"deliveryaddress"`
|
Pickuppincode string `json:"pickuppincode"`
|
||||||
Deliverypincode string `json:"deliverypincode"`
|
Pickuplatitude float64 `json:"pickuplatitude"`
|
||||||
Deliverycity string `json:"deliverycity"`
|
Pickuplongitude float64 `json:"pickuplongitude"`
|
||||||
Deliverylatitude float64 `json:"deliverylatitude"`
|
Deliveryaddress string `json:"deliveryaddress"`
|
||||||
Deliverylongitude float64 `json:"deliverylongitude"`
|
Deliverypincode string `json:"deliverypincode"`
|
||||||
Providercompany string `json:"providercompany"`
|
Deliverycity string `json:"deliverycity"`
|
||||||
Providerlocation string `json:"providerlocation"`
|
Deliverylatitude float64 `json:"deliverylatitude"`
|
||||||
Notes string `json:"notes"`
|
Deliverylongitude float64 `json:"deliverylongitude"`
|
||||||
ServiceOption string `json:"service_option"`
|
Providercompany string `json:"providercompany"`
|
||||||
Finalprice float64 `json:"finalprice"`
|
Providerlocation string `json:"providerlocation"`
|
||||||
Insuranceamount float64 `json:"insuranceamount"`
|
Notes string `json:"notes"`
|
||||||
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
|
ServiceOption string `json:"service_option"`
|
||||||
Preferredpickupto *time.Time `json:"preferredpickupto"`
|
Finalprice float64 `json:"finalprice"`
|
||||||
Parcels []dto.ParcelRequest `json:"parcels"`
|
Insuranceamount float64 `json:"insuranceamount"`
|
||||||
}
|
Preferredpickupfrom *time.Time `json:"preferredpickupfrom"`
|
||||||
|
Preferredpickupto *time.Time `json:"preferredpickupto"`
|
||||||
|
Parcels []dto.ParcelRequest `json:"parcels"`
|
||||||
|
}
|
||||||
|
|
||||||
req := new(AdminBookingRequest)
|
// expressBookingValidationError marks a createExpressBooking failure as a bad
|
||||||
if err := c.BodyParser(req); err != nil {
|
// request (missing/invalid input) rather than a server-side failure, so
|
||||||
return utils.BadRequest(c, "invalid request body")
|
// 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 == "" {
|
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 {
|
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()
|
tx := db.DB.Begin()
|
||||||
@@ -1135,14 +1461,16 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
if err := tx.Create(&newCustomer).Error; err != nil {
|
if err := tx.Create(&newCustomer).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to create customer record")
|
return nil, fmt.Errorf("failed to create customer record")
|
||||||
}
|
}
|
||||||
customerID = newCustomer.Appcustomerid
|
customerID = newCustomer.Appcustomerid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tenantID := req.Tenantid
|
||||||
booking := models.PickupBooking{
|
booking := models.PickupBooking{
|
||||||
Bookingno: generateBookingNo(),
|
Bookingno: generateBookingNo(),
|
||||||
|
Tenantid: &tenantID,
|
||||||
Appcustomerid: customerID,
|
Appcustomerid: customerID,
|
||||||
Pickupaddress: req.Pickupaddress,
|
Pickupaddress: req.Pickupaddress,
|
||||||
Pickuppincode: req.Pickuppincode,
|
Pickuppincode: req.Pickuppincode,
|
||||||
@@ -1164,7 +1492,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if err := tx.Create(&booking).Error; err != nil {
|
if err := tx.Create(&booking).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return utils.Internal(c, "failed to create booking")
|
return nil, fmt.Errorf("failed to create booking")
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalWeight float64
|
var totalWeight float64
|
||||||
@@ -1197,7 +1525,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
|||||||
parcel.Insuranceamount = p.Declaredvalue * 0.01
|
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)) {
|
if req.Insuranceamount > 0 && totalWeight == math.Max(p.Weight, calculateVolumetricWeight(p.Length, p.Width, p.Height)) {
|
||||||
parcel.Insuranceamount = req.Insuranceamount
|
parcel.Insuranceamount = req.Insuranceamount
|
||||||
parcel.Needsinsurance = true
|
parcel.Needsinsurance = true
|
||||||
@@ -1205,7 +1533,7 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
if err := tx.Create(&parcel).Error; err != nil {
|
if err := tx.Create(&parcel).Error; err != nil {
|
||||||
tx.Rollback()
|
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 {
|
if err := tx.Create(&srvOption).Error; err != nil {
|
||||||
tx.Rollback()
|
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 {
|
if requiresLargeVehicle || totalWeight > 20.0 {
|
||||||
@@ -1277,12 +1605,12 @@ func CreateCRMBooking(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
if err := tx.Create(&reqVeh).Error; err != nil {
|
if err := tx.Create(&reqVeh).Error; err != nil {
|
||||||
tx.Rollback()
|
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 {
|
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)
|
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)
|
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)
|
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 {
|
func GetAdminBookingDetails(c *fiber.Ctx) error {
|
||||||
id, _ := strconv.Atoi(c.Params("id"))
|
id, _ := strconv.Atoi(c.Params("id"))
|
||||||
var booking models.PickupBooking
|
var booking models.PickupBooking
|
||||||
@@ -1443,6 +1827,76 @@ func AdminCancelBooking(c *fiber.Ctx) error {
|
|||||||
return utils.Message(c, "booking cancelled")
|
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
|
// CONSIGNMENTS
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -2227,6 +2681,60 @@ func GetAdminProfile(c *fiber.Ctx) error {
|
|||||||
return utils.OK(c, user)
|
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.
|
// InternalNotify sends FCM push notifications on behalf of the Python agent system.
|
||||||
// The caller specifies target = "customer", "miler", or "both".
|
// The caller specifies target = "customer", "miler", or "both".
|
||||||
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
|
// Auth: X-Internal-Key header (see InternalKeyAuth middleware).
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
// hubAutoAssignTimeout bounds how long HubAutoAssign waits synchronously for
|
||||||
@@ -211,6 +212,23 @@ func isDoormileStaff(staff *models.HubStaffAccount) bool {
|
|||||||
return staff.Tenantid == nil
|
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 {
|
func GetHubDashboardStats(c *fiber.Ctx) error {
|
||||||
hubID := c.Locals("hubid").(int)
|
hubID := c.Locals("hubid").(int)
|
||||||
prefix := hubPincodePrefix(hubID)
|
prefix := hubPincodePrefix(hubID)
|
||||||
@@ -279,6 +297,7 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
|||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||||
}
|
}
|
||||||
|
query = scopeBookingsToOwnTenant(c, query)
|
||||||
|
|
||||||
var bookings []models.PickupBooking
|
var bookings []models.PickupBooking
|
||||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
||||||
@@ -343,6 +362,7 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
|
|||||||
if prefix != "" {
|
if prefix != "" {
|
||||||
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
query = query.Where("pickuppincode LIKE ?", prefix+"%")
|
||||||
}
|
}
|
||||||
|
query = scopeBookingsToOwnTenant(c, query)
|
||||||
|
|
||||||
var bookings []models.PickupBooking
|
var bookings []models.PickupBooking
|
||||||
if err := query.Order("createdat DESC").Find(&bookings).Error; err != nil {
|
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
|
// HUB REPORT EXPORT
|
||||||
// --------------------
|
// --------------------
|
||||||
|
|||||||
@@ -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
|
// EARNINGS
|
||||||
// --------------------
|
// --------------------
|
||||||
|
|||||||
@@ -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 {
|
func GetMilerProfile(c *fiber.Ctx) error {
|
||||||
milerUserID := c.Locals("userid").(int)
|
milerUserID := c.Locals("userid").(int)
|
||||||
|
|
||||||
@@ -443,6 +485,84 @@ func RejectMilerAssignment(c *fiber.Ctx) error {
|
|||||||
return utils.Message(c, "assignment rejected")
|
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 {
|
func BookingReachedCustomer(c *fiber.Ctx) error {
|
||||||
milerUserID := c.Locals("userid").(int)
|
milerUserID := c.Locals("userid").(int)
|
||||||
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
bookingID, err := strconv.Atoi(c.Params("bookingid"))
|
||||||
@@ -665,9 +785,20 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
consignmentStatus = constants.ConsignmentOutForDelivery
|
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{
|
consignment := models.Consignment{
|
||||||
Trackingno: trackingNo,
|
Trackingno: trackingNo,
|
||||||
Tenantid: c.Locals("tenantid").(int),
|
Tenantid: consignmentTenantID,
|
||||||
Pickuplatitude: booking.Pickuplatitude,
|
Pickuplatitude: booking.Pickuplatitude,
|
||||||
Pickuplongitude: booking.Pickuplongitude,
|
Pickuplongitude: booking.Pickuplongitude,
|
||||||
Deliverylatitude: booking.Deliverylatitude,
|
Deliverylatitude: booking.Deliverylatitude,
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ type TenantCustomerCreateRequest struct {
|
|||||||
Email string `json:"email"`
|
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 {
|
type HubCreateRequest struct {
|
||||||
Hubname string `json:"hubname"`
|
Hubname string `json:"hubname"`
|
||||||
Hubtype string `json:"hubtype"` // sorting_center, delivery_hub
|
Hubtype string `json:"hubtype"` // sorting_center, delivery_hub
|
||||||
|
|||||||
@@ -49,6 +49,12 @@ type MilerPinVerifyRequest struct {
|
|||||||
DeviceToken string `json:"device_token"`
|
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 {
|
type AdminLoginRequest struct {
|
||||||
Email string `json:"email" xml:"email" form:"email"`
|
Email string `json:"email" xml:"email" form:"email"`
|
||||||
Password string `json:"password" xml:"password" form:"password"`
|
Password string `json:"password" xml:"password" form:"password"`
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type milerCandidate struct {
|
|||||||
|
|
||||||
// AssignCRMMiler finds the best available nearby miler for a CRM booking and assigns them.
|
// 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.
|
// 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) {
|
func AssignCRMMiler(bookingID int) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ import (
|
|||||||
type PickupBooking struct {
|
type PickupBooking struct {
|
||||||
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
|
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
|
||||||
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
|
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"`
|
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
|
||||||
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
|
||||||
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
miler := api.Group("/miler")
|
miler := api.Group("/miler")
|
||||||
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
|
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
|
||||||
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
|
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
|
||||||
|
miler.Post("/reset-pin", authThrottle, controllers.ResetMilerPin)
|
||||||
|
|
||||||
// Authenticated Miler App routes
|
// Authenticated Miler App routes
|
||||||
milerAuth := miler.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(5))
|
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/payment", controllers.BookingPaymentCollect)
|
||||||
milerAuth.Post("/bookings/:bookingid/pickup-complete", controllers.BookingPickupComplete)
|
milerAuth.Post("/bookings/:bookingid/pickup-complete", controllers.BookingPickupComplete)
|
||||||
milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate)
|
milerAuth.Post("/bookings/:bookingid/vehicle-required", controllers.BookingVehicleRequiredEscalate)
|
||||||
|
milerAuth.Post("/bookings/:bookingid/cancel", controllers.MilerCancelAssignment)
|
||||||
|
|
||||||
// Redis periodic telemetry and status logs
|
// Redis periodic telemetry and status logs
|
||||||
milerAuth.Post("/logs", controllers.CreateMilerPeriodicLog)
|
milerAuth.Post("/logs", controllers.CreateMilerPeriodicLog)
|
||||||
@@ -160,6 +162,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
|
|
||||||
// Delivery confirmation
|
// Delivery confirmation
|
||||||
milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment)
|
milerAuth.Post("/consignments/:id/deliver", controllers.MilerDeliverConsignment)
|
||||||
|
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
||||||
|
|
||||||
// Earnings
|
// Earnings
|
||||||
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
|
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 := admin.Use(middlewares.AuthMiddleware(cfg), middlewares.RoleCheckMiddleware(1, 3, 4))
|
||||||
|
|
||||||
adminAuth.Get("/dashboard", controllers.GetAdminDashboard)
|
adminAuth.Get("/dashboard", controllers.GetAdminDashboard)
|
||||||
|
adminAuth.Get("/reports", controllers.GetAdminReports)
|
||||||
adminAuth.Get("/profile", controllers.GetAdminProfile)
|
adminAuth.Get("/profile", controllers.GetAdminProfile)
|
||||||
adminAuth.Get("/me", controllers.GetAdminProfile)
|
adminAuth.Get("/me", controllers.GetAdminProfile)
|
||||||
|
adminAuth.Put("/profile/password", controllers.AdminChangePassword)
|
||||||
|
|
||||||
// App Users Management
|
// App Users Management
|
||||||
adminAuth.Get("/users", controllers.GetAppUsers)
|
adminAuth.Get("/users", controllers.GetAppUsers)
|
||||||
@@ -192,6 +197,14 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
adminAuth.Put("/users/:id", controllers.UpdateAppUser)
|
adminAuth.Put("/users/:id", controllers.UpdateAppUser)
|
||||||
adminAuth.Delete("/users/:id", controllers.DeleteAppUser)
|
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
|
// Tenant management
|
||||||
adminAuth.Get("/tenants", controllers.GetTenants)
|
adminAuth.Get("/tenants", controllers.GetTenants)
|
||||||
adminAuth.Post("/tenants", controllers.CreateTenant)
|
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", controllers.UpdateMiler)
|
||||||
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
|
adminAuth.Put("/milers/:id/block", controllers.BlockMiler)
|
||||||
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
|
adminAuth.Put("/milers/:id/assign-vehicle", controllers.AssignMilerVehicle)
|
||||||
|
adminAuth.Post("/milers/:id/notify", controllers.AdminNotifyMiler)
|
||||||
|
|
||||||
// Bookings
|
// Bookings
|
||||||
adminAuth.Get("/bookings", controllers.GetAdminBookings)
|
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.Get("/bookings/:id", controllers.GetAdminBookingDetails)
|
||||||
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
|
adminAuth.Post("/bookings/:id/assign-miler", controllers.AdminAssignMiler)
|
||||||
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
|
adminAuth.Post("/bookings/:id/assign-vehicle", controllers.AdminAssignVehicle)
|
||||||
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
|
adminAuth.Put("/bookings/:id/status", controllers.AdminUpdateBookingStatus)
|
||||||
adminAuth.Post("/bookings/:id/cancel", controllers.AdminCancelBooking)
|
adminAuth.Post("/bookings/:id/cancel", controllers.AdminCancelBooking)
|
||||||
|
adminAuth.Post("/bookings/bulk-cancel", controllers.AdminBulkCancelBookings)
|
||||||
|
|
||||||
// Consignments
|
// Consignments
|
||||||
adminAuth.Get("/consignments", controllers.GetAdminConsignments)
|
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/inbound", controllers.CreateInboundScan)
|
||||||
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler)
|
||||||
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||||
|
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
||||||
hubAuth.Get("/batches", controllers.GetHubBatches)
|
hubAuth.Get("/batches", controllers.GetHubBatches)
|
||||||
hubAuth.Post("/batches", controllers.CreateHubBatch)
|
hubAuth.Post("/batches", controllers.CreateHubBatch)
|
||||||
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
|
hubAuth.Patch("/batches/:id/status", controllers.UpdateBatchStatus)
|
||||||
|
|||||||
Reference in New Issue
Block a user