@happyvertical/smrt-places 0.37.2 → 0.37.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,966 +1,666 @@
1
- import { ObjectRegistry, foreignKey, crossPackageRef, field, smrt, SmrtObject, SmrtJunction, SmrtHierarchical, SmrtCollection } from "@happyvertical/smrt-core";
2
- import { getOwnedAssetsFromCollection, addOwnedAssetFromCollection, removeOwnedAssetFromCollection, resolveOwnedAssetsById, assertValidOwnedAssetRelationship, assertValidOwnedAssetSortOrder } from "@happyvertical/smrt-assets";
3
- import { tenantId, TenantScoped, queryGlobal, queryWithGlobals } from "@happyvertical/smrt-tenancy";
4
- import { getGeoAdapter } from "@happyvertical/geo";
1
+ import { i as __exportAll, r as PlaceType, t as PlaceTypeCollection } from "./chunks/PlaceTypeCollection-zFJu53le.js";
5
2
  import { areCoordinatesNear, calculateDistance, formatCoordinates, generateDisplayName, locationToGeoData, mapLocationTypeToPlaceType, normalizeAddressComponents, parseCoordinates, validateCoordinates } from "./utils.js";
6
- ObjectRegistry.registerPackageManifest(
7
- new URL("./manifest.json", import.meta.url)
8
- );
9
- var __defProp$3 = Object.defineProperty;
10
- var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
11
- var __decorateClass$3 = (decorators, target, key, kind) => {
12
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
13
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
14
- if (decorator = decorators[i])
15
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
16
- if (kind && result) __defProp$3(target, key, result);
17
- return result;
18
- };
19
- let PlaceAsset = class extends SmrtObject {
20
- tenantId = null;
21
- placeId = "";
22
- assetId = "";
23
- relationship = "attachment";
24
- sortOrder = 0;
25
- constructor(options = {}) {
26
- super(options);
27
- if (options.placeId) this.placeId = options.placeId;
28
- if (options.assetId) this.assetId = options.assetId;
29
- if (options.relationship) this.relationship = options.relationship;
30
- if (options.sortOrder !== void 0) this.sortOrder = options.sortOrder;
31
- if (options.tenantId !== void 0) this.tenantId = options.tenantId;
32
- }
33
- };
34
- __decorateClass$3([
35
- tenantId({ nullable: true })
36
- ], PlaceAsset.prototype, "tenantId", 2);
37
- __decorateClass$3([
38
- foreignKey("Place", { required: true })
39
- ], PlaceAsset.prototype, "placeId", 2);
40
- __decorateClass$3([
41
- crossPackageRef("@happyvertical/smrt-assets:Asset", { required: true })
42
- ], PlaceAsset.prototype, "assetId", 2);
43
- __decorateClass$3([
44
- field({ required: true })
45
- ], PlaceAsset.prototype, "relationship", 2);
46
- __decorateClass$3([
47
- field()
48
- ], PlaceAsset.prototype, "sortOrder", 2);
49
- PlaceAsset = __decorateClass$3([
50
- TenantScoped({ mode: "optional" }),
51
- smrt({
52
- tableName: "place_assets",
53
- conflictColumns: ["place_id", "asset_id", "relationship"],
54
- api: false,
55
- mcp: false,
56
- cli: false
57
- })
58
- ], PlaceAsset);
3
+ import { ObjectRegistry, SmrtCollection, SmrtHierarchical, SmrtJunction, SmrtObject, crossPackageRef, field, foreignKey, smrt } from "@happyvertical/smrt-core";
4
+ import { addOwnedAssetFromCollection, assertValidOwnedAssetRelationship, assertValidOwnedAssetSortOrder, getOwnedAssetsFromCollection, removeOwnedAssetFromCollection, resolveOwnedAssetsById } from "@happyvertical/smrt-assets";
5
+ import { TenantScoped, queryGlobal, queryWithGlobals, tenantId } from "@happyvertical/smrt-tenancy";
6
+ import { getGeoAdapter } from "@happyvertical/geo";
7
+ //#region src/__smrt-register__.ts
8
+ ObjectRegistry.registerPackageManifest(new URL("./manifest.json", "" + import.meta.url));
9
+ //#endregion
10
+ //#region src/models/PlaceAsset.ts
59
11
  var __defProp$2 = Object.defineProperty;
60
12
  var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
61
- var __defNormalProp = (obj, key, value) => key in obj ? __defProp$2(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
62
13
  var __decorateClass$2 = (decorators, target, key, kind) => {
63
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
64
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
65
- if (decorator = decorators[i])
66
- result = decorator(result) || result;
67
- return result;
14
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
15
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
16
+ if (kind && result) __defProp$2(target, key, result);
17
+ return result;
68
18
  };
69
- var __publicField = (obj, key, value) => __defNormalProp(obj, key + "", value);
70
- let PlaceAssetCollection = class extends SmrtJunction {
71
- leftField = "placeId";
72
- rightField = "assetId";
73
- placeCollectionPromise = null;
74
- async getPlaceCollection() {
75
- if (!this.placeCollectionPromise) {
76
- const { PlaceCollection: PlaceCollection2 } = await Promise.resolve().then(() => PlaceCollection$1);
77
- this.placeCollectionPromise = PlaceCollection2.create({ db: this.db });
78
- }
79
- return this.placeCollectionPromise;
80
- }
81
- async getAssets(placeId, relationship) {
82
- return getOwnedAssetsFromCollection(
83
- await this.getPlaceCollection(),
84
- placeId,
85
- relationship
86
- );
87
- }
88
- async addAsset(placeId, asset, relationship = "attachment", sortOrder = 0) {
89
- await addOwnedAssetFromCollection(
90
- await this.getPlaceCollection(),
91
- "Place",
92
- placeId,
93
- asset,
94
- relationship,
95
- sortOrder
96
- );
97
- }
98
- async removeAsset(placeId, assetId, relationship) {
99
- await removeOwnedAssetFromCollection(
100
- await this.getPlaceCollection(),
101
- "Place",
102
- placeId,
103
- assetId,
104
- relationship
105
- );
106
- }
19
+ var PlaceAsset = class extends SmrtObject {
20
+ tenantId = null;
21
+ placeId = "";
22
+ assetId = "";
23
+ relationship = "attachment";
24
+ sortOrder = 0;
25
+ constructor(options = {}) {
26
+ super(options);
27
+ if (options.placeId) this.placeId = options.placeId;
28
+ if (options.assetId) this.assetId = options.assetId;
29
+ if (options.relationship) this.relationship = options.relationship;
30
+ if (options.sortOrder !== void 0) this.sortOrder = options.sortOrder;
31
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
32
+ }
107
33
  };
108
- __publicField(PlaceAssetCollection, "_itemClass", PlaceAsset);
109
- PlaceAssetCollection = __decorateClass$2([
110
- smrt({
111
- api: false,
112
- mcp: false,
113
- cli: false
114
- })
115
- ], PlaceAssetCollection);
116
- const PlaceAssetCollection$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
117
- __proto__: null,
118
- get PlaceAssetCollection() {
119
- return PlaceAssetCollection;
120
- }
121
- }, Symbol.toStringTag, { value: "Module" }));
34
+ __decorateClass$2([tenantId({ nullable: true })], PlaceAsset.prototype, "tenantId", 2);
35
+ __decorateClass$2([foreignKey("Place", { required: true })], PlaceAsset.prototype, "placeId", 2);
36
+ __decorateClass$2([crossPackageRef("@happyvertical/smrt-assets:Asset", { required: true })], PlaceAsset.prototype, "assetId", 2);
37
+ __decorateClass$2([field({ required: true })], PlaceAsset.prototype, "relationship", 2);
38
+ __decorateClass$2([field()], PlaceAsset.prototype, "sortOrder", 2);
39
+ PlaceAsset = __decorateClass$2([TenantScoped({ mode: "optional" }), smrt({
40
+ tableName: "place_assets",
41
+ conflictColumns: [
42
+ "place_id",
43
+ "asset_id",
44
+ "relationship"
45
+ ],
46
+ api: false,
47
+ mcp: false,
48
+ cli: false
49
+ })], PlaceAsset);
50
+ //#endregion
51
+ //#region src/collections/PlaceAssetCollection.ts
52
+ var PlaceAssetCollection_exports = /* @__PURE__ */ __exportAll({ PlaceAssetCollection: () => PlaceAssetCollection });
122
53
  var __defProp$1 = Object.defineProperty;
