FDK session offline storage in boltic tables
This commit is contained in:
parent
1a86cea207
commit
2ccab54fb3
|
|
@ -99,6 +99,23 @@ const DEFAULT_EXPANDED_GROUPS = EVENT_GROUPS.reduce((acc, group) => {
|
||||||
acc[group.id] = group.defaultExpanded;
|
acc[group.id] = group.defaultExpanded;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
const EVENT_TEMPLATE_STATUS_CONFIG = {
|
||||||
|
unselected: {
|
||||||
|
label: 'No template selected',
|
||||||
|
wrapper: 'border-gray-200 bg-gray-50 text-gray-500',
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
},
|
||||||
|
pending_whitelisting: {
|
||||||
|
label: 'Pending Whitelisting',
|
||||||
|
wrapper: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
},
|
||||||
|
whitelisted: {
|
||||||
|
label: 'Published',
|
||||||
|
wrapper: 'border-green-200 bg-green-50 text-green-700',
|
||||||
|
dot: 'bg-green-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function getEventGroupId(event) {
|
function getEventGroupId(event) {
|
||||||
const slug = String(event?.slug || '');
|
const slug = String(event?.slug || '');
|
||||||
|
|
@ -194,11 +211,17 @@ function syncDraftsWithVariants(existingDrafts, variantsBySlug) {
|
||||||
function buildTemplateUiState(templates = []) {
|
function buildTemplateUiState(templates = []) {
|
||||||
const nextVariants = {};
|
const nextVariants = {};
|
||||||
const nextGenState = {};
|
const nextGenState = {};
|
||||||
|
const nextTemplateStatusBySlug = {};
|
||||||
|
|
||||||
templates.forEach((template) => {
|
templates.forEach((template) => {
|
||||||
if (!template?.eventSlug) return;
|
if (!template?.eventSlug) return;
|
||||||
|
|
||||||
if (template.selectedTemplate) {
|
if (template.selectedTemplate) {
|
||||||
|
if (template.status === 'whitelisted') {
|
||||||
|
nextTemplateStatusBySlug[template.eventSlug] = 'whitelisted';
|
||||||
|
} else {
|
||||||
|
nextTemplateStatusBySlug[template.eventSlug] = 'pending_whitelisting';
|
||||||
|
}
|
||||||
nextGenState[template.eventSlug] = 'selected';
|
nextGenState[template.eventSlug] = 'selected';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -209,7 +232,7 @@ function buildTemplateUiState(templates = []) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { nextVariants, nextGenState };
|
return { nextVariants, nextGenState, nextTemplateStatusBySlug };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Events() {
|
export default function Events() {
|
||||||
|
|
@ -229,6 +252,7 @@ export default function Events() {
|
||||||
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
const [selectingVariantKey, setSelectingVariantKey] = useState('');
|
||||||
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
const [openVariableMenuKey, setOpenVariableMenuKey] = useState('');
|
||||||
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
|
const [activeCaretVariantKey, setActiveCaretVariantKey] = useState('');
|
||||||
|
const [templateStatusBySlug, setTemplateStatusBySlug] = useState({});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
const [readyToGenerate, setReadyToGenerate] = useState(false);
|
||||||
|
|
||||||
|
|
@ -259,12 +283,13 @@ export default function Events() {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const templates = templatesRes.data.templates || [];
|
const templates = templatesRes.data.templates || [];
|
||||||
const { nextVariants, nextGenState } = buildTemplateUiState(templates);
|
const { nextVariants, nextGenState, nextTemplateStatusBySlug } = buildTemplateUiState(templates);
|
||||||
|
|
||||||
setEvents(eventsRes.data.events || []);
|
setEvents(eventsRes.data.events || []);
|
||||||
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
setReadyToGenerate(!!activeProfileRes.data?.activeProfile?.rawCurl);
|
||||||
setVariants(nextVariants);
|
setVariants(nextVariants);
|
||||||
setGenState(nextGenState);
|
setGenState(nextGenState);
|
||||||
|
setTemplateStatusBySlug(nextTemplateStatusBySlug);
|
||||||
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
setVariantDrafts((currentDrafts) => syncDraftsWithVariants(currentDrafts, nextVariants));
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to load events');
|
setError('Failed to load events');
|
||||||
|
|
@ -323,6 +348,11 @@ export default function Events() {
|
||||||
}));
|
}));
|
||||||
setOpenVariableMenuKey('');
|
setOpenVariableMenuKey('');
|
||||||
setActiveCaretVariantKey('');
|
setActiveCaretVariantKey('');
|
||||||
|
setTemplateStatusBySlug((currentStatuses) => {
|
||||||
|
const nextStatuses = { ...currentStatuses };
|
||||||
|
delete nextStatuses[slug];
|
||||||
|
return nextStatuses;
|
||||||
|
});
|
||||||
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
setGenState((state) => ({ ...state, [slug]: 'done' }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Generation failed');
|
setError(err.response?.data?.error || 'Generation failed');
|
||||||
|
|
@ -390,8 +420,9 @@ export default function Events() {
|
||||||
setOpenVariableMenuKey('');
|
setOpenVariableMenuKey('');
|
||||||
setActiveCaretVariantKey('');
|
setActiveCaretVariantKey('');
|
||||||
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
setGenState((state) => ({ ...state, [slug]: 'selected' }));
|
||||||
|
setTemplateStatusBySlug((currentStatuses) => ({ ...currentStatuses, [slug]: 'pending_whitelisting' }));
|
||||||
if (shouldAutoAdvance) {
|
if (shouldAutoAdvance) {
|
||||||
navigate(`/${businessId}/templates`);
|
navigate(`/${businessId}/templates?event=${encodeURIComponent(slug)}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.error || 'Failed to select template');
|
setError(err.response?.data?.error || 'Failed to select template');
|
||||||
|
|
@ -617,6 +648,9 @@ export default function Events() {
|
||||||
{group.events.map((event) => {
|
{group.events.map((event) => {
|
||||||
const state = genState[event.slug] || 'idle';
|
const state = genState[event.slug] || 'idle';
|
||||||
const eventVariants = variants[event.slug] || [];
|
const eventVariants = variants[event.slug] || [];
|
||||||
|
const templateStatus = templateStatusBySlug[event.slug] || 'unselected';
|
||||||
|
const statusConfig = EVENT_TEMPLATE_STATUS_CONFIG[templateStatus] || EVENT_TEMPLATE_STATUS_CONFIG.unselected;
|
||||||
|
const canViewTemplate = templateStatus !== 'unselected';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
<div key={event.slug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
|
@ -641,10 +675,21 @@ export default function Events() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{state === 'selected' && (
|
<span
|
||||||
<span className="text-xs font-semibold px-2.5 py-1 rounded-md bg-green-50 border border-green-200 text-green-700">
|
title={statusConfig.label}
|
||||||
✓ Template Selected
|
aria-label={statusConfig.label}
|
||||||
</span>
|
className={`inline-flex h-9 w-9 items-center justify-center rounded-full border shadow-sm ${statusConfig.wrapper}`}
|
||||||
|
>
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${statusConfig.dot}`} />
|
||||||
|
</span>
|
||||||
|
{canViewTemplate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/${businessId}/templates?event=${encodeURIComponent(event.slug)}`)}
|
||||||
|
className="px-3.5 py-2 rounded-lg bg-white border border-gray-300 text-sm font-medium text-gray-700 hover:bg-gray-50 transition shadow-sm"
|
||||||
|
>
|
||||||
|
View in Templates
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleGenerate(event.slug)}
|
onClick={() => handleGenerate(event.slug)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
import WhitelistModal from '../components/WhitelistModal';
|
import WhitelistModal from '../components/WhitelistModal';
|
||||||
import TestSmsModal from '../components/TestSmsModal';
|
import TestSmsModal from '../components/TestSmsModal';
|
||||||
|
|
@ -12,6 +12,7 @@ const STATUS_CONFIG = {
|
||||||
|
|
||||||
export default function Templates() {
|
export default function Templates() {
|
||||||
const { businessId } = useParams();
|
const { businessId } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [profilesById, setProfilesById] = useState({});
|
const [profilesById, setProfilesById] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -19,6 +20,16 @@ export default function Templates() {
|
||||||
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
const [whitelistTarget, setWhitelistTarget] = useState(null);
|
||||||
const [testTarget, setTestTarget] = useState(null);
|
const [testTarget, setTestTarget] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
const [activeTab, setActiveTab] = useState('published'); // 'published' | 'pending'
|
||||||
|
const [highlightedEventSlug, setHighlightedEventSlug] = useState('');
|
||||||
|
const templateCardRefs = useRef({});
|
||||||
|
const highlightTimeoutRef = useRef(null);
|
||||||
|
const handledFocusSlugRef = useRef('');
|
||||||
|
|
||||||
|
const getTabForStatus = useCallback((status) => {
|
||||||
|
if (status === 'pending_whitelisting') return 'pending';
|
||||||
|
if (status === 'whitelisted') return 'published';
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadTemplates = useCallback(async () => {
|
const loadTemplates = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -45,6 +56,45 @@ export default function Templates() {
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
}, [loadTemplates]);
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
window.clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetEventSlug = searchParams.get('event');
|
||||||
|
if (!targetEventSlug || templates.length === 0) return;
|
||||||
|
if (handledFocusSlugRef.current === targetEventSlug) return;
|
||||||
|
|
||||||
|
const targetTemplate = templates.find(tmpl => tmpl.eventSlug === targetEventSlug);
|
||||||
|
if (!targetTemplate) return;
|
||||||
|
|
||||||
|
const targetTab = getTabForStatus(targetTemplate.status);
|
||||||
|
if (targetTab && activeTab !== targetTab) {
|
||||||
|
setActiveTab(targetTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCard = templateCardRefs.current[targetEventSlug];
|
||||||
|
if (!targetCard) return;
|
||||||
|
|
||||||
|
handledFocusSlugRef.current = targetEventSlug;
|
||||||
|
targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlightedEventSlug(targetEventSlug);
|
||||||
|
|
||||||
|
if (highlightTimeoutRef.current) {
|
||||||
|
window.clearTimeout(highlightTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setHighlightedEventSlug(currentSlug => (currentSlug === targetEventSlug ? '' : currentSlug));
|
||||||
|
highlightTimeoutRef.current = null;
|
||||||
|
}, 2200);
|
||||||
|
}, [activeTab, getTabForStatus, searchParams, templates]);
|
||||||
|
|
||||||
async function handleWhitelistSuccess() {
|
async function handleWhitelistSuccess() {
|
||||||
setWhitelistTarget(null);
|
setWhitelistTarget(null);
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
|
|
@ -127,7 +177,21 @@ export default function Templates() {
|
||||||
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.';
|
: 'This template is not bound to a cURL profile. Re-select it from Events to continue.';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={tmpl.eventSlug} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
<div
|
||||||
|
key={tmpl.eventSlug}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
templateCardRefs.current[tmpl.eventSlug] = node;
|
||||||
|
} else {
|
||||||
|
delete templateCardRefs.current[tmpl.eventSlug];
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`rounded-xl bg-white border shadow-sm overflow-hidden transition-all duration-300 ${
|
||||||
|
highlightedEventSlug === tmpl.eventSlug
|
||||||
|
? 'border-primary-blue ring-2 ring-indigo-200 animate-pulse'
|
||||||
|
: 'border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
<h3 className="text-base font-bold text-gray-900 capitalize tracking-tight">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ require('dotenv').config();
|
||||||
|
|
||||||
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
|
const { setupFdk } = require('@gofynd/fdk-extension-javascript/express');
|
||||||
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
|
const { MemoryStorage } = require('@gofynd/fdk-extension-javascript/express/storage');
|
||||||
|
const { createFdkStorage } = require('./postgresFdkStorage');
|
||||||
|
|
||||||
function normalizeEnvText(value) {
|
function normalizeEnvText(value) {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
|
@ -22,11 +23,21 @@ function createFdkExtension() {
|
||||||
const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET);
|
const apiSecret = normalizeEnvText(process.env.EXTENSION_API_SECRET);
|
||||||
const baseUrl = normalizeEnvText(process.env.EXTENSION_BASE_URL || process.env.EXTENSION_URL);
|
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 cluster = normalizeEnvText(process.env.EXTENSION_CLUSTER_URL) || 'https://api.fynd.com';
|
||||||
|
const storageConnectionString = normalizeEnvText(process.env.FDK_STORAGE_CONNECTION_STRING);
|
||||||
|
|
||||||
if (!apiKey || !apiSecret || !baseUrl) {
|
if (!apiKey || !apiSecret || !baseUrl) {
|
||||||
return null;
|
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 {
|
try {
|
||||||
return setupFdk({
|
return setupFdk({
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
|
|
@ -41,7 +52,7 @@ function createFdkExtension() {
|
||||||
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
|
console.log(`[FDK] uninstall callback received for company ${companyId || 'unknown'}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
storage: new MemoryStorage('sms_extension_'),
|
storage,
|
||||||
access_mode: 'offline',
|
access_mode: 'offline',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1668,6 +1669,96 @@
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
|
@ -1681,6 +1772,45 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
|
@ -2007,6 +2137,15 @@
|
||||||
"node": ">=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": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"openai": "^4.28.0",
|
"openai": "^4.28.0",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user