GEOSEEK 2.0
This commit is contained in:
parent
1c09ee751e
commit
5d3fe9d7a2
@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
|
||||
24
backend/src/routes/geoseekCollector.js
Normal file
24
backend/src/routes/geoseekCollector.js
Normal file
@ -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;
|
||||
@ -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 };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
462
backend/src/services/geoseekCollector.js
Normal file
462
backend/src/services/geoseekCollector.js
Normal file
@ -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;
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
|
||||
@ -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<SearchOrigin>;
|
||||
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<HTMLFormElement>) => {
|
||||
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() {
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => 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'
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={!hasActiveLocation || loading}
|
||||
disabled={!canSubmitSearch || loading}
|
||||
className='h-14 rounded-2xl bg-[#F26A4B] px-7 font-black text-white shadow-lg shadow-[#F26A4B]/30 transition hover:bg-[#E85A39] focus:outline-none focus:ring-4 focus:ring-[#F26A4B]/30 disabled:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
Cari
|
||||
@ -1288,7 +1559,9 @@ export default function Starter() {
|
||||
dikosongkan •{' '}
|
||||
{hasActiveLocation
|
||||
? 'Live search aktif'
|
||||
: 'Menunggu izin lokasi'}
|
||||
: canUseKeywordLocation
|
||||
? 'Bisa cari dari keyword/kota'
|
||||
: 'Menunggu izin lokasi'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -1309,8 +1582,8 @@ export default function Starter() {
|
||||
|
||||
<div className='mt-6 inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-bold text-white/80'>
|
||||
Kategori pencarian dikosongkan otomatis — hasil ditentukan
|
||||
dari kata kunci dan jarak. GeoSeek mengejar minimal 10 hasil
|
||||
terdekat bila data tersedia.
|
||||
dari kata kunci dan jarak. Jika lokasi browser belum aktif,
|
||||
keyword local SEO/kota memakai titik awal dari backend.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1327,7 +1600,7 @@ export default function Starter() {
|
||||
</h2>
|
||||
</div>
|
||||
<span className='rounded-full bg-[#073B3A] px-3 py-1 text-xs font-bold text-white'>
|
||||
{location?.label || 'Lokasi saya belum aktif'}
|
||||
{activeLocationLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative h-80 overflow-hidden rounded-[1.6rem] bg-[#D9E7D8]'>
|
||||
@ -1394,7 +1667,7 @@ export default function Starter() {
|
||||
<p className='mt-3 max-w-3xl text-[#5D6B62]'>
|
||||
Lokasi:{' '}
|
||||
<strong>
|
||||
{location?.label || 'Belum aktif — izinkan lokasi browser'}
|
||||
{activeLocationLabel}
|
||||
</strong>
|
||||
. Radius aktif:{' '}
|
||||
<strong>
|
||||
@ -1405,6 +1678,12 @@ export default function Starter() {
|
||||
{searchMeta.expanded_for_nearest
|
||||
? ` Radius awal ${formatRadius(radiusKm)} belum memenuhi target minimal 10 hasil, jadi GeoSeek memperluas pencarian sampai ${formatRadius(searchMeta.effective_radius_km || radiusKm)} dan tetap mengurutkan berdasarkan jarak.`
|
||||
: ''}
|
||||
{searchMeta.search_origin?.source === 'query_keyword'
|
||||
? ` Lokasi dipilih dari kata kunci: ${searchMeta.search_origin.label}.`
|
||||
: ''}
|
||||
{searchMeta.search_origin?.source === 'default_local_keyword'
|
||||
? ` ${searchMeta.search_origin.note || 'Lokasi browser tidak aktif, GeoSeek memakai titik awal SEO lokal.'}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
@ -1453,13 +1732,21 @@ export default function Starter() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hasActiveLocation ? (
|
||||
{loading ? (
|
||||
<div className='grid gap-5 md:grid-cols-3'>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className='h-96 animate-pulse rounded-[2rem] bg-white/70'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : !activeSearchOrigin ? (
|
||||
<div className='rounded-[2rem] border border-dashed border-[#087F6D]/40 bg-white p-10 text-center'>
|
||||
<h3 className='text-2xl font-black'>Aktifkan Lokasi Saya dulu</h3>
|
||||
<p className='mx-auto mt-3 max-w-xl text-[#5D6B62]'>
|
||||
GeoSeek hanya memakai lokasi pengguna yang sedang mencari.
|
||||
Pencarian berjalan setelah browser mengirim koordinat lokasi
|
||||
Anda.
|
||||
GeoSeek bisa memakai lokasi browser, kata kunci kota, atau
|
||||
titik awal SEO lokal untuk menampilkan hasil pencarian.
|
||||
</p>
|
||||
<div className='mt-6'>
|
||||
<button
|
||||
@ -1471,15 +1758,6 @@ export default function Starter() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className='grid gap-5 md:grid-cols-3'>
|
||||
{[0, 1, 2].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className='h-96 animate-pulse rounded-[2rem] bg-white/70'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : places.length ? (
|
||||
<div className='grid gap-5 md:grid-cols-2 xl:grid-cols-3'>
|
||||
{places.map((place) => (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user