123
54
  var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
55
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
56
+ enumerable: true,
57
+ configurable: true,
58
+ writable: true,
59
+ value
60
+ }) : obj[key] = value;
124
61
  var __decorateClass$1 = (decorators, target, key, kind) => {
125
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
126
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
127
- if (decorator = decorators[i])
128
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
129
- if (kind && result) __defProp$1(target, key, result);
130
- return result;
62
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
63
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
64
+ if (kind && result) __defProp$1(target, key, result);
65
+ return result;
131
66
  };
132
- let Place = class extends SmrtHierarchical {
133
- tenantId = null;
134
- typeId = "";
135
- // FK to PlaceType
136
- // parentId inherited from SmrtHierarchical (nullable, self-reference)
137
- name = "";
138
- // Place name/title
139
- description = "";
140
- latitude = null;
141
- longitude = null;
142
- streetNumber = "";
143
- streetName = "";
144
- city = "";
145
- region = "";
146
- country = "";
147
- postalCode = "";
148
- countryCode = "";
149
- timezone = "";
150
- // Metadata
151
- externalId = "";
152
- // External system identifier
153
- source = "";
154
- // Where this place came from (e.g., 'google', 'osm')
155
- metadata = "";
156
- // JSON metadata stored as text
157
- // Timestamps
158
- createdAt = /* @__PURE__ */ new Date();
159
- updatedAt = /* @__PURE__ */ new Date();
160
- constructor(options = {}) {
161
- super(options);
162
- if (options.tenantId !== void 0) this.tenantId = options.tenantId;
163
- if (options.typeId) this.typeId = options.typeId;
164
- if (options.parentId !== void 0)
165
- this.parentId = options.parentId ?? null;
166
- if (options.name) this.name = options.name;
167
- if (options.description !== void 0)
168
- this.description = options.description;
169
- if (options.latitude !== void 0) this.latitude = options.latitude;
170
- if (options.longitude !== void 0) this.longitude = options.longitude;
171
- if (options.streetNumber !== void 0)
172
- this.streetNumber = options.streetNumber;
173
- if (options.streetName !== void 0) this.streetName = options.streetName;
174
- if (options.city !== void 0) this.city = options.city;
175
- if (options.region !== void 0) this.region = options.region;
176
- if (options.country !== void 0) this.country = options.country;
177
- if (options.postalCode !== void 0) this.postalCode = options.postalCode;
178
- if (options.countryCode !== void 0)
179
- this.countryCode = options.countryCode;
180
- if (options.timezone !== void 0) this.timezone = options.timezone;
181
- if (options.externalId !== void 0) this.externalId = options.externalId;
182
- if (options.source !== void 0) this.source = options.source;
183
- if (options.metadata !== void 0) {
184
- if (typeof options.metadata === "string") {
185
- this.metadata = options.metadata;
186
- } else {
187
- this.metadata = JSON.stringify(options.metadata);
188
- }
189
- }
190
- if (options.createdAt) this.createdAt = options.createdAt;
191
- if (options.updatedAt) this.updatedAt = options.updatedAt;
192
- }
193
- /**
194
- * Get geographic data for this place
195
- *
196
- * @returns GeoData object with all geo fields
197
- */
198
- getGeoData() {
199
- return {
200
- latitude: this.latitude,
201
- longitude: this.longitude,
202
- streetNumber: this.streetNumber || void 0,
203
- streetName: this.streetName || void 0,
204
- city: this.city || void 0,
205
- region: this.region || void 0,
206
- country: this.country || void 0,
207
- postalCode: this.postalCode || void 0,
208
- countryCode: this.countryCode || void 0,
209
- timezone: this.timezone || void 0
210
- };
211
- }
212
- /**
213
- * Check if this place has geographic coordinates
214
- *
215
- * @returns True if latitude and longitude are set
216
- */
217
- hasCoordinates() {
218
- return this.latitude !== null && this.longitude !== null;
219
- }
220
- /**
221
- * Get metadata as parsed object
222
- *
223
- * @returns Parsed metadata object or empty object if no metadata
224
- */
225
- getMetadata() {
226
- if (!this.metadata) return {};
227
- try {
228
- return JSON.parse(this.metadata);
229
- } catch {
230
- return {};
231
- }
232
- }
233
- /**
234
- * Set metadata from object
235
- *
236
- * @param data - Metadata object to store
237
- */
238
- setMetadata(data) {
239
- this.metadata = JSON.stringify(data);
240
- }
241
- /**
242
- * Update metadata by merging with existing values
243
- *
244
- * @param updates - Partial metadata to merge
245
- */
246
- updateMetadata(updates) {
247
- const current = this.getMetadata();
248
- this.setMetadata({ ...current, ...updates });
249
- }
250
- /**
251
- * Get the place type
252
- *
253
- * @returns PlaceType instance or null if not found
254
- */
255
- async getType() {
256
- if (!this.typeId) return null;
257
- const { PlaceTypeCollection: PlaceTypeCollection2 } = await Promise.resolve().then(() => PlaceTypeCollection$1);
258
- const collection = await PlaceTypeCollection2.create(this.options);
259
- return await collection.get({ id: this.typeId });
260
- }
261
- // Hierarchy traversal (getParent / getChildren / getAncestors /
262
- // getDescendants / getHierarchy / moveTo) provided by SmrtHierarchical.
263
- async getPlaceAssetCollection() {
264
- const { PlaceAssetCollection: PlaceAssetCollection2 } = await Promise.resolve().then(() => PlaceAssetCollection$1);
265
- return PlaceAssetCollection2.create({ db: this.db });
266
- }
267
- async getAssets(relationship) {
268
- if (!this.id) {
269
- return [];
270
- }
271
- const placeAssets = await this.getPlaceAssetCollection();
272
- const linkedAssets = await placeAssets.byLeft(
273
- this.id,
274
- relationship ? { relationship } : {}
275
- );
276
- return resolveOwnedAssetsById(
277
- this.db,
278
- linkedAssets.map((link) => link.assetId),
279
- this.tenantId
280
- );
281
- }
282
- async addAsset(asset, relationship = "attachment", sortOrder = 0) {
283
- if (!this.id || !asset.id) {
284
- throw new Error("Cannot associate unsaved place or asset");
285
- }
286
- assertValidOwnedAssetRelationship(relationship);
287
- assertValidOwnedAssetSortOrder(sortOrder);
288
- const placeAssets = await this.getPlaceAssetCollection();
289
- await placeAssets.attach(this.id, asset.id, {
290
- relationship,
291
- sortOrder,
292
- tenantId: this.tenantId
293
- });
294
- }
295
- async removeAsset(assetId, relationship) {
296
- if (!this.id) {
297
- return;
298
- }
299
- const placeAssets = await this.getPlaceAssetCollection();
300
- await placeAssets.detach(
301
- this.id,
302
- assetId,
303
- relationship ? { relationship } : {}
304
- );
305
- }
67
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
68
+ var PlaceAssetCollection = class extends SmrtJunction {
69
+ leftField = "placeId";
70
+ rightField = "assetId";
71
+ placeCollectionPromise = null;
72
+ async getPlaceCollection() {
73
+ if (!this.placeCollectionPromise) {
74
+ const { PlaceCollection } = await Promise.resolve().then(() => PlaceCollection_exports);
75
+ this.placeCollectionPromise = PlaceCollection.create({ db: this.db });
76
+ }
77
+ return this.placeCollectionPromise;
78
+ }
79
+ async getAssets(placeId, relationship) {
80
+ return getOwnedAssetsFromCollection(await this.getPlaceCollection(), placeId, relationship);
81
+ }
82
+ async addAsset(placeId, asset, relationship = "attachment", sortOrder = 0) {
83
+ await addOwnedAssetFromCollection(await this.getPlaceCollection(), "Place", placeId, asset, relationship, sortOrder);
84
+ }
85
+ async removeAsset(placeId, assetId, relationship) {
86
+ await removeOwnedAssetFromCollection(await this.getPlaceCollection(), "Place", placeId, assetId, relationship);
87
+ }
306
88
  };
307
- __decorateClass$1([
308
- tenantId({ nullable: true })
309
- ], Place.prototype, "tenantId", 2);
310
- __decorateClass$1([
311
- foreignKey("PlaceType")
312
- ], Place.prototype, "typeId", 2);
313
- __decorateClass$1([
314
- field({ type: "decimal" })
315
- ], Place.prototype, "latitude", 2);
316
- __decorateClass$1([
317
- field({ type: "decimal" })
318
- ], Place.prototype, "longitude", 2);
319
- Place = __decorateClass$1([
320
- TenantScoped({ mode: "optional" }),
321
- smrt({
322
- tableStrategy: "sti",
323
- api: { include: ["list", "get", "create", "update", "delete"] },
324
- mcp: { include: ["list", "get", "create", "update"] },
325
- cli: true
326
- })
327
- ], Place);
89
+ __publicField(PlaceAssetCollection, "_itemClass", PlaceAsset);
90
+ PlaceAssetCollection = __decorateClass$1([smrt({
91
+ api: false,
92
+ mcp: false,
93
+ cli: false
94
+ })], PlaceAssetCollection);
95
+ //#endregion
96
+ //#region src/models/Place.ts
328
97
  var __defProp = Object.defineProperty;
329
98
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
330
99
  var __decorateClass = (decorators, target, key, kind) => {
331
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
332
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
333
- if (decorator = decorators[i])
334
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
335
- if (kind && result) __defProp(target, key, result);
336
- return result;
100
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
101
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
102
+ if (kind && result) __defProp(target, key, result);
103
+ return result;
337
104
  };
338
- let PlaceType = class extends SmrtObject {
339
- name = "";
340
- // Type name (e.g., 'Town', 'City', 'Country')
341
- description;
342
- // Optional description
343
- // Timestamps
344
- createdAt = /* @__PURE__ */ new Date();
345
- updatedAt = /* @__PURE__ */ new Date();
346
- constructor(options = {}) {
347
- super(options);
348
- if (options.name) this.name = options.name;
349
- if (options.description !== void 0)
350
- this.description = options.description;
351
- if (options.createdAt) this.createdAt = options.createdAt;
352
- if (options.updatedAt) this.updatedAt = options.updatedAt;
353
- }
354
- /**
355
- * Convenience method for slug-based lookup
356
- *
357
- * @param slug - The slug to search for
358
- * @returns PlaceType instance or null if not found
359
- */
360
- static async getBySlug(_slug) {
361
- return null;
362
- }
105
+ var Place = class extends SmrtHierarchical {
106
+ tenantId = null;
107
+ typeId = "";
108
+ name = "";
109
+ description = "";
110
+ latitude = null;
111
+ longitude = null;
112
+ streetNumber = "";
113
+ streetName = "";
114
+ city = "";
115
+ region = "";
116
+ country = "";
117
+ postalCode = "";
118
+ countryCode = "";
119
+ timezone = "";
120
+ externalId = "";
121
+ source = "";
122
+ metadata = "";
123
+ createdAt = /* @__PURE__ */ new Date();
124
+ updatedAt = /* @__PURE__ */ new Date();
125
+ constructor(options = {}) {
126
+ super(options);
127
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
128
+ if (options.typeId) this.typeId = options.typeId;
129
+ if (options.parentId !== void 0) this.parentId = options.parentId ?? null;
130
+ if (options.name) this.name = options.name;
131
+ if (options.description !== void 0) this.description = options.description;
132
+ if (options.latitude !== void 0) this.latitude = options.latitude;
133
+ if (options.longitude !== void 0) this.longitude = options.longitude;
134
+ if (options.streetNumber !== void 0) this.streetNumber = options.streetNumber;
135
+ if (options.streetName !== void 0) this.streetName = options.streetName;
136
+ if (options.city !== void 0) this.city = options.city;
137
+ if (options.region !== void 0) this.region = options.region;
138
+ if (options.country !== void 0) this.country = options.country;
139
+ if (options.postalCode !== void 0) this.postalCode = options.postalCode;
140
+ if (options.countryCode !== void 0) this.countryCode = options.countryCode;
141
+ if (options.timezone !== void 0) this.timezone = options.timezone;
142
+ if (options.externalId !== void 0) this.externalId = options.externalId;
143
+ if (options.source !== void 0) this.source = options.source;
144
+ if (options.metadata !== void 0) if (typeof options.metadata === "string") this.metadata = options.metadata;
145
+ else this.metadata = JSON.stringify(options.metadata);
146
+ if (options.createdAt) this.createdAt = options.createdAt;
147
+ if (options.updatedAt) this.updatedAt = options.updatedAt;
148
+ }
149
+ /**
150
+ * Get geographic data for this place
151
+ *
152
+ * @returns GeoData object with all geo fields
153
+ */
154
+ getGeoData() {
155
+ return {
156
+ latitude: this.latitude,
157
+ longitude: this.longitude,
158
+ streetNumber: this.streetNumber || void 0,
159
+ streetName: this.streetName || void 0,
160
+ city: this.city || void 0,
161
+ region: this.region || void 0,
162
+ country: this.country || void 0,
163
+ postalCode: this.postalCode || void 0,
164
+ countryCode: this.countryCode || void 0,
165
+ timezone: this.timezone || void 0
166
+ };
167
+ }
168
+ /**
169
+ * Check if this place has geographic coordinates
170
+ *
171
+ * @returns True if latitude and longitude are set
172
+ */
173
+ hasCoordinates() {
174
+ return this.latitude !== null && this.longitude !== null;
175
+ }
176
+ /**
177
+ * Get metadata as parsed object
178
+ *
179
+ * @returns Parsed metadata object or empty object if no metadata
180
+ */
181
+ getMetadata() {
182
+ if (!this.metadata) return {};
183
+ try {
184
+ return JSON.parse(this.metadata);
185
+ } catch {
186
+ return {};
187
+ }
188
+ }
189
+ /**
190
+ * Set metadata from object
191
+ *
192
+ * @param data - Metadata object to store
193
+ */
194
+ setMetadata(data) {
195
+ this.metadata = JSON.stringify(data);
196
+ }
197
+ /**
198
+ * Update metadata by merging with existing values
199
+ *
200
+ * @param updates - Partial metadata to merge
201
+ */
202
+ updateMetadata(updates) {
203
+ const current = this.getMetadata();
204
+ this.setMetadata({
205
+ ...current,
206
+ ...updates
207
+ });
208
+ }
209
+ /**
210
+ * Get the place type
211
+ *
212
+ * @returns PlaceType instance or null if not found
213
+ */
214
+ async getType() {
215
+ if (!this.typeId) return null;
216
+ const { PlaceTypeCollection } = await import("./chunks/PlaceTypeCollection-zFJu53le.js").then((n) => n.n);
217
+ return await (await PlaceTypeCollection.create(this.options)).get({ id: this.typeId });
218
+ }
219
+ async getPlaceAssetCollection() {
220
+ const { PlaceAssetCollection } = await Promise.resolve().then(() => PlaceAssetCollection_exports);
221
+ return PlaceAssetCollection.create({ db: this.db });
222
+ }
223
+ async getAssets(relationship) {
224
+ if (!this.id) return [];
225
+ const linkedAssets = await (await this.getPlaceAssetCollection()).byLeft(this.id, relationship ? { relationship } : {});
226
+ return resolveOwnedAssetsById(this.db, linkedAssets.map((link) => link.assetId), this.tenantId);
227
+ }
228
+ async addAsset(asset, relationship = "attachment", sortOrder = 0) {
229
+ if (!this.id || !asset.id) throw new Error("Cannot associate unsaved place or asset");
230
+ assertValidOwnedAssetRelationship(relationship);
231
+ assertValidOwnedAssetSortOrder(sortOrder);
232
+ await (await this.getPlaceAssetCollection()).attach(this.id, asset.id, {
233
+ relationship,
234
+ sortOrder,
235
+ tenantId: this.tenantId
236
+ });
237
+ }
238
+ async removeAsset(assetId, relationship) {
239
+ if (!this.id) return;
240
+ await (await this.getPlaceAssetCollection()).detach(this.id, assetId, relationship ? { relationship } : {});
241
+ }
363
242
  };
364
- __decorateClass([
365
- field({ required: true })
366
- ], PlaceType.prototype, "name", 2);
367
- PlaceType = __decorateClass([
368
- smrt({
369
- tableStrategy: "sti",
370
- api: { include: ["list", "get", "create", "update", "delete"] },
371
- mcp: { include: ["list", "get", "create"] },
372
- cli: true
373
- })
374
- ], PlaceType);
375
- class PlaceTypeCollection extends SmrtCollection {
376
- static _itemClass = PlaceType;
377
- /**
378
- * Get or create a place type by slug
379
- *
380
- * @param slug - PlaceType slug (e.g., 'city', 'building')
381
- * @param name - Optional display name (defaults to capitalized slug)
382
- * @returns PlaceType instance
383
- */
384
- async getOrCreate(slug, name) {
385
- const existing = await this.get({ slug });
386
- if (existing) {
387
- return existing;
388
- }
389
- const displayName = name || slug.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
390
- return await this.create({
391
- slug,
392
- name: displayName
393
- });
394
- }
395
- /**
396
- * Get a place type by slug
397
- *
398
- * @param slug - PlaceType slug to search for
399
- * @returns PlaceType instance or null if not found
400
- */
401
- async getBySlug(slug) {
402
- return await this.get({ slug });
403
- }
404
- /**
405
- * Initialize default place types
406
- *
407
- * Creates standard types if they don't exist:
408
- * - country
409
- * - region (state/province)
410
- * - city
411
- * - address
412
- * - building
413
- * - room
414
- * - zone (for abstract/virtual places)
415
- *
416
- * @returns Array of created/existing place types
417
- */
418
- async initializeDefaults() {
419
- const defaults = [
420
- { slug: "country", name: "Country" },
421
- { slug: "region", name: "Region" },
422
- { slug: "city", name: "City" },
423
- { slug: "address", name: "Address" },
424
- { slug: "building", name: "Building" },
425
- { slug: "room", name: "Room" },
426
- { slug: "zone", name: "Zone" },
427
- { slug: "point_of_interest", name: "Point of Interest" }
428
- ];
429
- const types = [];
430
- for (const def of defaults) {
431
- const type = await this.getOrCreate(def.slug, def.name);
432
- types.push(type);
433
- }
434
- return types;
435
- }
436
- }
437
- const PlaceTypeCollection$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
438
- __proto__: null,
439
- PlaceTypeCollection
440
- }, Symbol.toStringTag, { value: "Module" }));
441
- class PlaceCollection extends SmrtCollection {
442
- static _itemClass = Place;
443
- /**
444
- * Look up a place by query or coordinates, creating it if not found
445
- *
446
- * This is the key method for organic database growth:
447
- * 1. Search local database first
448
- * 2. If not found, query @happyvertical/geo
449
- * 3. Create place from geocoding result
450
- * 4. Return place
451
- *
452
- * @param query - Address or location query string
453
- * @param options - Lookup options (provider, type, parent, etc.)
454
- * @returns Place instance
455
- */
456
- async lookupOrCreate(query, options = {}) {
457
- const {
458
- geoProvider = "openstreetmap",
459
- typeSlug,
460
- parentId,
461
- createIfNotFound = true,
462
- coords
463
- } = options;
464
- let existingPlace = null;
465
- if (coords) {
466
- existingPlace = await this.findByCoordinates(coords.lat, coords.lng);
467
- }
468
- if (!existingPlace) {
469
- existingPlace = await this.findByQuery(query);
470
- }
471
- if (existingPlace) {
472
- return existingPlace;
473
- }
474
- if (!createIfNotFound) {
475
- return null;
476
- }
477
- const locations = await this.geocode(
478
- query,
479
- coords,
480
- geoProvider
481
- );
482
- if (locations.length === 0) {
483
- return null;
484
- }
485
- const location = locations[0];
486
- return await this.createFromLocation(location, typeSlug, parentId);
487
- }
488
- /**
489
- * Find place by coordinates (within small threshold)
490
- *
491
- * @param latitude - Latitude to search
492
- * @param longitude - Longitude to search
493
- * @param threshold - Max distance in degrees (default: 0.0001 ~11m)
494
- * @returns Place instance or null
495
- */
496
- async findByCoordinates(latitude, longitude, threshold = 1e-4) {
497
- const places = await this.list({
498
- where: {
499
- latitude: { $ne: null },
500
- longitude: { $ne: null }
501
- }
502
- });
503
- for (const place of places) {
504
- const placeObj = place;
505
- if (placeObj.latitude === null || placeObj.longitude === null) continue;
506
- const latDiff = Math.abs(placeObj.latitude - latitude);
507
- const lngDiff = Math.abs(placeObj.longitude - longitude);
508
- if (latDiff < threshold && lngDiff < threshold) {
509
- return place;
510
- }
511
- }
512
- return null;
513
- }
514
- /**
515
- * Find place by query text (matches name, city, region, country)
516
- *
517
- * @param query - Search query
518
- * @returns Place instance or null
519
- */
520
- async findByQuery(query) {
521
- const normalizedQuery = query.toLowerCase().trim();
522
- const places = await this.list({});
523
- for (const place of places) {
524
- if (place.name.toLowerCase().includes(normalizedQuery)) {
525
- return place;
526
- }
527
- const addressParts = [
528
- place.streetNumber,
529
- place.streetName,
530
- place.city,
531
- place.region,
532
- place.country
533
- ].filter((p) => p).join(" ").toLowerCase();
534
- if (addressParts.includes(normalizedQuery)) {
535
- return place;
536
- }
537
- }
538
- return null;
539
- }
540
- /**
541
- * Geocode query or coordinates using @happyvertical/geo
542
- *
543
- * @param query - Address query
544
- * @param coords - Optional coordinates for reverse geocoding
545
- * @param provider - Geo provider to use
546
- * @returns Array of Location results
547
- */
548
- async geocode(query, coords, provider = "openstreetmap") {
549
- const geo = await this.getGeoAdapter(provider);
550
- if (coords) {
551
- return await geo.reverseGeocode(coords.lat, coords.lng);
552
- }
553
- return await geo.lookup(query);
554
- }
555
- /**
556
- * Find a place by the provider's native id (Google place_id, OSM
557
- * osm-node-N, etc.). Used as the primary idempotency key by
558
- * `discoverNearby` so repeat POI searches don't create duplicate rows.
559
- */
560
- async findByExternalId(externalId) {
561
- if (!externalId) return null;
562
- const matches = await this.list({
563
- where: { externalId },
564
- limit: 1
565
- });
566
- return matches[0] ?? null;
567
- }
568
- /**
569
- * Idempotent "find or create" for a geo Location. Keyed purely on the
570
- * provider's externalId (Google place_id, osm-node-*, etc.), which is
571
- * stable per POI across repeat searches. The older `lookupOrCreate`
572
- * keeps its own fallback chain (name/address/coordinate matching) for
573
- * address-style workflows where externalId isn't reliable.
574
- *
575
- * Returns `{ place, created }` so callers can distinguish fresh rows
576
- * from reused ones without re-querying the table — this is how
577
- * `resolveTrackPlaces` derives its `cacheHitCount` without a full
578
- * scan per bucket.
579
- */
580
- async ensureFromLocation(location, typeSlug, parentId) {
581
- if (location.id) {
582
- const existing = await this.findByExternalId(location.id);
583
- if (existing) return { place: existing, created: false };
584
- }
585
- const place = await this.createFromLocation(location, typeSlug, parentId);
586
- return { place, created: true };
587
- }
588
- /**
589
- * Create place from @happyvertical/geo Location data
590
- *
591
- * @param location - Location from geocoding
592
- * @param typeSlug - Optional type slug override
593
- * @param parentId - Optional parent place ID
594
- * @returns Created Place instance
595
- */
596
- async createFromLocation(location, typeSlug, parentId) {
597
- const typeCollection = await PlaceTypeCollection.create(this.options);
598
- const slug = typeSlug || location.type || "address";
599
- const placeType = await typeCollection.getOrCreate(slug);
600
- const components = location.addressComponents || {};
601
- return await this.create({
602
- typeId: placeType.id ?? void 0,
603
- parentId: parentId ?? null,
604
- name: location.name,
605
- description: "",
606
- // Geo fields from location
607
- latitude: location.latitude,
608
- longitude: location.longitude,
609
- streetNumber: components.streetNumber || "",
610
- streetName: components.streetName || "",
611
- city: components.city || "",
612
- region: components.region || "",
613
- country: components.country || "",
614
- postalCode: components.postalCode || "",
615
- countryCode: location.countryCode || "",
616
- timezone: location.timezone || "",
617
- // Metadata
618
- externalId: location.id,
619
- source: location.raw?.provider || "unknown",
620
- metadata: JSON.stringify({ raw: location.raw ?? null })
621
- });
622
- }
623
- /**
624
- * Discover POIs near a coordinate and persist them as Place rows.
625
- *
626
- * Composes `@happyvertical/geo`'s `findPoisNear` with `ensureFromLocation`
627
- * so every returned POI is either reused from the local DB (when the
628
- * provider returns an id matching an existing Place `externalId`) or
629
- * created fresh otherwise. The cache is effectively automatic because
630
- * the provider's own place_id becomes the Place row's `externalId`, so
631
- * calling `discoverNearby` twice for the same area on the same provider
632
- * is a no-op after the first run when the provider returns stable ids.
633
- *
634
- * Requires a geo provider that implements `findPoisNear`. Throws a clear
635
- * error otherwise so consumers can fall back to `lookupOrCreate` or
636
- * switch providers.
637
- */
638
- async discoverNearby(latitude, longitude, radiusMeters, options = {}) {
639
- if (!(radiusMeters > 0)) {
640
- throw new Error(
641
- `discoverNearby: radiusMeters must be > 0 (got ${radiusMeters})`
642
- );
643
- }
644
- const detailed = await this.discoverNearbyDetailed(
645
- latitude,
646
- longitude,
647
- radiusMeters,
648
- options
649
- );
650
- return detailed.map((d) => d.place);
651
- }
652
- /**
653
- * Internal variant of `discoverNearby` that exposes whether each
654
- * returned Place was created during this call (`true`) or reused from
655
- * an earlier call (`false`). Used by `resolveTrackPlaces` to classify
656
- * buckets as cache hits without having to re-scan the Place table.
657
- */
658
- async discoverNearbyDetailed(latitude, longitude, radiusMeters, options = {}) {
659
- const {
660
- geoProvider = "openstreetmap",
661
- types,
662
- keyword,
663
- limit,
664
- language,
665
- typeSlug,
666
- parentId
667
- } = options;
668
- const geo = await this.getGeoAdapter(geoProvider);
669
- if (typeof geo.findPoisNear !== "function") {
670
- throw new Error(
671
- `Geo provider '${geoProvider}' does not implement findPoisNear`
672
- );
673
- }
674
- const results = await geo.findPoisNear(latitude, longitude, radiusMeters, {
675
- types,
676
- keyword,
677
- limit,
678
- language
679
- });
680
- const detailed = [];
681
- for (const result of results) {
682
- detailed.push(await this.ensureFromLocation(result, typeSlug, parentId));
683
- }
684
- return detailed;
685
- }
686
- /**
687
- * Resolve POIs along a GPS track (e.g. a video's per-frame path).
688
- *
689
- * Naively walking every point would hammer the provider with redundant
690
- * requests — consecutive samples are usually within a few meters. This
691
- * method buckets points into a `bucketMeters`-wide grid, calls
692
- * `discoverNearby` once per distinct bucket, and throttles requests per
693
- * `throttleMs` so free tiers (Overpass, Nominatim) stay inside their
694
- * community rate limits without the caller having to manage a queue.
695
- *
696
- * The returned `places` are deduped across buckets by Place id, so a
697
- * POI that falls within several overlapping search radii appears once.
698
- */
699
- async resolveTrackPlaces(points, options = {}) {
700
- const radiusMeters = options.radiusMeters ?? 50;
701
- const bucketMeters = options.bucketMeters ?? 50;
702
- const throttleMs = options.throttleMs ?? 1100;
703
- if (!(radiusMeters > 0)) {
704
- throw new Error(
705
- `resolveTrackPlaces: radiusMeters must be > 0 (got ${radiusMeters})`
706
- );
707
- }
708
- if (!(bucketMeters > 0)) {
709
- throw new Error(
710
- `resolveTrackPlaces: bucketMeters must be > 0 (got ${bucketMeters})`
711
- );
712
- }
713
- if (throttleMs < 0 || !Number.isFinite(throttleMs)) {
714
- throw new Error(
715
- `resolveTrackPlaces: throttleMs must be a finite non-negative number (got ${throttleMs})`
716
- );
717
- }
718
- const bucketMetersKm = bucketMeters / 1e3;
719
- const bucketCenters = [];
720
- for (const point of points) {
721
- if (!Number.isFinite(point.lat) || !Number.isFinite(point.lng)) continue;
722
- let bestIdx = -1;
723
- let bestKm = Number.POSITIVE_INFINITY;
724
- for (let i = 0; i < bucketCenters.length; i += 1) {
725
- const c = bucketCenters[i];
726
- const km = this.calculateDistance(c.lat, c.lng, point.lat, point.lng);
727
- if (km <= bucketMetersKm && km < bestKm) {
728
- bestKm = km;
729
- bestIdx = i;
730
- }
731
- }
732
- if (bestIdx < 0) bucketCenters.push(point);
733
- }
734
- const result = {
735
- places: [],
736
- requestCount: 0,
737
- cacheHitCount: 0,
738
- bucketCount: bucketCenters.length
739
- };
740
- const seen = /* @__PURE__ */ new Map();
741
- let lastCallAt = 0;
742
- for (const center of bucketCenters) {
743
- const wait = lastCallAt + throttleMs - Date.now();
744
- if (wait > 0) {
745
- await new Promise((resolve) => setTimeout(resolve, wait));
746
- }
747
- const detailed = await this.discoverNearbyDetailed(
748
- center.lat,
749
- center.lng,
750
- radiusMeters,
751
- options
752
- );
753
- lastCallAt = Date.now();
754
- result.requestCount += 1;
755
- if (detailed.length > 0 && detailed.every((d) => !d.created)) {
756
- result.cacheHitCount += 1;
757
- }
758
- for (const { place } of detailed) {
759
- if (place.id && !seen.has(place.id)) seen.set(place.id, place);
760
- }
761
- }
762
- result.places = [...seen.values()];
763
- return result;
764
- }
765
- /**
766
- * Provider wiring (env keys, user-agent string, etc.) that `geocode`
767
- * and `discoverNearby` share so the two code paths can't drift. Kept
768
- * separate from the adapter call itself so tests and future providers
769
- * can inspect or override the options before construction.
770
- */
771
- getGeoAdapterOptions(provider) {
772
- if (provider === "google") {
773
- return {
774
- provider: "google",
775
- apiKey: process.env.GOOGLE_MAPS_API_KEY || ""
776
- };
777
- }
778
- return { provider: "openstreetmap", userAgent: "@have/places" };
779
- }
780
- /**
781
- * Build a geo adapter configured for this call. Single source of truth
782
- * for provider wiring — `geocode` and `discoverNearby` both come through
783
- * here.
784
- */
785
- async getGeoAdapter(provider) {
786
- return getGeoAdapter(this.getGeoAdapterOptions(provider));
787
- }
788
- /**
789
- * Get immediate children of a parent place
790
- *
791
- * @param parentId - The parent place ID
792
- * @returns Array of child places
793
- */
794
- async getChildren(parentId) {
795
- return await this.list({
796
- where: { parentId }
797
- });
798
- }
799
- /**
800
- * Get root places (no parent)
801
- *
802
- * @returns Array of root places
803
- */
804
- async getRootPlaces() {
805
- return await this.list({
806
- where: { parentId: null }
807
- });
808
- }
809
- /**
810
- * Get places by type
811
- *
812
- * @param typeSlug - PlaceType slug
813
- * @returns Array of places of that type
814
- */
815
- async getByType(typeSlug) {
816
- const typeCollection = await PlaceTypeCollection.create(this.options);
817
- const placeType = await typeCollection.getBySlug(typeSlug);
818
- if (!placeType) return [];
819
- return await this.list({
820
- where: { typeId: placeType.id }
821
- });
822
- }
823
- /**
824
- * Get place hierarchy (all ancestors and descendants)
825
- *
826
- * @param placeId - The place ID
827
- * @returns Object with ancestors, current place, and descendants
828
- */
829
- async getHierarchy(placeId) {
830
- const place = await this.get({ id: placeId });
831
- if (!place) throw new Error(`Place '${placeId}' not found`);
832
- return await place.getHierarchy();
833
- }
834
- async getAssets(placeId, relationship) {
835
- return getOwnedAssetsFromCollection(this, placeId, relationship);
836
- }
837
- async addAsset(placeId, asset, relationship = "attachment", sortOrder = 0) {
838
- await addOwnedAssetFromCollection(
839
- this,
840
- "Place",
841
- placeId,
842
- asset,
843
- relationship,
844
- sortOrder
845
- );
846
- }
847
- async removeAsset(placeId, assetId, relationship) {
848
- await removeOwnedAssetFromCollection(
849
- this,
850
- "Place",
851
- placeId,
852
- assetId,
853
- relationship
854
- );
855
- }
856
- /**
857
- * Search places by proximity to coordinates
858
- *
859
- * @param latitude - Center latitude
860
- * @param longitude - Center longitude
861
- * @param radiusKm - Search radius in kilometers
862
- * @returns Array of places within radius, sorted by distance
863
- */
864
- async searchByProximity(latitude, longitude, radiusKm = 10) {
865
- const places = await this.list({
866
- where: {
867
- latitude: { $ne: null },
868
- longitude: { $ne: null }
869
- }
870
- });
871
- const placesWithDistance = places.map((place) => {
872
- if (place.latitude === null || place.longitude === null) return null;
873
- const distance = this.calculateDistance(
874
- latitude,
875
- longitude,
876
- place.latitude,
877
- place.longitude
878
- );
879
- return { place, distance };
880
- }).filter(
881
- (p) => p !== null && p.distance <= radiusKm
882
- ).sort((a, b) => a.distance - b.distance);
883
- return placesWithDistance.map((p) => p.place);
884
- }
885
- /**
886
- * Calculate distance between two coordinates using Haversine formula
887
- *
888
- * @param lat1 - First latitude
889
- * @param lng1 - First longitude
890
- * @param lat2 - Second latitude
891
- * @param lng2 - Second longitude
892
- * @returns Distance in kilometers
893
- */
894
- calculateDistance(lat1, lng1, lat2, lng2) {
895
- const R = 6371;
896
- const dLat = this.toRad(lat2 - lat1);
897
- const dLng = this.toRad(lng2 - lng1);
898
- const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
899
- const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
900
- return R * c;
901
- }
902
- /**
903
- * Convert degrees to radians
904
- */
905
- toRad(degrees) {
906
- return degrees * (Math.PI / 180);
907
- }
908
- // ─────────────────────────────────────────────────────────────────────────────
909
- // Tenant Helper Methods
910
- // ─────────────────────────────────────────────────────────────────────────────
911
- /**
912
- * Find all places belonging to a specific tenant
913
- *
914
- * @param tenantId - The tenant ID to filter by
915
- * @returns Array of places for the tenant
916
- */
917
- async findByTenant(tenantId2) {
918
- return this.list({ where: { tenantId: tenantId2 } });
919
- }
920
- /**
921
- * Find all global places (not associated with any tenant).
922
- *
923
- * Routes through the shared tenant-global helper so it does not throw under
924
- * an active tenant context (an explicit `tenant_id IS NULL` filter would be
925
- * flagged as an isolation violation). (#1600)
926
- *
927
- * @returns Array of global places
928
- */
929
- async findGlobal() {
930
- return queryGlobal(this);
931
- }
932
- /**
933
- * Find places for a tenant including global places.
934
- *
935
- * Fails closed if an active tenant context requests a different tenant's
936
- * rows; the admin/system path keeps the cross-tenant capability. (#1600)
937
- *
938
- * @param tenantId - The tenant ID to include
939
- * @returns Array of tenant-specific and global places
940
- */
941
- async findWithGlobals(tenantId2) {
942
- return queryWithGlobals(this, tenantId2, "Place.findWithGlobals");
943
- }
944
- }
945
- const PlaceCollection$1 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
946
- __proto__: null,
947
- PlaceCollection
948
- }, Symbol.toStringTag, { value: "Module" }));
949
- export {
950
- Place,
951
- PlaceAsset,
952
- PlaceAssetCollection,
953
- PlaceCollection,
954
- PlaceType,
955
- PlaceTypeCollection,
956
- areCoordinatesNear,
957
- calculateDistance,
958
- formatCoordinates,
959
- generateDisplayName,
960
- locationToGeoData,
961
- mapLocationTypeToPlaceType,
962
- normalizeAddressComponents,
963
- parseCoordinates,
964
- validateCoordinates
243
+ __decorateClass([tenantId({ nullable: true })], Place.prototype, "tenantId", 2);
244
+ __decorateClass([foreignKey("PlaceType")], Place.prototype, "typeId", 2);
245
+ __decorateClass([field({ type: "decimal" })], Place.prototype, "latitude", 2);
246
+ __decorateClass([field({ type: "decimal" })], Place.prototype, "longitude", 2);
247
+ Place = __decorateClass([TenantScoped({ mode: "optional" }), smrt({
248
+ tableStrategy: "sti",
249
+ api: { include: [
250
+ "list",
251
+ "get",
252
+ "create",
253
+ "update",
254
+ "delete"
255
+ ] },
256
+ mcp: { include: [
257
+ "list",
258
+ "get",
259
+ "create",
260
+ "update"
261
+ ] },
262
+ cli: true
263
+ })], Place);
264
+ //#endregion
265
+ //#region src/collections/PlaceCollection.ts
266
+ var PlaceCollection_exports = /* @__PURE__ */ __exportAll({ PlaceCollection: () => PlaceCollection });
267
+ var PlaceCollection = class extends SmrtCollection {
268
+ static _itemClass = Place;
269
+ /**
270
+ * Look up a place by query or coordinates, creating it if not found
271
+ *
272
+ * This is the key method for organic database growth:
273
+ * 1. Search local database first
274
+ * 2. If not found, query @happyvertical/geo
275
+ * 3. Create place from geocoding result
276
+ * 4. Return place
277
+ *
278
+ * @param query - Address or location query string
279
+ * @param options - Lookup options (provider, type, parent, etc.)
280
+ * @returns Place instance
281
+ */
282
+ async lookupOrCreate(query, options = {}) {
283
+ const { geoProvider = "openstreetmap", typeSlug, parentId, createIfNotFound = true, coords } = options;
284
+ let existingPlace = null;
285
+ if (coords) existingPlace = await this.findByCoordinates(coords.lat, coords.lng);
286
+ if (!existingPlace) existingPlace = await this.findByQuery(query);
287
+ if (existingPlace) return existingPlace;
288
+ if (!createIfNotFound) return null;
289
+ const locations = await this.geocode(query, coords, geoProvider);
290
+ if (locations.length === 0) return null;
291
+ const location = locations[0];
292
+ return await this.createFromLocation(location, typeSlug, parentId);
293
+ }
294
+ /**
295
+ * Find place by coordinates (within small threshold)
296
+ *
297
+ * @param latitude - Latitude to search
298
+ * @param longitude - Longitude to search
299
+ * @param threshold - Max distance in degrees (default: 0.0001 ~11m)
300
+ * @returns Place instance or null
301
+ */
302
+ async findByCoordinates(latitude, longitude, threshold = 1e-4) {
303
+ const places = await this.list({ where: {
304
+ latitude: { $ne: null },
305
+ longitude: { $ne: null }
306
+ } });
307
+ for (const place of places) {
308
+ const placeObj = place;
309
+ if (placeObj.latitude === null || placeObj.longitude === null) continue;
310
+ const latDiff = Math.abs(placeObj.latitude - latitude);
311
+ const lngDiff = Math.abs(placeObj.longitude - longitude);
312
+ if (latDiff < threshold && lngDiff < threshold) return place;
313
+ }
314
+ return null;
315
+ }
316
+ /**
317
+ * Find place by query text (matches name, city, region, country)
318
+ *
319
+ * @param query - Search query
320
+ * @returns Place instance or null
321
+ */
322
+ async findByQuery(query) {
323
+ const normalizedQuery = query.toLowerCase().trim();
324
+ const places = await this.list({});
325
+ for (const place of places) {
326
+ if (place.name.toLowerCase().includes(normalizedQuery)) return place;
327
+ if ([
328
+ place.streetNumber,
329
+ place.streetName,
330
+ place.city,
331
+ place.region,
332
+ place.country
333
+ ].filter((p) => p).join(" ").toLowerCase().includes(normalizedQuery)) return place;
334
+ }
335
+ return null;
336
+ }
337
+ /**
338
+ * Geocode query or coordinates using @happyvertical/geo
339
+ *
340
+ * @param query - Address query
341
+ * @param coords - Optional coordinates for reverse geocoding
342
+ * @param provider - Geo provider to use
343
+ * @returns Array of Location results
344
+ */
345
+ async geocode(query, coords, provider = "openstreetmap") {
346
+ const geo = await this.getGeoAdapter(provider);
347
+ if (coords) return await geo.reverseGeocode(coords.lat, coords.lng);
348
+ return await geo.lookup(query);
349
+ }
350
+ /**
351
+ * Find a place by the provider's native id (Google place_id, OSM
352
+ * osm-node-N, etc.). Used as the primary idempotency key by
353
+ * `discoverNearby` so repeat POI searches don't create duplicate rows.
354
+ */
355
+ async findByExternalId(externalId) {
356
+ if (!externalId) return null;
357
+ return (await this.list({
358
+ where: { externalId },
359
+ limit: 1
360
+ }))[0] ?? null;
361
+ }
362
+ /**
363
+ * Idempotent "find or create" for a geo Location. Keyed purely on the
364
+ * provider's externalId (Google place_id, osm-node-*, etc.), which is
365
+ * stable per POI across repeat searches. The older `lookupOrCreate`
366
+ * keeps its own fallback chain (name/address/coordinate matching) for
367
+ * address-style workflows where externalId isn't reliable.
368
+ *
369
+ * Returns `{ place, created }` so callers can distinguish fresh rows
370
+ * from reused ones without re-querying the table — this is how
371
+ * `resolveTrackPlaces` derives its `cacheHitCount` without a full
372
+ * scan per bucket.
373
+ */
374
+ async ensureFromLocation(location, typeSlug, parentId) {
375
+ if (location.id) {
376
+ const existing = await this.findByExternalId(location.id);
377
+ if (existing) return {
378
+ place: existing,
379
+ created: false
380
+ };
381
+ }
382
+ return {
383
+ place: await this.createFromLocation(location, typeSlug, parentId),
384
+ created: true
385
+ };
386
+ }
387
+ /**
388
+ * Create place from @happyvertical/geo Location data
389
+ *
390
+ * @param location - Location from geocoding
391
+ * @param typeSlug - Optional type slug override
392
+ * @param parentId - Optional parent place ID
393
+ * @returns Created Place instance
394
+ */
395
+ async createFromLocation(location, typeSlug, parentId) {
396
+ const typeCollection = await PlaceTypeCollection.create(this.options);
397
+ const slug = typeSlug || location.type || "address";
398
+ const placeType = await typeCollection.getOrCreate(slug);
399
+ const components = location.addressComponents || {};
400
+ return await this.create({
401
+ typeId: placeType.id ?? void 0,
402
+ parentId: parentId ?? null,
403
+ name: location.name,
404
+ description: "",
405
+ latitude: location.latitude,
406
+ longitude: location.longitude,
407
+ streetNumber: components.streetNumber || "",
408
+ streetName: components.streetName || "",
409
+ city: components.city || "",
410
+ region: components.region || "",
411
+ country: components.country || "",
412
+ postalCode: components.postalCode || "",
413
+ countryCode: location.countryCode || "",
414
+ timezone: location.timezone || "",
415
+ externalId: location.id,
416
+ source: location.raw?.provider || "unknown",
417
+ metadata: JSON.stringify({ raw: location.raw ?? null })
418
+ });
419
+ }
420
+ /**
421
+ * Discover POIs near a coordinate and persist them as Place rows.
422
+ *
423
+ * Composes `@happyvertical/geo`'s `findPoisNear` with `ensureFromLocation`
424
+ * so every returned POI is either reused from the local DB (when the
425
+ * provider returns an id matching an existing Place `externalId`) or
426
+ * created fresh otherwise. The cache is effectively automatic because
427
+ * the provider's own place_id becomes the Place row's `externalId`, so
428
+ * calling `discoverNearby` twice for the same area on the same provider
429
+ * is a no-op after the first run when the provider returns stable ids.
430
+ *
431
+ * Requires a geo provider that implements `findPoisNear`. Throws a clear
432
+ * error otherwise so consumers can fall back to `lookupOrCreate` or
433
+ * switch providers.
434
+ */
435
+ async discoverNearby(latitude, longitude, radiusMeters, options = {}) {
436
+ if (!(radiusMeters > 0)) throw new Error(`discoverNearby: radiusMeters must be > 0 (got ${radiusMeters})`);
437
+ return (await this.discoverNearbyDetailed(latitude, longitude, radiusMeters, options)).map((d) => d.place);
438
+ }
439
+ /**
440
+ * Internal variant of `discoverNearby` that exposes whether each
441
+ * returned Place was created during this call (`true`) or reused from
442
+ * an earlier call (`false`). Used by `resolveTrackPlaces` to classify
443
+ * buckets as cache hits without having to re-scan the Place table.
444
+ */
445
+ async discoverNearbyDetailed(latitude, longitude, radiusMeters, options = {}) {
446
+ const { geoProvider = "openstreetmap", types, keyword, limit, language, typeSlug, parentId } = options;
447
+ const geo = await this.getGeoAdapter(geoProvider);
448
+ if (typeof geo.findPoisNear !== "function") throw new Error(`Geo provider '${geoProvider}' does not implement findPoisNear`);
449
+ const results = await geo.findPoisNear(latitude, longitude, radiusMeters, {
450
+ types,
451
+ keyword,
452
+ limit,
453
+ language
454
+ });
455
+ const detailed = [];
456
+ for (const result of results) detailed.push(await this.ensureFromLocation(result, typeSlug, parentId));
457
+ return detailed;
458
+ }
459
+ /**
460
+ * Resolve POIs along a GPS track (e.g. a video's per-frame path).
461
+ *
462
+ * Naively walking every point would hammer the provider with redundant
463
+ * requests — consecutive samples are usually within a few meters. This
464
+ * method buckets points into a `bucketMeters`-wide grid, calls
465
+ * `discoverNearby` once per distinct bucket, and throttles requests per
466
+ * `throttleMs` so free tiers (Overpass, Nominatim) stay inside their
467
+ * community rate limits without the caller having to manage a queue.
468
+ *
469
+ * The returned `places` are deduped across buckets by Place id, so a
470
+ * POI that falls within several overlapping search radii appears once.
471
+ */
472
+ async resolveTrackPlaces(points, options = {}) {
473
+ const radiusMeters = options.radiusMeters ?? 50;
474
+ const bucketMeters = options.bucketMeters ?? 50;
475
+ const throttleMs = options.throttleMs ?? 1100;
476
+ if (!(radiusMeters > 0)) throw new Error(`resolveTrackPlaces: radiusMeters must be > 0 (got ${radiusMeters})`);
477
+ if (!(bucketMeters > 0)) throw new Error(`resolveTrackPlaces: bucketMeters must be > 0 (got ${bucketMeters})`);
478
+ if (throttleMs < 0 || !Number.isFinite(throttleMs)) throw new Error(`resolveTrackPlaces: throttleMs must be a finite non-negative number (got ${throttleMs})`);
479
+ const bucketMetersKm = bucketMeters / 1e3;
480
+ const bucketCenters = [];
481
+ for (const point of points) {
482
+ if (!Number.isFinite(point.lat) || !Number.isFinite(point.lng)) continue;
483
+ let bestIdx = -1;
484
+ let bestKm = Number.POSITIVE_INFINITY;
485
+ for (let i = 0; i < bucketCenters.length; i += 1) {
486
+ const c = bucketCenters[i];
487
+ const km = this.calculateDistance(c.lat, c.lng, point.lat, point.lng);
488
+ if (km <= bucketMetersKm && km < bestKm) {
489
+ bestKm = km;
490
+ bestIdx = i;
491
+ }
492
+ }
493
+ if (bestIdx < 0) bucketCenters.push(point);
494
+ }
495
+ const result = {
496
+ places: [],
497
+ requestCount: 0,
498
+ cacheHitCount: 0,
499
+ bucketCount: bucketCenters.length
500
+ };
501
+ const seen = /* @__PURE__ */ new Map();
502
+ let lastCallAt = 0;
503
+ for (const center of bucketCenters) {
504
+ const wait = lastCallAt + throttleMs - Date.now();
505
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
506
+ const detailed = await this.discoverNearbyDetailed(center.lat, center.lng, radiusMeters, options);
507
+ lastCallAt = Date.now();
508
+ result.requestCount += 1;
509
+ if (detailed.length > 0 && detailed.every((d) => !d.created)) result.cacheHitCount += 1;
510
+ for (const { place } of detailed) if (place.id && !seen.has(place.id)) seen.set(place.id, place);
511
+ }
512
+ result.places = [...seen.values()];
513
+ return result;
514
+ }
515
+ /**
516
+ * Provider wiring (env keys, user-agent string, etc.) that `geocode`
517
+ * and `discoverNearby` share so the two code paths can't drift. Kept
518
+ * separate from the adapter call itself so tests and future providers
519
+ * can inspect or override the options before construction.
520
+ */
521
+ getGeoAdapterOptions(provider) {
522
+ if (provider === "google") return {
523
+ provider: "google",
524
+ apiKey: process.env.GOOGLE_MAPS_API_KEY || ""
525
+ };
526
+ return {
527
+ provider: "openstreetmap",
528
+ userAgent: "@have/places"
529
+ };
530
+ }
531
+ /**
532
+ * Build a geo adapter configured for this call. Single source of truth
533
+ * for provider wiring `geocode` and `discoverNearby` both come through
534
+ * here.
535
+ */
536
+ async getGeoAdapter(provider) {
537
+ return getGeoAdapter(this.getGeoAdapterOptions(provider));
538
+ }
539
+ /**
540
+ * Get immediate children of a parent place
541
+ *
542
+ * @param parentId - The parent place ID
543
+ * @returns Array of child places
544
+ */
545
+ async getChildren(parentId) {
546
+ return await this.list({ where: { parentId } });
547
+ }
548
+ /**
549
+ * Get root places (no parent)
550
+ *
551
+ * @returns Array of root places
552
+ */
553
+ async getRootPlaces() {
554
+ return await this.list({ where: { parentId: null } });
555
+ }
556
+ /**
557
+ * Get places by type
558
+ *
559
+ * @param typeSlug - PlaceType slug
560
+ * @returns Array of places of that type
561
+ */
562
+ async getByType(typeSlug) {
563
+ const placeType = await (await PlaceTypeCollection.create(this.options)).getBySlug(typeSlug);
564
+ if (!placeType) return [];
565
+ return await this.list({ where: { typeId: placeType.id } });
566
+ }
567
+ /**
568
+ * Get place hierarchy (all ancestors and descendants)
569
+ *
570
+ * @param placeId - The place ID
571
+ * @returns Object with ancestors, current place, and descendants
572
+ */
573
+ async getHierarchy(placeId) {
574
+ const place = await this.get({ id: placeId });
575
+ if (!place) throw new Error(`Place '${placeId}' not found`);
576
+ return await place.getHierarchy();
577
+ }
578
+ async getAssets(placeId, relationship) {
579
+ return getOwnedAssetsFromCollection(this, placeId, relationship);
580
+ }
581
+ async addAsset(placeId, asset, relationship = "attachment", sortOrder = 0) {
582
+ await addOwnedAssetFromCollection(this, "Place", placeId, asset, relationship, sortOrder);
583
+ }
584
+ async removeAsset(placeId, assetId, relationship) {
585
+ await removeOwnedAssetFromCollection(this, "Place", placeId, assetId, relationship);
586
+ }
587
+ /**
588
+ * Search places by proximity to coordinates
589
+ *
590
+ * @param latitude - Center latitude
591
+ * @param longitude - Center longitude
592
+ * @param radiusKm - Search radius in kilometers
593
+ * @returns Array of places within radius, sorted by distance
594
+ */
595
+ async searchByProximity(latitude, longitude, radiusKm = 10) {
596
+ return (await this.list({ where: {
597
+ latitude: { $ne: null },
598
+ longitude: { $ne: null }
599
+ } })).map((place) => {
600
+ if (place.latitude === null || place.longitude === null) return null;
601
+ return {
602
+ place,
603
+ distance: this.calculateDistance(latitude, longitude, place.latitude, place.longitude)
604
+ };
605
+ }).filter((p) => p !== null && p.distance <= radiusKm).sort((a, b) => a.distance - b.distance).map((p) => p.place);
606
+ }
607
+ /**
608
+ * Calculate distance between two coordinates using Haversine formula
609
+ *
610
+ * @param lat1 - First latitude
611
+ * @param lng1 - First longitude
612
+ * @param lat2 - Second latitude
613
+ * @param lng2 - Second longitude
614
+ * @returns Distance in kilometers
615
+ */
616
+ calculateDistance(lat1, lng1, lat2, lng2) {
617
+ const R = 6371;
618
+ const dLat = this.toRad(lat2 - lat1);
619
+ const dLng = this.toRad(lng2 - lng1);
620
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
621
+ return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
622
+ }
623
+ /**
624
+ * Convert degrees to radians
625
+ */
626
+ toRad(degrees) {
627
+ return degrees * (Math.PI / 180);
628
+ }
629
+ /**
630
+ * Find all places belonging to a specific tenant
631
+ *
632
+ * @param tenantId - The tenant ID to filter by
633
+ * @returns Array of places for the tenant
634
+ */
635
+ async findByTenant(tenantId) {
636
+ return this.list({ where: { tenantId } });
637
+ }
638
+ /**
639
+ * Find all global places (not associated with any tenant).
640
+ *
641
+ * Routes through the shared tenant-global helper so it does not throw under
642
+ * an active tenant context (an explicit `tenant_id IS NULL` filter would be
643
+ * flagged as an isolation violation). (#1600)
644
+ *
645
+ * @returns Array of global places
646
+ */
647
+ async findGlobal() {
648
+ return queryGlobal(this);
649
+ }
650
+ /**
651
+ * Find places for a tenant including global places.
652
+ *
653
+ * Fails closed if an active tenant context requests a different tenant's
654
+ * rows; the admin/system path keeps the cross-tenant capability. (#1600)
655
+ *
656
+ * @param tenantId - The tenant ID to include
657
+ * @returns Array of tenant-specific and global places
658
+ */
659
+ async findWithGlobals(tenantId) {
660
+ return queryWithGlobals(this, tenantId, "Place.findWithGlobals");
661
+ }
965
662
  };
966
- //# sourceMappingURL=index.js.map
663
+ //#endregion
664
+ export { Place, PlaceAsset, PlaceAssetCollection, PlaceCollection, PlaceType, PlaceTypeCollection, areCoordinatesNear, calculateDistance, formatCoordinates, generateDisplayName, locationToGeoData, mapLocationTypeToPlaceType, normalizeAddressComponents, parseCoordinates, validateCoordinates };
665
+
666
+ //# sourceMappingURL=index.js.map