582c376df9
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
213 lines
6.5 KiB
TypeScript
213 lines
6.5 KiB
TypeScript
import {
|
|
getDb,
|
|
businessSettings,
|
|
clients,
|
|
and,
|
|
eq,
|
|
isNull,
|
|
sql,
|
|
} from "@groombook/db";
|
|
import {
|
|
resolveGeocodingProvider,
|
|
type GeocodingProvider,
|
|
type GeocodeResult,
|
|
} from "./geocoding.js";
|
|
|
|
/**
|
|
* Client geocoding orchestration (GRO-2154, Phase 1.3 of Route Optimization).
|
|
*
|
|
* Bridges the provider-agnostic {@link GeocodingProvider} layer (GRO-2153) and
|
|
* the `clients` table: resolves the configured provider from `businessSettings`,
|
|
* geocodes a client's address, and persists `latitude`/`longitude`/`geocodedAt`.
|
|
*
|
|
* Outcomes are returned as structured {@link ClientGeocodeOutcome} values so that
|
|
* callers (the geocode endpoints and the auto-geocode create/update hook) can
|
|
* surface clear, actionable feedback — groomers need to know when an address is
|
|
* ambiguous or unresolvable, not just that "something failed".
|
|
*/
|
|
|
|
type Db = ReturnType<typeof getDb>;
|
|
type ClientRow = typeof clients.$inferSelect;
|
|
|
|
/** Status of a single client geocode attempt. */
|
|
export type ClientGeocodeStatus =
|
|
/** Coordinates resolved and persisted. */
|
|
| "geocoded"
|
|
/** Client has no (non-blank) address on file — nothing to geocode. */
|
|
| "no_address"
|
|
/** Provider returned no match; the address is ambiguous or unrecognized. */
|
|
| "unresolved"
|
|
/** Provider call failed (transport, quota, bad key). Coordinates unchanged. */
|
|
| "error";
|
|
|
|
/** Structured, UI-surfaceable result of geocoding one client. */
|
|
export interface ClientGeocodeOutcome {
|
|
clientId: string;
|
|
status: ClientGeocodeStatus;
|
|
/** Human-readable explanation, safe to show to managers/groomers. */
|
|
message: string;
|
|
latitude: number | null;
|
|
longitude: number | null;
|
|
geocodedAt: string | null;
|
|
/** Provider-normalized address, when a match was found. */
|
|
formattedAddress: string | null;
|
|
/** Provider that produced (or attempted) this result. */
|
|
provider: string | null;
|
|
}
|
|
|
|
/**
|
|
* Builds the geocoding provider for the current business settings. A single
|
|
* provider instance should be reused across a batch so its internal rate limiter
|
|
* throttles the whole run (e.g. Nominatim's 1 req/sec policy).
|
|
*/
|
|
export async function resolveClientGeocodingProvider(
|
|
db: Db
|
|
): Promise<GeocodingProvider> {
|
|
const [settings] = await db.select().from(businessSettings).limit(1);
|
|
return resolveGeocodingProvider(settings ?? null);
|
|
}
|
|
|
|
/**
|
|
* Geocodes a single client row through the given provider and persists the
|
|
* result on success. Never throws on provider failure — transport/quota errors
|
|
* are captured as an `"error"` outcome so callers (especially the create/update
|
|
* auto-geocode hook) are not broken by a flaky geocoding backend.
|
|
*/
|
|
export async function geocodeClient(
|
|
db: Db,
|
|
client: ClientRow,
|
|
provider: GeocodingProvider
|
|
): Promise<ClientGeocodeOutcome> {
|
|
const base = {
|
|
clientId: client.id,
|
|
latitude: null as number | null,
|
|
longitude: null as number | null,
|
|
geocodedAt: null as string | null,
|
|
formattedAddress: null as string | null,
|
|
provider: provider.name as string | null,
|
|
};
|
|
|
|
const address = client.address?.trim();
|
|
if (!address) {
|
|
return {
|
|
...base,
|
|
status: "no_address",
|
|
message: "Client has no address on file, so it cannot be geocoded.",
|
|
};
|
|
}
|
|
|
|
let result: GeocodeResult | null;
|
|
try {
|
|
result = await provider.geocode(address);
|
|
} catch (err) {
|
|
return {
|
|
...base,
|
|
status: "error",
|
|
message: `Geocoding provider (${provider.name}) failed: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
};
|
|
}
|
|
|
|
if (!result) {
|
|
return {
|
|
...base,
|
|
status: "unresolved",
|
|
message: `Address could not be resolved to a location: "${address}". Please verify or correct the address.`,
|
|
};
|
|
}
|
|
|
|
const geocodedAt = new Date();
|
|
await db
|
|
.update(clients)
|
|
.set({
|
|
latitude: result.latitude,
|
|
longitude: result.longitude,
|
|
geocodedAt,
|
|
updatedAt: geocodedAt,
|
|
})
|
|
.where(eq(clients.id, client.id));
|
|
|
|
return {
|
|
clientId: client.id,
|
|
status: "geocoded",
|
|
message: `Geocoded via ${result.provider} to ${result.latitude}, ${result.longitude}.`,
|
|
latitude: result.latitude,
|
|
longitude: result.longitude,
|
|
geocodedAt: geocodedAt.toISOString(),
|
|
formattedAddress: result.formattedAddress,
|
|
provider: result.provider,
|
|
};
|
|
}
|
|
|
|
/** Summary returned by {@link geocodeUngeocodedClients}. */
|
|
export interface BatchGeocodeSummary {
|
|
provider: string;
|
|
/** Number of clients processed in this invocation. */
|
|
processed: number;
|
|
geocoded: number;
|
|
unresolved: number;
|
|
errors: number;
|
|
/** Un-geocoded clients with an address that were NOT processed (over `limit`). */
|
|
remaining: number;
|
|
/** Per-client outcomes for everything processed this invocation. */
|
|
outcomes: ClientGeocodeOutcome[];
|
|
}
|
|
|
|
/**
|
|
* Batch-geocodes clients that have an address but no `geocodedAt` yet, throttled
|
|
* by the active provider's rate limiter.
|
|
*
|
|
* Because Nominatim allows only ~1 req/sec, geocoding every un-geocoded client in
|
|
* a single HTTP request would risk timeouts on large datasets. Each invocation
|
|
* therefore processes at most `limit` clients (default 50, clamped 1..500) and
|
|
* reports `remaining`; managers re-run until `remaining` is 0.
|
|
*/
|
|
export async function geocodeUngeocodedClients(
|
|
db: Db,
|
|
limit = 50,
|
|
injectedProvider?: GeocodingProvider
|
|
): Promise<BatchGeocodeSummary> {
|
|
const effectiveLimit = Math.min(Math.max(Math.trunc(limit) || 0, 1), 500);
|
|
const provider =
|
|
injectedProvider ?? (await resolveClientGeocodingProvider(db));
|
|
|
|
// Un-geocoded = geocodedAt IS NULL with a non-blank address.
|
|
const candidateFilter = and(
|
|
isNull(clients.geocodedAt),
|
|
sql`${clients.address} IS NOT NULL AND length(trim(${clients.address})) > 0`
|
|
);
|
|
|
|
const countRows = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(clients)
|
|
.where(candidateFilter);
|
|
const totalRemaining = countRows[0]?.count ?? 0;
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(clients)
|
|
.where(candidateFilter)
|
|
.orderBy(clients.createdAt)
|
|
.limit(effectiveLimit);
|
|
|
|
const outcomes: ClientGeocodeOutcome[] = [];
|
|
for (const row of rows) {
|
|
outcomes.push(await geocodeClient(db, row, provider));
|
|
}
|
|
|
|
const geocoded = outcomes.filter((o) => o.status === "geocoded").length;
|
|
const unresolved = outcomes.filter((o) => o.status === "unresolved").length;
|
|
const errors = outcomes.filter((o) => o.status === "error").length;
|
|
|
|
return {
|
|
provider: provider.name,
|
|
processed: outcomes.length,
|
|
geocoded,
|
|
unresolved,
|
|
errors,
|
|
remaining: Math.max(totalRemaining - outcomes.length, 0),
|
|
outcomes,
|
|
};
|
|
}
|