From 388ede765acfcf7d5ebda700e8adc5716bacf400 Mon Sep 17 00:00:00 2001 From: Ritul-Work Date: Thu, 26 Mar 2026 19:15:08 +0530 Subject: [PATCH] Changes to extract company ID from path --- client/src/api/client.js | 13 +++ client/src/components/ImagePicker.jsx | 2 +- client/src/context/BrandContext.jsx | 1 + client/src/context/BusinessContext.jsx | 20 +++- client/src/pages/Brand.jsx | 3 +- client/src/utils/runtimeContext.js | 39 +++++++ server/routes/businesses.js | 143 ++++++++++++++++++++----- 7 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 client/src/utils/runtimeContext.js diff --git a/client/src/api/client.js b/client/src/api/client.js index ddd2b82..963bd17 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -1,8 +1,21 @@ import axios from 'axios'; +import { getRuntimeApplicationId, getRuntimeCompanyId } from '../utils/runtimeContext'; const apiClient = axios.create({ baseURL: '/', headers: { 'Content-Type': 'application/json' }, }); +apiClient.interceptors.request.use((config) => { + const companyId = getRuntimeCompanyId(); + const applicationId = getRuntimeApplicationId(); + const headers = config.headers || {}; + + if (companyId) headers['x-company-id'] = companyId; + if (applicationId) headers['x-application-id'] = applicationId; + + config.headers = headers; + return config; +}); + export default apiClient; diff --git a/client/src/components/ImagePicker.jsx b/client/src/components/ImagePicker.jsx index 8dfa529..e6ed2cb 100644 --- a/client/src/components/ImagePicker.jsx +++ b/client/src/components/ImagePicker.jsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; import apiClient from '../api/client'; -export default function ImagePicker({ slug, currentImage, onSelect }) { +export default function ImagePicker({ currentImage, onSelect }) { const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); diff --git a/client/src/context/BrandContext.jsx b/client/src/context/BrandContext.jsx index 3796bb6..2da09e2 100644 --- a/client/src/context/BrandContext.jsx +++ b/client/src/context/BrandContext.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import apiClient from '../api/client'; diff --git a/client/src/context/BusinessContext.jsx b/client/src/context/BusinessContext.jsx index 367baba..6ffe7f9 100644 --- a/client/src/context/BusinessContext.jsx +++ b/client/src/context/BusinessContext.jsx @@ -1,5 +1,7 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useState, useEffect, useCallback } from 'react'; import apiClient from '../api/client'; +import { getRuntimeCompanyId } from '../utils/runtimeContext'; const BusinessContext = createContext(null); @@ -16,14 +18,23 @@ export function BusinessProvider({ children }) { const stored = sessionStorage.getItem(SESSION_KEY); if (!stored) { setLoading(false); return; } try { - const { businessId } = JSON.parse(stored); + const { businessId, companyId } = JSON.parse(stored); + const runtimeCompanyId = getRuntimeCompanyId(); + + if (runtimeCompanyId && companyId && runtimeCompanyId !== companyId) { + throw new Error('Stored business belongs to a different company context'); + } + const [bizRes, smsRes] = await Promise.all([ apiClient.get(`/api/businesses/${businessId}`), apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })) ]); setActiveBusinessState(bizRes.data); setHasGlobalSms(!!smsRes.data?.activeProfile); - sessionStorage.setItem(SESSION_KEY, JSON.stringify({ businessId })); + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ + businessId, + companyId: runtimeCompanyId || companyId || '', + })); } catch { // Business no longer exists — clear stale session sessionStorage.removeItem(SESSION_KEY); @@ -38,7 +49,10 @@ export function BusinessProvider({ children }) { const setActiveBusiness = useCallback(async (business) => { setActiveBusinessState(business); - sessionStorage.setItem(SESSION_KEY, JSON.stringify({ businessId: business.businessId })); + sessionStorage.setItem(SESSION_KEY, JSON.stringify({ + businessId: business.businessId, + companyId: getRuntimeCompanyId(), + })); try { const smsRes = await apiClient.get(`/api/businesses/${business.businessId}/global-sms/active`); setHasGlobalSms(!!smsRes.data?.activeProfile); diff --git a/client/src/pages/Brand.jsx b/client/src/pages/Brand.jsx index 55bca65..a6b9194 100644 --- a/client/src/pages/Brand.jsx +++ b/client/src/pages/Brand.jsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { useBrand } from '../context/BrandContext'; import RegisterBusinessModal from '../components/RegisterBusinessModal'; import apiClient from '../api/client'; @@ -44,7 +44,6 @@ function DeleteConfirmModal({ brandName, onCancel, onConfirm, deleting }) { export default function Brand() { const { brand, loading, refetch } = useBrand(); - const navigate = useNavigate(); const [showModal, setShowModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [deleting, setDeleting] = useState(false); diff --git a/client/src/utils/runtimeContext.js b/client/src/utils/runtimeContext.js new file mode 100644 index 0000000..10c0e37 --- /dev/null +++ b/client/src/utils/runtimeContext.js @@ -0,0 +1,39 @@ +function getRuntimeUrl() { + if (typeof window === 'undefined') return null; + + try { + return new URL(window.location.href); + } catch { + return null; + } +} + +function getPathMatch(pathname, regex) { + const match = pathname.match(regex); + return match?.[1] || ''; +} + +export function getRuntimeCompanyId() { + const runtimeUrl = getRuntimeUrl(); + if (!runtimeUrl) return ''; + + return ( + runtimeUrl.searchParams.get('companyId') + || runtimeUrl.searchParams.get('company_id') + || getPathMatch(runtimeUrl.pathname, /\/company\/([^/]+)/i) + || '' + ).trim(); +} + +export function getRuntimeApplicationId() { + const runtimeUrl = getRuntimeUrl(); + if (!runtimeUrl) return ''; + + return ( + runtimeUrl.searchParams.get('applicationId') + || runtimeUrl.searchParams.get('application_id') + || getPathMatch(runtimeUrl.pathname, /\/application\/([^/]+)/i) + || getPathMatch(runtimeUrl.pathname, /\/applications\/([^/]+)/i) + || '' + ).trim(); +} diff --git a/server/routes/businesses.js b/server/routes/businesses.js index e7d91b7..126b193 100644 --- a/server/routes/businesses.js +++ b/server/routes/businesses.js @@ -17,6 +17,31 @@ const axios = require('axios'); const MERCHANT_ID = () => process.env.MERCHANT_ID; +function normalizeScopeId(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +function getCompanyId(req) { + return normalizeScopeId( + req.get('x-company-id') + || req.query?.companyId + || req.query?.company_id + || req.body?.companyId + || req.body?.company_id + || MERCHANT_ID() + ); +} + +function getApplicationId(req) { + return normalizeScopeId( + req.get('x-application-id') + || req.query?.applicationId + || req.query?.application_id + || req.body?.applicationId + || req.body?.application_id + ); +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function slugify(text) { @@ -40,6 +65,18 @@ async function saveIndex(merchantId, businesses) { await uploadJSON(indexPath(merchantId), 'index', { businesses }); } +async function findBusinessByApplicationId(merchantId, applicationId) { + const normalizedApplicationId = normalizeScopeId(applicationId); + if (!normalizedApplicationId) return null; + + const businesses = await getIndex(merchantId); + return businesses.find((business) => { + const storedApplicationId = normalizeScopeId(business.applicationId); + const storedBusinessId = normalizeScopeId(business.businessId); + return storedApplicationId === normalizedApplicationId || storedBusinessId === normalizedApplicationId; + }) || null; +} + const PROVIDER_FIELDS = ['providerName', 'senderId', 'dltEntityId', 'authKey']; function createHttpError(status, message, extra = {}) { @@ -67,6 +104,10 @@ function normalizeSenderId(value) { return normalizeText(value).toUpperCase(); } +function renderTemplateWithUsername(template, username) { + return String(template || '').replace(/\{#var#\}/g, normalizeText(username)); +} + function isValidCurlCommand(rawCurl) { return normalizeText(rawCurl).toLowerCase().startsWith('curl'); } @@ -239,7 +280,7 @@ function getMissingMandatoryProviderFields(provider = {}) { // GET /api/businesses router.get('/', async (req, res) => { try { - const businesses = await getIndex(MERCHANT_ID()); + const businesses = await getIndex(getCompanyId(req)); res.json({ businesses }); } catch (err) { res.status(500).json({ error: err.message }); @@ -252,7 +293,14 @@ router.post('/', async (req, res) => { const { websiteUrl } = req.body; if (!websiteUrl) return res.status(400).json({ error: 'websiteUrl is required' }); - const merchantId = MERCHANT_ID(); + const merchantId = getCompanyId(req); + const applicationId = getApplicationId(req); + const businesses = await getIndex(merchantId); + + if (applicationId && businesses.some((business) => normalizeScopeId(business.applicationId) === applicationId)) { + return res.status(409).json({ error: 'A business is already configured for this applicationId' }); + } + const businessId = uuidv4(); const bizRoot = businessRoot(merchantId, businessId); const imagesFolder = `${bizRoot}/images`; @@ -277,6 +325,8 @@ router.post('/', async (req, res) => { const contextJson = { businessId, merchantId, + companyId: merchantId, + applicationId, domain, brandName: brandContext.brandName || 'Unknown Brand', tone: brandContext.tone || 'professional', @@ -292,9 +342,10 @@ router.post('/', async (req, res) => { await uploadJSON(bizRoot, 'events', { events: DEFAULT_EVENTS }); // 6. Update index.json - const businesses = await getIndex(merchantId); businesses.push({ businessId, + companyId: merchantId, + applicationId, brandName: contextJson.brandName, domain: contextJson.domain, createdAt: contextJson.createdAt, @@ -313,7 +364,7 @@ router.post('/', async (req, res) => { router.get('/:businessId', async (req, res) => { try { const { businessId } = req.params; - const context = await fetchJSON(businessRoot(MERCHANT_ID(), businessId), 'context'); + const context = await fetchJSON(businessRoot(getCompanyId(req), businessId), 'context'); if (!context) return res.status(404).json({ error: 'Business not found' }); res.json(context); } catch (err) { @@ -324,7 +375,7 @@ router.get('/:businessId', async (req, res) => { // DELETE /api/businesses/:businessId router.delete('/:businessId', async (req, res) => { try { - const merchantId = MERCHANT_ID(); + const merchantId = getCompanyId(req); const { businessId } = req.params; await deleteBusinessFiles(merchantId, businessId); @@ -340,12 +391,52 @@ router.delete('/:businessId', async (req, res) => { } }); +// POST /api/businesses/resolve-template +router.post('/resolve-template', async (req, res) => { + try { + const companyId = getCompanyId(req); + const applicationId = getApplicationId(req); + const event = normalizeText(req.body?.event); + const username = normalizeText(req.body?.username); + + if (!companyId) return res.status(400).json({ error: 'companyId is required' }); + if (!applicationId) return res.status(400).json({ error: 'applicationId is required' }); + if (!event) return res.status(400).json({ error: 'event is required' }); + if (!username) return res.status(400).json({ error: 'username is required' }); + + const business = await findBusinessByApplicationId(companyId, applicationId); + if (!business) { + return res.status(404).json({ error: 'Business not found for applicationId' }); + } + + const eventSlug = slugify(event); + const folder = `${businessRoot(companyId, business.businessId)}/templates`; + const tmpl = await fetchJSON(folder, eventSlug); + + if (!tmpl || tmpl.status !== 'whitelisted' || !normalizeText(tmpl.selectedTemplate)) { + return res.status(404).json({ error: 'Whitelisted template not found' }); + } + + res.json({ + success: true, + companyId, + applicationId, + event: eventSlug, + username, + templateId: normalizeText(tmpl.templateId), + template: renderTemplateWithUsername(tmpl.selectedTemplate, username), + }); + } catch (err) { + sendRouteError(res, err); + } +}); + // ─── Providers ──────────────────────────────────────────────────────────────── // GET /api/businesses/:businessId/providers router.get('/:businessId/providers', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const activeProfile = await getActiveProfile(bizRoot); if (!activeProfile) { return res.status(400).json({ error: 'An active cURL profile is required before editing provider settings.' }); @@ -360,7 +451,7 @@ router.get('/:businessId/providers', async (req, res) => { // POST /api/businesses/:businessId/providers router.post('/:businessId/providers', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const providerPatch = getProviderPatch(req.body); const senderIdError = validateSenderId(providerPatch?.senderId || ''); if (senderIdError) { @@ -388,7 +479,7 @@ router.post('/:businessId/providers', async (req, res) => { // GET /api/businesses/:businessId/global-sms router.get('/:businessId/global-sms', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const activeProfile = await getActiveProfile(bizRoot); res.json(activeProfile ? { rawCurl: activeProfile.rawCurl, updatedAt: activeProfile.updatedAt } : {}); } catch (err) { @@ -408,7 +499,7 @@ router.post('/:businessId/global-sms', async (req, res) => { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData } = await getProfileState(bizRoot); const now = new Date().toISOString(); const normalizedCurl = normalizeText(rawCurl); @@ -446,7 +537,7 @@ router.post('/:businessId/global-sms', async (req, res) => { // GET /api/businesses/:businessId/global-sms/profiles router.get('/:businessId/global-sms/profiles', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData, activeProfileId } = await getProfileState(bizRoot); const profiles = profileData.profiles || []; res.json({ profiles, activeProfileId }); @@ -469,7 +560,7 @@ router.post('/:businessId/global-sms/profiles', async (req, res) => { return res.status(400).json({ error: 'rawCurl must be a valid cURL command' }); } - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { profileData } = await getProfileState(bizRoot); const now = new Date().toISOString(); const normalizedCurl = normalizeText(rawCurl); @@ -519,7 +610,7 @@ router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => return res.status(400).json({ error: senderIdError }); } - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const profile = profileData.profiles.find(p => p.id === profileId); if (!profile) return res.status(404).json({ error: 'Profile not found' }); @@ -539,7 +630,7 @@ router.patch('/:businessId/global-sms/profiles/:profileId', async (req, res) => router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => { try { const { businessId, profileId } = req.params; - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const idx = profileData.profiles.findIndex(p => p.id === profileId); @@ -567,7 +658,7 @@ router.delete('/:businessId/global-sms/profiles/:profileId', async (req, res) => router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, res) => { try { const { businessId, profileId } = req.params; - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const { profileData } = await getProfileState(bizRoot); const profile = profileData.profiles.find(p => p.id === profileId); if (!profile) return res.status(404).json({ error: 'Profile not found' }); @@ -582,7 +673,7 @@ router.post('/:businessId/global-sms/profiles/:profileId/activate', async (req, // GET /api/businesses/:businessId/global-sms/active router.get('/:businessId/global-sms/active', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const { activeProfile, activeProfileId } = await getProfileState(bizRoot); res.json({ activeProfile, activeProfileId }); } catch (err) { @@ -595,7 +686,7 @@ router.get('/:businessId/global-sms/active', async (req, res) => { // GET /api/businesses/:businessId/events router.get('/:businessId/events', async (req, res) => { try { - const data = await fetchJSON(businessRoot(MERCHANT_ID(), req.params.businessId), 'events'); + const data = await fetchJSON(businessRoot(getCompanyId(req), req.params.businessId), 'events'); res.json(data || { events: DEFAULT_EVENTS }); } catch (err) { res.status(500).json({ error: err.message }); @@ -608,7 +699,7 @@ router.post('/:businessId/events', async (req, res) => { const { label } = req.body; if (!label) return res.status(400).json({ error: 'label is required' }); - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const slug = slugify(label); @@ -629,7 +720,7 @@ router.post('/:businessId/events', async (req, res) => { router.delete('/:businessId/events/:slug', async (req, res) => { try { const { businessId, slug } = req.params; - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const data = await fetchJSON(bizRoot, 'events') || { events: [...DEFAULT_EVENTS] }; const event = data.events.find(e => e.slug === slug); @@ -648,7 +739,7 @@ router.delete('/:businessId/events/:slug', async (req, res) => { router.post('/:businessId/events/:slug/generate', async (req, res) => { try { const { businessId, slug } = req.params; - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const context = await fetchJSON(bizRoot, 'context'); if (!context) return res.status(400).json({ error: 'Business context not found.' }); @@ -692,7 +783,7 @@ router.post('/:businessId/events/:slug/generate', async (req, res) => { // GET /api/businesses/:businessId/templates/images (must be before /:slug) router.get('/:businessId/templates/images', async (req, res) => { try { - const images = await listImages(`${businessRoot(MERCHANT_ID(), req.params.businessId)}/images`); + const images = await listImages(`${businessRoot(getCompanyId(req), req.params.businessId)}/images`); res.json({ images }); } catch (err) { res.status(500).json({ error: err.message }); @@ -702,7 +793,7 @@ router.get('/:businessId/templates/images', async (req, res) => { // GET /api/businesses/:businessId/templates router.get('/:businessId/templates', async (req, res) => { try { - const bizRoot = businessRoot(MERCHANT_ID(), req.params.businessId); + const bizRoot = businessRoot(getCompanyId(req), req.params.businessId); const folder = `${bizRoot}/templates`; const slugs = await listTemplateFiles(folder); const templates = []; @@ -720,7 +811,7 @@ router.get('/:businessId/templates', async (req, res) => { router.get('/:businessId/templates/:slug', async (req, res) => { try { const { businessId, slug } = req.params; - const tmpl = await fetchJSON(`${businessRoot(MERCHANT_ID(), businessId)}/templates`, slug); + const tmpl = await fetchJSON(`${businessRoot(getCompanyId(req), businessId)}/templates`, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); res.json(tmpl); } catch (err) { @@ -735,7 +826,7 @@ router.post('/:businessId/templates/:slug/select', async (req, res) => { const { selectedVariant } = req.body; if (!selectedVariant) return res.status(400).json({ error: 'selectedVariant is required' }); - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; const tmpl = await fetchJSON(folder, slug); @@ -776,7 +867,7 @@ router.post('/:businessId/templates/:slug/whitelist', async (req, res) => { return res.status(400).json({ error: 'templateId is required' }); } - const folder = `${businessRoot(MERCHANT_ID(), businessId)}/templates`; + const folder = `${businessRoot(getCompanyId(req), businessId)}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' }); if (tmpl.status !== 'pending_whitelisting') { @@ -809,7 +900,7 @@ router.post('/:businessId/templates/:slug/publish', async (req, res) => { return res.status(400).json({ error: 'toNumber is required' }); } - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; // Load template @@ -877,7 +968,7 @@ router.post('/:businessId/templates/:slug/test', async (req, res) => { const { toNumber } = req.body; if (!normalizeText(toNumber)) return res.status(400).json({ error: 'toNumber is required' }); - const bizRoot = businessRoot(MERCHANT_ID(), businessId); + const bizRoot = businessRoot(getCompanyId(req), businessId); const folder = `${bizRoot}/templates`; const tmpl = await fetchJSON(folder, slug); if (!tmpl) return res.status(404).json({ error: 'Template not found' });