Lifts the connector's "static location → body='lat,lng'" hack and the dropped liveLocationMessage filter. Static location cards render as a Leaflet/OSM map thumbnail with name/address subtitle and "Open in Maps" link. Live location streams render the same card with a Live · expires in {N}m badge — subsequent position updates from Baileys mutate the same row's location_data and re-emit message.created, so the marker moves without inserting new rows. Agents send static one-shot locations via a map picker with click-to-pin or browser geolocation. Outbound live-location is intentionally out of scope (WhatsApp's live-location is a phone-side feature).
Alembic 0012_message_location: new messages.location_data JSONB NULL holding {lat, lng, name?, address?, accuracy_m?, live_until?, sequence?, speed_mps?, heading_deg?}. One-shot backfill parses legacy body = "lat,lng" rows into structured JSON via split_part(body, ',', N)::float gated by a regex.
body keeps its meaning as the user-facing preview/caption fallback. New inbound location rows set body = name ?? address ?? null so the [location] emoji label in format.ts fires cleanly for the conversation-list rollup.
schemas/inbox.py: LocationData (full structured shape, exposed on MessagePublic.location_data); InboundLocation (wire payload from the connector on the inbound-messages batch); OutboundLocationCreate (lat/lng with -90..90 / -180..180 bounds, optional name/address); LiveLocationUpdatePayload (internal webhook body keyed by provider_message_id).
services/inbox.py: ingest_inbound_message populates location_data from payload.location and overrides body to the name/address fallback. New insert_outbound_location_message(...) mirrors the media path with kind="location", media_status="none". New apply_live_location_update(db, channel, payload) resolves by (channel_id, provider_message_id), gates on sequence (drops out-of-order updates when both rows carry a sequence), mutates location_data in place (preserving live_until/name/address), and returns the message.
New app/api/internal/locations.py — HMAC-protected POST /internal/v1/channels/{cid}/messages/by-provider-id/location-update. Unknown targets respond 200 with applied:false (a target row may be ingested moments later; transient out-of-order isn't worth a 4xx).
api/v1/conversations.py: new POST /{conv}/messages/location (perm message:send). 409 if channel disconnected. Inserts row, commits, re-fetches via serialize_message_for_emit, emits, enqueues send_location.
queue/producer.py::enqueue_send_location(...) — send:{message_id} shared job_id with text/media.
whatsapp/messages.ts: extracts structured location from both inner.locationMessage (static) and inner.liveLocationMessage (initial live share, with expirationTimestamp → live_until and sequenceNumber). Sets body = name ?? address ?? caption ?? null. New MappedMessage.location field carries the structured payload to the API on the normal inbound-messages batch.
whatsapp/manager.ts::messages.update subscriber gained a live-location branch: when update.message?.liveLocationMessage is present, extracts lat/lng/accuracy/sequence/speed/heading and fires postLiveLocationUpdate(channelId, ...). The existing outbound-receipt branch is unchanged and runs after.
internal/api-client.ts::postLiveLocationUpdate HMAC helper.
queue/worker.ts::handleSendLocation — send-guard pattern shared with handleSend and handleSendMedia: cache hit re-posts status without re-sending; on miss, sock.sendMessage(jid, { location: { degreesLatitude, degreesLongitude, name?, address? } }) → markSent → status post. Failed-listener extended to cover send_location.
New deps: leaflet, react-leaflet@^4 (React 18 compat — v5 requires React 19), @types/leaflet.
app/layout.tsx imports leaflet/dist/leaflet.css once.
New components/inbox/leaflet/icons.ts — idempotent shim merging default marker URLs to unpkg-hosted PNGs (Webpack/Next don't bundle Leaflet's PNG assets by default).
New components/inbox/leaflet/StaticMap.tsx — non-interactive 288×140 thumbnail (drag/zoom/keyboard disabled), next/dynamic({ ssr: false }).
New components/inbox/leaflet/PickerMap.tsx — interactive 640×400 picker with click-to-pin via useMapEvents, parent-controlled marker state, and a CenterOn helper that recenters on geolocation.
New components/inbox/MessageLocation.tsx — renders the bubble card: map thumbnail + name/address subtitle + "Open in Maps" link to maps.google.com/?q=lat,lng. Live shares get a top-right pill (Live · 14m left → "Live ended" once live_until passes) with a 30s setInterval tick. Falls back to parsing legacy body = "lat,lng" when location_data is null.
New components/inbox/LocationPickerDialog.tsx — Radix Dialog wrapping PickerMap, "Use my current position" button (navigator.geolocation with permission-denied toast fallback), Name/Address inputs (capped to match server validators), Send/Cancel. Closes via its own onSuccess.
Composer.tsx: new MapPin button in the toolbar next to Paperclip/Smile, opens the picker, gated on channelConnected.
MessageBubble.tsx: when kind === "location", renders MessageLocation instead of the text body; treats location as "naked" (no rounded chrome). Caption is suppressed since the card already shows name/address.
hooks/useInbox.ts::useSendLocationMessage — POSTs the new endpoint and invalidates the conversations list. No optimistic insert (canonical coords matter; server response + realtime echo populate the thread).
Outbound live-location is intentionally not supported — WhatsApp's live-location is a phone-side feature (the moving device IS the source), not a CRM operator one.
Marker icon PNGs come from the unpkg CDN; offline dev environments would need to vendor them into public/.
No active server-side expiry sweep — the UI computes "Live ended" client-side from live_until and stops the countdown. Late updates from Baileys (post-expiration) still apply to the row but the UI no longer ticks.
Multiple live shares from the same contact each get their own row (different provider_message_id); WhatsApp itself treats them as separate streams.