diff --git a/Dockerfile b/Dockerfile index ef786a4..da5775e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,6 @@ RUN npm ci --omit=dev COPY server/index.js ./ COPY server/fdk.js ./ -COPY server/postgresFdkStorage.js ./ COPY server/config ./config COPY server/routes ./routes COPY server/services ./services diff --git a/client/src/components/RegisterBusinessModal.jsx b/client/src/components/RegisterBusinessModal.jsx index 197b758..ff87f8d 100644 --- a/client/src/components/RegisterBusinessModal.jsx +++ b/client/src/components/RegisterBusinessModal.jsx @@ -1,112 +1,29 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useState } from 'react'; import apiClient from '../api/client'; import { getBusinessDomain, getBusinessImage, getBusinessName, getBusinessTagline, - getChannelId, - normalizeChannelsPayload, } from '../utils/businessProfile'; -export default function RegisterBusinessModal({ - onClose, - initialSalesChannels = [], - initialSalesChannelsStatus = 'idle', - initialSalesChannelsError = '', -}) { +export default function RegisterBusinessModal({ onClose }) { const [url, setUrl] = useState(''); - const [status, setStatus] = useState('idle'); // idle | loading | success | error + const [status, setStatus] = useState('idle'); const [createdBusiness, setCreatedBusiness] = useState(null); const [error, setError] = useState(''); - const [entryMode, setEntryMode] = useState( - initialSalesChannelsStatus === 'error' ? 'manual' : 'sales-channel' - ); - const [salesChannels, setSalesChannels] = useState(initialSalesChannels); - const [salesChannelsStatus, setSalesChannelsStatus] = useState( - initialSalesChannelsStatus === 'idle' ? 'loading' : initialSalesChannelsStatus - ); // loading | success | error - const [salesChannelsError, setSalesChannelsError] = useState(initialSalesChannelsError); - const [selectedSalesChannelId, setSelectedSalesChannelId] = useState(() => ( - initialSalesChannels[0] ? getChannelId(initialSalesChannels[0]) : '' - )); - const [searchQuery, setSearchQuery] = useState(''); - - useEffect(() => { - let cancelled = false; - - if (initialSalesChannelsStatus !== 'idle') return undefined; - - async function fetchSalesChannels() { - setSalesChannelsStatus('loading'); - setSalesChannelsError(''); - try { - const res = await apiClient.get('/api/businesses/sales-channels'); - if (cancelled) return; - - const channels = normalizeChannelsPayload(res.data); - setSalesChannels(channels); - - if (channels.length > 0) { - setSelectedSalesChannelId(getChannelId(channels[0])); - setSalesChannelsStatus('success'); - setEntryMode('sales-channel'); - } else { - setSalesChannelsStatus('error'); - setSalesChannelsError('No sales channels were found for this company yet. You can still enter the website URL manually.'); - setEntryMode('manual'); - } - } catch (err) { - if (cancelled) return; - setSalesChannels([]); - setSalesChannelsStatus('error'); - setSalesChannelsError(err.response?.data?.error || 'Sales channels could not be fetched right now. You can still enter the website URL manually.'); - setEntryMode('manual'); - } - } - - fetchSalesChannels(); - return () => { - cancelled = true; - }; - }, [initialSalesChannelsStatus]); - - const filteredSalesChannels = useMemo(() => { - if (!searchQuery.trim()) return salesChannels; - const query = searchQuery.trim().toLowerCase(); - return salesChannels.filter((channel) => { - const name = String(channel?.name || '').toLowerCase(); - const domain = String(channel?.domain || '').toLowerCase(); - return name.includes(query) || domain.includes(query); - }); - }, [salesChannels, searchQuery]); - - const canUseSalesChannels = salesChannelsStatus === 'success' && salesChannels.length > 0; - const isSalesChannelMode = entryMode === 'sales-channel' && canUseSalesChannels; - const effectiveSelectedSalesChannelId = filteredSalesChannels.some( - (channel) => getChannelId(channel) === selectedSalesChannelId - ) - ? selectedSalesChannelId - : (filteredSalesChannels[0] ? getChannelId(filteredSalesChannels[0]) : ''); - const submitDisabled = status === 'loading' - || (isSalesChannelMode ? !effectiveSelectedSalesChannelId : !url.trim()); async function handleSubmit(e) { e.preventDefault(); - - const isSalesChannelMode = entryMode === 'sales-channel' && salesChannelsStatus === 'success'; - if (isSalesChannelMode && !effectiveSelectedSalesChannelId) return; - if (!isSalesChannelMode && !url.trim()) return; + if (!url.trim()) return; setStatus('loading'); setError(''); try { - const payload = isSalesChannelMode - ? { salesChannelId: effectiveSelectedSalesChannelId } - : { websiteUrl: url.trim() }; - - const res = await apiClient.post('/api/businesses', payload); + const res = await apiClient.post('/api/businesses', { + websiteUrl: url.trim(), + }); setCreatedBusiness(res.data); setStatus('success'); } catch (err) { @@ -165,106 +82,24 @@ export default function RegisterBusinessModal({

Add a Business

- Choose a sales channel first when available. If that lookup fails, you can still enter the website URL and we'll scrape it directly. + Enter the storefront website URL and we'll scrape it to detect the brand, images, and copy you need for onboarding.

-
- - + className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm" + required + />
- {isSalesChannelMode ? ( -
-
- - setSearchQuery(e.target.value)} - placeholder="Search by name or domain" - disabled={status === 'loading' || salesChannelsStatus === 'loading'} - className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm" - /> -
- -
- - {filteredSalesChannels.length > 0 ? ( - - ) : ( -
- No sales channels matched your search. -
- )} -
- - {salesChannelsStatus === 'loading' && ( -

- Loading sales channels for this company… -

- )} - - {salesChannelsError && ( -

- {salesChannelsError} -

- )} -
- ) : ( -
- - setUrl(e.target.value)} - placeholder="https://yourstore.com" - disabled={status === 'loading'} - className="w-full px-4 py-2.5 rounded-lg bg-white border border-gray-300 text-gray-900 placeholder-gray-400 font-medium focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition disabled:opacity-50 text-sm shadow-sm" - required={!isSalesChannelMode} - /> - {salesChannelsError && ( -

- {salesChannelsError} -

- )} -
- )} - {status === 'error' && (

{error}

)} @@ -280,7 +115,7 @@ export default function RegisterBusinessModal({ - - - ); -} - function BusinessCreatedModal({ business, onClose }) { const name = getBusinessName(business); const domain = getBusinessDomain(business); @@ -107,7 +55,7 @@ function BusinessCreatedModal({ business, onClose }) {

Business Added!

-

Your sales channel has been connected successfully.

+

Your business is ready for onboarding.

@@ -135,6 +83,42 @@ function BusinessCreatedModal({ business, onClose }) { ); } +function SalesChannelCard({ channel, disabled, onImport }) { + const name = getBusinessName(channel); + const domain = getBusinessDomain(channel); + const image = getBusinessImage(channel); + + return ( +
+
+
+ {image ? ( + {name} + ) : ( + {name?.[0]?.toUpperCase() || 'S'} + )} +
+
+

{name}

+

{domain || 'Domain unavailable'}

+
+
+
+ + {channel.websiteUrl ? 'Ready to scrape' : 'Use manual URL fallback'} + + +
+
+ ); +} + export default function Businesses() { const navigate = useNavigate(); const { setActiveBusiness } = useBusiness(); @@ -143,6 +127,7 @@ export default function Businesses() { const [loading, setLoading] = useState(true); const [salesChannelsStatus, setSalesChannelsStatus] = useState('loading'); const [salesChannelsError, setSalesChannelsError] = useState(''); + const [salesChannelQuery, setSalesChannelQuery] = useState(''); const [selectingBusinessId, setSelectingBusinessId] = useState(''); const [creatingSalesChannelId, setCreatingSalesChannelId] = useState(''); const [createdBusiness, setCreatedBusiness] = useState(null); @@ -163,6 +148,17 @@ export default function Businesses() { salesChannels.filter((channel) => !configuredApplicationIds.has(getChannelId(channel))) ), [configuredApplicationIds, salesChannels]); + const filteredSalesChannels = useMemo(() => { + const query = salesChannelQuery.trim().toLowerCase(); + if (!query) return availableSalesChannels; + + return availableSalesChannels.filter((channel) => { + const name = String(getBusinessName(channel) || '').toLowerCase(); + const domain = String(getBusinessDomain(channel) || '').toLowerCase(); + return name.includes(query) || domain.includes(query); + }); + }, [availableSalesChannels, salesChannelQuery]); + const loadBusinesses = useCallback(async () => { const res = await apiClient.get('/api/businesses'); setBusinesses(res.data.businesses || []); @@ -170,8 +166,7 @@ export default function Businesses() { const loadSalesChannels = useCallback(async () => { setSalesChannelsStatus('loading'); - const res = await apiClient.get('/api/businesses/sales-channels'); - const channels = normalizeChannelsPayload(res.data).filter(isChannelActive); + const channels = await fetchActiveSalesChannels(); setSalesChannels(channels); setSalesChannelsStatus('success'); }, []); @@ -195,7 +190,7 @@ export default function Businesses() { setSalesChannels([]); setSalesChannelsStatus('error'); setSalesChannelsError( - salesChannelsRes.reason?.response?.data?.error || 'Active sales channels could not be loaded right now.' + salesChannelsRes.reason?.message || 'Active sales channels could not be loaded right now.' ); } } finally { @@ -219,15 +214,23 @@ export default function Businesses() { } async function handleCreateFromSalesChannel(channel) { - const salesChannelId = getChannelId(channel); - if (!salesChannelId) return; + const applicationId = getChannelId(channel); + if (!applicationId) return; - setCreatingSalesChannelId(salesChannelId); + if (!channel.websiteUrl) { + setSalesChannelsError('A website URL could not be derived from this sales channel. Please use Add Business and enter the URL manually.'); + return; + } + + setCreatingSalesChannelId(applicationId); setError(''); setSalesChannelsError(''); try { - const res = await apiClient.post('/api/businesses', { salesChannelId }); + const res = await apiClient.post('/api/businesses', { + applicationId, + websiteUrl: channel.websiteUrl, + }); setCreatedBusiness(res.data); await Promise.all([loadBusinesses(), loadSalesChannels()]); setSalesChannelsStatus('success'); @@ -262,14 +265,14 @@ export default function Businesses() { return (
-
+

{businesses.length > 0 ? 'Your Businesses' : 'Set Up Your First Business'}

- Add directly from an active sales channel, or use the existing fallback modal for manual testing. + Import from an active sales channel when available, or use the website URL fallback to scrape manually.

-
- - {salesChannelsError && ( -
- {salesChannelsError} -
- )} - - {salesChannelsStatus === 'loading' ? ( -
-
-

Loading active sales channels…

-
- ) : availableSalesChannels.length > 0 ? ( -
- {availableSalesChannels.map((channel) => { - const channelId = getChannelId(channel); - - return ( - handleCreateFromSalesChannel(channel)} - disabled={creatingSalesChannelId === channelId} - loadingLabel="Creating business from this sales channel…" - footer={getBusinessDomain(channel) ? `Sales channel • ${getBusinessDomain(channel)}` : 'Sales channel ready'} - /> - ); - })} -
- ) : ( -
-

No active sales channels are available to add right now.

-

Already-configured channels are hidden here to avoid duplicate onboarding. You can still use the fallback Add Business modal for manual testing.

-
- )} - - -
+

Configured Businesses

Select a business to manage its SMS templates.

@@ -402,19 +353,81 @@ export default function Businesses() { ) : (

No configured businesses yet.

-

Start from an active sales channel above, or use the Add Business modal as a fallback.

+

Import from an active sales channel below, or use Add Business to enter a storefront URL manually.

+
+ )} +
+ +
+
+
+

Active Sales Channels

+

These are pulled directly from Commerce and can be scraped into businesses with one click.

+
+ +
+ + {salesChannelsError && ( +
+ {salesChannelsError} +
+ )} + + {salesChannelsStatus === 'loading' ? ( +
+
+

Loading active sales channels…

+
+ ) : availableSalesChannels.length > 0 ? ( +
+
+ + setSalesChannelQuery(e.target.value)} + placeholder="Search by channel name or domain" + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent" + /> +
+ +
+ {filteredSalesChannels.map((channel) => { + const channelId = getChannelId(channel); + return ( + handleCreateFromSalesChannel(channel)} + /> + ); + })} +
+ + {filteredSalesChannels.length === 0 && ( +
+

No active sales channels matched your search.

+

Use the website URL fallback if you want to scrape a storefront directly.

+
+ )} +
+ ) : ( +
+

No active sales channels are available right now.

+

Use Add Business to enter a website URL manually and keep moving.

)}
{showModal && ( - { setShowModal(false); load(); }} - initialSalesChannels={availableSalesChannels} - initialSalesChannelsStatus={salesChannelsStatus} - initialSalesChannelsError={salesChannelsError} - /> + { setShowModal(false); loadBusinesses(); }} /> )} {createdBusiness && setCreatedBusiness(null)} />} {deleteTarget && ( diff --git a/client/src/utils/businessProfile.js b/client/src/utils/businessProfile.js index 6e06e94..b2c2385 100644 --- a/client/src/utils/businessProfile.js +++ b/client/src/utils/businessProfile.js @@ -2,11 +2,118 @@ function normalizeList(value) { return Array.isArray(value) ? value.filter(Boolean) : []; } +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function firstNonEmptyText(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + + return ''; +} + +function extractDomainName(domain) { + if (!domain) return ''; + if (typeof domain === 'string') return normalizeText(domain); + if (typeof domain === 'object') { + return firstNonEmptyText(domain.name, domain.domain_url, domain.url, domain.host); + } + return ''; +} + +function rankDomain(domain) { + if (!domain || typeof domain !== 'object') return 0; + + let score = 0; + if (domain.is_primary) score += 4; + if (domain.verified) score += 2; + if (!domain.is_shortlink) score += 1; + return score; +} + +function pickPreferredDomain(...sources) { + const candidates = []; + + for (const source of sources) { + if (!source) continue; + + if (Array.isArray(source)) { + for (const item of source) { + const name = extractDomainName(item); + if (name) candidates.push({ raw: item, name }); + } + continue; + } + + const name = extractDomainName(source); + if (name) candidates.push({ raw: source, name }); + } + + if (!candidates.length) return ''; + + candidates.sort((left, right) => rankDomain(right.raw) - rankDomain(left.raw)); + return candidates[0].name; +} + +function normalizeWebsiteUrl(value) { + const rawValue = normalizeText(value); + if (!rawValue) return ''; + + const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`; + + try { + return new URL(candidate).toString().replace(/\/$/, ''); + } catch { + return ''; + } +} + +function normalizeChannel(channel = {}) { + const domain = pickPreferredDomain( + channel.domain, + channel.domains, + channel.website, + channel.website_url, + channel.url + ); + const id = String(channel?.salesChannelId || channel?.applicationId || channel?.application_id || channel?.id || channel?._id || '').trim(); + + return { + ...channel, + id, + salesChannelId: id, + applicationId: id, + name: firstNonEmptyText(channel.name, channel.display_name, channel.title, channel.slug), + domain, + websiteUrl: normalizeWebsiteUrl(channel.websiteUrl || channel.website_url || channel.url || domain), + isActive: channel.is_active !== false && channel.isActive !== false, + logoUrl: firstNonEmptyText( + channel.logoUrl, + channel.logo?.secure_url, + channel.logo?.url, + channel.logo, + channel.icon?.secure_url, + channel.icon?.url, + channel.icon, + channel.favicon?.secure_url, + channel.favicon?.url + ), + }; +} + export function normalizeChannelsPayload(data) { - if (Array.isArray(data)) return data; - if (Array.isArray(data?.salesChannels)) return data.salesChannels; - if (Array.isArray(data?.channels)) return data.channels; - return []; + const channels = ( + Array.isArray(data) ? data + : Array.isArray(data?.salesChannels) ? data.salesChannels + : Array.isArray(data?.channels) ? data.channels + : Array.isArray(data?.items) ? data.items + : Array.isArray(data?.applications) ? data.applications + : [] + ); + + return channels.map(normalizeChannel); } export function getChannelId(channel) { diff --git a/client/src/utils/fyndSalesChannels.js b/client/src/utils/fyndSalesChannels.js new file mode 100644 index 0000000..1fdeea2 --- /dev/null +++ b/client/src/utils/fyndSalesChannels.js @@ -0,0 +1,99 @@ +import { getRuntimeCompanyId, getRuntimeExtensionId } from './runtimeContext'; +import { + getChannelId, + isChannelActive, + normalizeChannelsPayload, +} from './businessProfile'; + +const FYND_PORTAL_API_BASE = 'https://api.fynd.com'; + +function normalizeText(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeWebsiteUrl(value) { + const rawValue = normalizeText(value); + if (!rawValue) return ''; + + const candidate = /^https?:\/\//i.test(rawValue) ? rawValue : `https://${rawValue}`; + + try { + return new URL(candidate).toString().replace(/\/$/, ''); + } catch { + return ''; + } +} + +function readCookie(name) { + if (typeof document === 'undefined') return ''; + + const cookies = `; ${document.cookie || ''}`; + const parts = cookies.split(`; ${name}=`); + if (parts.length < 2) return ''; + return parts.pop().split(';').shift() || ''; +} + +function buildPortalRequestUrl(companyId, extensionId) { + const search = new URLSearchParams({ + company_id: companyId, + page_no: '1', + page_size: '100', + query: JSON.stringify({ is_active: true }), + }); + + if (extensionId) { + search.set('extension_id', extensionId); + } + + return `${FYND_PORTAL_API_BASE}/service/portal/configuration/v1.0/company/${encodeURIComponent(companyId)}/application?${search.toString()}`; +} + +function buildPortalHeaders() { + const headers = { + accept: 'application/json, text/plain, */*', + }; + + const token = normalizeText(readCookie('token')); + if (token) { + headers.authorization = `Bearer ${token}`; + } + + return headers; +} + +function withDerivedWebsiteUrl(channel) { + const explicitWebsiteUrl = normalizeWebsiteUrl( + channel?.websiteUrl || channel?.domain || channel?.url + ); + + return explicitWebsiteUrl + ? { ...channel, websiteUrl: explicitWebsiteUrl } + : channel; +} + +export async function fetchActiveSalesChannels() { + const companyId = getRuntimeCompanyId(); + const extensionId = getRuntimeExtensionId(); + + if (!companyId) { + throw new Error('Company ID is unavailable for fetching sales channels.'); + } + + const response = await fetch(buildPortalRequestUrl(companyId, extensionId), { + method: 'GET', + credentials: 'include', + headers: buildPortalHeaders(), + }); + + const payload = await response.json().catch(() => null); + + if (!response.ok) { + const message = normalizeText(payload?.message || payload?.error) + || `Sales channels could not be fetched (${response.status}).`; + throw new Error(message); + } + + return normalizeChannelsPayload(payload) + .map(withDerivedWebsiteUrl) + .filter((channel) => isChannelActive(channel) && getChannelId(channel)); +} diff --git a/client/src/utils/runtimeContext.js b/client/src/utils/runtimeContext.js index d547af4..5705cae 100644 --- a/client/src/utils/runtimeContext.js +++ b/client/src/utils/runtimeContext.js @@ -2,6 +2,7 @@ const COMPANY_ID_QUERY_KEYS = [ 'blt-gtw-fc-cid', ]; const COMPANY_ID_STORAGE_KEY = 'sms_runtime_company_id'; +const EXTENSION_ID_STORAGE_KEY = 'sms_runtime_extension_id'; function getRuntimeUrl() { if (typeof window === 'undefined') return null; @@ -48,6 +49,26 @@ function persistCompanyId(companyId) { } } +function readStoredExtensionId() { + if (typeof sessionStorage === 'undefined') return ''; + + try { + return (sessionStorage.getItem(EXTENSION_ID_STORAGE_KEY) || '').trim(); + } catch { + return ''; + } +} + +function persistExtensionId(extensionId) { + if (!extensionId || typeof sessionStorage === 'undefined') return; + + try { + sessionStorage.setItem(EXTENSION_ID_STORAGE_KEY, extensionId); + } catch { + // Ignore storage errors; URL/referrer extraction still works for the current request. + } +} + function getFirstSearchParam(url, keys) { if (!url) return ''; @@ -75,6 +96,20 @@ export function getRuntimeCompanyId() { return companyId; } +export function getRuntimeExtensionId() { + const runtimeUrl = getRuntimeUrl(); + const referrerUrl = getReferrerUrl(); + const extensionId = ( + getPathMatch(runtimeUrl?.pathname || '', /\/extensions\/([^/]+)/i) + || getPathMatch(referrerUrl?.pathname || '', /\/extensions\/([^/]+)/i) + || readStoredExtensionId() + || '' + ); + + persistExtensionId(extensionId); + return extensionId; +} + export function getRuntimeApplicationId() { const runtimeUrl = getRuntimeUrl(); if (!runtimeUrl) return ''; diff --git a/server/fdk.js b/server/fdk.js index 941ba55..e7dcb8e 100644 --- a/server/fdk.js +++ b/server/fdk.js @@ -2,7 +2,6 @@ require('dotenv').config(); const { setupFdk } = require('@gofynd/fdk-extension-javascript/express'); const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage'); -const { createFdkStorage } = require('./postgresFdkStorage'); function normalizeEnvText(value) { return typeof value === 'string' ? value.trim() : ''; @@ -23,21 +22,11 @@ function createFdkExtension() { const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET); const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL); const cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com'; - const storageConnectionString = normalizeEnvText(process.env.FDK_STORAGE_CONNECTION_STRING); if (!apiKey || !apiSecret || !baseUrl) { return null; } - const storage = createFdkStorage({ - prefixKey: 'sms_extension_', - connectionString: storageConnectionString, - }) || new MemoryStorage('sms_extension_'); - - if (!storageConnectionString) { - console.warn('[FDK] FDK_STORAGE_CONNECTION_STRING is not set; falling back to in-memory session storage.'); - } - try { return setupFdk({ api_key: apiKey, @@ -52,8 +41,7 @@ function createFdkExtension() { console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`); }, }, - storage, - access_mode: 'offline', + storage: new MemoryStorage('sms_extension_'), }); } catch (error) { console.warn(`[FDK] Failed to initialize FDK: ${error.message}`); @@ -63,20 +51,7 @@ function createFdkExtension() { const fdkExtension = createFdkExtension(); -async function getPlatformClientForCompany(companyId) { - if (!fdkExtension) { - throw new Error('FDK is not configured'); - } - - if (!normalizeEnvText(companyId)) { - throw new Error('companyId is required to fetch a platform client'); - } - - return fdkExtension.getPlatformClient(companyId); -} - module.exports = { fdkExtension, isFdkConfigured: Boolean(fdkExtension), - getPlatformClientForCompany, }; diff --git a/server/package-lock.json b/server/package-lock.json index 49fc4aa..3970eb3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -17,7 +17,6 @@ "express": "^4.18.2", "multer": "^1.4.5-lts.1", "openai": "^4.28.0", - "pg": "^8.20.0", "uuid": "^13.0.0" }, "devDependencies": { @@ -1669,96 +1668,6 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, - "node_modules/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "pg-connection-string": "^2.12.0", - "pg-pool": "^3.13.0", - "pg-protocol": "^1.13.0", - "pg-types": "2.2.0", - "pgpass": "1.0.5" - }, - "engines": { - "node": ">= 16.0.0" - }, - "optionalDependencies": { - "pg-cloudflare": "^1.3.0" - }, - "peerDependencies": { - "pg-native": ">=3.0.1" - }, - "peerDependenciesMeta": { - "pg-native": { - "optional": true - } - } - }, - "node_modules/pg-cloudflare": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", - "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", - "license": "MIT", - "optional": true - }, - "node_modules/pg-connection-string": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", - "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", - "license": "MIT" - }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-pool": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", - "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", - "license": "MIT", - "peerDependencies": { - "pg": ">=8.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", - "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, "node_modules/picomatch": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", @@ -1772,45 +1681,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", - "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", - "license": "MIT", - "dependencies": { - "xtend": "^4.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -2137,15 +2007,6 @@ "node": ">=6" } }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", diff --git a/server/package.json b/server/package.json index daab0b2..2b11c4d 100644 --- a/server/package.json +++ b/server/package.json @@ -17,7 +17,6 @@ "express": "^4.18.2", "multer": "^1.4.5-lts.1", "openai": "^4.28.0", - "pg": "^8.20.0", "uuid": "^13.0.0" }, "devDependencies": { diff --git a/server/postgresFdkStorage.js b/server/postgresFdkStorage.js deleted file mode 100644 index 2b7dd80..0000000 --- a/server/postgresFdkStorage.js +++ /dev/null @@ -1,149 +0,0 @@ -const { Pool } = require('pg'); -const BaseStorage = require('@gofynd/fdk-extension-javascript/express/storage/base_storage'); - -const DEFAULT_TABLE_NAME = 'fdk session storage'; - -function normalizeEnvText(value) { - return typeof value === 'string' ? value.trim() : ''; -} - -function quoteIdentifier(identifier) { - return `"${String(identifier).replace(/"/g, '""')}"`; -} - -function shouldUseSsl(connectionString) { - const normalized = normalizeEnvText(connectionString).toLowerCase(); - if (!normalized) return false; - if (normalized.includes('sslmode=disable')) return false; - if (normalized.includes('sslmode=require')) return true; - return !normalized.includes('localhost') && !normalized.includes('127.0.0.1'); -} - -function getPool(connectionString) { - if (!globalThis.__smsExtensionFdkStoragePool) { - globalThis.__smsExtensionFdkStoragePool = new Pool({ - connectionString, - max: 3, - idleTimeoutMillis: 30000, - ssl: shouldUseSsl(connectionString) ? { rejectUnauthorized: false } : undefined, - }); - } - - return globalThis.__smsExtensionFdkStoragePool; -} - -function serializeValue(value) { - return JSON.stringify(value); -} - -function parseValue(rawValue) { - if (!rawValue) return null; - if (typeof rawValue === 'object') return rawValue; - - try { - return JSON.parse(rawValue); - } catch { - return null; - } -} - -class PostgresFdkStorage extends BaseStorage { - constructor({ prefixKey, connectionString, tableName = DEFAULT_TABLE_NAME }) { - super(prefixKey); - - if (!normalizeEnvText(connectionString)) { - throw new Error('FDK_STORAGE_CONNECTION_STRING is required for Postgres FDK storage'); - } - - this.pool = getPool(connectionString); - this.tableIdentifier = quoteIdentifier(tableName); - } - - buildKey(key) { - return `${this.prefixKey}${key}`; - } - - async get(key) { - const storageKey = this.buildKey(key); - const { rows } = await this.pool.query( - `SELECT value, expires_at - FROM ${this.tableIdentifier} - WHERE storage_key = $1 - ORDER BY updated_at DESC NULLS LAST, created_at DESC NULLS LAST, id DESC - LIMIT 1`, - [storageKey] - ); - - const row = rows[0]; - if (!row) return null; - - const expiresAt = row.expires_at ? new Date(row.expires_at) : null; - if (expiresAt && expiresAt.getTime() <= Date.now()) { - await this.del(key); - return null; - } - - return parseValue(row.value); - } - - async set(key, value) { - await this.writeRecord(key, value, null); - return 'OK'; - } - - async setex(key, value, ttl) { - const expiresAt = Number.isFinite(ttl) - ? new Date(Date.now() + ttl * 1000) - : null; - - await this.writeRecord(key, value, expiresAt); - return 'OK'; - } - - async del(key) { - const storageKey = this.buildKey(key); - await this.pool.query( - `DELETE FROM ${this.tableIdentifier} - WHERE storage_key = $1`, - [storageKey] - ); - } - - async writeRecord(key, value, expiresAt) { - const storageKey = this.buildKey(key); - const serializedValue = serializeValue(value); - - const updateResult = await this.pool.query( - `UPDATE ${this.tableIdentifier} - SET value = $2, - expires_at = $3, - updated_at = NOW() - WHERE storage_key = $1`, - [storageKey, serializedValue, expiresAt] - ); - - if (updateResult.rowCount > 0) return; - - await this.pool.query( - `INSERT INTO ${this.tableIdentifier} (storage_key, value, expires_at) - VALUES ($1, $2, $3)`, - [storageKey, serializedValue, expiresAt] - ); - } -} - -function createFdkStorage({ prefixKey, connectionString }) { - if (!normalizeEnvText(connectionString)) return null; - - return new PostgresFdkStorage({ - prefixKey, - connectionString, - tableName: DEFAULT_TABLE_NAME, - }); -} - -module.exports = { - DEFAULT_TABLE_NAME, - PostgresFdkStorage, - createFdkStorage, -}; diff --git a/server/routes/businesses.js b/server/routes/businesses.js index a4e3471..c865aad 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -13,7 +13,6 @@ const { deleteBusinessFiles, } = require('../services/pixelbin'); const DEFAULT_EVENTS = require('../config/defaultEvents'); -const { getPlatformClientForCompany } = require('../fdk'); const axios = require('axios'); const MERCHANT_ID = () => process.env.MERCHANT_ID; @@ -216,69 +215,6 @@ function normalizeWebsiteUrl(value) { } } -function extractDomainName(domain) { - if (!domain) return ''; - if (typeof domain === 'string') return normalizeText(domain); - if (typeof domain === 'object') { - return firstNonEmptyText(domain.name, domain.domain_url, domain.url, domain.host); - } - return ''; -} - -function rankDomain(domain) { - if (!domain || typeof domain !== 'object') return 0; - let score = 0; - if (domain.is_primary) score += 4; - if (domain.verified) score += 2; - if (!domain.is_shortlink) score += 1; - return score; -} - -function pickPreferredDomain(...sources) { - const candidates = []; - - for (const source of sources) { - if (!source) continue; - if (Array.isArray(source)) { - for (const item of source) { - const name = extractDomainName(item); - if (name) candidates.push({ raw: item, name }); - } - continue; - } - - const name = extractDomainName(source); - if (name) candidates.push({ raw: source, name }); - } - - if (!candidates.length) return ''; - - candidates.sort((left, right) => rankDomain(right.raw) - rankDomain(left.raw)); - return candidates[0].name; -} - -function normalizeSalesChannel(application = {}, domains = []) { - const domainName = pickPreferredDomain(application.domain, application.domains, domains); - const id = normalizeScopeId(application._id || application.id || application.application_id); - - return { - id, - salesChannelId: id, - applicationId: id, - name: firstNonEmptyText(application.name, application.display_name, application.slug), - domain: domainName, - websiteUrl: normalizeWebsiteUrl(domainName), - isActive: application.is_active !== false, - logoUrl: firstNonEmptyText( - application.logo?.secure_url, - application.logo?.url, - application.logo, - application.favicon?.secure_url, - application.favicon?.url - ), - }; -} - function getBusinessPreviewSummary(source = {}) { const taglines = Array.isArray(source?.taglines) ? source.taglines.map((tagline) => normalizeText(tagline)).filter(Boolean) @@ -303,58 +239,6 @@ function mergeBusinessSummary(baseBusiness = {}, context = null) { }; } -async function getPlatformClient(req, companyId) { - if (req?.platformClient) return req.platformClient; - return getPlatformClientForCompany(companyId); -} - -async function listAllSalesChannels(platformClient, query = '') { - const items = []; - const pageSize = 100; - let pageNo = 1; - - while (true) { - const response = await platformClient.configuration.getApplications({ - pageNo, - pageSize, - q: normalizeText(query) || undefined, - }); - - const batch = Array.isArray(response?.items) ? response.items : []; - items.push(...batch); - - const totalPages = Number(response?.page?.total_page) || Number(response?.page?.total_pages) || 0; - if (!batch.length || batch.length < pageSize || (totalPages && pageNo >= totalPages)) { - break; - } - - pageNo += 1; - } - - return items; -} - -async function getSalesChannelDetails(platformClient, salesChannelId) { - const applicationClient = platformClient.application(salesChannelId).configuration; - - const [applicationResponse, domainsResponse] = await Promise.allSettled([ - applicationClient.getApplicationById(), - applicationClient.getDomains(), - ]); - - if (applicationResponse.status !== 'fulfilled' && domainsResponse.status !== 'fulfilled') { - const error = applicationResponse.reason || domainsResponse.reason || new Error('Unable to fetch sales channel details'); - throw error; - } - - const application = applicationResponse.status === 'fulfilled' - ? applicationResponse.value - : { _id: salesChannelId }; - const domains = domainsResponse.status === 'fulfilled' ? domainsResponse.value?.domains || [] : []; - - return normalizeSalesChannel(application, domains); -} - const LEGACY_DEFAULT_EVENT_SLUGS = new Set(['confirmed', 'pack', 'cancelled']); const EVENT_TEMPLATE_FALLBACKS = { bag_confirmed: ['confirmed'], @@ -924,39 +808,7 @@ router.get('/', async (req, res) => { } }); -// GET /api/businesses/sales-channels -router.get('/sales-channels', async (req, res) => { - try { - const companyId = getCompanyId(req); - if (!companyId) { - throw createHttpError(400, 'companyId is required'); - } - - const platformClient = await getPlatformClient(req, companyId); - const applications = await listAllSalesChannels(platformClient); - const salesChannels = applications - .map((application) => normalizeSalesChannel(application)) - .filter((channel) => channel.id && channel.name && channel.isActive) - .sort((left, right) => left.name.localeCompare(right.name)); - - res.json({ salesChannels }); - } catch (err) { - if (err.message === 'FDK is not configured') { - return res.status(503).json({ - error: 'Sales channel fetch is unavailable', - code: 'SALES_CHANNEL_FETCH_UNAVAILABLE', - }); - } - - sendRouteError(res, createHttpError( - err.status || 502, - err.message || 'Failed to fetch sales channels', - err.code ? { code: err.code, details: err.details } : {} - )); - } -}); - -// POST /api/businesses — create new business from sales channel or websiteUrl fallback +// POST /api/businesses — create new business from websiteUrl with optional applicationId router.post('/', async (req, res) => { try { const merchantId = getCompanyId(req); @@ -964,51 +816,21 @@ router.post('/', async (req, res) => { throw createHttpError(400, 'companyId is required'); } - const requestedSalesChannelId = normalizeScopeId( - req.body?.salesChannelId - || req.body?.applicationId + const applicationId = normalizeScopeId( + req.body?.applicationId || req.body?.application_id + || req.body?.salesChannelId + || getApplicationId(req) ); - let websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl); - let salesChannel = null; - - if (!requestedSalesChannelId && !websiteUrl) { - throw createHttpError( - 400, - 'Either salesChannelId or websiteUrl is required', - { code: 'MISSING_BUSINESS_SOURCE' } - ); - } - - if (requestedSalesChannelId) { - try { - const platformClient = await getPlatformClient(req, merchantId); - salesChannel = await getSalesChannelDetails(platformClient, requestedSalesChannelId); - } catch (error) { - if (!websiteUrl) { - throw createHttpError( - error.message === 'FDK is not configured' ? 503 : 502, - 'Unable to fetch sales channel details', - { - code: 'SALES_CHANNEL_DETAILS_UNAVAILABLE', - details: { salesChannelId: requestedSalesChannelId, reason: error.message }, - } - ); - } - } - - websiteUrl = websiteUrl || salesChannel?.websiteUrl || ''; - } + const websiteUrl = normalizeWebsiteUrl(req.body?.websiteUrl); if (!websiteUrl) { throw createHttpError( - 422, - 'A website URL could not be derived from the selected sales channel. Please enter it manually.', - { code: 'MISSING_SALES_CHANNEL_WEBSITE' } + 400, + 'websiteUrl is required', + { code: 'MISSING_WEBSITE_URL' } ); } - - const applicationId = requestedSalesChannelId || getApplicationId(req); const businesses = await getIndex(merchantId); if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) { @@ -1070,14 +892,7 @@ router.post('/', async (req, res) => { }); await saveIndex(merchantId, businesses); - res.json({ - ...contextJson, - salesChannel: salesChannel ? { - salesChannelId: salesChannel.id, - name: salesChannel.name, - domain: salesChannel.domain, - } : null, - }); + res.json(contextJson); } catch (err) { console.error('Create business error:', err.message); sendRouteError(res, err);