frontend changes upto based features ruels generation

This commit is contained in:
Chandini 2025-09-11 15:47:46 +05:30
parent 343ef63563
commit d1c150e055
8 changed files with 220 additions and 52 deletions

View File

@ -31,23 +31,80 @@ export function AICustomFeatureCreator({
projectType, projectType,
onAdd, onAdd,
onClose, onClose,
editingFeature,
}: { }: {
projectType?: string projectType?: string
onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => void onAdd: (feature: { name: string; description: string; complexity: Complexity; logic_rules?: string[]; requirements?: Array<{ text: string; rules: string[] }>; business_rules?: Array<{ requirement: string; rules: string[] }> }) => void
onClose: () => void onClose: () => void
editingFeature?: {
id: string;
name: string;
description: string;
complexity: Complexity;
business_rules?: any;
technical_requirements?: any;
additional_business_rules?: any;
}
}) { }) {
const [featureName, setFeatureName] = useState('') const [featureName, setFeatureName] = useState(editingFeature?.name || '')
const [featureDescription, setFeatureDescription] = useState('') const [featureDescription, setFeatureDescription] = useState(editingFeature?.description || '')
const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(undefined) const [selectedComplexity, setSelectedComplexity] = useState<Complexity | undefined>(editingFeature?.complexity || undefined)
const [isAnalyzing, setIsAnalyzing] = useState(false) const [isAnalyzing, setIsAnalyzing] = useState(false)
const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(null) const [aiAnalysis, setAiAnalysis] = useState<AIAnalysisResult | null>(() => {
if (editingFeature) {
return {
suggested_name: editingFeature.name,
complexity: editingFeature.complexity,
confidence_score: 1,
}
}
return null
})
const [analysisError, setAnalysisError] = useState<string | null>(null) const [analysisError, setAnalysisError] = useState<string | null>(null)
const [requirements, setRequirements] = useState<Array<{ text: string; rules: string[] }>>([ const [requirements, setRequirements] = useState<Array<{ text: string; rules: string[] }>>(() => {
{ text: '', rules: [] }, // Initialize requirements from existing feature data
]) if (editingFeature) {
console.log('🔍 Editing feature data:', editingFeature)
try {
// Try to get business rules from multiple sources
let businessRules = null;
// First try direct business_rules field
if (editingFeature.business_rules) {
console.log('📋 Found business_rules field:', editingFeature.business_rules)
businessRules = Array.isArray(editingFeature.business_rules)
? editingFeature.business_rules
: JSON.parse(editingFeature.business_rules)
}
// Then try additional_business_rules from feature_business_rules table
else if ((editingFeature as any).additional_business_rules) {
console.log('📋 Found additional_business_rules field:', (editingFeature as any).additional_business_rules)
businessRules = Array.isArray((editingFeature as any).additional_business_rules)
? (editingFeature as any).additional_business_rules
: JSON.parse((editingFeature as any).additional_business_rules)
}
console.log('📋 Parsed business rules:', businessRules)
if (businessRules && Array.isArray(businessRules) && businessRules.length > 0) {
const requirements = businessRules.map((rule: any) => ({
text: rule.requirement || rule.text || '',
rules: rule.rules || []
}))
console.log('📋 Mapped requirements:', requirements)
return requirements
}
} catch (error) {
console.error('Error parsing business rules:', error)
}
}
return [{ text: '', rules: [] }]
})
const [analyzingIdx, setAnalyzingIdx] = useState<number | null>(null) const [analyzingIdx, setAnalyzingIdx] = useState<number | null>(null)
const hasAnyAnalysis = !!aiAnalysis || requirements.some(r => (r.rules || []).length > 0)
const handleAnalyze = async () => { const handleAnalyze = async () => {
if (hasAnyAnalysis) return
if (!featureDescription.trim() && requirements.every(r => !r.text.trim())) return if (!featureDescription.trim() && requirements.every(r => !r.text.trim())) return
setIsAnalyzing(true) setIsAnalyzing(true)
setAnalysisError(null) setAnalysisError(null)
@ -100,7 +157,9 @@ export function AICustomFeatureCreator({
const handleAnalyzeRequirement = async (idx: number) => { const handleAnalyzeRequirement = async (idx: number) => {
const req = requirements[idx] const req = requirements[idx]
if (hasAnyAnalysis) return
if (!req?.text?.trim()) return if (!req?.text?.trim()) return
if ((req.rules || []).length > 0) return
setAnalyzingIdx(idx) setAnalyzingIdx(idx)
setAnalysisError(null) setAnalysisError(null)
try { try {
@ -138,14 +197,10 @@ export function AICustomFeatureCreator({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!aiAnalysis) {
await handleAnalyze()
return
}
onAdd({ onAdd({
name: aiAnalysis.suggested_name || featureName.trim() || 'Custom Feature', name: aiAnalysis?.suggested_name || featureName.trim() || 'Custom Feature',
description: featureDescription.trim(), description: featureDescription.trim(),
complexity: aiAnalysis.complexity || selectedComplexity || 'medium', complexity: aiAnalysis?.complexity || selectedComplexity || 'medium',
logic_rules: requirements.flatMap(r => r.rules || []), logic_rules: requirements.flatMap(r => r.rules || []),
requirements: requirements, requirements: requirements,
business_rules: requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })), business_rules: requirements.map(r => ({ requirement: r.text, rules: r.rules || [] })),
@ -158,7 +213,9 @@ export function AICustomFeatureCreator({
<div className="bg-white/5 border border-white/10 rounded-xl max-w-4xl w-full max-h-[90vh] backdrop-blur flex flex-col"> <div className="bg-white/5 border border-white/10 rounded-xl max-w-4xl w-full max-h-[90vh] backdrop-blur flex flex-col">
<div className="p-6 border-b border-white/10"> <div className="p-6 border-b border-white/10">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-white text-lg font-semibold">AI-Powered Feature Creator</h3> <h3 className="text-white text-lg font-semibold">
{editingFeature ? 'Edit Custom Feature' : 'AI-Powered Feature Creator'}
</h3>
<button onClick={onClose} className="text-white/60 hover:text-white">×</button> <button onClick={onClose} className="text-white/60 hover:text-white">×</button>
</div> </div>
</div> </div>
@ -197,10 +254,10 @@ export function AICustomFeatureCreator({
type="button" type="button"
variant="outline" variant="outline"
onClick={() => handleAnalyzeRequirement(idx)} onClick={() => handleAnalyzeRequirement(idx)}
disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim()} disabled={isAnalyzing || analyzingIdx === idx || !r.text.trim() || hasAnyAnalysis || (r.rules || []).length > 0}
className="border-white/20 text-white hover:bg-white/10" className="border-white/20 text-white hover:bg-white/10"
> >
{analyzingIdx === idx ? 'Analyzing…' : (r.rules?.length ? 'Re-analyze' : 'Analyze With AI')} {analyzingIdx === idx ? 'Analyzing…' : (((r.rules || []).length > 0) || hasAnyAnalysis ? 'Analyzed' : 'Analyze With AI')}
</Button> </Button>
<button <button
type="button" type="button"
@ -276,7 +333,10 @@ export function AICustomFeatureCreator({
)} )}
<Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button> <Button type="button" variant="outline" onClick={onClose} className="border-white/20 text-white hover:bg-white/10">Cancel</Button>
<Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing} className="bg-orange-500 hover:bg-orange-400 text-black"> <Button type="submit" disabled={(!featureDescription.trim() && requirements.every(r => !r.text.trim())) || isAnalyzing} className="bg-orange-500 hover:bg-orange-400 text-black">
{aiAnalysis ? 'Add Feature with Tagged Rules' : 'Analyze & Add Feature'} {editingFeature
? (aiAnalysis ? 'Update Feature with Tagged Rules' : 'Update Feature')
: (aiAnalysis ? 'Add Feature with Tagged Rules' : 'Add Feature')
}
</Button> </Button>
</div> </div>
</form> </form>
@ -287,5 +347,3 @@ export function AICustomFeatureCreator({
} }
export default AICustomFeatureCreator export default AICustomFeatureCreator

View File

@ -100,7 +100,8 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) { const isRefreshEndpoint = originalRequest?.url?.includes('/api/auth/refresh');
if (error.response?.status === 401 && !originalRequest._retry && !isRefreshEndpoint) {
originalRequest._retry = true; originalRequest._retry = true;
try { try {
if (refreshToken) { if (refreshToken) {
@ -112,6 +113,11 @@ const addTokenRefreshInterceptor = (client: typeof authApiClient) => {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return client(originalRequest); return client(originalRequest);
} }
// No refresh token available
clearTokens();
safeLocalStorage.removeItem('codenuk_user');
window.location.href = '/signin';
return Promise.reject(error);
} catch (refreshError) { } catch (refreshError) {
console.error('Token refresh failed:', refreshError); console.error('Token refresh failed:', refreshError);
clearTokens(); clearTokens();

View File

@ -21,6 +21,7 @@ interface DualCanvasEditorProps {
onGenerationStart?: () => void onGenerationStart?: () => void
selectedDevice?: 'desktop' | 'tablet' | 'mobile' selectedDevice?: 'desktop' | 'tablet' | 'mobile'
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
initialPrompt?: string
} }
export function DualCanvasEditor({ export function DualCanvasEditor({
@ -28,7 +29,8 @@ export function DualCanvasEditor({
onWireframeGenerated, onWireframeGenerated,
onGenerationStart, onGenerationStart,
selectedDevice = 'desktop', selectedDevice = 'desktop',
onDeviceChange onDeviceChange,
initialPrompt
}: DualCanvasEditorProps) { }: DualCanvasEditorProps) {
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [canvasMode, setCanvasMode] = useState<'wireframe' | 'components'>('wireframe') const [canvasMode, setCanvasMode] = useState<'wireframe' | 'components'>('wireframe')
@ -216,6 +218,7 @@ export function DualCanvasEditor({
className="shrink-0" className="shrink-0"
selectedDevice={selectedDevice} selectedDevice={selectedDevice}
onDeviceChange={handleDeviceChange} onDeviceChange={handleDeviceChange}
initialPrompt={initialPrompt}
/> />
</div> </div>
) : ( ) : (

View File

@ -7,7 +7,6 @@ import { Input } from "@/components/ui/input"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { EditFeatureForm } from "@/components/edit-feature-form"
import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette } from "lucide-react" import { ArrowRight, Plus, Globe, BarChart3, Zap, Code, Search, Star, Clock, Users, Layers, AlertCircle, Edit, Trash2, User, Palette } from "lucide-react"
import { useTemplates } from "@/hooks/useTemplates" import { useTemplates } from "@/hooks/useTemplates"
import { CustomTemplateForm } from "@/components/custom-template-form" import { CustomTemplateForm } from "@/components/custom-template-form"
@ -20,6 +19,7 @@ import { Tooltip } from "@/components/ui/tooltip"
import WireframeCanvas from "@/components/wireframe-canvas" import WireframeCanvas from "@/components/wireframe-canvas"
import PromptSidePanel from "@/components/prompt-side-panel" import PromptSidePanel from "@/components/prompt-side-panel"
import { DualCanvasEditor } from "@/components/dual-canvas-editor" import { DualCanvasEditor } from "@/components/dual-canvas-editor"
import { getAccessToken } from "@/components/apis/authApiClients"
interface Template { interface Template {
id: string id: string
@ -857,14 +857,14 @@ function FeatureSelectionStep({
const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null) const [editingFeature, setEditingFeature] = useState<TemplateFeature | null>(null)
const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => { const handleUpdate = async (f: TemplateFeature, updates: Partial<TemplateFeature>) => {
const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id // Use the actual id field directly (no need to extract from feature_id)
await updateFeature(idForApi, { ...updates, isCustom: f.feature_type === 'custom' }) await updateFeature(f.id, { ...updates, isCustom: f.feature_type === 'custom' })
await load() await load()
} }
const handleDelete = async (f: TemplateFeature) => { const handleDelete = async (f: TemplateFeature) => {
const idForApi = f.feature_type === 'custom' ? (f.feature_id?.replace(/^custom_/, '') || f.id) : f.id // Use the actual id field directly (no need to extract from feature_id)
await deleteFeature(idForApi, { isCustom: f.feature_type === 'custom' }) await deleteFeature(f.id, { isCustom: f.feature_type === 'custom' })
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.delete(f.id) next.delete(f.id)
@ -977,23 +977,25 @@ function FeatureSelectionStep({
{section('Your Custom Features', custom)} {section('Your Custom Features', custom)}
{showAIModal && ( {(showAIModal || editingFeature) && (
<AICustomFeatureCreator <AICustomFeatureCreator
projectType={template.type || template.title} projectType={template.type || template.title}
onAdd={async (f) => { await handleAddAIAnalyzed(f); setShowAIModal(false) }} onAdd={async (f) => {
onClose={() => setShowAIModal(false)} if (editingFeature) {
/> // Update existing feature
)} await handleUpdate(editingFeature, f)
setEditingFeature(null)
{editingFeature && ( } else {
<EditFeatureForm // Add new feature
feature={editingFeature} await handleAddAIAnalyzed(f)
onSubmit={async (updates) => { setShowAIModal(false)
await handleUpdate(editingFeature, updates) }
}}
onClose={() => {
setShowAIModal(false)
setEditingFeature(null) setEditingFeature(null)
}} }}
onCancel={() => setEditingFeature(null)} editingFeature={editingFeature || undefined}
isOpen={!!editingFeature}
/> />
)} )}
@ -1037,9 +1039,10 @@ function BusinessQuestionsStep({
setError('No features selected') setError('No features selected')
return return
} }
const resp = await fetch(`${BACKEND_URL}/api/v1/generate-comprehensive-business-questions`, { const token = getAccessToken()
const resp = await fetch(`${BACKEND_URL}/api/questions/generate-comprehensive-business-questions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify({ body: JSON.stringify({
allFeatures: selected, allFeatures: selected,
projectName: template.title, projectName: template.title,
@ -1327,6 +1330,7 @@ function AIMockupStep({
const [wireframeData, setWireframeData] = useState<any>(null) const [wireframeData, setWireframeData] = useState<any>(null)
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [selectedDevice, setSelectedDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop') const [selectedDevice, setSelectedDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
const [initialPrompt, setInitialPrompt] = useState<string>("")
// Load state from localStorage after component mounts // Load state from localStorage after component mounts
useEffect(() => { useEffect(() => {
@ -1352,6 +1356,17 @@ function AIMockupStep({
} }
}, []) }, [])
// Build prompt from selected features and template
useEffect(() => {
if (!template) return
const featureNames = (selectedFeatures || []).map(f => f.name).filter(Boolean)
const base = `${template.title} dashboard`
const parts = featureNames.length > 0 ? ` with ${featureNames.join(', ')}` : ''
const footer = '. Optimize layout and spacing. Include navigation.'
const prompt = `${base}${parts}${footer}`
setInitialPrompt(prompt)
}, [template, JSON.stringify(selectedFeatures)])
const handleWireframeGenerated = (data: any) => { const handleWireframeGenerated = (data: any) => {
setWireframeData(data) setWireframeData(data)
setIsGenerating(false) setIsGenerating(false)
@ -1414,6 +1429,7 @@ function AIMockupStep({
onGenerationStart={handleWireframeGenerationStart} onGenerationStart={handleWireframeGenerationStart}
selectedDevice={selectedDevice} selectedDevice={selectedDevice}
onDeviceChange={handleDeviceChange} onDeviceChange={handleDeviceChange}
initialPrompt={initialPrompt}
/> />
</div> </div>

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useMemo, useState, useEffect } from "react" import { useMemo, useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@ -11,11 +11,13 @@ import { getAIMockupHealthUrl, AI_MOCKUP_CONFIG } from "@/lib/api-config"
export function PromptSidePanel({ export function PromptSidePanel({
className, className,
selectedDevice = 'desktop', selectedDevice = 'desktop',
onDeviceChange onDeviceChange,
initialPrompt
}: { }: {
className?: string className?: string
selectedDevice?: 'desktop' | 'tablet' | 'mobile' selectedDevice?: 'desktop' | 'tablet' | 'mobile'
onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void onDeviceChange?: (device: 'desktop' | 'tablet' | 'mobile') => void
initialPrompt?: string
}) { }) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [prompt, setPrompt] = useState( const [prompt, setPrompt] = useState(
@ -23,6 +25,7 @@ export function PromptSidePanel({
) )
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking') const [backendStatus, setBackendStatus] = useState<'connected' | 'disconnected' | 'checking'>('checking')
const autoTriggeredRef = useRef(false)
const examples = useMemo( const examples = useMemo(
() => [ () => [
@ -66,6 +69,25 @@ export function PromptSidePanel({
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
// Update prompt from parent when provided
useEffect(() => {
if (typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) {
setPrompt(initialPrompt)
}
}, [initialPrompt])
// Auto-generate once when an initial prompt arrives
useEffect(() => {
if (!autoTriggeredRef.current && typeof initialPrompt === 'string' && initialPrompt.trim().length > 0) {
autoTriggeredRef.current = true
// Slight delay to ensure canvas listeners are mounted
const id = setTimeout(() => {
dispatchGenerate(initialPrompt)
}, 300)
return () => clearTimeout(id)
}
}, [initialPrompt])
const dispatchGenerate = async (text: string) => { const dispatchGenerate = async (text: string) => {
setIsGenerating(true) setIsGenerating(true)
@ -208,7 +230,7 @@ export function PromptSidePanel({
</div> </div>
)} )}
<div className="pt-1"> {/* <div className="pt-1">
<p className="text-xs font-medium mb-2">Examples</p> <p className="text-xs font-medium mb-2">Examples</p>
<ScrollArea className="h-40 border rounded"> <ScrollArea className="h-40 border rounded">
<ul className="p-2 space-y-2"> <ul className="p-2 space-y-2">
@ -226,7 +248,7 @@ export function PromptSidePanel({
))} ))}
</ul> </ul>
</ScrollArea> </ScrollArea>
</div> </div> */}
{/* AI Features Info */} {/* AI Features Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">

View File

@ -1375,6 +1375,19 @@ export default function WireframeCanvas({
return ( return (
<div className={cn("relative h-full w-full flex flex-col", className)}> <div className={cn("relative h-full w-full flex flex-col", className)}>
{/* Centered loading overlay during generation */}
{busy && (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-white/60 dark:bg-black/60"
aria-busy="true"
aria-live="polite"
>
<div className="flex flex-col items-center gap-3">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-blue-600 border-t-transparent"></div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Generating wireframe</span>
</div>
</div>
)}
{error && ( {error && (
<div className="absolute top-4 left-4 right-4 z-50 bg-red-50 border border-red-200 rounded-lg p-3 shadow-lg"> <div className="absolute top-4 left-4 right-4 z-50 bg-red-50 border border-red-200 rounded-lg p-3 shadow-lg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -1391,7 +1404,7 @@ export default function WireframeCanvas({
)} )}
{busy && ( {busy && (
<div className="absolute top-4 left-4 right-4 z-50 bg-blue-50 border border-blue-200 rounded-lg p-3 shadow-lg"> <div className="absolute top-4 left-4 right-4 z-40 bg-blue-50 border border-blue-200 rounded-lg p-3 shadow-lg">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div> <div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span className="text-blue-600 text-sm font-medium">Generating wireframe with AI...</span> <span className="text-blue-600 text-sm font-medium">Generating wireframe with AI...</span>

View File

@ -409,9 +409,31 @@ export const featureApi = {
technical_requirements?: Record<string, unknown>; technical_requirements?: Record<string, unknown>;
created_by_user_session?: string; created_by_user_session?: string;
}): Promise<{ data: AdminFeature; similarityInfo?: Record<string, unknown> }> => { }): Promise<{ data: AdminFeature; similarityInfo?: Record<string, unknown> }> => {
const normalizeJsonField = (value: unknown): unknown => {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value === 'string') {
try {
const t = value.trim();
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
return JSON.parse(t);
}
return value;
} catch {
return undefined;
}
}
return value;
};
const cleaned = {
...featureData,
business_rules: normalizeJsonField(featureData.business_rules) as Record<string, unknown>,
technical_requirements: normalizeJsonField(featureData.technical_requirements) as Record<string, unknown>,
};
const response = await apiCall<AdminFeature>('/api/features/custom', { const response = await apiCall<AdminFeature>('/api/features/custom', {
method: 'POST', method: 'POST',
body: JSON.stringify(featureData), body: JSON.stringify(cleaned),
}); });
return { return {

View File

@ -442,9 +442,37 @@ class TemplateService {
} }
async createFeature(featureData: CreateFeaturePayload): Promise<TemplateFeature> { async createFeature(featureData: CreateFeaturePayload): Promise<TemplateFeature> {
// Normalize potentially stringified JSON fields to proper JSON before sending
const normalizeJsonField = (value: unknown): unknown => {
if (value === undefined || value === null || value === '') return undefined
if (typeof value === 'string') {
try {
const trimmed = value.trim()
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
return JSON.parse(trimmed)
}
// If it's a plain string not JSON, leave as-is for non-JSONB fields
return value
} catch {
// Leave as-is; backend will reject invalid JSON, but we prefer to avoid sending broken JSONB
return undefined
}
}
return value
}
const cleanedFeatureData: CreateFeaturePayload = {
...featureData,
// Only include normalized fields if valid
business_rules: normalizeJsonField(featureData.business_rules) as CreateFeaturePayload['business_rules'],
logic_rules: normalizeJsonField(featureData.logic_rules) as CreateFeaturePayload['logic_rules'],
// @ts-expect-error: allow passthrough of optional technical_requirements if present in callers
technical_requirements: normalizeJsonField((featureData as any).technical_requirements),
}
if ( if (
featureData && cleanedFeatureData &&
(featureData.feature_type === 'custom') (cleanedFeatureData.feature_type === 'custom')
) { ) {
const customHeaders: Record<string, string> = { 'Content-Type': 'application/json' } const customHeaders: Record<string, string> = { 'Content-Type': 'application/json' }
const customToken = getAccessToken() const customToken = getAccessToken()
@ -454,7 +482,7 @@ class TemplateService {
const response = await fetch(`${BACKEND_URL}/api/features/custom`, { const response = await fetch(`${BACKEND_URL}/api/features/custom`, {
method: 'POST', method: 'POST',
headers: customHeaders, headers: customHeaders,
body: JSON.stringify(featureData), body: JSON.stringify(cleanedFeatureData),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const data = await response.json() const data = await response.json()
@ -469,7 +497,7 @@ class TemplateService {
const response = await fetch(`${BACKEND_URL}/api/features`, { const response = await fetch(`${BACKEND_URL}/api/features`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify(featureData), body: JSON.stringify(cleanedFeatureData),
}) })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const data = await response.json() const data = await response.json()