Skip to content

Commit ba64d95

Browse files
authored
feat(places): promote confirmed OSM venue picks into the places catalogue (#119)
## Summary - Closes the remaining "search *and creation*" gap from the earlier investigation (#118, merged): `geocodeAddress` already searched `places.places` first and fell back to Nominatim, but a Nominatim hit was used for exactly one event and then forgotten — it never got promoted into the DB catalogue, so the next search for the same venue hit Nominatim again instead of the (much faster, richer) DB tier. - `ensurePlaceFromOsmSuggestion` now runs when a host confirms an OSM/Nominatim suggestion in the location picker (`LocationModal`). It's idempotent (keyed on `sourceProvenance.legacyId`, the same convention the platform's own OSM ingestion pipeline uses — verified against live sample docs), enriches the pick via a single-element Overpass API lookup to infer a `placeType` from the closed enum, and writes a paired external/organization entity alongside the place (every place needs an entity owner per the validator). - Both document shapes were verified directly against the real production `places`/`entity` Atlas validators (insert → confirm accepted → delete) before wiring this up, since a malformed write to a platform-owned schema is expensive to get wrong. ## Type of Change - [x] New feature (non-breaking change that adds functionality) ## Changes - `src/app/actions/geocode.ts` — `ensurePlaceFromOsmSuggestion`, `fetchOverpassTags` (Overpass single-element tag lookup), `inferPlaceType` (OSM tag → `PlaceDoc.placeType` enum mapping), `osmType`/`osmId` added to `GeocodeSuggestion`. - `src/components/ui/address-autocomplete.tsx` — carries `source`/`osmType`/`osmId` through `AddressComponents`. - `src/components/modals/location-modal.tsx` — calls `ensurePlaceFromOsmSuggestion` (fire-and-forget) when an OSM-sourced suggestion is confirmed. - `src/app/actions/geocode.test.ts` — idempotency, successful create + tag-based `placeType` inference, Overpass-unreachable fallback, and never-throws coverage. ## Test Plan - [x] Existing tests pass (`npm run test:run` — 792 passed) - [x] New tests added for this change (`ensurePlaceFromOsmSuggestion`: 4 new cases) - [x] Lint passes (`npm run lint`) - [x] Build succeeds (`npm run build`) - [x] Manual testing performed — both the entity and place document shapes were inserted into the real production collections, confirmed accepted by the Atlas JSON-Schema validators, then deleted immediately (no test data left behind) ## Checklist - [x] My code follows the project's code conventions - [x] I have added tests that cover my changes - [x] This PR has a clear, focused scope (one feature, following on from #118) --- _Generated by [Claude Code](https://claude.ai/code/session_01Sn4ULkV3dFFxEq5x3nCdaX)_
2 parents c66576c + 6c37acf commit ba64d95

4 files changed

Lines changed: 259 additions & 7 deletions

File tree

src/app/actions/geocode.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
33
vi.mock("server-only", () => ({}));
44

55
// Mongo accessor + auth gate are mocked so the action runs in isolation.
6-
const places = { find: vi.fn(), aggregate: vi.fn() };
6+
const places = { find: vi.fn(), aggregate: vi.fn(), findOne: vi.fn(), insertOne: vi.fn() };
77
const placesGeo = { findOne: vi.fn() };
8+
const entities = { insertOne: vi.fn() };
89
vi.mock("@/lib/mongo/databases", () => ({
910
placesCollection: vi.fn(async () => places),
1011
placesGeoCollection: vi.fn(async () => placesGeo),
12+
entitiesCollection: vi.fn(async () => entities),
1113
}));
1214
vi.mock("@workos-inc/authkit-nextjs", () => ({
1315
withAuth: vi.fn(async () => ({ user: { id: "user_1" } })),
@@ -16,7 +18,7 @@ vi.mock("@/lib/auth/dev", () => ({
1618
isDevBypass: vi.fn(() => true),
1719
}));
1820

19-
import { geocodeAddress, reverseGeocode, resolveCountryTimezone } from "./geocode";
21+
import { geocodeAddress, reverseGeocode, resolveCountryTimezone, ensurePlaceFromOsmSuggestion } from "./geocode";
2022

2123
/** Build a chainable find() result (.limit().toArray()). */
2224
function findReturning(docs: unknown[]) {
@@ -41,6 +43,9 @@ beforeEach(() => {
4143
// Simulate Atlas Search being unavailable by default (e.g. no index on a
4244
// local/test cluster) so existing regex-path tests keep exercising find().
4345
places.aggregate.mockReturnValue(aggregateReturning(new Error("no such index")));
46+
places.findOne.mockResolvedValue(null);
47+
places.insertOne.mockResolvedValue({ acknowledged: true });
48+
entities.insertOne.mockResolvedValue({ acknowledged: true });
4449
placesGeo.findOne.mockResolvedValue(null);
4550
global.fetch = vi.fn();
4651
});
@@ -306,3 +311,74 @@ describe("resolveCountryTimezone", () => {
306311
expect(await resolveCountryTimezone("Nowhere")).toBeUndefined();
307312
});
308313
});
314+
315+
describe("ensurePlaceFromOsmSuggestion", () => {
316+
const input = {
317+
name: "Miekles Hotel",
318+
address: "Jason Moyo Avenue",
319+
city: "Harare",
320+
country: "Zimbabwe",
321+
latitude: -17.8303379,
322+
longitude: 31.0527331,
323+
osmType: "way",
324+
osmId: 136597457,
325+
};
326+
327+
it("is a no-op (returns the existing id) when the OSM element is already catalogued", async () => {
328+
places.findOne.mockResolvedValue({ _id: "existing-place-1" });
329+
330+
const id = await ensurePlaceFromOsmSuggestion(input);
331+
332+
expect(places.findOne).toHaveBeenCalledWith({ "sourceProvenance.legacyId": "way/136597457" });
333+
expect(id).toBe("existing-place-1");
334+
expect(places.insertOne).not.toHaveBeenCalled();
335+
expect(entities.insertOne).not.toHaveBeenCalled();
336+
});
337+
338+
it("creates a paired external entity + place, inferring placeType from Overpass tags", async () => {
339+
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
340+
ok: true,
341+
json: async () => ({ elements: [{ type: "way", id: 136597457, tags: { tourism: "hotel", name: "Miekles Hotel" } }] }),
342+
});
343+
344+
const id = await ensurePlaceFromOsmSuggestion(input);
345+
346+
expect(id).toEqual(expect.any(String));
347+
expect(entities.insertOne).toHaveBeenCalledTimes(1);
348+
const entityDoc = entities.insertOne.mock.calls[0][0];
349+
expect(entityDoc).toMatchObject({
350+
entityType: "organization",
351+
ecosystemRole: "external",
352+
name: "Miekles Hotel",
353+
primaryPlaceId: id,
354+
sourceProvenance: { legacyId: "way/136597457", mirroredFrom: "osm" },
355+
});
356+
357+
expect(places.insertOne).toHaveBeenCalledTimes(1);
358+
const placeDoc = places.insertOne.mock.calls[0][0];
359+
expect(placeDoc).toMatchObject({
360+
_id: id,
361+
ownerEntityId: entityDoc._id,
362+
name: "Miekles Hotel",
363+
placeType: ["Accommodation"],
364+
geo: { type: "Point", coordinates: [31.0527331, -17.8303379] },
365+
sourceProvenance: { legacyId: "way/136597457", dataOrigin: "osm" },
366+
});
367+
});
368+
369+
it("falls back to a generic LocalBusiness placeType when Overpass is unreachable", async () => {
370+
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("network down"));
371+
372+
await ensurePlaceFromOsmSuggestion(input);
373+
374+
const placeDoc = places.insertOne.mock.calls[0][0];
375+
expect(placeDoc.placeType).toEqual(["LocalBusiness"]);
376+
});
377+
378+
it("never throws — swallows a Mongo write failure and returns null", async () => {
379+
places.insertOne.mockRejectedValue(new Error("insert failed"));
380+
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: true, json: async () => ({ elements: [] }) });
381+
382+
await expect(ensurePlaceFromOsmSuggestion(input)).resolves.toBeNull();
383+
});
384+
});

