From 1c09ee751e66fac06c0debfedf86c5f59100adf6 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 18 Jun 2026 10:42:13 +0000 Subject: [PATCH] Autosave: 20260618-104218 --- backend/src/auth/auth.js | 16 +- .../20260618120000-add-email-otp-to-users.js | 94 ++ backend/src/db/models/users.js | 40 +- backend/src/routes/auth.js | 68 +- backend/src/services/auth.js | 257 +++- backend/src/services/email/list/emailOtp.js | 58 + backend/src/services/notifications/list.js | 6 + .../GeoSeek/GeoSeekProWorkspace.tsx | 289 ++++- frontend/src/pages/dashboard.tsx | 1098 ++++++++++------- frontend/src/pages/login.tsx | 133 +- frontend/src/pages/register.tsx | 120 +- frontend/src/stores/authSlice.ts | 82 +- 12 files changed, 1760 insertions(+), 501 deletions(-) create mode 100644 backend/src/db/migrations/20260618120000-add-email-otp-to-users.js create mode 100644 backend/src/services/email/list/emailOtp.js diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js index 251c149..25c6700 100644 --- a/backend/src/auth/auth.js +++ b/backend/src/auth/auth.js @@ -1,13 +1,11 @@ const config = require('../config'); const providers = config.providers; -const helpers = require('../helpers'); -const db = require('../db/models'); - const passport = require('passport'); const JWTstrategy = require('passport-jwt').Strategy; const ExtractJWT = require('passport-jwt').ExtractJwt; const GoogleStrategy = require('passport-google-oauth2').Strategy; const MicrosoftStrategy = require('passport-microsoft').Strategy; +const AuthService = require('../services/auth'); const UsersDBApi = require('../db/api/users'); @@ -56,13 +54,7 @@ passport.use(new MicrosoftStrategy({ )); function socialStrategy(email, profile, provider, done) { - db.users.findOrCreate({where: {email, provider}}).then(([user, created]) => { - const body = { - id: user.id, - email: user.email, - name: profile.displayName, - }; - const token = helpers.jwtSign({user: body}); - return done(null, {token}); - }); + AuthService.socialSignin(email, profile, provider) + .then((token) => done(null, { token })) + .catch((error) => done(error)); } diff --git a/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js b/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js new file mode 100644 index 0000000..e2d5029 --- /dev/null +++ b/backend/src/db/migrations/20260618120000-add-email-otp-to-users.js @@ -0,0 +1,94 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const table = await queryInterface.describeTable('users'); + + if (!table.emailOtpHash) { + await queryInterface.addColumn( + 'users', + 'emailOtpHash', + { + type: Sequelize.DataTypes.TEXT, + allowNull: true, + }, + { transaction }, + ); + } + + if (!table.emailOtpExpiresAt) { + await queryInterface.addColumn( + 'users', + 'emailOtpExpiresAt', + { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + { transaction }, + ); + } + + if (!table.emailOtpLastSentAt) { + await queryInterface.addColumn( + 'users', + 'emailOtpLastSentAt', + { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + { transaction }, + ); + } + + if (!table.emailOtpAttempts) { + await queryInterface.addColumn( + 'users', + 'emailOtpAttempts', + { + type: Sequelize.DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const table = await queryInterface.describeTable('users'); + + if (table.emailOtpAttempts) { + await queryInterface.removeColumn('users', 'emailOtpAttempts', { transaction }); + } + + if (table.emailOtpLastSentAt) { + await queryInterface.removeColumn('users', 'emailOtpLastSentAt', { transaction }); + } + + if (table.emailOtpExpiresAt) { + await queryInterface.removeColumn('users', 'emailOtpExpiresAt', { transaction }); + } + + if (table.emailOtpHash) { + await queryInterface.removeColumn('users', 'emailOtpHash', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/users.js b/backend/src/db/models/users.js index 9ece124..d51711c 100644 --- a/backend/src/db/models/users.js +++ b/backend/src/db/models/users.js @@ -2,7 +2,6 @@ const config = require('../../config'); const providers = config.providers; const crypto = require('crypto'); const bcrypt = require('bcrypt'); -const moment = require('moment'); module.exports = function(sequelize, DataTypes) { const users = sequelize.define( @@ -95,6 +94,37 @@ passwordResetTokenExpiresAt: { + }, + +emailOtpHash: { + type: DataTypes.TEXT, + + + + }, + +emailOtpExpiresAt: { + type: DataTypes.DATE, + + + + }, + +emailOtpLastSentAt: { + type: DataTypes.DATE, + + + + }, + +emailOtpAttempts: { + type: DataTypes.INTEGER, + + allowNull: false, + defaultValue: 0, + + + }, provider: { @@ -229,8 +259,8 @@ provider: { }; - users.beforeCreate((users, options) => { - users = trimStringFields(users); + users.beforeCreate((users) => { + trimStringFields(users); if (users.provider !== providers.LOCAL && Object.values(providers).indexOf(users.provider) > -1) { users.emailVerified = true; @@ -250,8 +280,8 @@ provider: { } }); - users.beforeUpdate((users, options) => { - users = trimStringFields(users); + users.beforeUpdate((users) => { + trimStringFields(users); }); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index d6f29e8..3b8ad23 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -84,8 +84,12 @@ router.get('/me', passport.authenticate('jwt', {session: false}), (req, res) => throw new ForbiddenError(); } - const payload = req.currentUser; + const payload = { ...req.currentUser }; delete payload.password; + delete payload.emailOtpHash; + delete payload.emailOtpExpiresAt; + delete payload.emailOtpLastSentAt; + delete payload.emailOtpAttempts; res.status(200).send(payload); }); @@ -171,12 +175,31 @@ router.get('/email-configured', (req, res) => { res.status(200).send(payload); }); +router.post('/otp/request', wrapAsync(async (req, res) => { + const payload = await AuthService.requestEmailOtp(req.body.email); + res.status(200).send(payload); +})); + +router.post('/otp/verify', wrapAsync(async (req, res) => { + const payload = await AuthService.verifyEmailOtp(req.body.email, req.body.otp); + res.status(200).send(payload); +})); + router.get('/signin/google', (req, res, next) => { - passport.authenticate("google", {scope: ["profile", "email"], state: req.query.app})(req, res, next); + passport.authenticate("google", { + scope: ["profile", "email"], + state: req.query.app, + callbackURL: oauthCallbackUrl(req, 'google'), + })(req, res, next); }); -router.get('/signin/google/callback', passport.authenticate("google", {failureRedirect: "/login", session: false}), - +router.get('/signin/google/callback', (req, res, next) => { + passport.authenticate("google", { + failureRedirect: "/login", + session: false, + callbackURL: oauthCallbackUrl(req, 'google'), + })(req, res, next); + }, function (req, res) { socialRedirect(res, req.query.state, req.user.token, config); } @@ -185,14 +208,18 @@ router.get('/signin/google/callback', passport.authenticate("google", {failureRe router.get('/signin/microsoft', (req, res, next) => { passport.authenticate("microsoft", { scope: ["https://graph.microsoft.com/user.read openid"], - state: req.query.app + state: req.query.app, + callbackURL: oauthCallbackUrl(req, 'microsoft'), })(req, res, next); }); -router.get('/signin/microsoft/callback', passport.authenticate("microsoft", { - failureRedirect: "/login", - session: false - }), +router.get('/signin/microsoft/callback', (req, res, next) => { + passport.authenticate("microsoft", { + failureRedirect: "/login", + session: false, + callbackURL: oauthCallbackUrl(req, 'microsoft'), + })(req, res, next); + }, function (req, res) { socialRedirect(res, req.query.state, req.user.token, config); } @@ -200,8 +227,29 @@ router.get('/signin/microsoft/callback', passport.authenticate("microsoft", { router.use('/', require('../helpers').commonErrorHandler); +function oauthCallbackUrl(req, provider) { + const forwardedProto = req.get('x-forwarded-proto'); + const forwardedHost = req.get('x-forwarded-host'); + const protocol = (forwardedProto || req.protocol || 'http').split(',')[0].trim(); + const host = (forwardedHost || req.get('host')).split(',')[0].trim(); + + return `${protocol}://${host}/api/auth/signin/${provider}/callback`; +} + function socialRedirect(res, state, token, config) { - res.redirect(config.uiUrl + "/login?token=" + token); + let uiUrl = config.backUrl || config.uiUrl; + + if (state) { + try { + const decodedState = decodeURIComponent(state); + const parsedState = new URL(decodedState); + uiUrl = parsedState.origin; + } catch (error) { + console.error('Invalid social redirect state:', error); + } + } + + res.redirect(`${uiUrl}/login?token=${encodeURIComponent(token)}`); } module.exports = router; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index 2862da4..a9d33dc 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -2,15 +2,269 @@ const UsersDBApi = require('../db/api/users'); const ValidationError = require('./notifications/errors/validation'); const ForbiddenError = require('./notifications/errors/forbidden'); const bcrypt = require('bcrypt'); +const crypto = require('crypto'); const EmailAddressVerificationEmail = require('./email/list/addressVerification'); +const EmailOtpEmail = require('./email/list/emailOtp'); const InvitationEmail = require("./email/list/invitation"); const PasswordResetEmail = require('./email/list/passwordReset'); const EmailSender = require('./email'); const config = require('../config'); const helpers = require('../helpers'); +const db = require('../db/models'); class Auth { + static normalizeEmail(email) { + if (!email || typeof email !== 'string' || !email.includes('@')) { + throw new ValidationError('auth.invalidEmail'); + } + + return email.trim().toLowerCase(); + } + + static async findUserModelByEmail(email, transaction) { + return db.users.findOne({ + where: { + email: { + [db.Sequelize.Op.iLike]: email, + }, + }, + transaction, + }); + } + + static async ensureDefaultMemberRole(user, transaction) { + const currentRole = await user.getApp_role({ transaction }); + + if (currentRole?.id) { + return currentRole; + } + + const role = await db.roles.findOne({ + where: { name: config.roles?.user || 'Registered User' }, + transaction, + }); + + if (role?.id) { + await user.setApp_role(role.id, { transaction }); + } + + return role; + } + + static async createOtpUser(email, transaction) { + const randomPassword = crypto.randomBytes(20).toString('hex'); + const hashedPassword = await bcrypt.hash( + randomPassword, + config.bcrypt.saltRounds, + ); + + const user = await db.users.create( + { + email, + firstName: email.split('@')[0], + password: hashedPassword, + provider: config.providers.LOCAL, + emailVerified: false, + }, + { transaction }, + ); + + await this.ensureDefaultMemberRole(user, transaction); + + return user; + } + + static async requestEmailOtp(email) { + const normalizedEmail = this.normalizeEmail(email); + + if (!EmailSender.isConfigured) { + throw new ValidationError('auth.emailOtp.emailNotConfigured'); + } + + const transaction = await db.sequelize.transaction(); + + try { + let user = await this.findUserModelByEmail(normalizedEmail, transaction); + + if (user?.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user) { + user = await this.createOtpUser(normalizedEmail, transaction); + } + + if (user.emailOtpLastSentAt) { + const secondsSinceLastOtp = (Date.now() - new Date(user.emailOtpLastSentAt).getTime()) / 1000; + + if (secondsSinceLastOtp < 60) { + throw new ValidationError('auth.emailOtp.tooManyRequests'); + } + } + + const otp = String(crypto.randomInt(100000, 1000000)); + const expiresInMinutes = 10; + const emailOtpHash = await bcrypt.hash(otp, config.bcrypt.saltRounds); + + await user.update( + { + emailOtpHash, + emailOtpExpiresAt: new Date(Date.now() + expiresInMinutes * 60 * 1000), + emailOtpLastSentAt: new Date(), + emailOtpAttempts: 0, + }, + { transaction }, + ); + + const emailOtpEmail = new EmailOtpEmail(normalizedEmail, otp, expiresInMinutes); + await new EmailSender(emailOtpEmail).send(); + + await transaction.commit(); + + return { + success: true, + email: normalizedEmail, + expiresInMinutes, + }; + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + console.error('Email OTP request failed:', error); + throw error; + } + } + + static async verifyEmailOtp(email, otp) { + const normalizedEmail = this.normalizeEmail(email); + const normalizedOtp = String(otp || '').trim(); + + if (!/^\d{6}$/.test(normalizedOtp)) { + throw new ValidationError('auth.emailOtp.invalid'); + } + + const transaction = await db.sequelize.transaction(); + + try { + const user = await this.findUserModelByEmail(normalizedEmail, transaction); + + if (!user || user.disabled) { + throw new ValidationError(user?.disabled ? 'auth.userDisabled' : 'auth.emailOtp.invalid'); + } + + const attempts = Number(user.emailOtpAttempts || 0); + + if (attempts >= 5) { + throw new ValidationError('auth.emailOtp.tooManyAttempts'); + } + + if (!user.emailOtpHash || !user.emailOtpExpiresAt || new Date(user.emailOtpExpiresAt).getTime() < Date.now()) { + throw new ValidationError('auth.emailOtp.invalid'); + } + + const otpMatches = await bcrypt.compare(normalizedOtp, user.emailOtpHash); + + if (!otpMatches) { + await user.update( + { + emailOtpAttempts: attempts + 1, + }, + { transaction }, + ); + await transaction.commit(); + throw new ValidationError('auth.emailOtp.invalid'); + } + + await this.ensureDefaultMemberRole(user, transaction); + + await user.update( + { + emailVerified: true, + emailOtpHash: null, + emailOtpExpiresAt: null, + emailOtpAttempts: 0, + }, + { transaction }, + ); + + await transaction.commit(); + + const data = { + user: { + id: user.id, + email: user.email, + }, + }; + + return helpers.jwtSign(data); + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + console.error('Email OTP verification failed:', error); + throw error; + } + } + + static async socialSignin(email, profile, provider) { + const normalizedEmail = this.normalizeEmail(email); + const transaction = await db.sequelize.transaction(); + + try { + let user = await this.findUserModelByEmail(normalizedEmail, transaction); + + if (user?.disabled) { + throw new ValidationError('auth.userDisabled'); + } + + if (!user) { + const displayName = profile.displayName || profile.name?.givenName || normalizedEmail.split('@')[0]; + + user = await db.users.create( + { + email: normalizedEmail, + firstName: displayName, + provider, + emailVerified: true, + }, + { transaction }, + ); + + await this.ensureDefaultMemberRole(user, transaction); + } else { + const updatePayload = { + emailVerified: true, + }; + + if (!user.provider) { + updatePayload.provider = provider; + } + + await user.update(updatePayload, { transaction }); + } + + await transaction.commit(); + + const data = { + user: { + id: user.id, + email: user.email, + name: profile.displayName, + }, + }; + + return helpers.jwtSign(data); + } catch (error) { + if (!transaction.finished) { + await transaction.rollback(); + } + console.error('Social sign-in failed:', error); + throw error; + } + } + static async signup(email, password, options = {}, host) { + email = this.normalizeEmail(email); const user = await UsersDBApi.findBy({email}); const hashedPassword = await bcrypt.hash( @@ -81,7 +335,8 @@ class Auth { return helpers.jwtSign(data); } - static async signin(email, password, options = {}) { + static async signin(email, password) { + email = this.normalizeEmail(email); const user = await UsersDBApi.findBy({email}); if (!user) { diff --git a/backend/src/services/email/list/emailOtp.js b/backend/src/services/email/list/emailOtp.js new file mode 100644 index 0000000..67d14d0 --- /dev/null +++ b/backend/src/services/email/list/emailOtp.js @@ -0,0 +1,58 @@ +const { getNotification } = require('../../notifications/helpers'); + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +module.exports = class EmailOtpEmail { + constructor(to, otp, expiresInMinutes) { + this.to = to; + this.otp = otp; + this.expiresInMinutes = expiresInMinutes; + } + + get subject() { + return `Kode OTP ${getNotification('app.title')}`; + } + + async html() { + const appTitle = escapeHtml(getNotification('app.title')); + const otp = escapeHtml(this.otp); + const expiresInMinutes = escapeHtml(this.expiresInMinutes); + + return ` + + + + + + +
+ + + +
+ + + `; + } +}; diff --git a/backend/src/services/notifications/list.js b/backend/src/services/notifications/list.js index 55e7af5..2a12577 100644 --- a/backend/src/services/notifications/list.js +++ b/backend/src/services/notifications/list.js @@ -26,6 +26,12 @@ const errors = { 'Email verification link is invalid or has expired', error: `Email not recognized`, }, + emailOtp: { + emailNotConfigured: 'Email OTP is not configured yet. Please configure SMTP email first.', + invalid: 'OTP code is invalid or has expired', + tooManyRequests: 'Please wait before requesting another OTP code', + tooManyAttempts: 'Too many invalid OTP attempts. Please request a new code', + }, }, iam: { diff --git a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx index e65e7cb..08f5293 100644 --- a/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx +++ b/frontend/src/components/GeoSeek/GeoSeekProWorkspace.tsx @@ -10,7 +10,8 @@ import CardBox from '../CardBox'; import SectionMain from '../SectionMain'; import SectionTitleLineWithButton from '../SectionTitleLineWithButton'; import { getPageTitle } from '../../config'; -import { useAppSelector } from '../../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { aiResponse } from '../../stores/openAiSlice'; import { defaultGeoSeekLocation, GeoSeekItem, @@ -132,6 +133,15 @@ type GeoSeekApiMeta = { expandedForNearest: boolean; }; +type AiLocationPlan = { + allowed: boolean; + keyword: string; + radiusKm: number; + reason: string; + categoryHint?: string; + safetyNote?: string; +}; + const typeLabels: Record = { place: 'Tempat', product: 'Produk', @@ -166,6 +176,13 @@ const emptyApiMeta: GeoSeekApiMeta = { const radiusOptions = [1, 5, 10, 20]; +const aiLocationExamples = [ + 'Cari ATM terdekat dari lokasi saya', + 'Saya di Bogor, cari apotek buka sekarang radius 5 km', + 'Cari bengkel motor yang dekat dan masih buka', + 'Temukan tempat makan murah yang bisa antar', +]; + const apiQueryByModule: Partial> = { home: 'produk jasa umkm lokal', search: 'produk jasa umkm lokal', @@ -189,6 +206,120 @@ const apiQueryByModule: Partial> = { const getApiQuery = (query: string, moduleKey: GeoSeekModuleKey) => query.trim() || apiQueryByModule[moduleKey] || ''; +const normalizeAiRadius = (value: unknown) => { + const number = Number(value); + + if (!Number.isFinite(number)) return 5; + if (number <= 1) return 1; + if (number <= 5) return 5; + if (number <= 10) return 10; + + return 20; +}; + +const getAiResponseText = (response: any): string => { + const payload = response?.data || response; + + if (typeof payload === 'string') return payload; + if (!payload || typeof payload !== 'object') return ''; + + if (Array.isArray(payload.output)) { + return payload.output + .flatMap((item) => (Array.isArray(item?.content) ? item.content : [])) + .filter((block) => block?.type === 'output_text' && typeof block.text === 'string') + .map((block) => block.text) + .join('') + .trim(); + } + + if (typeof payload.text === 'string') return payload.text; + if (typeof payload.message === 'string') return payload.message; + + return ''; +}; + +const parseAiLocationPlan = (text: string, fallbackKeyword: string): AiLocationPlan => { + const stripped = text + .trim() + .replace(/^```json/i, '') + .replace(/^```/i, '') + .replace(/```$/i, '') + .trim(); + const jsonText = stripped.startsWith('{') ? stripped : stripped.match(/\{[\s\S]*\}/)?.[0]; + + if (!jsonText) { + throw new Error('AI tidak mengembalikan JSON rencana lokasi.'); + } + + const parsed = JSON.parse(jsonText); + const allowed = parsed.allowed !== false; + const keyword = String(parsed.keyword || fallbackKeyword || '').replace(/\s+/g, ' ').trim().slice(0, 90); + const reason = String(parsed.reason || 'AI membuat rencana pencarian berdasarkan kalimat Anda.').trim(); + const safetyNote = parsed.safetyNote ? String(parsed.safetyNote).trim() : undefined; + const categoryHint = parsed.categoryHint ? String(parsed.categoryHint).trim() : undefined; + + if (allowed && !keyword) { + throw new Error('AI tidak mengembalikan keyword pencarian.'); + } + + return { + allowed, + keyword, + radiusKm: normalizeAiRadius(parsed.radiusKm), + reason, + categoryHint, + safetyNote, + }; +}; + +const buildLocalLocationPlan = (input: string, fallbackRadiusKm: number): AiLocationPlan => { + const normalized = normalizeTagText(input); + + if (/lacak|melacak|stalking|stalk|lokasi real time|lokasi realtime|chat pribadi|akun private|akun privat|sadap|bajak/.test(normalized)) { + return { + allowed: false, + keyword: '', + radiusKm: normalizeAiRadius(fallbackRadiusKm), + reason: 'Permintaan mengarah ke pelacakan pribadi atau data non-publik.', + safetyNote: 'GeoSeek hanya mencari lokasi, bisnis, produk, jasa, dan informasi publik. Pelacakan orang pribadi atau akun private tidak didukung.', + }; + } + + const explicitRadius = input.match(/(\d+(?:[,.]\d+)?)\s*(?:km|kilometer)/i)?.[1]; + const radiusKm = explicitRadius + ? normalizeAiRadius(explicitRadius.replace(',', '.')) + : /terdekat|dekat|sekitar|jalan kaki/.test(normalized) + ? 1 + : normalizeAiRadius(fallbackRadiusKm); + + const keywordRules: Array<{ pattern: RegExp; keyword: string; categoryHint: string }> = [ + { pattern: /\batm\b|bank|tunai|tarik uang|setor/, keyword: 'atm bank tunai', categoryHint: 'Finansial' }, + { pattern: /apotek|obat|klinik|dokter|vitamin|kesehatan/, keyword: 'apotek klinik obat', categoryHint: 'Kesehatan' }, + { pattern: /bengkel|tambal ban|ban|motor|mobil|otomotif|servis|service/, keyword: 'bengkel otomotif servis', categoryHint: 'Otomotif' }, + { pattern: /makan|makanan|kuliner|restoran|warung|cafe|kopi|nasi|murah/, keyword: 'kuliner makanan murah', categoryHint: 'Kuliner' }, + { pattern: /hotel|penginapan|wisata|travel|tour/, keyword: 'hotel wisata', categoryHint: 'Wisata' }, + { pattern: /kurir|antar|kirim|ongkir|delivery/, keyword: 'kurir antar lokal', categoryHint: 'Kurir' }, + { pattern: /pupuk|tani|pertanian|bibit|organik/, keyword: 'pupuk pertanian', categoryHint: 'Pertanian' }, + { pattern: /toko|minimarket|market|supermarket|produk|belanja/, keyword: 'toko minimarket produk', categoryHint: 'Toko' }, + ]; + const matchedRule = keywordRules.find((rule) => rule.pattern.test(normalized)); + const fallbackKeyword = normalized + .split(/\s+/) + .filter((word) => word.length > 2 && !['cari', 'saya', 'dari', 'yang', 'dan', 'atau', 'untuk', 'lokasi', 'radius', 'dekat', 'terdekat'].includes(word)) + .slice(0, 6) + .join(' ') + || input.trim(); + + return { + allowed: true, + keyword: matchedRule?.keyword || fallbackKeyword, + radiusKm, + reason: 'Fallback lokal membaca intent pencarian saat AI proxy belum tersedia.', + categoryHint: matchedRule?.categoryHint || 'Pencarian umum', + safetyNote: 'AI proxy belum tersedia, jadi GeoSeek memakai parser lokal sementara.', + }; +}; + const toOptionalNumber = (value: unknown) => { if (value === null || value === undefined || value === '') return undefined; @@ -687,6 +818,10 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const activeModule = getModule(normalizedModuleKey); const [query, setQuery] = useState(getDefaultQuery(activeModule.key)); const [radiusKm, setRadiusKm] = useState(5); + const [aiLocationInput, setAiLocationInput] = useState(aiLocationExamples[0]); + const [aiLocationPlan, setAiLocationPlan] = useState(null); + const [aiLocationStatus, setAiLocationStatus] = useState('AI siap menerjemahkan kalimat bebas menjadi keyword dan radius pencarian GeoSeek.'); + const [isAiLocationLoading, setIsAiLocationLoading] = useState(false); const [smartInput, setSmartInput] = useState(sampleSmartInputs[0]); const [actionStatus, setActionStatus] = useState('Sistem otomasi siap digunakan. Pilih aksi cepat atau jalankan Smart Input.'); const [publishedItems, setPublishedItems] = useState([]); @@ -700,6 +835,7 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { const [apiError, setApiError] = useState(''); const [apiMeta, setApiMeta] = useState(emptyApiMeta); const [searchRequestVersion, setSearchRequestVersion] = useState(0); + const dispatch = useAppDispatch(); const { currentUser } = useAppSelector((state) => state.auth); const activeDistancePriority = useMemo(() => getDistancePriority(radiusKm), [radiusKm]); @@ -804,6 +940,89 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { recordAction(`Draft “${newItem.name}” dipublikasikan sebagai ${typeLabels[newItem.type]} lokal dan langsung masuk hasil GeoSeek.`, 'Publikasi Smart Input'); }; + const runAiLocationFinder = async () => { + const trimmedInput = aiLocationInput.trim(); + + if (!trimmedInput) { + setAiLocationStatus('Tulis dulu kebutuhan lokasi Anda, misalnya “cari ATM terdekat”.'); + return; + } + + setIsAiLocationLoading(true); + setAiLocationStatus('AI sedang memahami kebutuhan lokasi dan menyiapkan keyword pencarian...'); + + const payload = { + input: [ + { + role: 'system', + content: [ + 'Anda adalah AI Pencari Lokasi untuk GeoSeek.', + 'Ubah kalimat bebas pengguna menjadi rencana pencarian tempat, produk, jasa, atau bisnis publik.', + 'Jangan bantu melacak orang pribadi, akun private, chat WhatsApp pribadi, lokasi real-time personal, atau data non-publik.', + 'Jika permintaan tidak aman atau bersifat pelacakan pribadi, balas JSON dengan allowed false dan safetyNote singkat.', + 'Balas hanya JSON valid tanpa markdown.', + 'Format: {"allowed":true,"keyword":"keyword 2-6 kata","radiusKm":5,"reason":"alasan singkat","categoryHint":"kategori opsional","safetyNote":"catatan opsional"}.', + 'Pilih radiusKm hanya salah satu dari 1, 5, 10, atau 20.', + 'Untuk permintaan ATM gunakan keyword yang jelas seperti "atm bank tunai".', + ].join(' '), + }, + { + role: 'user', + content: [ + `Permintaan pengguna: ${trimmedInput}`, + `Modul GeoSeek aktif: ${activeModule.menuLabel}`, + `Lokasi aktif: ${resolvedLocation.label}`, + `Radius saat ini: ${radiusKm} km`, + 'Buat rencana yang bisa dipakai untuk endpoint /public/places.', + ].join('\n'), + }, + ], + options: { poll_interval: 3, poll_timeout: 120 }, + }; + + try { + const response = await dispatch(aiResponse(payload)).unwrap(); + const responseText = getAiResponseText(response); + const plan = parseAiLocationPlan(responseText, trimmedInput); + + setAiLocationPlan(plan); + + if (!plan.allowed) { + const safetyMessage = plan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, dan data publik.'; + + setAiLocationStatus(safetyMessage); + recordAction(`AI menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi'); + return; + } + + setQuery(plan.keyword); + setRadiusKm(plan.radiusKm); + setSearchRequestVersion((version) => version + 1); + setAiLocationStatus(`AI menjalankan pencarian “${plan.keyword}” radius ${plan.radiusKm} km. ${plan.reason}`); + recordAction(`AI menerjemahkan “${trimmedInput}” menjadi pencarian “${plan.keyword}” radius ${plan.radiusKm} km.`, 'AI Pencari Lokasi'); + } catch (error) { + console.error('AI Pencari Lokasi gagal, memakai fallback lokal:', { error, input: trimmedInput, payload }); + + const fallbackPlan = buildLocalLocationPlan(trimmedInput, radiusKm); + setAiLocationPlan(fallbackPlan); + + if (!fallbackPlan.allowed) { + const safetyMessage = fallbackPlan.safetyNote || 'GeoSeek hanya mendukung pencarian tempat, bisnis, produk, jasa, dan data publik.'; + + setAiLocationStatus(safetyMessage); + recordAction(`AI/fallback menolak permintaan karena alasan keamanan: ${safetyMessage}`, 'AI Pencari Lokasi'); + } else { + setQuery(fallbackPlan.keyword); + setRadiusKm(fallbackPlan.radiusKm); + setSearchRequestVersion((version) => version + 1); + setAiLocationStatus(`AI proxy belum tersedia, fallback lokal menjalankan “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km. ${fallbackPlan.reason}`); + recordAction(`Fallback AI menerjemahkan “${trimmedInput}” menjadi pencarian “${fallbackPlan.keyword}” radius ${fallbackPlan.radiusKm} km.`, 'AI Pencari Lokasi'); + } + } finally { + setIsAiLocationLoading(false); + } + }; + useEffect(() => { try { const storedItems = window.localStorage.getItem(localPublishedItemsKey); @@ -1071,6 +1290,74 @@ export default function GeoSeekProWorkspace({ moduleKey = 'home' }: Props) { )} + + +
+
+

AI Pencari Lokasi

+

Cari tempat dengan bahasa bebas

+

+ Tulis kebutuhan seperti “cari ATM terdekat” atau “apotek buka sekarang”. AI akan mengubahnya menjadi keyword dan radius, lalu GeoSeek menjalankan pencarian lokasi publik. +

+
+
+ +
+
+ +
+