Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e42b3104 | |||
| d617c69571 | |||
| cd2f60e282 | |||
| 76d9850464 | |||
| 96dbb8c41d | |||
| 636fa713e1 | |||
| 6120b96c7c | |||
| eb92f99c4a | |||
| 587fd4ec95 | |||
| 8cf72d926d | |||
| 8721f0b63c | |||
| 027e012a58 | |||
| b3db206588 | |||
| 6538406db2 | |||
| e2eacbc9fe | |||
| e639cc82d1 | |||
| f2931d7be2 | |||
| d4a4ddce37 | |||
| bd384bdf5c | |||
| 411c42b2c4 | |||
| bf97849324 | |||
| 7181d41b24 | |||
| 4e9c4c5e08 | |||
| 16c959434b | |||
| 23484dc90a | |||
| 6a81a52a50 | |||
| 5a4b9a98bd | |||
| f7f88156e1 | |||
| 8af5a49d14 |
@@ -165,6 +165,8 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
|
||||
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
|
||||
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
|
||||
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
|
||||
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
|
||||
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
|
||||
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
|
||||
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
|
||||
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
|
||||
@@ -406,6 +408,29 @@ Builds on §4.16. After optimization each consecutive leg carries a travel `buff
|
||||
| TC-API-17.7 | Reorder invalid routeId | `PATCH /api/routes/not-a-uuid/reorder` | 400 `{ error: "routeId must be a UUID" }` |
|
||||
| TC-API-17.8 | Groomer cannot reorder another's route | As groomer, reorder a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||||
|
||||
### 4.18 Route Optimization — Navigation Export (GRO-2157, Phase 2.3)
|
||||
|
||||
Builds on §4.16/§4.17. Two read-only endpoints turn an optimized route into a native-navigation deep-link URL the frontend opens on the groomer's phone:
|
||||
|
||||
- `GET /api/routes/:routeId/export/google-maps` → Google Maps URLs API link (`https://www.google.com/maps/dir/?api=1&travelmode=driving&origin=…&destination=…&waypoints=…`)
|
||||
- `GET /api/routes/:routeId/export/apple-maps` → Apple Maps URL scheme (`maps://?saddr=…&daddr=<first>+to:<next>…&dirflg=d`)
|
||||
|
||||
Both use the stops' stored `latitude`/`longitude` in `stopOrder`: **origin = first stop, destination = last stop, the rest are ordered intermediate waypoints**. Each response body is `{ platform, url, stopCount, waypointCount }` where `waypointCount` = stops minus origin and destination. Waypoint limits are validated per platform: **Google Maps ≤ 9**, **Apple Maps ≤ 15** intermediate waypoints; over-limit routes return 400. **Auth: manager (any route) or groomer (own route only); receptionists have no access.**
|
||||
|
||||
| ID | Scenario | Steps | Expected |
|
||||
|----|----------|-------|----------|
|
||||
| TC-API-18.1 | Google Maps export of a multi-stop route | As manager, optimize a multi-stop day (§4.16), then `GET /api/routes/{routeId}/export/google-maps` | 200 OK; `platform:"google-maps"`, `url` starts `https://www.google.com/maps/dir/?api=1`, contains `travelmode=driving`, `origin`/`destination` are the first/last stop coords, `waypoints` lists the middle stops in order (pipe-separated). `stopCount` = total stops, `waypointCount` = `stopCount − 2` |
|
||||
| TC-API-18.2 | Apple Maps export of a multi-stop route | As manager, `GET /api/routes/{routeId}/export/apple-maps` for the same route | 200 OK; `platform:"apple-maps"`, `url` starts `maps://?saddr=`, `daddr` chains the remaining stops with `+to:`, ends `&dirflg=d`; `stopCount`/`waypointCount` as above |
|
||||
| TC-API-18.3 | Single-stop route | Export a route (google-maps and apple-maps) that has exactly one stop | 200 OK; `waypointCount:0`. Google url has `destination` and no `waypoints=`; Apple url is `maps://?daddr=<coord>&dirflg=d` (no `saddr`) |
|
||||
| TC-API-18.4 | Empty route rejected | Export a route with no stops (a fresh `draft` route) | 400 `{ error: "route has no stops to export" }` |
|
||||
| TC-API-18.5 | Google waypoint limit | Export (google-maps) a route with >11 stops (>9 intermediate waypoints) | 400 with an `error` mentioning Google Maps' limit of 9 |
|
||||
| TC-API-18.6 | Apple waypoint limit | Export (apple-maps) a route with >17 stops (>15 intermediate waypoints) | 400 with an `error` mentioning Apple Maps' limit of 15 |
|
||||
| TC-API-18.7 | Unknown route | `GET /api/routes/{randomUuid}/export/google-maps` | 404 `{ error: "Route not found" }` |
|
||||
| TC-API-18.8 | Invalid routeId | `GET /api/routes/not-a-uuid/export/apple-maps` | 400 `{ error: "routeId must be a UUID" }` |
|
||||
| TC-API-18.9 | Groomer exports own route | As **groomer**, export a route owned by self | 200 OK; deep-link returned |
|
||||
| TC-API-18.10 | Groomer cannot export another's route | As groomer, export a route owned by a different groomer | 403 Forbidden (`groomers may only access their own route`) |
|
||||
| TC-API-18.11 | Receptionist denied | As **receptionist**, export any route | 403 Forbidden (role not permitted) |
|
||||
|
||||
## Pass/Fail Criteria
|
||||
|
||||
**Pass:**
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildGoogleMapsUrl,
|
||||
buildAppleMapsUrl,
|
||||
buildNavigationUrl,
|
||||
intermediateWaypointCount,
|
||||
GOOGLE_MAPS_MAX_WAYPOINTS,
|
||||
APPLE_MAPS_MAX_WAYPOINTS,
|
||||
type NavigationStop,
|
||||
} from "../services/navigationExport.js";
|
||||
|
||||
function stops(n: number): NavigationStop[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
latitude: 47 + i / 100,
|
||||
longitude: -122 - i / 100,
|
||||
label: `Stop ${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("intermediateWaypointCount", () => {
|
||||
it("excludes origin and destination", () => {
|
||||
expect(intermediateWaypointCount(0)).toBe(0);
|
||||
expect(intermediateWaypointCount(1)).toBe(0);
|
||||
expect(intermediateWaypointCount(2)).toBe(0);
|
||||
expect(intermediateWaypointCount(5)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGoogleMapsUrl", () => {
|
||||
it("rejects an empty route", () => {
|
||||
const r = buildGoogleMapsUrl([]);
|
||||
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
|
||||
});
|
||||
|
||||
it("builds a single-stop link (destination only, no waypoints)", () => {
|
||||
const r = buildGoogleMapsUrl(stops(1));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("google-maps");
|
||||
expect(r.stopCount).toBe(1);
|
||||
expect(r.waypointCount).toBe(0);
|
||||
expect(r.url).toContain("https://www.google.com/maps/dir/?");
|
||||
expect(r.url).toContain("api=1");
|
||||
expect(r.url).toContain("travelmode=driving");
|
||||
expect(r.url).toContain("origin=47%2C-122");
|
||||
expect(r.url).toContain("destination=47%2C-122");
|
||||
expect(r.url).not.toContain("waypoints=");
|
||||
});
|
||||
|
||||
it("builds origin/destination only for two stops", () => {
|
||||
const r = buildGoogleMapsUrl(stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(0);
|
||||
expect(r.url).not.toContain("waypoints=");
|
||||
expect(r.url).toContain("origin=47%2C-122");
|
||||
expect(r.url).toContain("destination=47.01%2C-122.01");
|
||||
});
|
||||
|
||||
it("includes intermediate waypoints in order, pipe-separated", () => {
|
||||
const r = buildGoogleMapsUrl(stops(4));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.stopCount).toBe(4);
|
||||
expect(r.waypointCount).toBe(2);
|
||||
// waypoints param holds stops[1] and stops[2], pipe-joined (encoded %7C)
|
||||
const url = new URL(r.url);
|
||||
expect(url.searchParams.get("origin")).toBe("47,-122");
|
||||
expect(url.searchParams.get("destination")).toBe("47.03,-122.03");
|
||||
expect(url.searchParams.get("waypoints")).toBe(
|
||||
"47.01,-122.01|47.02,-122.02"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a route at exactly the waypoint limit", () => {
|
||||
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(GOOGLE_MAPS_MAX_WAYPOINTS);
|
||||
});
|
||||
|
||||
it("rejects a route over the waypoint limit", () => {
|
||||
const r = buildGoogleMapsUrl(stops(GOOGLE_MAPS_MAX_WAYPOINTS + 3));
|
||||
expect("error" in r).toBe(true);
|
||||
if ("error" in r) {
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.error).toContain(`${GOOGLE_MAPS_MAX_WAYPOINTS}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAppleMapsUrl", () => {
|
||||
it("rejects an empty route", () => {
|
||||
const r = buildAppleMapsUrl([]);
|
||||
expect(r).toEqual({ error: "route has no stops to export", status: 400 });
|
||||
});
|
||||
|
||||
it("builds a destination-only link for one stop", () => {
|
||||
const r = buildAppleMapsUrl(stops(1));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("apple-maps");
|
||||
expect(r.url).toBe("maps://?daddr=47,-122&dirflg=d");
|
||||
expect(r.url).not.toContain("saddr=");
|
||||
});
|
||||
|
||||
it("chains destinations with +to: for multiple stops", () => {
|
||||
const r = buildAppleMapsUrl(stops(3));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.stopCount).toBe(3);
|
||||
expect(r.waypointCount).toBe(1);
|
||||
expect(r.url).toBe(
|
||||
"maps://?saddr=47,-122&daddr=47.01,-122.01+to:47.02,-122.02&dirflg=d"
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a route at exactly the waypoint limit", () => {
|
||||
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.waypointCount).toBe(APPLE_MAPS_MAX_WAYPOINTS);
|
||||
});
|
||||
|
||||
it("rejects a route over the waypoint limit", () => {
|
||||
const r = buildAppleMapsUrl(stops(APPLE_MAPS_MAX_WAYPOINTS + 3));
|
||||
expect("error" in r).toBe(true);
|
||||
if ("error" in r) {
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.error).toContain(`${APPLE_MAPS_MAX_WAYPOINTS}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNavigationUrl", () => {
|
||||
it("dispatches to the google-maps builder", () => {
|
||||
const r = buildNavigationUrl("google-maps", stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("google-maps");
|
||||
});
|
||||
|
||||
it("dispatches to the apple-maps builder", () => {
|
||||
const r = buildNavigationUrl("apple-maps", stops(2));
|
||||
if ("error" in r) throw new Error(r.error);
|
||||
expect(r.platform).toBe("apple-maps");
|
||||
});
|
||||
});
|
||||
+68
-1
@@ -1,4 +1,4 @@
|
||||
import { Hono } from "hono";
|
||||
import { Hono, type Context } from "hono";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import {
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type RouteStopInput,
|
||||
type StopConflictFlags,
|
||||
} from "../services/routeOptimization.js";
|
||||
import {
|
||||
buildNavigationUrl,
|
||||
type NavigationPlatform,
|
||||
type NavigationStop,
|
||||
} from "../services/navigationExport.js";
|
||||
|
||||
export const routesRouter = new Hono<AppEnv>();
|
||||
|
||||
@@ -460,3 +465,65 @@ routesRouter.patch(
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /:routeId/export/:platform — build a native-navigation deep-link URL for an
|
||||
* optimized route. Origin = first stop, destination = last stop, the rest carried
|
||||
* as ordered intermediate waypoints. Waypoint count is validated against the
|
||||
* platform's limit. Auth: manager (any route) or groomer (own route only).
|
||||
*/
|
||||
async function handleNavigationExport(
|
||||
c: Context<AppEnv>,
|
||||
platform: NavigationPlatform
|
||||
) {
|
||||
const db = getDb();
|
||||
const routeId = c.req.param("routeId");
|
||||
if (!routeId || !z.string().uuid().safeParse(routeId).success) {
|
||||
return c.json({ error: "routeId must be a UUID" }, 400);
|
||||
}
|
||||
|
||||
const [route] = await db
|
||||
.select()
|
||||
.from(groomerRoutes)
|
||||
.where(eq(groomerRoutes.id, routeId));
|
||||
if (!route) {
|
||||
return c.json({ error: "Route not found" }, 404);
|
||||
}
|
||||
|
||||
// Reuse the groomer-own / manager authorization rule against the route owner.
|
||||
const resolved = resolveTargetStaffId(c.get("staff"), route.staffId);
|
||||
if ("error" in resolved) {
|
||||
return c.json({ error: resolved.error }, resolved.status);
|
||||
}
|
||||
|
||||
const stops = await loadRouteStops(db, routeId);
|
||||
if (stops.length === 0) {
|
||||
return c.json({ error: "route has no stops to export" }, 400);
|
||||
}
|
||||
|
||||
const navStops: NavigationStop[] = stops.map((s) => ({
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
label: s.clientName,
|
||||
}));
|
||||
|
||||
const result = buildNavigationUrl(platform, navStops);
|
||||
if ("error" in result) {
|
||||
return c.json({ error: result.error }, result.status);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
platform: result.platform,
|
||||
url: result.url,
|
||||
stopCount: result.stopCount,
|
||||
waypointCount: result.waypointCount,
|
||||
});
|
||||
}
|
||||
|
||||
routesRouter.get("/:routeId/export/google-maps", (c) =>
|
||||
handleNavigationExport(c, "google-maps")
|
||||
);
|
||||
|
||||
routesRouter.get("/:routeId/export/apple-maps", (c) =>
|
||||
handleNavigationExport(c, "apple-maps")
|
||||
);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Navigation export — turn an optimized groomer route into a deep-link URL that
|
||||
// opens the device's native navigation app (Google Maps / Apple Maps).
|
||||
//
|
||||
// A route is exported as: origin = first stop, destination = last stop, with the
|
||||
// in-between stops carried as ordered intermediate waypoints. Each platform caps
|
||||
// how many intermediate waypoints a deep link may carry, so callers must validate
|
||||
// the route length before handing the URL to the client.
|
||||
|
||||
/**
|
||||
* Max intermediate waypoints a Google Maps URLs API deep link supports
|
||||
* (`https://www.google.com/maps/dir/?api=1&...&waypoints=...`). Google documents
|
||||
* a ceiling of 9 waypoints between origin and destination.
|
||||
*/
|
||||
export const GOOGLE_MAPS_MAX_WAYPOINTS = 9;
|
||||
|
||||
/**
|
||||
* Max intermediate waypoints we allow in an Apple Maps `maps://` deep link. Apple's
|
||||
* URL scheme chains destinations with `+to:` but does not publish a hard cap; 15 is
|
||||
* a conservative practical limit that keeps the URL well under length limits.
|
||||
*/
|
||||
export const APPLE_MAPS_MAX_WAYPOINTS = 15;
|
||||
|
||||
export type NavigationPlatform = "google-maps" | "apple-maps";
|
||||
|
||||
/** A single ordered point on the route. `label` is optional, for display only. */
|
||||
export interface NavigationStop {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface NavigationExportSuccess {
|
||||
platform: NavigationPlatform;
|
||||
url: string;
|
||||
/** Total stops included (origin + waypoints + destination). */
|
||||
stopCount: number;
|
||||
/** Intermediate waypoints only (excludes origin and destination). */
|
||||
waypointCount: number;
|
||||
}
|
||||
|
||||
export interface NavigationExportError {
|
||||
error: string;
|
||||
status: 400;
|
||||
}
|
||||
|
||||
export type NavigationExportResult =
|
||||
| NavigationExportSuccess
|
||||
| NavigationExportError;
|
||||
|
||||
function isError(r: NavigationExportResult): r is NavigationExportError {
|
||||
return "error" in r;
|
||||
}
|
||||
|
||||
/** Intermediate waypoints = every stop that is neither origin nor destination. */
|
||||
export function intermediateWaypointCount(stopCount: number): number {
|
||||
return Math.max(0, stopCount - 2);
|
||||
}
|
||||
|
||||
function coord(stop: NavigationStop): string {
|
||||
return `${stop.latitude},${stop.longitude}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Google Maps URLs API driving deep link. On mobile this opens the
|
||||
* native Google Maps app; on desktop it opens maps.google.com.
|
||||
*/
|
||||
export function buildGoogleMapsUrl(
|
||||
stops: NavigationStop[]
|
||||
): NavigationExportResult {
|
||||
if (stops.length === 0) {
|
||||
return { error: "route has no stops to export", status: 400 };
|
||||
}
|
||||
const waypointCount = intermediateWaypointCount(stops.length);
|
||||
if (waypointCount > GOOGLE_MAPS_MAX_WAYPOINTS) {
|
||||
return {
|
||||
error: `route has ${waypointCount} intermediate waypoints, exceeding Google Maps' limit of ${GOOGLE_MAPS_MAX_WAYPOINTS}`,
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const origin = stops[0]!;
|
||||
const destination = stops[stops.length - 1]!;
|
||||
const params = new URLSearchParams();
|
||||
params.set("api", "1");
|
||||
params.set("travelmode", "driving");
|
||||
params.set("origin", coord(origin));
|
||||
params.set("destination", coord(destination));
|
||||
if (stops.length > 2) {
|
||||
const mids = stops
|
||||
.slice(1, -1)
|
||||
.map(coord)
|
||||
.join("|");
|
||||
params.set("waypoints", mids);
|
||||
}
|
||||
|
||||
return {
|
||||
platform: "google-maps",
|
||||
url: `https://www.google.com/maps/dir/?${params.toString()}`,
|
||||
stopCount: stops.length,
|
||||
waypointCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an Apple Maps `maps://` driving deep link. The first stop is the source
|
||||
* (`saddr`); the remaining stops are chained as destinations with `+to:` (`daddr`).
|
||||
* Built by hand because the `+to:` separators are part of Apple's scheme and must
|
||||
* not be percent-encoded.
|
||||
*/
|
||||
export function buildAppleMapsUrl(
|
||||
stops: NavigationStop[]
|
||||
): NavigationExportResult {
|
||||
if (stops.length === 0) {
|
||||
return { error: "route has no stops to export", status: 400 };
|
||||
}
|
||||
const waypointCount = intermediateWaypointCount(stops.length);
|
||||
if (waypointCount > APPLE_MAPS_MAX_WAYPOINTS) {
|
||||
return {
|
||||
error: `route has ${waypointCount} intermediate waypoints, exceeding Apple Maps' limit of ${APPLE_MAPS_MAX_WAYPOINTS}`,
|
||||
status: 400,
|
||||
};
|
||||
}
|
||||
|
||||
const params: string[] = ["dirflg=d"];
|
||||
if (stops.length === 1) {
|
||||
// Single stop: destination only, no source.
|
||||
params.unshift(`daddr=${coord(stops[0]!)}`);
|
||||
} else {
|
||||
const daddr = stops
|
||||
.slice(1)
|
||||
.map(coord)
|
||||
.join("+to:");
|
||||
params.unshift(`daddr=${daddr}`);
|
||||
params.unshift(`saddr=${coord(stops[0]!)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
platform: "apple-maps",
|
||||
url: `maps://?${params.join("&")}`,
|
||||
stopCount: stops.length,
|
||||
waypointCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Dispatches to the correct builder for the requested platform. */
|
||||
export function buildNavigationUrl(
|
||||
platform: NavigationPlatform,
|
||||
stops: NavigationStop[]
|
||||
): NavigationExportResult {
|
||||
return platform === "google-maps"
|
||||
? buildGoogleMapsUrl(stops)
|
||||
: buildAppleMapsUrl(stops);
|
||||
}
|
||||
|
||||
export { isError as isNavigationExportError };
|
||||
Reference in New Issue
Block a user