diff --git a/backend/src/db/api/places.js b/backend/src/db/api/places.js index 2592e3f..61cff8c 100644 --- a/backend/src/db/api/places.js +++ b/backend/src/db/api/places.js @@ -1,7 +1,6 @@ const db = require('../models'); const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -9,6 +8,88 @@ const Utils = require('../utils'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; + +const hasOwn = (data, key) => Object.prototype.hasOwnProperty.call(data || {}, key); + +const firstProvided = (data, keys) => { + for (const key of keys) { + if (hasOwn(data, key) && data[key] !== undefined) { + return data[key]; + } + } + return undefined; +}; + +const buildPlacesPayload = (data = {}, includeDefaults = false) => { + const payload = {}; + const set = (field, keys, defaultValue) => { + const value = firstProvided(data, keys); + if (value !== undefined) { + payload[field] = value; + } else if (includeDefaults) { + payload[field] = defaultValue; + } + }; + + set('name', ['name', 'nama'], null); + set('nama', ['nama', 'name'], null); + set('kategori', ['kategori'], null); + set('subkategori', ['subkategori'], null); + set('short_description', ['short_description'], null); + set('full_description', ['full_description'], null); + set('address', ['address', 'alamat'], null); + set('alamat', ['alamat', 'address'], null); + set('kelurahan', ['kelurahan'], null); + set('kecamatan', ['kecamatan'], null); + set('city', ['city', 'kota'], null); + set('kota', ['kota', 'city'], null); + set('province', ['province', 'provinsi'], null); + set('provinsi', ['provinsi', 'province'], null); + set('postal_code', ['postal_code', 'kode_pos'], null); + set('kode_pos', ['kode_pos', 'postal_code'], null); + set('latitude', ['latitude'], null); + set('longitude', ['longitude'], null); + set('phone_number', ['phone_number', 'telepon'], null); + set('telepon', ['telepon', 'phone_number'], null); + set('whatsapp_number', ['whatsapp_number', 'whatsapp'], null); + set('whatsapp', ['whatsapp', 'whatsapp_number'], null); + set('email', ['email'], null); + set('website_url', ['website_url', 'website'], null); + set('website', ['website', 'website_url'], null); + set('google_maps_url', ['google_maps_url'], null); + set('jam_buka', ['jam_buka'], null); + set('price_level', ['price_level'], null); + set('average_price', ['average_price'], null); + set('rating_average', ['rating_average', 'rating'], null); + set('rating', ['rating', 'rating_average'], 0); + set('rating_count', ['rating_count', 'review_count'], null); + set('review_count', ['review_count', 'rating_count'], 0); + set('status', ['status'], 'aktif'); + set('is_verified', ['is_verified', 'verified'], false); + set('verified', ['verified', 'is_verified'], false); + set('featured', ['featured'], false); + set('popularitas', ['popularitas'], 0); + set('created_at', ['created_at'], undefined); + set('updated_at', ['updated_at'], undefined); + set('sumber', ['sumber', 'source'], null); + set('external_id', ['external_id', 'externalId'], null); + set('external_type', ['external_type', 'externalType'], null); + set('raw_source_data', ['raw_source_data', 'rawSourceData'], null); + set('last_synced_at', ['last_synced_at', 'lastSyncedAt'], undefined); + + if (!includeDefaults) { + return payload; + } + + Object.keys(payload).forEach((field) => { + if (payload[field] === undefined) { + delete payload[field]; + } + }); + + return payload; +}; + module.exports = class PlacesDBApi { @@ -122,6 +203,7 @@ module.exports = class PlacesDBApi { , + ...buildPlacesPayload(data, true), importHash: data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -266,6 +348,7 @@ module.exports = class PlacesDBApi { , + ...buildPlacesPayload(item, true), importHash: item.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, @@ -365,6 +448,8 @@ module.exports = class PlacesDBApi { if (data.is_verified !== undefined) updatePayload.is_verified = data.is_verified; + Object.assign(updatePayload, buildPlacesPayload(data, false)); + updatePayload.updated_at = new Date(); updatePayload.updatedById = currentUser.id; await places.update(updatePayload, {transaction}); @@ -538,9 +623,6 @@ module.exports = class PlacesDBApi { offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; let include = [ @@ -557,7 +639,8 @@ module.exports = class PlacesDBApi { } }, ] - } : {}, + } : undefined, + required: Boolean(filter.category), }, @@ -574,7 +657,8 @@ module.exports = class PlacesDBApi { } }, ] - } : {}, + } : undefined, + required: Boolean(filter.owner), }, @@ -728,6 +812,35 @@ module.exports = class PlacesDBApi { }; } + const indonesianTextFilters = [ + 'nama', + 'kategori', + 'subkategori', + 'alamat', + 'kelurahan', + 'kecamatan', + 'kota', + 'provinsi', + 'kode_pos', + 'telepon', + 'whatsapp', + 'website', + 'jam_buka', + ]; + + for (const field of indonesianTextFilters) { + if (filter[field]) { + where = { + ...where, + [Op.and]: Utils.ilike( + 'places', + field, + filter[field], + ), + }; + } + } + @@ -853,6 +966,38 @@ module.exports = class PlacesDBApi { } } + const applyRangeFilter = (field, range) => { + if (!range) { + return; + } + + const [start, end] = range; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + [field]: { + ...where[field], + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + [field]: { + ...where[field], + [Op.lte]: end, + }, + }; + } + }; + + applyRangeFilter('rating', filter.ratingRange); + applyRangeFilter('review_count', filter.review_countRange); + applyRangeFilter('popularitas', filter.popularitasRange); + if (filter.active !== undefined) { where = { @@ -883,6 +1028,20 @@ module.exports = class PlacesDBApi { }; } + if (filter.verified !== undefined) { + where = { + ...where, + verified: filter.verified === true || filter.verified === 'true', + }; + } + + if (filter.featured !== undefined) { + where = { + ...where, + featured: filter.featured === true || filter.featured === 'true', + }; + } + @@ -962,12 +1121,17 @@ module.exports = class PlacesDBApi { 'name', query, ), + Utils.ilike( + 'places', + 'nama', + query, + ), ], }; } const records = await db.places.findAll({ - attributes: [ 'id', 'name' ], + attributes: [ 'id', 'name', 'nama' ], where, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, @@ -976,7 +1140,7 @@ module.exports = class PlacesDBApi { return records.map((record) => ({ id: record.id, - label: record.name, + label: record.name || record.nama, })); } diff --git a/backend/src/db/migrations/20260618161000-extend-places-indonesian-schema.js b/backend/src/db/migrations/20260618161000-extend-places-indonesian-schema.js new file mode 100644 index 0000000..a048a5d --- /dev/null +++ b/backend/src/db/migrations/20260618161000-extend-places-indonesian-schema.js @@ -0,0 +1,226 @@ +'use strict'; + +const isMissingTableError = (err) => { + const message = String(err && err.message); + return message.includes('No description found') + || message.includes('does not exist') + || message.includes('Cannot read properties of undefined'); +}; + +const requestedColumns = (Sequelize) => ({ + nama: { + type: Sequelize.DataTypes.STRING(255), + }, + kategori: { + type: Sequelize.DataTypes.STRING(100), + }, + subkategori: { + type: Sequelize.DataTypes.STRING(100), + }, + alamat: { + type: Sequelize.DataTypes.TEXT, + }, + kelurahan: { + type: Sequelize.DataTypes.STRING(100), + }, + kecamatan: { + type: Sequelize.DataTypes.STRING(100), + }, + kota: { + type: Sequelize.DataTypes.STRING(100), + }, + provinsi: { + type: Sequelize.DataTypes.STRING(100), + }, + kode_pos: { + type: Sequelize.DataTypes.STRING(20), + }, + telepon: { + type: Sequelize.DataTypes.STRING(100), + }, + whatsapp: { + type: Sequelize.DataTypes.STRING(100), + }, + website: { + type: Sequelize.DataTypes.STRING(255), + }, + jam_buka: { + type: Sequelize.DataTypes.STRING(255), + }, + rating: { + type: Sequelize.DataTypes.DECIMAL(2, 1), + defaultValue: 0, + }, + review_count: { + type: Sequelize.DataTypes.INTEGER, + defaultValue: 0, + }, + verified: { + type: Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + }, + featured: { + type: Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + }, + popularitas: { + type: Sequelize.DataTypes.INTEGER, + defaultValue: 0, + }, + created_at: { + type: Sequelize.DataTypes.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, + updated_at: { + type: Sequelize.DataTypes.DATE, + defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'), + }, +}); + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + let table; + try { + table = await queryInterface.describeTable('places'); + } catch (error) { + if (!isMissingTableError(error)) { + throw error; + } + await transaction.commit(); + return; + } + + const columns = requestedColumns(Sequelize); + for (const [columnName, definition] of Object.entries(columns)) { + if (!table[columnName]) { + await queryInterface.addColumn('places', columnName, definition, { transaction }); + } + } + + if (table.latitude) { + await queryInterface.sequelize.query( + 'ALTER TABLE "places" ALTER COLUMN "latitude" TYPE DOUBLE PRECISION USING "latitude"::double precision;', + { transaction }, + ); + } + + if (table.longitude) { + await queryInterface.sequelize.query( + 'ALTER TABLE "places" ALTER COLUMN "longitude" TYPE DOUBLE PRECISION USING "longitude"::double precision;', + { transaction }, + ); + } + + if (table.status) { + await queryInterface.sequelize.query( + `ALTER TABLE "places" + ALTER COLUMN "status" DROP DEFAULT, + ALTER COLUMN "status" TYPE VARCHAR(50) USING "status"::text, + ALTER COLUMN "status" SET DEFAULT 'aktif';`, + { transaction }, + ); + } else { + await queryInterface.addColumn( + 'places', + 'status', + { + type: Sequelize.DataTypes.STRING(50), + defaultValue: 'aktif', + }, + { transaction }, + ); + } + + await queryInterface.sequelize.query( + `UPDATE "places" + SET + nama = COALESCE(nama, name), + alamat = COALESCE(alamat, address), + kota = COALESCE(kota, city), + provinsi = COALESCE(provinsi, province), + kode_pos = COALESCE(kode_pos, postal_code), + telepon = COALESCE(telepon, phone_number), + whatsapp = COALESCE(whatsapp, whatsapp_number), + website = COALESCE(website, website_url), + rating = COALESCE(rating, ROUND(LEAST(GREATEST(COALESCE(rating_average, 0), 0), 9.9)::numeric, 1), 0), + review_count = COALESCE(review_count, rating_count, 0), + verified = COALESCE(verified, is_verified, false), + updated_at = COALESCE(updated_at, "updatedAt", NOW()), + created_at = COALESCE(created_at, "createdAt", NOW()) + WHERE "deletedAt" IS NULL;`, + { transaction }, + ); + + const indexes = await queryInterface.showIndex('places'); + const hasIndex = (name) => indexes.some((index) => index.name === name); + + if (!hasIndex('places_kota_kategori_idx')) { + await queryInterface.addIndex('places', ['kota', 'kategori'], { + name: 'places_kota_kategori_idx', + transaction, + }); + } + + if (!hasIndex('places_status_featured_idx')) { + await queryInterface.addIndex('places', ['status', 'featured'], { + name: 'places_status_featured_idx', + transaction, + }); + } + + if (!hasIndex('places_popularitas_idx')) { + await queryInterface.addIndex('places', ['popularitas'], { + name: 'places_popularitas_idx', + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + let table; + try { + table = await queryInterface.describeTable('places'); + } catch (error) { + if (!isMissingTableError(error)) { + throw error; + } + await transaction.commit(); + return; + } + + const indexes = await queryInterface.showIndex('places'); + const removeIndexIfExists = async (name) => { + if (indexes.some((index) => index.name === name)) { + await queryInterface.removeIndex('places', name, { transaction }); + } + }; + + await removeIndexIfExists('places_popularitas_idx'); + await removeIndexIfExists('places_status_featured_idx'); + await removeIndexIfExists('places_kota_kategori_idx'); + + for (const columnName of Object.keys(requestedColumns(Sequelize)).reverse()) { + if (table[columnName]) { + await queryInterface.removeColumn('places', columnName, { transaction }); + } + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260619120000-add-geoseek-collector-metadata-to-places.js b/backend/src/db/migrations/20260619120000-add-geoseek-collector-metadata-to-places.js new file mode 100644 index 0000000..5265ac6 --- /dev/null +++ b/backend/src/db/migrations/20260619120000-add-geoseek-collector-metadata-to-places.js @@ -0,0 +1,116 @@ +'use strict'; + +const isMissingTableError = (err) => { + const message = String(err && err.message); + return message.includes('No description found') + || message.includes('does not exist') + || message.includes('Cannot read properties of undefined'); +}; + +const metadataColumns = (Sequelize) => ({ + sumber: { + type: Sequelize.DataTypes.STRING(100), + }, + external_id: { + type: Sequelize.DataTypes.STRING(255), + }, + external_type: { + type: Sequelize.DataTypes.STRING(50), + }, + raw_source_data: { + type: Sequelize.DataTypes.JSONB, + }, + last_synced_at: { + type: Sequelize.DataTypes.DATE, + }, +}); + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + let table; + try { + table = await queryInterface.describeTable('places'); + } catch (error) { + if (!isMissingTableError(error)) { + throw error; + } + await transaction.commit(); + return; + } + + const columns = metadataColumns(Sequelize); + for (const [columnName, definition] of Object.entries(columns)) { + if (!table[columnName]) { + await queryInterface.addColumn('places', columnName, definition, { transaction }); + } + } + + const indexes = await queryInterface.showIndex('places'); + const hasIndex = (name) => indexes.some((index) => index.name === name); + + if (!hasIndex('places_source_external_unique_idx')) { + await queryInterface.addIndex('places', ['sumber', 'external_type', 'external_id'], { + name: 'places_source_external_unique_idx', + unique: true, + where: { + deletedAt: null, + }, + transaction, + }); + } + + if (!hasIndex('places_sumber_idx')) { + await queryInterface.addIndex('places', ['sumber'], { + name: 'places_sumber_idx', + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + let table; + try { + table = await queryInterface.describeTable('places'); + } catch (error) { + if (!isMissingTableError(error)) { + throw error; + } + await transaction.commit(); + return; + } + + const indexes = await queryInterface.showIndex('places'); + const removeIndexIfExists = async (name) => { + if (indexes.some((index) => index.name === name)) { + await queryInterface.removeIndex('places', name, { transaction }); + } + }; + + await removeIndexIfExists('places_sumber_idx'); + await removeIndexIfExists('places_source_external_unique_idx'); + + for (const columnName of Object.keys(metadataColumns(Sequelize)).reverse()) { + if (table[columnName]) { + await queryInterface.removeColumn('places', columnName, { transaction }); + } + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/places.js b/backend/src/db/models/places.js index 2775f34..9fd290e 100644 --- a/backend/src/db/models/places.js +++ b/backend/src/db/models/places.js @@ -14,6 +14,27 @@ name: { + }, + +nama: { + type: DataTypes.STRING(255), + + + + }, + +kategori: { + type: DataTypes.STRING(100), + + + + }, + +subkategori: { + type: DataTypes.STRING(100), + + + }, short_description: { @@ -35,6 +56,27 @@ address: { + }, + +alamat: { + type: DataTypes.TEXT, + + + + }, + +kelurahan: { + type: DataTypes.STRING(100), + + + + }, + +kecamatan: { + type: DataTypes.STRING(100), + + + }, city: { @@ -42,6 +84,13 @@ city: { + }, + +kota: { + type: DataTypes.STRING(100), + + + }, province: { @@ -49,6 +98,13 @@ province: { + }, + +provinsi: { + type: DataTypes.STRING(100), + + + }, postal_code: { @@ -56,17 +112,24 @@ postal_code: { + }, + +kode_pos: { + type: DataTypes.STRING(20), + + + }, latitude: { - type: DataTypes.DECIMAL, + type: DataTypes.DOUBLE, }, longitude: { - type: DataTypes.DECIMAL, + type: DataTypes.DOUBLE, @@ -77,6 +140,13 @@ phone_number: { + }, + +telepon: { + type: DataTypes.STRING(100), + + + }, whatsapp_number: { @@ -84,6 +154,13 @@ whatsapp_number: { + }, + +whatsapp: { + type: DataTypes.STRING(100), + + + }, email: { @@ -98,6 +175,13 @@ website_url: { + }, + +website: { + type: DataTypes.STRING(255), + + + }, google_maps_url: { @@ -105,6 +189,13 @@ google_maps_url: { + }, + +jam_buka: { + type: DataTypes.STRING(255), + + + }, price_level: { @@ -141,6 +232,14 @@ rating_average: { + }, + +rating: { + type: DataTypes.DECIMAL(2, 1), + defaultValue: 0, + + + }, rating_count: { @@ -148,25 +247,22 @@ rating_count: { + }, + +review_count: { + type: DataTypes.INTEGER, + defaultValue: 0, + + + }, status: { - type: DataTypes.ENUM, + type: DataTypes.STRING(50), + defaultValue: 'aktif', - values: [ - -"draft", - - -"published", - - -"archived" - - ], - }, is_verified: { @@ -177,6 +273,81 @@ is_verified: { + }, + +verified: { + type: DataTypes.BOOLEAN, + defaultValue: false, + + + + }, + +featured: { + type: DataTypes.BOOLEAN, + defaultValue: false, + + + + }, + +popularitas: { + type: DataTypes.INTEGER, + defaultValue: 0, + + + + }, + +created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + + + + }, + +updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + + + + }, + +sumber: { + type: DataTypes.STRING(100), + + + + }, + +external_id: { + type: DataTypes.STRING(255), + + + + }, + +external_type: { + type: DataTypes.STRING(50), + + + + }, + +raw_source_data: { + type: DataTypes.JSONB, + + + + }, + +last_synced_at: { + type: DataTypes.DATE, + + + }, importHash: { diff --git a/backend/src/index.js b/backend/src/index.js index 05ad21e..7e05176 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -16,6 +16,7 @@ const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); const pexelsRoutes = require('./routes/pexels'); const publicPlacesRoutes = require('./routes/publicPlaces'); +const geoseekCollectorRoutes = require('./routes/geoseekCollector'); const openaiRoutes = require('./routes/openai'); @@ -114,6 +115,8 @@ app.use('/api/place_categories', passport.authenticate('jwt', {session: false}), app.use('/api/places', passport.authenticate('jwt', {session: false}), placesRoutes); +app.use('/api/geoseek-collector', passport.authenticate('jwt', {session: false}), geoseekCollectorRoutes); + app.use('/api/place_opening_hours', passport.authenticate('jwt', {session: false}), place_opening_hoursRoutes); app.use('/api/place_features', passport.authenticate('jwt', {session: false}), place_featuresRoutes); diff --git a/backend/src/routes/geoseekCollector.js b/backend/src/routes/geoseekCollector.js new file mode 100644 index 0000000..9c95c20 --- /dev/null +++ b/backend/src/routes/geoseekCollector.js @@ -0,0 +1,24 @@ +const express = require('express'); +const GeoSeekCollectorService = require('../services/geoseekCollector'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +router.get('/osm/amenities', checkPermissions('READ_PLACES'), wrapAsync(async (req, res) => { + res.status(200).send({ + source: 'OpenStreetMap', + amenities: GeoSeekCollectorService.supportedAmenities(), + }); +})); + +router.post('/osm', checkPermissions('CREATE_PLACES'), wrapAsync(async (req, res) => { + const payload = await GeoSeekCollectorService.collectOpenStreetMap( + req.body || {}, + req.currentUser, + ); + + res.status(200).send(payload); +})); + +module.exports = router; diff --git a/backend/src/routes/places.js b/backend/src/routes/places.js index 3e6f5f3..3f71ad2 100644 --- a/backend/src/routes/places.js +++ b/backend/src/routes/places.js @@ -29,6 +29,15 @@ router.use(checkCrudPermissions('places')); * name: * type: string * default: name + * nama: + * type: string + * default: nama + * kategori: + * type: string + * default: kategori + * subkategori: + * type: string + * default: subkategori * short_description: * type: string * default: short_description @@ -38,34 +47,77 @@ router.use(checkCrudPermissions('places')); * address: * type: string * default: address + * alamat: + * type: string + * default: alamat + * kelurahan: + * type: string + * default: kelurahan + * kecamatan: + * type: string + * default: kecamatan * city: * type: string * default: city + * kota: + * type: string + * default: kota * province: * type: string * default: province + * provinsi: + * type: string + * default: provinsi * postal_code: * type: string * default: postal_code + * kode_pos: + * type: string + * default: kode_pos * phone_number: * type: string * default: phone_number + * telepon: + * type: string + * default: telepon * whatsapp_number: * type: string * default: whatsapp_number + * whatsapp: + * type: string + * default: whatsapp * email: * type: string * default: email * website_url: * type: string * default: website_url + * website: + * type: string + * default: website * google_maps_url: * type: string * default: google_maps_url + * jam_buka: + * type: string + * default: jam_buka * rating_count: * type: integer * format: int64 + * review_count: + * type: integer + * format: int64 + * status: + * type: string + * default: aktif + * verified: + * type: boolean + * featured: + * type: boolean + * popularitas: + * type: integer + * format: int64 * latitude: * type: integer @@ -79,6 +131,9 @@ router.use(checkCrudPermissions('places')); * rating_average: * type: integer * format: int64 + * rating: + * type: number + * format: float * * @@ -127,8 +182,8 @@ router.use(checkCrudPermissions('places')); router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); - await PlacesService.create(req.body.data, req.currentUser, true, link.host); - const payload = true; + const placeData = req.body.data || req.body; + const payload = await PlacesService.create(placeData, req.currentUser, true, link.host); res.status(200).send(payload); })); @@ -224,7 +279,9 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { * description: Some server error */ router.put('/:id', wrapAsync(async (req, res) => { - await PlacesService.update(req.body.data, req.body.id, req.currentUser); + const placeData = req.body.data || req.body; + const placeId = req.body.id || req.params.id; + await PlacesService.update(placeData, placeId, req.currentUser); const payload = true; res.status(200).send(payload); })); @@ -300,7 +357,8 @@ router.delete('/:id', wrapAsync(async (req, res) => { * description: Some server error */ router.post('/deleteByIds', wrapAsync(async (req, res) => { - await PlacesService.deleteByIds(req.body.data, req.currentUser); + const ids = req.body.data || req.body.ids; + await PlacesService.deleteByIds(ids, req.currentUser); const payload = true; res.status(200).send(payload); })); @@ -338,9 +396,10 @@ router.get('/', wrapAsync(async (req, res) => { req.query, { currentUser } ); if (filetype && filetype === 'csv') { - const fields = ['id','name','short_description','full_description','address','city','province','postal_code','phone_number','whatsapp_number','email','website_url','google_maps_url', - 'rating_count', - 'latitude','longitude','average_price','rating_average', + const fields = ['id','name','nama','kategori','subkategori','short_description','full_description','address','alamat','kelurahan','kecamatan','city','kota','province','provinsi','postal_code','kode_pos','phone_number','telepon','whatsapp_number','whatsapp','email','website_url','website','google_maps_url','jam_buka', + 'rating_count','review_count','status','is_verified','verified','featured','popularitas', + 'sumber','external_id','external_type','last_synced_at', + 'latitude','longitude','average_price','rating_average','rating', ]; const opts = { fields }; diff --git a/backend/src/routes/publicPlaces.js b/backend/src/routes/publicPlaces.js index 48b8e87..44ae1ab 100644 --- a/backend/src/routes/publicPlaces.js +++ b/backend/src/routes/publicPlaces.js @@ -1,5 +1,8 @@ const express = require('express'); const axios = require('axios'); +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); const db = require('../db/models'); const wrapAsync = require('../helpers').wrapAsync; const commonErrorHandler = require('../helpers').commonErrorHandler; @@ -16,6 +19,9 @@ const GLOBAL_RADIUS_KM = 20038; const MIN_NEAREST_RESULTS = 10; const OSM_PROFILE_MATCH_LIMIT = 6; const MAX_OSM_EXPANSION_RADIUS_KM = 150; +const OSM_OVERPASS_QUERY_TIMEOUT_SECONDS = 15; +const OSM_HTTP_TIMEOUT_MS = 18000; +const OSM_LIVE_SEARCH_MAX_LIMIT = 36; const GEO_SCORE_FORMULA = { distance: 60, @@ -372,6 +378,78 @@ Object.assign(SYNONYMS, { }); +Object.assign(SYNONYMS, { + aesthetic: ['cafe', 'kafe', 'kopi', 'nongkrong', 'wisata'], + alfamart: ['minimarket', 'supermarket', 'toko', 'belanja'], + alun: ['wisata', 'taman', 'rekreasi'], + apartemen: ['apartment', 'properti', 'penginapan', 'sewa'], + band: ['bandara', 'airport', 'transportasi'], + bandara: ['airport', 'aerodrome', 'transportasi', 'travel'], + barbershop: ['pangkas', 'rambut', 'salon', 'hairdresser'], + bca: ['atm', 'bank', 'tunai'], + bekasi: ['kota', 'lokasi', 'jabodetabek'], + bidan: ['klinik', 'kesehatan', 'dokter', 'midwife'], + bmkg: ['cuaca', 'prakiraan', 'pemerintah', 'informasi', 'lokal'], + bni: ['atm', 'bank', 'tunai'], + boba: ['minuman', 'cafe', 'kafe', 'kedai', 'beverage'], + bogor: ['kota', 'lokasi', 'jabodetabek'], + bri: ['atm', 'bank', 'tunai'], + busway: ['halte', 'bus', 'transportasi'], + cikarang: ['kota', 'lokasi', 'bekasi', 'industri'], + cuaca: ['bmkg', 'prakiraan', 'ramalan', 'pemerintah', 'informasi', 'lokal'], + depok: ['kota', 'lokasi', 'jabodetabek'], + earth: ['maps', 'peta', 'navigasi', 'lokasi'], + es: ['minuman', 'cafe', 'kafe', 'kedai'], + fitnes: ['fitness', 'gym', 'olahraga'], + franchise: ['waralaba', 'minuman', 'cafe', 'toko'], + google: ['maps', 'peta', 'navigasi', 'lokasi'], + halte: ['bus', 'busway', 'transportasi', 'terminal'], + herbal: ['jamu', 'apotek', 'obat', 'kesehatan'], + homestay: ['penginapan', 'hotel', 'guesthouse', 'wisata'], + indomaret: ['minimarket', 'supermarket', 'toko', 'belanja'], + jakarta: ['kota', 'lokasi', 'jabodetabek'], + jajanan: ['makanan', 'kuliner', 'cemilan', 'snack', 'pasar'], + karawang: ['kota', 'lokasi', 'industri'], + katering: ['catering', 'makanan', 'kuliner', 'sehat'], + kolam: ['renang', 'swimming', 'olahraga', 'rekreasi'], + kontrakan: ['properti', 'kost', 'sewa', 'rumah'], + kos: ['kost', 'properti', 'sewa', 'penginapan'], + kost: ['kos', 'properti', 'sewa', 'penginapan'], + lapangan: ['futsal', 'olahraga', 'sport', 'rekreasi'], + mandiri: ['atm', 'bank', 'tunai'], + maps: ['peta', 'google', 'navigasi', 'lokasi', 'tempat', 'restoran', 'cafe', 'hotel', 'spbu', 'atm', 'toko', 'wisata', 'stasiun'], + minuman: ['beverage', 'cafe', 'kafe', 'kopi', 'boba', 'es', 'kedai'], + mushola: ['musala', 'masjid', 'ibadah'], + navigasi: ['maps', 'peta', 'waze', 'rute', 'lokasi', 'transportasi'], + nongkrong: ['cafe', 'kafe', 'kopi', 'kedai', 'kuliner'], + pasar: ['marketplace', 'toko', 'belanja', 'jajanan'], + pangkas: ['barbershop', 'rambut', 'salon', 'hairdresser'], + peta: ['maps', 'google', 'navigasi', 'lokasi', 'tempat', 'rute', 'jalan'], + pertamini: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'], + pijat: ['massage', 'spa', 'kecantikan', 'wellness'], + prakiraan: ['cuaca', 'bmkg', 'ramalan'], + puskesmas: ['klinik', 'rumah sakit', 'kesehatan', 'dokter'], + ramalan: ['cuaca', 'prakiraan', 'bmkg'], + renang: ['kolam', 'swimming', 'olahraga', 'rekreasi'], + satellite: ['satelit', 'maps', 'peta', 'google', 'earth'], + satelit: ['satellite', 'maps', 'peta'], + sd: ['sekolah', 'pendidikan'], + sehat: ['kesehatan', 'makanan', 'katering', 'herbal'], + simpel: ['makanan', 'kuliner', 'resep'], + sma: ['sekolah', 'pendidikan'], + smp: ['sekolah', 'pendidikan'], + spa: ['pijat', 'massage', 'kecantikan', 'wellness'], + stasiun: ['kereta', 'railway', 'transportasi', 'terminal'], + street: ['view', 'maps', 'peta', 'navigasi'], + tangerang: ['kota', 'lokasi', 'jabodetabek'], + terminal: ['bus', 'stasiun', 'transportasi'], + view: ['street', 'maps', 'peta', 'navigasi'], + viral: ['populer', 'kuliner', 'makanan', 'minuman'], + warkop: ['warung', 'kopi', 'cafe', 'kafe', 'kedai'], + waze: ['maps', 'peta', 'navigasi', 'rute', 'jalan'], +}); + + const STRICT_CULINARY_QUERY_TOKENS = new Set([ 'ayam', 'bakso', @@ -467,6 +545,346 @@ const normalizeText = (value) => .replace(/[^a-z0-9]+/g, ' ') .trim(); + +const QUERY_LOCATION_ORIGINS = [ + { + label: 'Jakarta', + aliases: ['jakarta', 'dki jakarta', 'jakarta pusat', 'kuliner jakarta'], + lat: -6.2, + lng: 106.816666, + minimumRadiusKm: 25, + }, + { + label: 'Bogor', + aliases: ['bogor', 'kota bogor', 'kuliner bogor'], + lat: -6.595038, + lng: 106.816635, + minimumRadiusKm: 25, + }, + { + label: 'Bekasi', + aliases: ['bekasi', 'kota bekasi', 'kuliner bekasi'], + lat: -6.23827, + lng: 106.975573, + minimumRadiusKm: 25, + }, + { + label: 'Tangerang', + aliases: ['tangerang', 'kota tangerang', 'kuliner tangerang'], + lat: -6.178306, + lng: 106.631889, + minimumRadiusKm: 25, + }, + { + label: 'Tangerang Selatan', + aliases: ['tangerang selatan', 'tangsel', 'kuliner tangerang selatan'], + lat: -6.28862, + lng: 106.71789, + minimumRadiusKm: 25, + }, + { + label: 'Depok', + aliases: ['depok', 'kota depok', 'kuliner depok'], + lat: -6.402484, + lng: 106.794243, + minimumRadiusKm: 25, + }, + { + label: 'Cikarang', + aliases: ['cikarang', 'kuliner cikarang'], + lat: -6.307923, + lng: 107.172085, + minimumRadiusKm: 25, + }, + { + label: 'Karawang', + aliases: ['karawang', 'kuliner karawang'], + lat: -6.32273, + lng: 107.33758, + minimumRadiusKm: 25, + }, + { + label: 'Bandung', + aliases: ['bandung', 'kota bandung', 'wisata bandung'], + lat: -6.917464, + lng: 107.619123, + minimumRadiusKm: 25, + }, + { + label: 'Indonesia (titik awal Jakarta)', + aliases: ['indonesia', 'peta indonesia', 'maps indonesia'], + lat: -6.2, + lng: 106.816666, + minimumRadiusKm: 100, + }, +]; + +const DEFAULT_LOCAL_SEO_ORIGIN = { + label: 'Jakarta (titik awal SEO lokal)', + lat: -6.2, + lng: 106.816666, + minimumRadiusKm: 25, + note: + 'Lokasi browser tidak aktif, jadi GeoSeek memakai Jakarta sebagai titik awal default untuk keyword local SEO.', +}; + +const LOCAL_SEO_QUERY_PHRASES = [ + 'maps', + 'google maps', + 'maps google', + 'peta', + 'google earth', + 'waze', + 'street view', + 'google maps satellite', + 'peta indonesia', + 'cuaca', + 'cuaca hari ini', + 'prakiraan cuaca', + 'bmkg', + 'ramalan cuaca', + 'cuaca esok hari', + 'terdekat', + 'dekat saya', + 'near me', + 'cafe', + 'restoran', + 'kuliner', + 'rumah makan', + 'tempat makan', + 'warkop', + 'kedai kopi', + 'hotel', + 'penginapan', + 'villa', + 'homestay', + 'kos', + 'kost', + 'kontrakan', + 'apartemen', + 'pom bensin', + 'spbu', + 'pertamini', + 'bengkel', + 'tambal ban', + 'atm', + 'bank', + 'minimarket', + 'indomaret', + 'alfamart', + 'supermarket', + 'toko kelontong', + 'pasar', + 'mall', + 'pusat perbelanjaan', + 'toko baju', + 'toko buku', + 'toko obat', + 'apotek', + 'rumah sakit', + 'puskesmas', + 'klinik', + 'dokter', + 'dokter gigi', + 'bidan', + 'salon', + 'barbershop', + 'pangkas rambut', + 'spa', + 'pijat', + 'gym', + 'fitness', + 'kolam renang', + 'lapangan futsal', + 'taman', + 'tempat wisata', + 'wisata alam', + 'pantai', + 'alun alun', + 'museum', + 'kebun binatang', + 'masjid', + 'mushola', + 'gereja', + 'pura', + 'vihara', + 'sekolah', + 'kampus', + 'universitas', + 'pesantren', + 'lpk', + 'tempat les', + 'stasiun', + 'halte', + 'terminal', + 'bandara', + 'band', +]; + +const GENERIC_MAP_QUERY_TOKENS = new Set([ + 'maps', + 'map', + 'peta', + 'navigasi', + 'waze', + 'earth', + 'satellite', + 'satelit', + 'street', + 'view', +]); + +const WEATHER_QUERY_TOKENS = new Set([ + 'cuaca', + 'prakiraan', + 'ramalan', + 'bmkg', +]); + +const QUERY_LOCATION_SOFT_TOKENS = new Set( + QUERY_LOCATION_ORIGINS.flatMap((origin) => + origin.aliases.flatMap((alias) => normalizeText(alias).split(/\s+/)), + ), +); + +const SOFT_QUERY_TOKENS = new Set([ + ...QUERY_LOCATION_SOFT_TOKENS, + 'aesthetic', + 'aestetik', + 'bagus', + 'banyak', + 'barat', + 'besok', + 'bintang', + 'esok', + 'fresh', + 'google', + 'hari', + 'hemat', + 'indonesia', + 'kekinian', + 'low', + 'malam', + 'modern', + 'murah', + 'pedas', + 'pusat', + 'rendah', + 'satelit', + 'satellite', + 'segar', + 'sehat', + 'selatan', + 'simpel', + 'simple', + 'timur', + 'unik', + 'utara', + 'viral', +]); + +const isGenericMapSearchQuery = (query) => + normalizeText(query) + .split(/\s+/) + .some((token) => GENERIC_MAP_QUERY_TOKENS.has(token)); + +const isWeatherSearchQuery = (query) => + normalizeText(query) + .split(/\s+/) + .some((token) => WEATHER_QUERY_TOKENS.has(token)); + +const findQueryLocationOrigin = (query) => { + const normalized = normalizeText(query); + + if (!normalized) { + return null; + } + + return QUERY_LOCATION_ORIGINS.flatMap((origin) => + origin.aliases.map((alias) => ({ + ...origin, + matched_keyword: alias, + normalized_alias: normalizeText(alias), + })), + ) + .filter( + (origin) => + origin.normalized_alias && normalized.includes(origin.normalized_alias), + ) + .sort((a, b) => b.normalized_alias.length - a.normalized_alias.length)[0]; +}; + +const isLocalSeoSearchQuery = (query) => { + const normalized = normalizeText(query); + + if (!normalized) { + return false; + } + + return LOCAL_SEO_QUERY_PHRASES.some((phrase) => + normalized.includes(normalizeText(phrase)), + ); +}; + +const buildSearchOriginContext = (lat, lng, query) => { + if (lat !== null && lng !== null) { + return { + origin: { lat, lng }, + meta: { + lat, + lng, + label: 'Lokasi saya', + source: 'browser', + }, + }; + } + + const queryOrigin = findQueryLocationOrigin(query); + + if (queryOrigin) { + return { + origin: { lat: queryOrigin.lat, lng: queryOrigin.lng }, + meta: { + lat: queryOrigin.lat, + lng: queryOrigin.lng, + label: queryOrigin.label, + source: 'query_keyword', + matched_keyword: queryOrigin.matched_keyword, + }, + minimumRadiusKm: queryOrigin.minimumRadiusKm, + }; + } + + if (isLocalSeoSearchQuery(query)) { + return { + origin: { + lat: DEFAULT_LOCAL_SEO_ORIGIN.lat, + lng: DEFAULT_LOCAL_SEO_ORIGIN.lng, + }, + meta: { + lat: DEFAULT_LOCAL_SEO_ORIGIN.lat, + lng: DEFAULT_LOCAL_SEO_ORIGIN.lng, + label: DEFAULT_LOCAL_SEO_ORIGIN.label, + source: 'default_local_keyword', + note: DEFAULT_LOCAL_SEO_ORIGIN.note, + }, + minimumRadiusKm: DEFAULT_LOCAL_SEO_ORIGIN.minimumRadiusKm, + }; + } + + const origin = requireSearchOrigin(lat, lng); + + return { + origin, + meta: { + lat: origin.lat, + lng: origin.lng, + label: 'Lokasi saya', + source: 'browser', + }, + }; +}; + const parseRadiusFromQueryText = (query) => { const normalized = normalizeText(query); const radiusMatch = @@ -661,6 +1079,9 @@ const scoreTokenInSearchFields = (fields, token, weights) => const calculateRawRelevanceScore = (place, query) => { const originalTokens = tokenizeQuery(query); + const requiredTokens = originalTokens.filter( + (token) => !SOFT_QUERY_TOKENS.has(token), + ); if (!originalTokens.length) { return 62 + Math.min(calculateInventoryScore(place) * 0.18, 18); @@ -771,8 +1192,8 @@ const calculateRawRelevanceScore = (place, query) => { }); if ( - originalTokens.length > 1 && - matchedSearchTerms.size < originalTokens.length + requiredTokens.length > 0 && + requiredTokens.some((token) => !matchedSearchTerms.has(token)) ) { return 0; } @@ -791,7 +1212,11 @@ const calculateRawRelevanceScore = (place, query) => { return 0; } - if (matchedSearchTerms.size === originalTokens.length) { + if ( + (requiredTokens.length > 0 && + requiredTokens.every((token) => matchedSearchTerms.has(token))) || + (!requiredTokens.length && matchedSearchTerms.size > 0) + ) { score += 24; } @@ -1243,8 +1668,8 @@ const calculateDistanceKm = (lat1, lon1, lat2, lon2) => { const OVERPASS_ENDPOINTS = [ 'https://z.overpass-api.de/api/interpreter', - 'https://overpass-api.de/api/interpreter', 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', + 'https://overpass-api.de/api/interpreter', ]; const OSM_SEARCH_PROFILES = [ @@ -2113,6 +2538,228 @@ extendOsmProfile('bank-atm', { maxRadiusKm: 100, }); + +extendOsmProfile('restaurant', { + matchTerms: [ + 'restoran terdekat', + 'kuliner terdekat', + 'rumah makan terdekat', + 'tempat makan terdekat', + 'makanan enak', + 'kuliner malam', + 'resep makanan simpel', + 'cemilan unik', + 'jajanan pasar modern', + 'makanan viral murah', + 'ide jualan makanan', + 'makanan pedas', + 'katering', + 'katering sehat', + 'snack', + 'cemilan', + 'jajanan', + ], + filters: [ + { key: 'shop', value: 'deli' }, + { key: 'shop', value: 'pastry' }, + { key: 'shop', value: 'greengrocer' }, + ], + keywords: + 'rumah makan tempat makan makanan enak kuliner malam resep makanan simpel cemilan unik jajanan pasar modern makanan viral murah ide jualan makanan makanan pedas katering catering snack pastry deli', + maxRadiusKm: 100, +}); + +extendOsmProfile('cafe', { + matchTerms: [ + 'cafe aesthetic', + 'tempat nongkrong terdekat', + 'warkop terdekat', + 'kedai kopi terdekat', + 'minuman kekinian', + 'resep minuman segar', + 'es kopi susu', + 'franchise minuman', + 'boba drink', + 'boba', + 'warkop', + 'tempat nongkrong', + ], + filters: [ + { key: 'amenity', value: 'bar' }, + { key: 'shop', value: 'tea' }, + ], + keywords: + 'cafe aesthetic tempat nongkrong warkop kedai kopi minuman kekinian resep minuman segar es kopi susu franchise minuman boba drink beverage tea bar', + maxRadiusKm: 100, +}); + +extendOsmProfile('fuel', { + matchTerms: ['pom bensin terdekat', 'spbu terdekat', 'pertamini terdekat'], + keywords: + 'pom bensin terdekat spbu terdekat pertamini terdekat pertamina shell vivo bp akr gas station maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('automotive', { + matchTerms: [ + 'bengkel terdekat', + 'bengkel mobil terdekat', + 'bengkel motor terdekat', + 'tambal ban terdekat', + ], + keywords: + 'bengkel terdekat bengkel mobil terdekat bengkel motor terdekat tambal ban terdekat maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('bank-atm', { + matchTerms: [ + 'atm terdekat', + 'atm bca terdekat', + 'atm bri terdekat', + 'atm mandiri terdekat', + 'atm bni terdekat', + 'bank terdekat', + 'bca', + 'bri', + 'mandiri', + 'bni', + ], + keywords: + 'atm terdekat atm bca atm bri atm mandiri atm bni bank terdekat bca bri mandiri bni maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('retail', { + matchTerms: [ + 'minimarket terdekat', + 'indomaret terdekat', + 'alfamart terdekat', + 'supermarket terdekat', + 'toko kelontong terdekat', + 'pasar terdekat', + 'mall terdekat', + 'pusat perbelanjaan terdekat', + 'toko baju terdekat', + 'toko buku terdekat', + ], + filters: [ + { key: 'shop', value: 'department_store' }, + { key: 'shop', value: 'mall' }, + ], + keywords: + 'minimarket indomaret alfamart supermarket toko kelontong pasar mall pusat perbelanjaan toko baju toko buku maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('pharmacy', { + matchTerms: ['toko obat terdekat', 'apotek 24 jam terdekat'], + keywords: + 'toko obat terdekat apotek 24 jam terdekat apotek farmasi herbal maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('healthcare', { + matchTerms: [ + 'puskesmas terdekat', + 'bidan terdekat', + 'dokter terdekat', + 'dokter gigi terdekat', + ], + filters: [ + { key: 'amenity', value: 'community_health_centre' }, + { key: 'healthcare', value: 'midwife' }, + ], + keywords: + 'puskesmas bidan dokter terdekat dokter gigi terdekat community health centre midwife maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('hotel', { + matchTerms: [ + 'hotel terdekat', + 'penginapan terdekat', + 'villa terdekat', + 'homestay terdekat', + ], + keywords: + 'hotel terdekat penginapan terdekat villa terdekat homestay terdekat maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('property-listings', { + matchTerms: [ + 'kos terdekat', + 'kost terdekat', + 'kontrakan terdekat', + 'apartemen terdekat', + ], + keywords: + 'kos terdekat kost terdekat kontrakan terdekat apartemen terdekat maps peta navigasi', + maxRadiusKm: 150, +}); + +extendOsmProfile('tourism', { + matchTerms: [ + 'taman terdekat', + 'tempat wisata terdekat', + 'wisata alam terdekat', + 'pantai terdekat', + 'alun alun terdekat', + 'museum terdekat', + 'kebun binatang terdekat', + ], + filters: [ + { key: 'leisure', value: 'nature_reserve' }, + { key: 'natural', value: 'beach' }, + ], + keywords: + 'taman tempat wisata wisata alam pantai alun alun museum kebun binatang maps peta navigasi street view', + maxRadiusKm: 150, +}); + +extendOsmProfile('worship', { + matchTerms: ['pura terdekat', 'vihara terdekat'], + keywords: 'pura vihara kelenteng tempat ibadah maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('education', { + matchTerms: [ + 'sd terdekat', + 'smp terdekat', + 'sma terdekat', + 'pesantren terdekat', + 'lpk terdekat', + 'tempat les terdekat', + ], + filters: [ + { key: 'amenity', value: 'training' }, + { key: 'amenity', value: 'music_school' }, + { key: 'amenity', value: 'driving_school' }, + ], + keywords: + 'sd smp sma pesantren lpk tempat les kursus sekolah pendidikan maps peta navigasi', + maxRadiusKm: 100, +}); + +extendOsmProfile('fitness', { + matchTerms: [ + 'tempat fitness terdekat', + 'kolam renang terdekat', + 'lapangan futsal terdekat', + ], + filters: [ + { key: 'leisure', value: 'swimming_pool' }, + { key: 'leisure', value: 'pitch' }, + { key: 'sport', value: 'soccer' }, + { key: 'sport', value: 'futsal' }, + ], + keywords: + 'tempat fitness kolam renang swimming pool lapangan futsal olahraga sport maps peta navigasi', + maxRadiusKm: 100, +}); + const EXTRA_OSM_SEARCH_PROFILES = [ { id: 'building-services', @@ -2416,6 +3063,150 @@ const EXTRA_OSM_SEARCH_PROFILES = [ OSM_SEARCH_PROFILES.push(...EXTRA_OSM_SEARCH_PROFILES); + +OSM_SEARCH_PROFILES.push( + { + id: 'transportation', + matchTerms: [ + 'stasiun', + 'stasiun terdekat', + 'stasiun kereta terdekat', + 'halte terdekat', + 'halte busway terdekat', + 'terminal terdekat', + 'terminal bus', + 'band', + 'bandara', + 'airport', + 'transportasi', + ], + filters: [ + { key: 'railway', value: 'station' }, + { key: 'railway', value: 'halt' }, + { key: 'railway', value: 'tram_stop' }, + { key: 'amenity', value: 'bus_station' }, + { key: 'highway', value: 'bus_stop' }, + { key: 'public_transport', value: 'station' }, + { key: 'public_transport', value: 'platform' }, + { key: 'amenity', value: 'ferry_terminal' }, + { key: 'aeroway', value: 'aerodrome' }, + { key: 'aeroway', value: 'terminal' }, + ], + defaultName: 'Transportasi Publik', + shortDescription: + 'Stasiun, halte, terminal, bandara, atau transportasi publik dari OpenStreetMap.', + fullDescription: + 'Data transportasi publik diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi pencarian.', + keywords: + 'stasiun stasiun kereta halte halte busway terminal terminal bus bandara airport transportasi publik maps peta google maps waze navigasi rute', + category: { + id: 'openstreetmap-transportation', + name: 'Transportasi Publik', + slug: 'transportasi-publik', + color_hex: '#2563EB', + description: 'Stasiun, halte, terminal, dan bandara dari OpenStreetMap.', + }, + offering: { + name: 'Akses transportasi', + description: 'Informasi transportasi mengikuti data lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 150, + priority: 6, + }, + { + id: 'beauty-wellness', + matchTerms: [ + 'salon', + 'salon terdekat', + 'barbershop', + 'barbershop terdekat', + 'pangkas rambut', + 'pangkas rambut terdekat', + 'spa', + 'spa terdekat', + 'pijat', + 'pijat terdekat', + 'massage', + ], + filters: [ + { key: 'shop', value: 'hairdresser' }, + { key: 'shop', value: 'beauty' }, + { key: 'shop', value: 'massage' }, + { key: 'leisure', value: 'spa' }, + { key: 'leisure', value: 'sauna' }, + ], + defaultName: 'Salon / Barbershop / Spa', + shortDescription: + 'Salon, barbershop, pangkas rambut, spa, atau pijat dari OpenStreetMap.', + fullDescription: + 'Data kecantikan dan wellness diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi pencarian.', + keywords: + 'salon barbershop pangkas rambut spa pijat massage beauty wellness maps peta navigasi', + category: { + id: 'openstreetmap-beauty-wellness', + name: 'Salon / Wellness', + slug: 'salon-wellness', + color_hex: '#DB2777', + description: 'Salon, barbershop, spa, dan pijat dari OpenStreetMap.', + }, + offering: { + name: 'Layanan kecantikan / wellness', + description: 'Layanan mengikuti informasi lokasi.', + offering_type: 'service', + }, + maxRadiusKm: 100, + priority: 5, + }, + { + id: 'weather-local-info', + matchTerms: [ + 'cuaca', + 'cuaca hari ini', + 'prakiraan cuaca', + 'ramalan cuaca', + 'cuaca esok hari', + 'bmkg', + ], + filters: [ + { key: 'office', value: 'government' }, + { key: 'amenity', value: 'townhall' }, + { key: 'government' }, + { key: 'office', value: 'administrative' }, + ], + defaultName: 'Info Lokal / Cuaca', + shortDescription: + 'Titik layanan publik dan informasi lokal terkait cuaca/lokasi dari OpenStreetMap.', + fullDescription: + 'GeoSeek mengarahkan keyword cuaca ke data lokasi dan layanan publik terdekat. Data prakiraan real-time dapat dihubungkan ke API cuaca/BMKG pada tahap berikutnya.', + keywords: + 'cuaca cuaca hari ini prakiraan cuaca ramalan cuaca cuaca esok hari bmkg informasi lokal kantor pemerintah maps peta lokasi', + category: { + id: 'openstreetmap-weather-local-info', + name: 'Info Lokal / Cuaca', + slug: 'info-lokal-cuaca', + color_hex: '#0EA5E9', + description: 'Info lokal dan titik layanan publik dari OpenStreetMap.', + }, + offering: { + name: 'Informasi lokal / cuaca', + description: 'Informasi mengikuti data lokasi publik.', + offering_type: 'service', + }, + maxRadiusKm: 150, + priority: 7, + }, +); + +const MAP_NAVIGATION_KEYWORDS = + 'maps google maps maps google peta google earth waze street view google maps satellite peta indonesia navigasi rute lokasi local seo terdekat'; + +OSM_SEARCH_PROFILES.forEach((profile) => { + profile.keywords = [profile.keywords, MAP_NAVIGATION_KEYWORDS] + .filter(Boolean) + .join(' '); +}); + const OSM_TAG_VALUE_KEYWORDS = { atm: 'atm bank tunai uang cash', bakery: 'roti bakery kue makanan kuliner', @@ -2539,6 +3330,24 @@ Object.assign(OSM_TAG_VALUE_KEYWORDS, { travel_agency: 'agen travel wisata rental mobil perjalanan', veterinary: 'klinik hewan dokter hewan pet shop grooming hewan', watches: 'jam tangan aksesoris fashion toko', + + aerodrome: 'bandara airport transportasi publik travel', + beauty: 'salon kecantikan beauty skincare kosmetik', + bus_station: 'terminal bus halte transportasi publik', + bus_stop: 'halte bus halte busway transportasi publik', + ferry_terminal: 'terminal ferry pelabuhan transportasi publik', + massage: 'pijat massage spa wellness', + midwife: 'bidan klinik kesehatan ibu anak', + nature_reserve: 'wisata alam taman rekreasi alam', + platform: 'halte platform transportasi publik', + public_transport: 'transportasi publik stasiun halte terminal', + sauna: 'spa sauna wellness pijat', + soccer: 'lapangan futsal sepak bola olahraga', + spa: 'spa pijat massage wellness', + station: 'stasiun transportasi publik kereta', + swimming_pool: 'kolam renang olahraga rekreasi', + terminal: 'terminal transportasi publik bandara bus', + training: 'lpk kursus tempat les pelatihan pendidikan', }); const normalizeOsmTagValue = (value) => @@ -2593,13 +3402,22 @@ const buildExpandedOsmTokens = (tokens) => { return expanded; }; +const getOsmProfilesByIds = (ids) => { + const idSet = new Set(ids); + + return OSM_SEARCH_PROFILES.filter((profile) => idSet.has(profile.id)).slice( + 0, + OSM_PROFILE_MATCH_LIMIT, + ); +}; + const scoreOsmProfileMatch = ( profile, normalizedText, directTokens, expandedTokens, -) => - profile.matchTerms.reduce((total, term) => { +) => { + const matchScore = profile.matchTerms.reduce((total, term) => { const normalizedTerm = normalizeText(term); if (!normalizedTerm) { @@ -2624,7 +3442,10 @@ const scoreOsmProfileMatch = ( } return total; - }, Number(profile.priority || 0)); + }, 0); + + return matchScore > 0 ? matchScore + Number(profile.priority || 0) : 0; +}; const getOsmSearchProfiles = (query, category) => { const searchText = [query, category].filter(Boolean).join(' '); @@ -2636,6 +3457,33 @@ const getOsmSearchProfiles = (query, category) => { const directTokens = new Set(tokenizeQuery(searchText)); const expandedTokens = buildExpandedOsmTokens(directTokens); + + if (isWeatherSearchQuery(searchText)) { + return getOsmProfilesByIds([ + 'weather-local-info', + 'government-office', + 'bumn-office', + 'healthcare', + 'tourism', + 'transportation', + ]); + } + + if (isGenericMapSearchQuery(searchText)) { + return getOsmProfilesByIds([ + 'restaurant', + 'cafe', + 'fuel', + 'bank-atm', + 'retail', + 'pharmacy', + 'healthcare', + 'hotel', + 'tourism', + 'transportation', + ]); + } + const matchedProfiles = OSM_SEARCH_PROFILES.map((profile) => ({ profile, score: scoreOsmProfileMatch( @@ -2765,6 +3613,291 @@ const buildOsmAddress = (tags = {}) => const buildOsmMapsUrl = (latitude, longitude) => `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`; + +const FALLBACK_DATASET_FILES = [ + { + filePath: path.join( + __dirname, + '../../../data/geoseek/geo_places_sample.csv', + ), + maxRows: 200, + }, + { + filePath: path.join( + __dirname, + '../../../data/geoseek/generated/geo_places_1M.csv', + ), + maxRows: 2500, + }, +]; + +let fallbackDatasetPromise = null; + +const parseGeoSeekCsvLine = (line) => { + const columns = String(line || '').split(','); + + if (columns.length < 16 || columns[0] === 'id') { + return null; + } + + const tail = columns.slice(-8); + const middle = columns.slice(4, -8); + + if (middle.length < 4) { + return null; + } + + return { + id: columns[0], + name: columns[1], + category: columns[2], + subcategory: columns[3], + address: middle.slice(0, -3).join(', '), + district: middle[middle.length - 3], + city: middle[middle.length - 2], + province: middle[middle.length - 1], + latitude: toNumber(tail[0]), + longitude: toNumber(tail[1]), + phone_number: tail[2], + opening_hours: tail[3], + rating_average: toNumber(tail[4]), + rating_count: Number(tail[5]) || 0, + source: tail[6], + verification_status: tail[7], + }; +}; + +const readGeoSeekCsvFile = async (filePath, maxRows) => { + if (!fs.existsSync(filePath)) { + return []; + } + + const rows = []; + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const reader = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + try { + for await (const line of reader) { + const row = parseGeoSeekCsvLine(line); + + if (!row) { + continue; + } + + rows.push(row); + + if (rows.length >= maxRows) { + reader.close(); + stream.destroy(); + break; + } + } + } catch (err) { + console.error('Gagal membaca dataset fallback GeoSeek CSV', { + filePath, + message: err.message, + }); + throw err; + } + + return rows; +}; + +const loadFallbackDatasetRows = () => { + if (!fallbackDatasetPromise) { + fallbackDatasetPromise = Promise.all( + FALLBACK_DATASET_FILES.map((item) => + readGeoSeekCsvFile(item.filePath, item.maxRows), + ), + ).then((groups) => groups.flat()); + } + + return fallbackDatasetPromise; +}; + +const slugify = (value) => normalizeText(value).replace(/\s+/g, '-'); + +const FALLBACK_CATEGORY_KEYWORDS = { + apotek: + 'apotek apotek 24 jam toko obat farmasi obat herbal kesehatan maps peta navigasi', + atm: + 'atm atm bca atm bri atm mandiri atm bni bank tunai uang cash maps peta navigasi', + bank: + 'bank atm atm bca atm bri atm mandiri atm bni tunai uang cash maps peta navigasi', + barbershop: + 'salon barbershop pangkas rambut potong rambut kecantikan maps peta navigasi', + bengkel: + 'bengkel bengkel mobil bengkel motor tambal ban otomotif service servis maps peta navigasi', + cafe: + 'cafe kafe cafe aesthetic kedai kopi warkop tempat nongkrong kopi minuman boba es kopi susu maps peta navigasi', + gereja: 'gereja tempat ibadah kristen katolik maps peta navigasi', + gym: + 'gym tempat fitness kolam renang lapangan futsal olahraga rekreasi maps peta navigasi', + hotel: + 'hotel penginapan villa homestay resort travel wisata maps peta navigasi', + kampus: + 'kampus universitas pendidikan sekolah tempat les lpk maps peta navigasi', + kost: + 'kost kos kontrakan apartemen properti sewa rumah penginapan maps peta navigasi', + laundry: 'laundry jasa cuci pakaian maps peta navigasi', + logistik: + 'stasiun stasiun kereta halte halte busway terminal terminal bus bandara airport transportasi publik maps peta navigasi', + mall: + 'mall pusat perbelanjaan supermarket minimarket toko pasar belanja maps peta navigasi', + masjid: 'masjid mushola musala tempat ibadah islam maps peta navigasi', + pasar: + 'pasar toko kelontong jajanan pasar modern kuliner belanja maps peta navigasi', + restoran: + 'restoran rumah makan tempat makan kuliner kuliner malam makanan enak makanan pedas makanan viral murah cemilan jajanan katering catering maps peta navigasi', + 'rumah sakit': + 'rumah sakit puskesmas klinik dokter dokter gigi bidan kesehatan maps peta navigasi', + sekolah: + 'sekolah sd smp sma pesantren lpk tempat les pendidikan maps peta navigasi', + spbu: + 'spbu pom bensin pertamini pertamina bensin bbm fuel gas station maps peta navigasi', + toko: + 'toko minimarket indomaret alfamart supermarket toko kelontong toko baju toko buku toko obat belanja maps peta navigasi', + 'toko bangunan': + 'toko bahan bangunan material bangunan hardware renovasi rumah maps peta navigasi', + 'toko elektronik': + 'toko elektronik smartphone hp laptop komputer gadget internet wifi maps peta navigasi', + wisata: + 'wisata tempat wisata wisata alam pantai alun alun museum kebun binatang taman street view maps peta navigasi', +}; + +const FALLBACK_UNIVERSAL_KEYWORDS = [ + MAP_NAVIGATION_KEYWORDS, + 'cuaca cuaca hari ini prakiraan cuaca bmkg ramalan cuaca cuaca esok hari informasi lokal lokasi', +].join(' '); + +const getFallbackKeywordText = (row) => { + const normalizedCategory = normalizeText(row.category); + const normalizedSubcategory = normalizeText(row.subcategory); + const matchedKeywordValues = Object.entries(FALLBACK_CATEGORY_KEYWORDS) + .filter( + ([key]) => + normalizedCategory.includes(key) || normalizedSubcategory.includes(key), + ) + .map(([, value]) => value); + + return [ + row.name, + row.category, + row.subcategory, + row.address, + row.district, + row.city, + row.province, + FALLBACK_UNIVERSAL_KEYWORDS, + ...matchedKeywordValues, + ] + .filter(Boolean) + .join(' '); +}; + +const buildFallbackLiveStatus = (openingHours) => { + const normalized = normalizeText(openingHours); + + if (normalized.includes('24')) { + return { + status: 'open', + label: 'Buka 24 jam', + crowd: 'Aktif', + updated_label: 'Dataset GeoSeek', + source: 'GeoSeek CSV', + }; + } + + return { + status: 'open', + label: openingHours || 'Jam operasional tersedia', + crowd: 'Normal', + updated_label: 'Dataset GeoSeek', + source: 'GeoSeek CSV', + }; +}; + +const toFallbackDatasetPlace = (row, origin) => { + const latitude = toNumber(row.latitude); + const longitude = toNumber(row.longitude); + + if (latitude === null || longitude === null || !origin) { + return null; + } + + const distanceKm = calculateDistanceKm(origin.lat, origin.lng, latitude, longitude); + const slug = slugify(row.category || 'tempat-lokal') || 'tempat-lokal'; + const keywordText = getFallbackKeywordText(row); + const offerings = [ + { + id: `geoseek-csv-${row.id}-offering`, + name: row.subcategory || row.category || 'Info tempat', + description: `Kategori ${row.category || 'tempat lokal'} dari dataset GeoSeek.`, + offering_type: 'service', + price: null, + stock_status: 'by_request', + stock_label: STOCK_STATUS_LABELS.by_request, + stock_quantity: null, + is_verified: row.verification_status === 'verified', + }, + ]; + + return { + id: `geoseek-csv-${row.id}`, + name: row.name, + short_description: `${row.category || 'Tempat lokal'} · ${row.subcategory || row.city || 'Dataset GeoSeek'}`, + full_description: [ + `Data fallback dari dataset GeoSeek CSV untuk ${row.category || 'tempat lokal'}.`, + `Kata kunci lokasi: ${keywordText}.`, + ].join(' '), + address: [row.address, row.district, row.city, row.province] + .filter(Boolean) + .join(', '), + city: row.city, + province: row.province, + latitude, + longitude, + phone_number: row.phone_number, + whatsapp_number: '', + google_maps_url: buildOsmMapsUrl(latitude, longitude), + website_url: '', + price_level: 'unknown', + average_price: null, + rating_average: row.rating_average, + rating_count: row.rating_count, + status: 'published', + is_verified: row.verification_status === 'verified', + category: { + id: `geoseek-csv-${slug}`, + name: row.category || 'Tempat Lokal', + slug, + color_hex: '#087F6D', + description: `${row.category || 'Tempat lokal'} dari dataset GeoSeek CSV.`, + }, + distance_km: Number(distanceKm.toFixed(2)), + offerings, + offerings_summary: summarizeOfferings(offerings), + live_status: buildFallbackLiveStatus(row.opening_hours), + external_source: 'geoseek_csv', + external_search_match: true, + }; +}; + +const loadFallbackDatasetPlaces = async (origin) => { + if (!origin) { + return []; + } + + const rows = await loadFallbackDatasetRows(); + + return rows + .map((row) => toFallbackDatasetPlace(row, origin)) + .filter(Boolean); +}; + const toOsmPlace = (element, origin, profile) => { const coordinate = getOsmCoordinate(element); @@ -2852,7 +3985,7 @@ const buildOsmOverpassQuery = ( }); return ` - [out:json][timeout:8]; + [out:json][timeout:${OSM_OVERPASS_QUERY_TIMEOUT_SECONDS}]; ( ${clauses.join('\n ')} ); @@ -2890,6 +4023,15 @@ const summarizeOverpassError = (err, endpoint) => ({ : err.response?.data, }); +const getOverpassRemark = (data) => + typeof data?.remark === 'string' ? data.remark.trim() : ''; + +const isOverpassRemarkError = (data) => { + const remark = getOverpassRemark(data); + + return /runtime error|timed out|timeout/i.test(remark); +}; + const postOverpassQuery = async ( body, endpointIndex = 0, @@ -2898,14 +4040,25 @@ const postOverpassQuery = async ( const endpoint = OVERPASS_ENDPOINTS[endpointIndex]; try { - return await axios.post(endpoint, body.toString(), { - timeout: 4500, + const response = await axios.post(endpoint, body.toString(), { + timeout: OSM_HTTP_TIMEOUT_MS, headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8', 'User-Agent': 'GeoSeek/1.0', }, }); + + if (isOverpassRemarkError(response.data)) { + const error = new Error(getOverpassRemark(response.data)); + error.response = { + status: response.status, + data: response.data, + }; + throw error; + } + + return response; } catch (err) { const overpassErrors = [ ...previousErrors, @@ -2939,7 +4092,7 @@ const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => { const radiusMeters = Math.round(searchRadiusKm * 1000); const overpassLimit = Math.min( Math.max((limit || DEFAULT_LIMIT) * 3, DEFAULT_LIMIT), - MAX_LIMIT, + OSM_LIVE_SEARCH_MAX_LIMIT, ); const overpassQuery = buildOsmOverpassQuery( profiles, @@ -3267,9 +4420,12 @@ router.get( const lat = parseCoordinate(req.query.lat, 'Latitude'); const lng = parseCoordinate(req.query.lng, 'Longitude'); const queryIntent = analyzeHyperlocalIntent(query); - const radiusKm = - queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm); - const origin = requireSearchOrigin(lat, lng); + const originContext = buildSearchOriginContext(lat, lng, query); + const radiusKm = Math.max( + queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm), + originContext.minimumRadiusKm || 0, + ); + const origin = originContext.origin; const rawLimit = Number(req.query.limit || DEFAULT_LIMIT); const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.round(rawLimit), 1), MAX_LIMIT) @@ -3277,15 +4433,19 @@ router.get( const hasQuery = tokenizeQuery(query).length > 0; const externalSourceErrors = []; + const useFallbackDataset = Boolean(hasQuery || category || isLocalSeoSearchQuery(query)); const places = await loadPublicPlaces(category); const publicPlaces = places.map((place) => toPublicPlace(place, origin)); + const fallbackPlaces = useFallbackDataset + ? await loadFallbackDatasetPlaces(origin) + : []; const minimumResultTarget = Math.min(MIN_NEAREST_RESULTS, limit); let externalPlaces = []; let effectiveRadiusKm = radiusKm; let expandedForNearest = false; const buildCombinedPlaces = () => - dedupePlacesById([...externalPlaces, ...publicPlaces]); + dedupePlacesById([...externalPlaces, ...fallbackPlaces, ...publicPlaces]); const getPlacesWithinRadius = (candidatePlaces, currentRadiusKm) => origin @@ -3315,8 +4475,8 @@ router.get( if (origin && (hasQuery || category)) { try { const externalRadiusKm = queryIntent.nearby - ? Math.min(Math.max(radiusKm * 5, 25), 50) - : radiusKm; + ? Math.min(Math.max(radiusKm, DEFAULT_RADIUS_KM), 15) + : Math.min(radiusKm, 25); externalPlaces = await fetchOsmPlaces( origin, @@ -3441,11 +4601,15 @@ router.get( effective_radius_km: origin ? effectiveRadiusKm : null, radius_zone: getRadiusZone(null, effectiveRadiusKm), query_intent: queryIntent, + search_origin: originContext.meta, radius_zones: RADIUS_ZONES, distance_buckets: buildDistanceBuckets(nearbyIndexedPlaces), filtered_by_radius: Boolean(origin) && !expandedForNearest, expanded_for_nearest: expandedForNearest, - external_sources: externalPlaces.length ? ['openstreetmap'] : [], + external_sources: [ + ...(externalPlaces.length ? ['openstreetmap'] : []), + ...(fallbackPlaces.length ? ['geoseek_csv'] : []), + ], external_source_errors: externalSourceErrors, total_candidates: nearbyIndexedPlaces.length, geo_score_formula: GEO_SCORE_FORMULA, diff --git a/backend/src/services/geoseekCollector.js b/backend/src/services/geoseekCollector.js new file mode 100644 index 0000000..dfa8037 --- /dev/null +++ b/backend/src/services/geoseekCollector.js @@ -0,0 +1,462 @@ +const axios = require('axios'); +const db = require('../db/models'); + +const OVERPASS_URL = 'https://overpass-api.de/api/interpreter'; + +const OSM_CATEGORY_LABELS = { + restaurant: 'Restoran', + cafe: 'Cafe', + hospital: 'Rumah Sakit', + pharmacy: 'Apotek', + bank: 'Bank', + atm: 'ATM', + fuel: 'SPBU', + school: 'Sekolah', + university: 'Kampus', + hotel: 'Hotel', +}; + +const DEFAULT_CITIES = [ + 'Jakarta', + 'Bandung', + 'Surabaya', + 'Medan', + 'Semarang', + 'Makassar', + 'Palembang', + 'Yogyakarta', + 'Bekasi', + 'Depok', +]; + +const CONTENT_FIELDS = [ + 'name', + 'nama', + 'kategori', + 'subkategori', + 'short_description', + 'full_description', + 'address', + 'alamat', + 'kelurahan', + 'kecamatan', + 'city', + 'kota', + 'province', + 'provinsi', + 'postal_code', + 'kode_pos', + 'latitude', + 'longitude', + 'phone_number', + 'telepon', + 'whatsapp_number', + 'whatsapp', + 'email', + 'website_url', + 'website', + 'google_maps_url', + 'jam_buka', + 'status', + 'is_verified', + 'verified', + 'featured', + 'popularitas', +]; + +const METADATA_FIELDS = [ + 'sumber', + 'external_id', + 'external_type', + 'raw_source_data', + 'last_synced_at', +]; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const normalizeList = (value, fallback) => { + if (Array.isArray(value)) { + const list = value.map((item) => String(item).trim()).filter(Boolean); + return list.length ? list : fallback; + } + + if (typeof value === 'string') { + const list = value.split(',').map((item) => item.trim()).filter(Boolean); + return list.length ? list : fallback; + } + + return fallback; +}; + +const toPositiveInteger = (value, fallback, max) => { + const number = Number(value); + if (!Number.isFinite(number) || number < 0) { + return fallback; + } + + const integer = Math.floor(number); + if (max === undefined) { + return integer; + } + + return Math.min(integer, max); +}; + +const toBoolean = (value, fallback = false) => { + if (value === undefined || value === null) { + return fallback; + } + + return value === true || value === 'true' || value === '1' || value === 1; +}; + +const titleCase = (value) => String(value) + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +const amenityKeyFromInput = (value) => { + const normalized = String(value).trim().toLowerCase(); + if (!normalized) { + return null; + } + + if (OSM_CATEGORY_LABELS[normalized]) { + return normalized; + } + + const matched = Object.entries(OSM_CATEGORY_LABELS).find((entry) => ( + entry[1].toLowerCase() === normalized + )); + + return matched ? matched[0] : normalized.replace(/\s+/g, '_'); +}; + +const normalizeAmenities = (params) => { + const requested = normalizeList( + params.amenities || params.categories, + Object.keys(OSM_CATEGORY_LABELS), + ); + + return [...new Set(requested.map(amenityKeyFromInput).filter(Boolean))]; +}; + +const escapeOverpassString = (value) => String(value) + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + +const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const buildOverpassQuery = (city, amenities, timeoutSeconds) => { + const escapedCity = escapeOverpassString(city); + const amenityRegex = amenities.map(escapeRegex).join('|'); + const selector = amenityRegex ? `["amenity"~"^(${amenityRegex})$"]` : '["amenity"]'; + + return ` +[out:json][timeout:${timeoutSeconds}]; +area["name"="${escapedCity}"]->.searchArea; +( + node${selector}(area.searchArea); + way${selector}(area.searchArea); + relation${selector}(area.searchArea); +); +out center tags; +`; +}; + +const getTag = (tags, keys) => { + for (const key of keys) { + const value = tags[key]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + return String(value).trim(); + } + } + + return null; +}; + +const buildAddress = (tags) => { + const fullAddress = getTag(tags, ['addr:full', 'contact:address']); + if (fullAddress) { + return fullAddress; + } + + return [ + getTag(tags, ['addr:street']), + getTag(tags, ['addr:housenumber']), + getTag(tags, ['addr:neighbourhood', 'addr:suburb']), + ].filter(Boolean).join(', ') || null; +}; + +const normalizeOsmElement = (element, city, amenities, includeUnnamed) => { + const tags = element.tags || {}; + const amenity = tags.amenity; + + if (!amenity || !amenities.includes(amenity)) { + return { reason: 'unsupported_amenity' }; + } + + const latitude = element.lat !== undefined ? element.lat : element.center && element.center.lat; + const longitude = element.lon !== undefined ? element.lon : element.center && element.center.lon; + + if (latitude === undefined || latitude === null || longitude === undefined || longitude === null) { + return { reason: 'missing_coordinates' }; + } + + const nama = getTag(tags, ['name', 'brand', 'operator']); + if (!nama && !includeUnnamed) { + return { reason: 'missing_name' }; + } + + const alamat = buildAddress(tags); + const kota = getTag(tags, ['addr:city', 'is_in:city']) || city; + const provinsi = getTag(tags, ['addr:province', 'addr:state']); + const kodePos = getTag(tags, ['addr:postcode']); + const telepon = getTag(tags, ['phone', 'contact:phone']); + const whatsapp = getTag(tags, ['whatsapp', 'contact:whatsapp']); + const website = getTag(tags, ['website', 'contact:website', 'url']); + const email = getTag(tags, ['email', 'contact:email']); + const jamBuka = getTag(tags, ['opening_hours']); + const displayName = nama || `${titleCase(amenity)} ${city}`; + const now = new Date(); + + return { + place: { + name: displayName, + nama: displayName, + kategori: OSM_CATEGORY_LABELS[amenity] || titleCase(amenity), + subkategori: amenity, + short_description: `${OSM_CATEGORY_LABELS[amenity] || titleCase(amenity)} dari OpenStreetMap`, + address: alamat, + alamat, + city: kota, + kota, + province: provinsi, + provinsi, + postal_code: kodePos, + kode_pos: kodePos, + latitude, + longitude, + phone_number: telepon, + telepon, + whatsapp_number: whatsapp, + whatsapp, + email, + website_url: website, + website, + jam_buka: jamBuka, + status: 'aktif', + is_verified: false, + verified: false, + featured: false, + popularitas: 0, + sumber: 'OpenStreetMap', + external_id: String(element.id), + external_type: element.type, + raw_source_data: element, + last_synced_at: now, + }, + }; +}; + +const compactForUpdate = (payload, currentUser) => { + const updatePayload = {}; + + for (const field of CONTENT_FIELDS) { + const value = payload[field]; + if (value !== undefined && value !== null && value !== '') { + updatePayload[field] = value; + } + } + + for (const field of METADATA_FIELDS) { + updatePayload[field] = payload[field]; + } + + updatePayload.updated_at = new Date(); + updatePayload.updatedById = currentUser && currentUser.id ? currentUser.id : null; + + return updatePayload; +}; + +module.exports = class GeoSeekCollectorService { + static supportedAmenities() { + return Object.entries(OSM_CATEGORY_LABELS).map(([value, label]) => ({ value, label })); + } + + static async fetchOpenStreetMapCity(city, amenities, options) { + const timeoutSeconds = options.timeoutSeconds; + const timeoutMs = timeoutSeconds * 1000 + 15000; + const query = buildOverpassQuery(city, amenities, timeoutSeconds); + + try { + const response = await axios.get(options.overpassUrl, { + params: { data: query }, + timeout: timeoutMs, + maxBodyLength: Infinity, + maxContentLength: Infinity, + }); + + const data = response.data || {}; + if (!Array.isArray(data.elements)) { + console.error('Invalid OpenStreetMap Overpass response', { city, data }); + throw new Error(`Invalid OpenStreetMap Overpass response for ${city}`); + } + + return data.elements; + } catch (error) { + console.error('OpenStreetMap Overpass request failed', { + city, + query, + status: error.response && error.response.status, + response: error.response && error.response.data, + message: error.message, + }); + throw error; + } + } + + static async upsertPlace(payload, currentUser, transaction) { + const where = { + sumber: payload.sumber, + external_type: payload.external_type, + external_id: payload.external_id, + }; + + const existing = await db.places.findOne({ where, transaction }); + + if (existing) { + await existing.update(compactForUpdate(payload, currentUser), { transaction }); + return 'updated'; + } + + try { + await db.places.create({ + ...payload, + createdById: currentUser && currentUser.id ? currentUser.id : null, + updatedById: currentUser && currentUser.id ? currentUser.id : null, + }, { transaction }); + return 'inserted'; + } catch (error) { + if (error.name !== 'SequelizeUniqueConstraintError') { + throw error; + } + + const duplicate = await db.places.findOne({ where, transaction }); + if (!duplicate) { + throw error; + } + + await duplicate.update(compactForUpdate(payload, currentUser), { transaction }); + return 'updated'; + } + } + + static async collectOpenStreetMap(params = {}, currentUser) { + const cities = normalizeList(params.cities || params.kota, DEFAULT_CITIES); + const amenities = normalizeAmenities(params); + const includeUnnamed = toBoolean(params.includeUnnamed, false); + const dryRun = toBoolean(params.dryRun, false); + const delayMs = toPositiveInteger(params.delayMs, 3000, 30000); + const timeoutSeconds = toPositiveInteger(params.timeoutSeconds, 60, 180) || 60; + const limitPerCity = toPositiveInteger(params.limitPerCity, 0, 5000); + const overpassUrl = params.overpassUrl || OVERPASS_URL; + + const summary = { + success: true, + source: 'OpenStreetMap', + dryRun, + citiesRequested: cities, + amenities, + inserted: 0, + updated: 0, + skipped: 0, + skippedByReason: {}, + fetched: 0, + processed: 0, + cityResults: [], + }; + + for (let index = 0; index < cities.length; index += 1) { + const city = cities[index]; + const elements = await GeoSeekCollectorService.fetchOpenStreetMapCity(city, amenities, { + overpassUrl, + timeoutSeconds, + }); + + summary.fetched += elements.length; + + const cityResult = { + city, + fetched: elements.length, + processed: 0, + inserted: 0, + updated: 0, + skipped: 0, + skippedByReason: {}, + preview: [], + }; + + const places = []; + for (const element of elements) { + const normalized = normalizeOsmElement(element, city, amenities, includeUnnamed); + if (!normalized.place) { + const reason = normalized.reason || 'unknown'; + cityResult.skipped += 1; + cityResult.skippedByReason[reason] = (cityResult.skippedByReason[reason] || 0) + 1; + continue; + } + + places.push(normalized.place); + } + + const selectedPlaces = limitPerCity > 0 ? places.slice(0, limitPerCity) : places; + const limited = places.length - selectedPlaces.length; + if (limited > 0) { + cityResult.skipped += limited; + cityResult.skippedByReason.limit_per_city = limited; + } + + if (dryRun) { + cityResult.processed = selectedPlaces.length; + cityResult.preview = selectedPlaces.slice(0, 10).map((place) => ({ + nama: place.nama, + kategori: place.kategori, + kota: place.kota, + latitude: place.latitude, + longitude: place.longitude, + external_id: place.external_id, + external_type: place.external_type, + })); + } else { + await db.sequelize.transaction(async (transaction) => { + for (const place of selectedPlaces) { + const action = await GeoSeekCollectorService.upsertPlace(place, currentUser, transaction); + if (action === 'inserted') { + cityResult.inserted += 1; + } else { + cityResult.updated += 1; + } + cityResult.processed += 1; + } + }); + } + + summary.inserted += cityResult.inserted; + summary.updated += cityResult.updated; + summary.skipped += cityResult.skipped; + summary.processed += cityResult.processed; + for (const [reason, count] of Object.entries(cityResult.skippedByReason)) { + summary.skippedByReason[reason] = (summary.skippedByReason[reason] || 0) + count; + } + summary.cityResults.push(cityResult); + + if (delayMs > 0 && index < cities.length - 1) { + await sleep(delayMs); + } + } + + return summary; + } +}; diff --git a/backend/src/services/places.js b/backend/src/services/places.js index 5f30f4e..dec5a47 100644 --- a/backend/src/services/places.js +++ b/backend/src/services/places.js @@ -3,8 +3,6 @@ const PlacesDBApi = require('../db/api/places'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); @@ -15,7 +13,7 @@ module.exports = class PlacesService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await PlacesDBApi.create( + const createdPlaces = await PlacesDBApi.create( data, { currentUser, @@ -24,13 +22,14 @@ module.exports = class PlacesService { ); await transaction.commit(); + return createdPlaces; } catch (error) { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -95,7 +94,7 @@ module.exports = class PlacesService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 4ee9b9a..4e818db 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -131,6 +131,16 @@ type LocationState = { label: string; }; + +type SearchOrigin = { + lat: number; + lng: number; + label: string; + source: 'browser' | 'query_keyword' | 'default_local_keyword' | string; + matched_keyword?: string; + note?: string; +}; + type DistanceBucket = { radius_km: number; label: string; @@ -165,6 +175,7 @@ type SearchMeta = { expanded_for_nearest?: boolean; external_sources?: string[]; external_source_errors?: string[]; + search_origin?: SearchOrigin | null; }; const LOCATION_REQUIRED_MESSAGE = @@ -173,6 +184,136 @@ const LOCATION_REQUIRED_MESSAGE = const DEFAULT_RADIUS_KM = 5; const GLOBAL_RADIUS_KM = 20038; + +const keywordLocationPhrases = [ + 'maps', + 'google maps', + 'maps google', + 'peta', + 'google earth', + 'waze', + 'street view', + 'google maps satellite', + 'peta indonesia', + 'cuaca', + 'cuaca hari ini', + 'prakiraan cuaca', + 'bmkg', + 'ramalan cuaca', + 'cuaca esok hari', + 'terdekat', + 'dekat saya', + 'near me', + 'jakarta', + 'bogor', + 'bekasi', + 'tangerang', + 'depok', + 'cikarang', + 'karawang', + 'bandung', + 'cafe', + 'restoran', + 'kuliner', + 'rumah makan', + 'tempat makan', + 'warkop', + 'kedai kopi', + 'hotel', + 'penginapan', + 'villa', + 'homestay', + 'kos', + 'kost', + 'kontrakan', + 'apartemen', + 'pom bensin', + 'spbu', + 'pertamini', + 'bengkel', + 'tambal ban', + 'atm', + 'bank', + 'minimarket', + 'indomaret', + 'alfamart', + 'supermarket', + 'toko kelontong', + 'pasar', + 'mall', + 'pusat perbelanjaan', + 'toko obat', + 'apotek', + 'rumah sakit', + 'puskesmas', + 'klinik', + 'dokter', + 'salon', + 'barbershop', + 'spa', + 'pijat', + 'gym', + 'fitness', + 'taman', + 'wisata', + 'masjid', + 'mushola', + 'gereja', + 'pura', + 'vihara', + 'sekolah', + 'kampus', + 'universitas', + 'stasiun', + 'halte', + 'terminal', + 'bandara', + 'band', +]; + +const normalizeSearchText = (value: string) => + value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +const canSearchWithKeywordOrigin = (value: string) => { + const normalized = normalizeSearchText(value); + + if (!normalized) { + return false; + } + + return keywordLocationPhrases.some((phrase) => + normalized.includes(normalizeSearchText(phrase)), + ); +}; + +const toSearchOrigin = (value: unknown): SearchOrigin | null => { + if (!value || typeof value !== 'object') { + return null; + } + + const origin = value as Partial; + const lat = Number(origin.lat); + const lng = Number(origin.lng); + + if (!Number.isFinite(lat) || !Number.isFinite(lng) || !origin.label) { + return null; + } + + return { + lat, + lng, + label: String(origin.label), + source: origin.source || 'query_keyword', + matched_keyword: origin.matched_keyword, + note: origin.note, + }; +}; + const fallbackRadiusZones: RadiusZone[] = [ { value: 1, label: 'Walking Zone', range: '0–1 Km' }, { value: 5, label: 'Neighborhood Zone', range: '1–5 Km' }, @@ -518,6 +659,28 @@ const topLocationKeywords: KeywordChip[] = [ ]; const categoryKeywordGroups: KeywordGroup[] = [ + { + title: 'Maps, Navigasi & Cuaca', + description: + 'Intent maps, peta, navigasi, street view, peta Indonesia, dan keyword cuaca/BMKG yang diarahkan ke data lokasi publik.', + keywords: [ + { label: 'Maps', query: 'maps' }, + { label: 'Google Maps', query: 'google maps' }, + { label: 'Maps Google', query: 'maps google' }, + { label: 'Peta', query: 'peta' }, + { label: 'Google Earth', query: 'google earth' }, + { label: 'Waze', query: 'waze' }, + { label: 'Street View', query: 'street view' }, + { label: 'Google Maps Satellite', query: 'google maps satellite' }, + { label: 'Peta Indonesia', query: 'peta indonesia', radiusKm: 100 }, + { label: 'Cuaca', query: 'cuaca' }, + { label: 'Cuaca Hari Ini', query: 'cuaca hari ini' }, + { label: 'Prakiraan Cuaca', query: 'prakiraan cuaca' }, + { label: 'BMKG', query: 'BMKG' }, + { label: 'Ramalan Cuaca', query: 'ramalan cuaca' }, + { label: 'Cuaca Esok Hari', query: 'cuaca esok hari' }, + ], + }, { title: 'Kuliner', description: @@ -525,15 +688,33 @@ const categoryKeywordGroups: KeywordGroup[] = [ keywords: [ { label: 'Makanan terdekat', query: 'makanan terdekat' }, { label: 'Restoran terdekat', query: 'restoran terdekat' }, + { label: 'Kuliner terdekat', query: 'kuliner terdekat' }, + { label: 'Rumah makan terdekat', query: 'rumah makan terdekat' }, + { label: 'Tempat makan terdekat', query: 'tempat makan terdekat' }, { label: 'Cafe terdekat', query: 'cafe terdekat' }, + { label: 'Cafe aesthetic', query: 'cafe aesthetic' }, + { label: 'Tempat nongkrong', query: 'tempat nongkrong terdekat' }, { label: 'Warung makan terdekat', query: 'warung makan terdekat' }, + { label: 'Warkop terdekat', query: 'warkop terdekat' }, + { label: 'Kedai kopi terdekat', query: 'kedai kopi terdekat' }, { label: 'Warteg terdekat', query: 'warteg terdekat' }, { label: 'Bakso terdekat', query: 'bakso terdekat' }, { label: 'Soto terdekat', query: 'soto terdekat' }, { label: 'Ayam goreng terdekat', query: 'ayam goreng terdekat' }, { label: 'Seafood terdekat', query: 'seafood terdekat' }, + { label: 'Kuliner malam', query: 'kuliner malam' }, + { label: 'Cemilan unik', query: 'cemilan unik' }, + { label: 'Jajanan pasar modern', query: 'jajanan pasar modern' }, + { label: 'Makanan viral murah', query: 'makanan viral murah' }, + { label: 'Ide jualan makanan', query: 'ide jualan makanan' }, + { label: 'Makanan pedas', query: 'makanan pedas' }, { label: 'Catering terdekat', query: 'catering terdekat' }, + { label: 'Katering sehat', query: 'katering sehat' }, { label: 'Minuman terdekat', query: 'minuman terdekat' }, + { label: 'Minuman kekinian', query: 'minuman kekinian' }, + { label: 'Es kopi susu', query: 'es kopi susu' }, + { label: 'Boba drink', query: 'boba drink' }, + { label: 'Resep minuman segar', query: 'resep minuman segar' }, ], }, { @@ -551,6 +732,11 @@ const categoryKeywordGroups: KeywordGroup[] = [ { label: 'Skincare terdekat', query: 'toko skincare terdekat' }, { label: 'Herbal terdekat', query: 'toko herbal terdekat' }, { label: 'Obat terdekat', query: 'toko obat terdekat' }, + { label: 'Indomaret terdekat', query: 'indomaret terdekat' }, + { label: 'Alfamart terdekat', query: 'alfamart terdekat' }, + { label: 'Supermarket terdekat', query: 'supermarket terdekat' }, + { label: 'Pusat perbelanjaan', query: 'pusat perbelanjaan terdekat' }, + { label: 'Toko baju terdekat', query: 'toko baju terdekat' }, { label: 'Furniture terdekat', query: 'toko furniture terdekat' }, { label: 'Peralatan rumah tangga', @@ -584,6 +770,8 @@ const categoryKeywordGroups: KeywordGroup[] = [ { label: 'Tambal ban', query: 'tambal ban terdekat' }, { label: 'Cuci mobil', query: 'cuci mobil terdekat' }, { label: 'SPBU terdekat', query: 'SPBU terdekat' }, + { label: 'Pom bensin', query: 'pom bensin terdekat' }, + { label: 'Pertamini', query: 'pertamini terdekat' }, ], }, { @@ -594,8 +782,11 @@ const categoryKeywordGroups: KeywordGroup[] = [ { label: 'Rumah sakit terdekat', query: 'rumah sakit terdekat' }, { label: 'Klinik terdekat', query: 'klinik terdekat' }, { label: 'Apotek terdekat', query: 'apotek terdekat' }, + { label: 'Apotek 24 jam', query: 'apotek 24 jam terdekat' }, + { label: 'Puskesmas terdekat', query: 'puskesmas terdekat' }, { label: 'Dokter terdekat', query: 'dokter terdekat' }, { label: 'Dokter gigi', query: 'dokter gigi terdekat' }, + { label: 'Bidan terdekat', query: 'bidan terdekat' }, { label: 'Laboratorium', query: 'laboratorium terdekat' }, { label: 'Alat kesehatan', query: 'alat kesehatan terdekat' }, { label: 'Pet shop', query: 'pet shop terdekat' }, @@ -662,6 +853,10 @@ const categoryKeywordGroups: KeywordGroup[] = [ keywords: [ { label: 'Bank terdekat', query: 'bank terdekat' }, { label: 'ATM terdekat', query: 'ATM terdekat' }, + { label: 'ATM BCA', query: 'atm bca terdekat' }, + { label: 'ATM BRI', query: 'atm bri terdekat' }, + { label: 'ATM Mandiri', query: 'atm mandiri terdekat' }, + { label: 'ATM BNI', query: 'atm bni terdekat' }, { label: 'Koperasi terdekat', query: 'koperasi terdekat' }, { label: 'Pegadaian terdekat', query: 'pegadaian terdekat' }, ], @@ -687,6 +882,9 @@ const categoryKeywordGroups: KeywordGroup[] = [ keywords: [ { label: 'Salon terdekat', query: 'salon terdekat' }, { label: 'Barbershop terdekat', query: 'barbershop terdekat' }, + { label: 'Pangkas rambut', query: 'pangkas rambut terdekat' }, + { label: 'Spa terdekat', query: 'spa terdekat' }, + { label: 'Pijat terdekat', query: 'pijat terdekat' }, { label: 'Skincare terdekat', query: 'skincare terdekat' }, { label: 'Kosmetik terdekat', query: 'kosmetik terdekat' }, ], @@ -725,12 +923,57 @@ const categoryKeywordGroups: KeywordGroup[] = [ description: 'Intent tempat wisata, hotel, penginapan, villa, dan pantai.', keywords: [ { label: 'Tempat wisata', query: 'tempat wisata terdekat' }, + { label: 'Wisata alam', query: 'wisata alam terdekat' }, + { label: 'Taman terdekat', query: 'taman terdekat' }, + { label: 'Alun-alun', query: 'alun alun terdekat' }, + { label: 'Museum terdekat', query: 'museum terdekat' }, + { label: 'Kebun binatang', query: 'kebun binatang terdekat' }, { label: 'Hotel terdekat', query: 'hotel terdekat' }, { label: 'Penginapan terdekat', query: 'penginapan terdekat' }, { label: 'Villa terdekat', query: 'villa terdekat' }, + { label: 'Homestay terdekat', query: 'homestay terdekat' }, { label: 'Pantai terdekat', query: 'pantai terdekat' }, ], }, + { + title: 'Ibadah, Pendidikan & Transportasi', + description: + 'Intent masjid, mushola, gereja, pura, vihara, sekolah, kampus, tempat les, stasiun, halte, terminal, dan bandara.', + keywords: [ + { label: 'Masjid terdekat', query: 'masjid terdekat' }, + { label: 'Mushola terdekat', query: 'mushola terdekat' }, + { label: 'Gereja terdekat', query: 'gereja terdekat' }, + { label: 'Pura terdekat', query: 'pura terdekat' }, + { label: 'Vihara terdekat', query: 'vihara terdekat' }, + { label: 'Sekolah terdekat', query: 'sekolah terdekat' }, + { label: 'SD terdekat', query: 'sd terdekat' }, + { label: 'SMP terdekat', query: 'smp terdekat' }, + { label: 'SMA terdekat', query: 'sma terdekat' }, + { label: 'Kampus terdekat', query: 'kampus terdekat' }, + { label: 'Universitas terdekat', query: 'universitas terdekat' }, + { label: 'Pesantren terdekat', query: 'pesantren terdekat' }, + { label: 'LPK terdekat', query: 'lpk terdekat' }, + { label: 'Tempat les', query: 'tempat les terdekat' }, + { label: 'Stasiun terdekat', query: 'stasiun terdekat' }, + { label: 'Stasiun kereta', query: 'stasiun kereta terdekat' }, + { label: 'Halte terdekat', query: 'halte terdekat' }, + { label: 'Halte busway', query: 'halte busway terdekat' }, + { label: 'Terminal terdekat', query: 'terminal terdekat' }, + { label: 'Bandara', query: 'bandara terdekat' }, + ], + }, + { + title: 'Olahraga & Rekreasi Harian', + description: + 'Intent gym, tempat fitness, kolam renang, lapangan futsal, taman, dan aktivitas rekreasi sekitar.', + keywords: [ + { label: 'Gym terdekat', query: 'gym terdekat' }, + { label: 'Tempat fitness', query: 'tempat fitness terdekat' }, + { label: 'Kolam renang', query: 'kolam renang terdekat' }, + { label: 'Lapangan futsal', query: 'lapangan futsal terdekat' }, + { label: 'Taman terdekat', query: 'taman terdekat' }, + ], + }, { title: 'Pemerintahan & BUMN', description: @@ -979,6 +1222,19 @@ export default function Starter() { const featuredPlaces = places.slice(0, 3); const formulaEntries = Object.entries(searchMeta.geo_score_formula || {}); const hasActiveLocation = locationReady && Boolean(location); + const canUseKeywordLocation = canSearchWithKeywordOrigin(query); + const canSubmitSearch = hasActiveLocation || canUseKeywordLocation; + const activeSearchOrigin = + location || + (searchMeta.search_origin + ? { + lat: searchMeta.search_origin.lat, + lng: searchMeta.search_origin.lng, + label: searchMeta.search_origin.label, + } + : null); + const activeLocationLabel = + activeSearchOrigin?.label || 'Belum aktif — izinkan lokasi browser'; const openNow = searchMeta.trending?.live?.open_now || places.filter((place) => place.live_status?.status === 'open').length; @@ -993,22 +1249,36 @@ export default function Starter() { const fetchPlaces = useCallback( async ( nextQuery: string, - nextLocation: LocationState, + nextLocation: LocationState | null, nextRadiusKm: number, ) => { setLoading(true); setError(''); try { - const response = await axios.get('/public/places', { - params: { - q: nextQuery, - lat: nextLocation.lat, - lng: nextLocation.lng, - radiusKm: nextRadiusKm, - limit: 30, - }, - }); + const params: { + q: string; + radiusKm: number; + limit: number; + lat?: number; + lng?: number; + } = { + q: nextQuery, + radiusKm: nextRadiusKm, + limit: 30, + }; + + if (nextLocation) { + params.lat = nextLocation.lat; + params.lng = nextLocation.lng; + } + + const response = await axios.get('/public/places', { params }); + const responseSearchOrigin = toSearchOrigin(response.data?.search_origin); + + if (!nextLocation && responseSearchOrigin) { + setLocationStatus(`Lokasi dari kata kunci: ${responseSearchOrigin.label}`); + } setPlaces(Array.isArray(response.data?.rows) ? response.data.rows : []); setSearchMeta({ @@ -1042,6 +1312,7 @@ export default function Starter() { ) ? response.data.external_source_errors : [], + search_origin: responseSearchOrigin, }); } catch (err) { console.error('Gagal memuat GeoSeek public search', err); @@ -1125,10 +1396,10 @@ export default function Starter() { const handleSearch = async (event: React.FormEvent) => { event.preventDefault(); - if (!locationReady || !location) { + if (!canSubmitSearch) { setError(LOCATION_REQUIRED_MESSAGE); setLocationStatus( - 'Lokasi belum aktif. Klik “Gunakan lokasi saya” dan izinkan akses lokasi browser.', + 'Lokasi belum aktif. Klik “Gunakan lokasi saya” atau ketik keyword local SEO/kota seperti “kuliner Jakarta”, “maps”, “SPBU terdekat”, atau “cuaca”.', ); return; } @@ -1155,11 +1426,11 @@ export default function Starter() { return place.google_maps_url; } - if (!location) { + if (!activeSearchOrigin) { return '#search'; } - return `/tempat/${place.id}?lat=${location.lat}&lng=${location.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`; + return `/tempat/${place.id}?lat=${activeSearchOrigin.lat}&lng=${activeSearchOrigin.lng}&radiusKm=${radiusKm}&q=${encodeURIComponent(query)}`; }; const shouldOpenExternalPlace = (place: PublicPlace) => @@ -1261,13 +1532,13 @@ export default function Starter() { setQuery(event.target.value)} - placeholder='Cari “warteg terdekat”, “toko bahan bangunan”, “service laptop”, “smartphone”, “Samsat”, “SPBU terdekat”...' + placeholder='Cari “maps”, “cuaca”, “kuliner Jakarta”, “cafe aesthetic”, “SPBU terdekat”, “ATM BCA”, “stasiun terdekat”...' className='h-14 w-full rounded-2xl border border-[#E0D6C3] bg-[#FCFAF5] pl-12 pr-4 text-sm outline-none transition focus:border-[#2CA58D] focus:ring-4 focus:ring-[#2CA58D]/15' />