feat(GRO-2154): geocoding endpoints + auto-geocode on client mutations (#170)
CI / Test (push) Successful in 28s
CI / Test (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
CI / Lint & Typecheck (push) Failing after 14m33s
CI / Build & Push Docker Images (push) Has been skipped
CI / Test (push) Successful in 28s
CI / Test (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
CI / Lint & Typecheck (push) Failing after 14m33s
CI / Build & Push Docker Images (push) Has been skipped
This commit was merged in pull request #170.
This commit is contained in:
+121
-3
@@ -3,9 +3,61 @@ import { zValidator } from "@hono/zod-validator";
|
||||
import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
import {
|
||||
geocodeClient,
|
||||
geocodeUngeocodedClients,
|
||||
resolveClientGeocodingProvider,
|
||||
type ClientGeocodeOutcome,
|
||||
} from "../services/clientGeocoding.js";
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
type ClientRow = typeof clients.$inferSelect;
|
||||
|
||||
/**
|
||||
* Best-effort auto-geocode of a freshly created/updated client (GRO-2154).
|
||||
* Never throws: a flaky geocoding backend must not break client mutations.
|
||||
* Returns the (possibly coordinate-enriched) row plus a structured outcome the
|
||||
* caller surfaces under a `geocoding` field so ambiguous addresses are visible.
|
||||
*/
|
||||
async function autoGeocodeClient(
|
||||
db: ReturnType<typeof getDb>,
|
||||
row: ClientRow
|
||||
): Promise<{ row: ClientRow; outcome: ClientGeocodeOutcome }> {
|
||||
try {
|
||||
const provider = await resolveClientGeocodingProvider(db);
|
||||
const outcome = await geocodeClient(db, row, provider);
|
||||
const enriched =
|
||||
outcome.status === "geocoded"
|
||||
? {
|
||||
...row,
|
||||
latitude: outcome.latitude,
|
||||
longitude: outcome.longitude,
|
||||
geocodedAt: outcome.geocodedAt
|
||||
? new Date(outcome.geocodedAt)
|
||||
: row.geocodedAt,
|
||||
}
|
||||
: row;
|
||||
return { row: enriched, outcome };
|
||||
} catch (err) {
|
||||
return {
|
||||
row,
|
||||
outcome: {
|
||||
clientId: row.id,
|
||||
status: "error",
|
||||
message: `Auto-geocode failed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
geocodedAt: null,
|
||||
formattedAddress: null,
|
||||
provider: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
@@ -91,9 +143,59 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
||||
const db = getDb();
|
||||
const body = c.req.valid("json");
|
||||
const [row] = await db.insert(clients).values(body).returning();
|
||||
if (!row) return c.json({ error: "Failed to create client" }, 500);
|
||||
|
||||
// Auto-geocode on create when an address is supplied (GRO-2154). Best-effort:
|
||||
// the client is created regardless; the `geocoding` field surfaces failures.
|
||||
if (body.address && body.address.trim()) {
|
||||
const { row: enriched, outcome } = await autoGeocodeClient(db, row);
|
||||
return c.json({ ...enriched, geocoding: outcome }, 201);
|
||||
}
|
||||
return c.json(row, 201);
|
||||
});
|
||||
|
||||
// Geocode a single client's address and persist coordinates (manager-only;
|
||||
// enforced by the route guard in index.ts).
|
||||
clientsRouter.post("/:clientId/geocode", async (c) => {
|
||||
const db = getDb();
|
||||
const clientId = c.req.param("clientId");
|
||||
const [client] = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId));
|
||||
if (!client) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
const provider = await resolveClientGeocodingProvider(db);
|
||||
const outcome = await geocodeClient(db, client, provider);
|
||||
|
||||
// Map outcome to an HTTP status so the result is unambiguous to the caller:
|
||||
// geocoded -> 200, provider error -> 502, no_address/unresolved -> 422.
|
||||
const status =
|
||||
outcome.status === "geocoded"
|
||||
? 200
|
||||
: outcome.status === "error"
|
||||
? 502
|
||||
: 422;
|
||||
return c.json(outcome, status);
|
||||
});
|
||||
|
||||
// Batch-geocode un-geocoded clients with provider-rate-limited throttling
|
||||
// (manager-only). Processes up to ?limit clients (default 50, max 500) per call;
|
||||
// re-invoke while `remaining` > 0 to finish large datasets.
|
||||
clientsRouter.post("/geocode-batch", async (c) => {
|
||||
const db = getDb();
|
||||
const limitRaw = c.req.query("limit");
|
||||
let limit = 50;
|
||||
if (limitRaw !== undefined) {
|
||||
limit = Number(limitRaw);
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return c.json({ error: "limit must be a positive integer" }, 400);
|
||||
}
|
||||
}
|
||||
const summary = await geocodeUngeocodedClients(db, limit);
|
||||
return c.json(summary);
|
||||
});
|
||||
|
||||
// Update a client (including status changes)
|
||||
const patchClientSchema = createClientSchema.partial().extend({
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
@@ -123,13 +225,29 @@ clientsRouter.patch(
|
||||
}
|
||||
delete setValues.smsOptOut;
|
||||
|
||||
const [row] = await db
|
||||
// Auto-geocode on address change (GRO-2154). If the address was cleared,
|
||||
// drop any stale coordinates so a disabled/blank address never keeps a pin.
|
||||
const addressProvided = Object.prototype.hasOwnProperty.call(body, "address");
|
||||
const trimmedAddress =
|
||||
typeof body.address === "string" ? body.address.trim() : undefined;
|
||||
if (addressProvided && !trimmedAddress) {
|
||||
setValues.latitude = null;
|
||||
setValues.longitude = null;
|
||||
setValues.geocodedAt = null;
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(clients)
|
||||
.set(setValues)
|
||||
.where(eq(clients.id, c.req.param("id")))
|
||||
.returning();
|
||||
if (!row) return c.json({ error: "Not found" }, 404);
|
||||
return c.json(row);
|
||||
if (!updated) return c.json({ error: "Not found" }, 404);
|
||||
|
||||
if (addressProvided && trimmedAddress) {
|
||||
const { row: enriched, outcome } = await autoGeocodeClient(db, updated);
|
||||
return c.json({ ...enriched, geocoding: outcome });
|
||||
}
|
||||
return c.json(updated);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user