src/app/actions/geocode.ts

Lines changed: 151 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424

2525
import { withAuth } from "@workos-inc/authkit-nextjs";
2626
import tzlookup from "tz-lookup";
27-
import { placesCollection, placesGeoCollection } from "@/lib/mongo/databases";
27+
import { placesCollection, placesGeoCollection, entitiesCollection } from "@/lib/mongo/databases";
2828
import { isDevBypass } from "@/lib/auth/dev";
29-
import type { PlaceDoc } from "@/lib/mongo/types";
29+
import { newId, slugify, stampNew } from "@/lib/mongo/ids";
30+
import type { PlaceDoc, EntityDoc } from "@/lib/mongo/types";
3031

3132
export interface GeocodeSuggestion {
3233
/** Where the row came from — DB hits are surfaced above OSM hits. */
@@ -47,6 +48,11 @@ export interface GeocodeSuggestion {
4748
longitude: number;
4849
/** IANA timezone resolved from the coordinates (e.g. "Africa/Harare"). */
4950
timezone?: string;
51+
/** OSM element type/id backing an `source: "osm"` suggestion — used to
52+
* promote a selected suggestion into the `places.places` catalogue via
53+
* `ensurePlaceFromOsmSuggestion`. Absent for `source: "db"` rows. */
54+
osmType?: string;
55+
osmId?: number;
5056
}
5157

5258
/** Resolve an IANA timezone from coordinates; `tzlookup` throws on out-of-range input. */
@@ -66,6 +72,8 @@ const NOMINATIM_ENDPOINT = "https://nominatim.openstreetmap.org";
6672
// could contact. Kept generic (no PII) but app-specific.
6773
const NOMINATIM_USER_AGENT = "nhimbe/1.0 (+https://nhimbe.com; events discovery)";
6874

75+
const OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter";
76+
6977
const DEFAULT_LIMIT = 6;
7078
const MIN_QUERY_LENGTH = 3;
7179

@@ -222,11 +230,11 @@ function mapNominatimFeature(f: NominatimFeature): GeocodeSuggestion | null {
222230
const street = [addr.house_number, addr.road].filter(Boolean).join(" ");
223231
const name = props.name || street || city || (props.display_name ?? "").split(",")[0] || "";
224232
const osmType = props.osm_type ?? "node";
225-
const osmId = props.osm_id ?? "";
233+
const osmIdNum = Number(props.osm_id);
226234

227235
return {
228236
source: "osm",
229-
placeId: `osm:${osmType}/${osmId}`,
237+
placeId: `osm:${osmType}/${props.osm_id ?? ""}`,
230238
name,
231239
address: street,
232240
city,
@@ -235,6 +243,8 @@ function mapNominatimFeature(f: NominatimFeature): GeocodeSuggestion | null {
235243
latitude: lat,
236244
longitude: lng,
237245
timezone: timezoneForCoords(lat, lng),
246+
osmType,
247+
osmId: Number.isFinite(osmIdNum) ? osmIdNum : undefined,
238248
};
239249
}
240250

@@ -378,3 +388,140 @@ export async function resolveCountryTimezone(country: string): Promise<string |
378388
if (!ll) return undefined;
379389
return timezoneForCoords(ll[0], ll[1]);
380390
}
391+
392+
/**
393+
* Fetch a single OSM element's raw tags from the Overpass API — a targeted
394+
* single-element lookup (fast, not a wide search), used to enrich a
395+
* Nominatim hit with the same category/amenity data the Mukoko platform's own
396+
* OSM ingestion pipeline reads before promoting it into `places.places`.
397+
*/
398+
async function fetchOverpassTags(osmType: string, osmId: number): Promise<Record<string, string> | null> {
399+
const kind = osmType === "way" || osmType === "relation" ? osmType : "node";
400+
try {
401+
const res = await fetch(OVERPASS_ENDPOINT, {
402+
method: "POST",
403+
body: `data=${encodeURIComponent(`[out:json][timeout:10];${kind}(${osmId});out tags;`)}`,
404+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
405+
next: { revalidate: 86400 },
406+
});
407+
if (!res.ok) return null;
408+
const body = (await res.json()) as { elements?: Array<{ tags?: Record<string, string> }> };
409+
return body.elements?.[0]?.tags ?? null;
410+
} catch {
411+
return null;
412+
}
413+
}
414+
415+
/** `places.places.placeType` is a closed enum — map common OSM tags onto it. */
416+
function inferPlaceType(tags: Record<string, string> | null): PlaceDoc["placeType"] {
417+
if (!tags) return ["LocalBusiness"];
418+
const tourism = tags.tourism;
419+
const amenity = tags.amenity;
420+
if (tourism && ["hotel", "guest_house", "motel", "hostel", "apartment", "chalet"].includes(tourism)) {
421+
return ["Accommodation"];
422+
}
423+
if (tourism && ["attraction", "museum", "viewpoint", "artwork", "gallery", "zoo"].includes(tourism)) {
424+
return ["TouristAttraction"];
425+
}
426+
if (amenity && ["restaurant", "cafe", "fast_food", "bar", "pub", "food_court"].includes(amenity)) {
427+
return ["Restaurant"];
428+
}
429+
if (tags.shop) return ["Store"];
430+
if (tags.leisure === "park" || tags.leisure === "nature_reserve") return ["Park"];
431+
if (tags.natural === "beach") return ["Beach"];
432+
if (tags.natural === "peak" || tags.natural === "volcano") return ["Mountain"];
433+
if (tags.natural === "water" && tags.water === "lake") return ["Lake"];
434+
if (tags.waterway === "river") return ["River"];
435+
if (
436+
amenity &&
437+
["townhall", "courthouse", "police", "fire_station", "embassy", "public_building"].includes(amenity)
438+
) {
439+
return ["CivicStructure"];
440+
}
441+
if (tags.building === "residential" || tags.building === "house" || tags.building === "apartments") {
442+
return ["Residence"];
443+
}
444+
return ["LocalBusiness"];
445+
}
446+
447+
export interface EnsurePlaceInput {
448+
name: string;
449+
address: string;
450+
city: string;
451+
country: string;
452+
latitude: number;
453+
longitude: number;
454+
osmType: string;
455+
osmId: number;
456+
}
457+
458+
/**
459+
* Promote a confirmed OSM/Nominatim venue selection into the `places.places`
460+
* catalogue (closing the "search then creation" loop) so the NEXT search for
461+
* the same venue hits the DB tier instead of Nominatim again.
462+
*
463+
* Idempotent — keyed on `sourceProvenance.legacyId` (`"<osmType>/<osmId>"`,
464+
* the same key the platform's own OSM ingestion pipeline uses), so re-picking
465+
* the same venue never inserts a duplicate. Per the `places.places` /
466+
* `entity.entities` validators (Rule 10 — every place has an entity owner),
467+
* this writes a paired external/organization entity alongside the place,
468+
* mirroring the shape already used by every OSM-sourced row in the catalogue
469+
* today (confirmed against a live sample via the Mongo/MCP inspection this
470+
* function grew out of). Best-effort: never throws, so a catalogue-write
471+
* failure never blocks the caller from finishing whatever they were doing
472+
* (e.g. selecting a venue for an event).
473+
*/
474+
export async function ensurePlaceFromOsmSuggestion(input: EnsurePlaceInput): Promise<string | null> {
475+
try {
476+
await assertCaller();
477+
const places = await placesCollection();
478+
const legacyId = `${input.osmType}/${input.osmId}`;
479+
480+
const existing = await places.findOne({ "sourceProvenance.legacyId": legacyId });
481+
if (existing) return existing._id;
482+
483+
const tags = await fetchOverpassTags(input.osmType, input.osmId);
484+
const placeType = inferPlaceType(tags);
485+
486+
const entities = await entitiesCollection();
487+
const entityId = newId();
488+
const placeId = newId();
489+
490+
const entityDoc = {
491+
...stampNew(entityId),
492+
entityType: "organization",
493+
ecosystemRole: "external",
494+
schemaOrgType: "LocalBusiness",
495+
slug: slugify(input.name),
496+
name: input.name,
497+
isActive: true,
498+
isPrivateByDefault: false,
499+
primaryPlaceId: placeId,
500+
sourceProvenance: { legacyId, mirroredFrom: "osm", sourceProject: "nhimbe" },
501+
} as EntityDoc;
502+
await entities.insertOne(entityDoc);
503+
504+
const placeDoc = {
505+
...stampNew(placeId),
506+
ownerEntityId: entityId,
507+
slug: slugify(input.name),
508+
name: input.name,
509+
isActive: true,
510+
placeType,
511+
geo: { type: "Point", coordinates: [input.longitude, input.latitude] },
512+
address: {
513+
"@type": "PostalAddress",
514+
streetAddress: input.address,
515+
addressLocality: input.city,
516+
addressCountry: input.country,
517+
},
518+
sourceProvenance: { legacyId, dataOrigin: "osm", dataConfidence: 0.6 },
519+
} as PlaceDoc;
520+
await places.insertOne(placeDoc);
521+
522+
return placeId;
523+
} catch (e) {
524+
console.error("[mukoko] ensurePlaceFromOsmSuggestion failed", e);
525+
return null;
526+
}
527+
}

src/components/modals/location-modal.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
77
import { Button } from "@/components/ui/button";
88
import { Label } from "@/components/ui/label";
99
import { ResponsiveModal } from "@/components/ui/responsive-modal";
10-
import { resolveCountryTimezone } from "@/app/actions/geocode";
10+
import { resolveCountryTimezone, ensurePlaceFromOsmSuggestion } from "@/app/actions/geocode";
1111
import { timezoneLabel } from "@/lib/timezone";
1212

1313
function isValidMeetingUrl(value: string): boolean {
@@ -158,6 +158,27 @@ export function LocationModal({
158158
} else {
159159
setSelectedTimezone(null);
160160
}
161+
// Promote a confirmed OSM/Nominatim pick into the places
162+
// catalogue so the next search for this venue hits the DB
163+
// tier first — best-effort, fire-and-forget.
164+
if (
165+
components.source === "osm" &&
166+
components.osmType &&
167+
components.osmId !== undefined &&
168+
components.latitude !== undefined &&
169+
components.longitude !== undefined
170+
) {
171+
ensurePlaceFromOsmSuggestion({
172+
name: components.venue,
173+
address: components.address,
174+
city: components.city,
175+
country: components.country,
176+
latitude: components.latitude,
177+
longitude: components.longitude,
178+
osmType: components.osmType,
179+
osmId: components.osmId,
180+
});
181+
}
161182
}}
162183
placeholder="Search for a venue or address..."
163184
/>

src/components/ui/address-autocomplete.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ interface AddressComponents {
2727
longitude?: number;
2828
/** IANA timezone resolved from the selected coordinates (e.g. "Africa/Harare"). */
2929
timezone?: string;
30+
/** Set only for `source: "osm"` picks — lets the caller promote the
31+
* selection into the places catalogue via `ensurePlaceFromOsmSuggestion`. */
32+
source?: "db" | "osm";
33+
osmType?: string;
34+
osmId?: number;
3035
}
3136

3237
interface AddressAutocompleteProps {
@@ -125,6 +130,9 @@ export function AddressAutocomplete({
125130
latitude: s.latitude,
126131
longitude: s.longitude,
127132
timezone: s.timezone,
133+
source: s.source,
134+
osmType: s.osmType,
135+
osmId: s.osmId,
128136
});
129137
onChange(s.displayName);
130138
setSuggestions([]);

0 commit comments

Comments
 (0)