import { productMappingSolr } from '@/utils/solrMapping'; import axios from 'axios'; import camelcaseObjectDeep from 'camelcase-object-deep'; import { chunkArray, buildProductIdOrClause, mergeSolrResults, } from '@/lib/utils/batchSolrQueries'; const escapeSolrQuery = (query) => { if (query == '*') return query; query = query.replace(/-/g, ' '); const specialChars = /([\+\!\(\)\{\}\[\]\^"~\*\?:\\\/])/g; const words = query.split(/\s+/); return words .map((word) => specialChars.test(word) ? word.replace(specialChars, '\\$1') : word, ) .join(' '); }; /** * Get parameter value from either GET (req.query) or POST (req.body) * POST takes precedence if available */ const getParam = (req, key, defaultValue = undefined) => { // POST takes precedence if (req.body && req.body[key] !== undefined) { return req.body[key]; } // Fallback to GET if (req.query && req.query[key] !== undefined) { return req.query[key]; } return defaultValue; }; export default async function handler(req, res) { // Support both GET and POST requests const params = req.method === 'POST' ? req.body : req.query; const { q = '*', page = 1, brand = '', category = '', priceFrom = 0, priceTo = 0, orderBy = '', operation = 'AND', fq = '', // bisa berupa string atau array limit = 30, source = '', group = 'true', } = params; let { stock = '' } = params; // Convert string parameters to appropriate types (for POST requests) const pageNum = parseInt(page, 10) || 1; const limitNum = parseInt(limit, 10) || 30; const priceFromNum = parseFloat(priceFrom) || 0; const priceToNum = parseFloat(priceTo) || 0; // ============================================================ // [PERBAIKAN] 1. LOGIC KHUSUS COMPARE (PAKAI URLSearchParams) // ============================================================ if (source === 'compare') { try { let qCompare = q === '*' ? '*:*' : q; if (qCompare !== '*:*') { qCompare = escapeSolrQuery(qCompare); qCompare = qCompare .split(/\s+/) .map((term) => { if (term && !term.includes('*')) { return term + '*'; } return term; }) .join(' '); } // [SOLUSI] Gunakan URLSearchParams untuk menyusun URL dengan aman const params = new URLSearchParams(); params.append('q', qCompare); params.append('rows', limitNum); params.append('wt', 'json'); params.append('indent', 'true'); // Gunakan eDisMax parser (Otak Cerdas) params.append('defType', 'edismax'); // Set Prioritas Pencarian (Boost ^) // 1. default_code_s^20 : SKU persis (Prioritas Tertinggi) // 2. search_keywords_t^10 : Field baru (Case insensitive) // 3. display_name_s^1 : Cadangan params.append( 'qf', 'default_code_s^20 search_keywords_t^10 display_name_s^1', ); const compareWords = qCompare.split(/\s+/).filter((w) => w.length > 0); let compareMm = '100%'; if (compareWords.length >= 3) { compareMm = '75%'; } params.append('mm', compareMm); if (group === 'false') { params.append('group', 'false'); } else { params.append('group', 'true'); params.append('group.field', 'template_id_i'); params.append('group.limit', '1'); params.append('group.main', 'true'); } // Field List (fl) params.append( 'fl', 'id,display_name_s,default_code_s,image_s,price_tier1_v2_f,attribute_set_id_i,attribute_set_name_s,template_id_i,product_id_i', ); // Filter Query (fq) Dasar params.append('fq', '-publish_b:false'); params.append('fq', 'price_tier1_v2_f:[1 TO *]'); // Logic Locking (Filter Attribute Set ID dari Frontend) if (fq) { if (Array.isArray(fq)) { fq.forEach((f) => params.append('fq', f)); } else { params.append('fq', fq); } } // Target Core: VARIANTS // HAPUS parameter manual dari string URL, gunakan params object const solrUrl = process.env.SOLR_HOST + '/solr/variants/select'; // Axios akan otomatis handle encoding % dan & dengan benar const result = await axios.get(solrUrl, { params: params }); // Mapping Result const mappedProducts = productMappingSolr( result.data.response.docs, false, ); const finalResponse = { ...result.data, response: { ...result.data.response, products: mappedProducts, }, }; delete finalResponse.response.docs; const camelCasedData = camelcaseObjectDeep(finalResponse); return res.status(200).json(camelCasedData); } catch (e) { console.error('[COMPARE SEARCH ERROR]', e.message); if (e.response && e.response.data) { // Log detail error dari Solr console.error( '[SOLR DETAILS]:', JSON.stringify(e.response.data, null, 2), ); } return res.status(200).json({ response: { products: [], numFound: 0 } }); } } // ============================================================ // LOGIC KHUSUS UPSELL (KODE LAMA ANDA) // ============================================================ if (source === 'upsell') { try { // Ambil fq dari query (format: product_id_i:(...)) // Pastikan fq adalah string tunggal let fqUpsell = Array.isArray(fq) ? fq.join(' OR ') : fq; fqUpsell = decodeURIComponent(fqUpsell); const parameter = [ 'q=*:*', `rows=${limit}`, 'wt=json', 'indent=true', 'defType=edismax', // Filter Query khusus Upsell `fq=${encodeURIComponent(fqUpsell)}`, // Tetap filter yang publish & ada harga agar produk valid `fq=${encodeURIComponent('-publish_b:false')}`, `fq=${encodeURIComponent('price_tier1_v2_f:[1 TO *]')}`, ]; // PENTING: SEARCH DI CORE 'VARIANTS' const solrUrl = process.env.SOLR_HOST + '/solr/variants/select?' + parameter.join('&'); const result = await axios(solrUrl); // 1. Mapping dasar const mappedProducts = productMappingSolr( result.data.response.docs, false, ); // 2. FIX URL LINK: Override ID Varian dengan Template ID const rawDocs = result.data.response.docs; const fixedProducts = mappedProducts.map((p, index) => { const raw = rawDocs[index]; if (raw && raw.template_id_i) { return { ...p, id: raw.template_id_i, // Ganti ID Varian jadi ID Template agar link valid variantId: raw.product_id_i, }; } return p; }); const finalResponse = { ...result.data, response: { ...result.data.response, products: fixedProducts, }, }; delete finalResponse.response.docs; const camelCasedData = camelcaseObjectDeep(finalResponse); return res.status(200).json(camelCasedData); } catch (e) { console.error('[UPSELL ERROR]', e.response?.data || e.message); return res.status(200).json({ response: { products: [], numFound: 0 } }); } } // ============================================================ // SITEMAP (KODE LAMA ANDA) // ============================================================ if (source === 'sitemap') { try { const offset = (pageNum - 1) * limitNum; const parameter = [ 'q=*:*', `rows=${limit}`, `start=${offset}`, 'fl=product_id_i,name_s,default_code_s,image_s,category_name', 'wt=json', 'omitHeader=true', ]; const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); const result = await axios(solrUrl, { timeout: 25000 }); result.data.response.products = productMappingSolr( result.data.response.docs, false, ); delete result.data.response.docs; result.data = camelcaseObjectDeep(result.data); return res.status(200).json(result.data); } catch (e) { return res.status(500).json({ error: 'Sitemap query failed' }); } } // ============================================================ // SEARCH NORMAL (KODE LAMA ANDA) // ============================================================ let paramOrderBy = ''; switch (orderBy) { case 'flashsale-discount-desc': paramOrderBy += 'flashsale_discount_f DESC'; break; case 'price-asc': paramOrderBy += 'price_tier1_v2_f ASC'; break; case 'price-desc': paramOrderBy += 'price_tier1_v2_f DESC'; break; case 'popular': paramOrderBy += 'product_rating_f DESC, search_rank_i DESC,'; break; case 'popular-weekly': paramOrderBy += 'search_rank_weekly_i DESC'; break; case 'stock': paramOrderBy += 'product_rating_f DESC, stock_total_f DESC'; break; case 'flashsale-price-asc': paramOrderBy += 'flashsale_price_f ASC'; break; default: paramOrderBy += ''; break; } let checkQ = q.trim().split(/[\s\+\-\!\(\)\{\}\[\]\^"~\*\?:\\\/]+/); let newQ = escapeSolrQuery(q); const formattedQuery = `(${newQ .split(' ') .map((term) => (term.length < 2 ? term : `${term}*`)) .join(' ')})`; const mm = checkQ.length > 2 ? checkQ.length > 5 ? '55%' : '85%' : `${checkQ.length}`; const filterQueries = [ '-publish_b:false', 'product_rating_f:[8 TO *]', 'price_tier1_v2_f:[1 TO *]', ]; if (orderBy === 'stock') filterQueries.push('stock_total_f:[1 TO *]'); // Handle 'fq' parameter from request let finalFq = [...filterQueries]; if (fq) { if (Array.isArray(fq)) finalFq.push(...fq); else finalFq.push(fq); } let keywords = newQ; if (source === 'similar' || checkQ.length < 3) { if (checkQ.length < 2 || checkQ[1].length < 2) keywords = newQ; else keywords = newQ + '*'; } else { keywords = formattedQuery; } let offset = (pageNum - 1) * limitNum; let parameter = [ 'facet.field={!ex=brand}manufacture_name_s', 'facet.field={!ex=cat}category_name', 'facet=true', 'indent=true', `facet.query=${escapeSolrQuery(q)}`, `q.op=OR`, `q=${keywords}`, `defType=edismax`, 'qf=name_s description_clean_t category_name manufacture_name_s variants_code_t variants_name_t category_id_ids default_code_s manufacture_id_i category_id_i', `start=${parseInt(offset)}`, `rows=${limit}`, `sort=${paramOrderBy}`, `mm=${encodeURIComponent(mm)}`, ]; // Masukkan semua Filter Query (fq) finalFq.forEach((f) => { parameter.push(`fq=${encodeURIComponent(f)}`); }); if (priceFromNum > 0 || priceToNum > 0) { parameter.push( `fq=price_tier1_v2_f:[${priceFromNum || '*'} TO ${priceToNum || '*'}]`, ); } let { auth } = req.cookies; if (auth) { auth = JSON.parse(auth); if (auth.feature.onlyReadyStock) stock = true; } if (brand) { const brandExpr = brand .split(',') .map((m) => `manufacture_name:"${encodeURIComponent(m)}"`) .join(' OR '); parameter.push(`fq={!tag=brand}(${brandExpr})`); } if (category) { const catExpr = category .split(',') .map((c) => `category_name:"${encodeURIComponent(c)}"`) .join(' OR '); parameter.push(`fq={!tag=cat}(${catExpr})`); } if (stock) parameter.push(`fq=stock_total_f:(1 TO *)`); if (typeof fq === 'string') parameter.push(`fq=${encodeURIComponent(fq)}`); if (Array.isArray(fq)) parameter = parameter.concat( fq.map((val) => `fq=${encodeURIComponent(val)}`), ); // Searchkey with batching for large ID arrays if (params.from === 'searchkey') { try { // Extract IDs from either query params (GET) or body (POST) let ids = []; if (req.method === 'POST' && req.body && req.body.ids) { // POST request: IDs in body ids = Array.isArray(req.body.ids) ? req.body.ids : req.body.ids.split(',').filter(Boolean); } else if (req.query.ids) { // GET request: IDs in query params ids = req.query.ids.split(',').filter(Boolean); } if (ids.length === 0) { return res.status(400).json({ error: 'No product IDs provided' }); } // console.log(`[SEARCHKEY] Processing ${ids.length} product IDs`); // If less than 100 IDs, use single query if (ids.length <= 100) { const q = buildProductIdOrClause(ids); const strictQuery = [ `q=${encodeURIComponent(q)}`, `fq=-publish_b:false AND price_tier1_v2_f:[1 TO *] AND product_rating_f:[8 TO *]`, `rows=${limitNum}`, `start=${offset}`, `sort=${paramOrderBy}`, ]; const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + strictQuery.join('&'); // console.log('[SEARCHKEY SINGLE QUERY]', solrUrl); const result = await axios(solrUrl); result.data.response.products = productMappingSolr( result.data.response.docs, auth?.pricelist || false, ); delete result.data.response.docs; result.data = camelcaseObjectDeep(result.data); return res.status(200).json(result.data); } // Batch large ID arrays into chunks of 100 const idChunks = chunkArray(ids, 100); // console.log( // `[SEARCHKEY BATCH] Splitting ${ids.length} IDs into ${idChunks.length} chunks`, // ); // Execute all chunk queries in parallel const batchQueries = idChunks.map((chunk) => { const q = buildProductIdOrClause(chunk); const queryParams = [ `q=${encodeURIComponent(q)}`, `fq=-publish_b:false AND price_tier1_v2_f:[1 TO *] AND product_rating_f:[8 TO *]`, `rows=100`, // Request maximum 100 rows per chunk (100 IDs per chunk) `start=0`, `sort=${paramOrderBy}`, ]; const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + queryParams.join('&'); console.log( `[SEARCHKEY BATCH QUERY] Chunk: ${chunk.slice(0, 5).join(',')}...`, ); return axios(solrUrl).catch((error) => { console.error('[SEARCHKEY BATCH ERROR]', error.message); throw error; }); }); // Wait for all queries to complete const batchResults = await Promise.all(batchQueries); // Merge all documents from all chunks, removing duplicates const allDocs = mergeSolrResults( batchResults.map((r) => r.data.response.docs), ); // console.log( // `[SEARCHKEY MERGE] Merged ${allDocs.length} unique documents from ${batchResults.length} chunks`, // ); // Apply pagination on merged results const paginatedDocs = allDocs.slice(offset, offset + limitNum); // Use first result's response structure as template const templateResponse = batchResults[0].data; const mergedResponse = { ...templateResponse, response: { ...templateResponse.response, numFound: allDocs.length, start: offset, rows: limitNum, docs: paginatedDocs, }, responseHeader: { ...templateResponse.responseHeader, params: { ...templateResponse.responseHeader.params, start: offset, rows: limitNum, }, }, }; mergedResponse.response.products = productMappingSolr( paginatedDocs, auth?.pricelist || false, ); delete mergedResponse.response.docs; mergedResponse.data = camelcaseObjectDeep(mergedResponse); return res.status(200).json(mergedResponse.data); } catch (e) { console.error('[SEARCHKEY ERROR]', e.message); return res.status(400).json({ error: e.message }); } } // SEARCH NORMAL: DEFAULT KE CORE 'PRODUCT' const solrUrl = process.env.SOLR_HOST + '/solr/product/select?' + parameter.join('&'); try { const result = await axios(solrUrl); result.data.response.products = productMappingSolr( result.data.response.docs, auth?.pricelist || false, ); result.data.responseHeader.params.start = parseInt( result.data.responseHeader.params.start, ); result.data.responseHeader.params.rows = parseInt( result.data.responseHeader.params.rows, ); delete result.data.response.docs; result.data = camelcaseObjectDeep(result.data); res.status(200).json(result.data); } catch (error) { res.status(400).json({ error: error.message }); } }