diff --git a/services/api-gateway/src/server.js b/services/api-gateway/src/server.js index feee54b..6097663 100644 --- a/services/api-gateway/src/server.js +++ b/services/api-gateway/src/server.js @@ -375,6 +375,19 @@ app.use('/api/requirements', serviceRouter.createServiceProxy(serviceTargets.REQUIREMENT_PROCESSOR_URL, 'requirement-processor') ); +// Questions (Requirement Processor) - expose /api/questions via gateway +// Rewrites /api/questions/* -> /api/v1/* at the Requirement Processor +app.use('/api/questions', + createServiceLimiter(300), + // Allow unauthenticated access for generating questions (public step in builder) + (req, res, next) => next(), + serviceRouter.createServiceProxy( + serviceTargets.REQUIREMENT_PROCESSOR_URL, + 'requirement-processor-questions', + { pathRewrite: { '^/api/questions': '/api/v1' } } + ) +); + // Tech Stack Selector Service app.use('/api/tech-stack', createServiceLimiter(200), diff --git a/services/template-manager/src/models/custom_feature.js b/services/template-manager/src/models/custom_feature.js index 1946785..67c7066 100644 --- a/services/template-manager/src/models/custom_feature.js +++ b/services/template-manager/src/models/custom_feature.js @@ -42,13 +42,55 @@ class CustomFeature { static async create(data) { const id = uuidv4(); + // Normalize JSONB-like fields to ensure valid JSON is sent to PG + const normalizeJsonb = (value) => { + if (value === undefined || value === null || value === '') return null; + if (typeof value === 'string') { + try { return JSON.parse(value); } catch { + // Accept plain strings by storing as JSON string (quoted) + return String(value); + } + } + return value; + }; + const toJsonbSafe = (value) => { + try { + const v = normalizeJsonb(value); + // Only objects/arrays/strings are JSON-serializable; numbers/booleans fine too + // But if we get something unexpected, fallback to null + if (v === null || v === undefined) return null; + // If array, coerce entries away from undefined + if (Array.isArray(v)) { + return v.map((item) => (item === undefined ? null : item)); + } + // Plain object or primitive + return v; + } catch { + return null; + } + }; + const businessRules = toJsonbSafe(data.business_rules); + const technicalRequirements = toJsonbSafe(data.technical_requirements); + // Debug logging to trace JSON payloads that will be cast to jsonb + try { + console.log('๐Ÿงช [CustomFeature.create] JSON payloads:', { + businessRulesPreview: businessRules === null ? null : JSON.stringify(businessRules).slice(0, 200), + technicalRequirementsPreview: technicalRequirements === null ? null : JSON.stringify(technicalRequirements).slice(0, 200) + }); + } catch (_) {} + const query = ` INSERT INTO custom_features ( id, template_id, template_type, name, description, complexity, business_rules, technical_requirements, approved, usage_count, created_by_user_session, status, admin_notes, admin_reviewed_at, admin_reviewed_by, canonical_feature_id, similarity_score, created_at, updated_at - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,DEFAULT,DEFAULT) + ) VALUES ( + $1,$2,$3,$4,$5,$6, + $7::jsonb,$8::jsonb,$9,$10,$11, + $12,$13,$14,$15,$16,$17, + DEFAULT,DEFAULT + ) RETURNING * `; const values = [ @@ -58,8 +100,8 @@ class CustomFeature { data.name, data.description || null, data.complexity, - data.business_rules || null, - data.technical_requirements || null, + (() => { try { return businessRules === null ? null : JSON.stringify(businessRules); } catch { return null; } })(), + (() => { try { return technicalRequirements === null ? null : JSON.stringify(technicalRequirements); } catch { return null; } })(), data.approved ?? false, data.usage_count ?? 1, data.created_by_user_session || null, @@ -75,6 +117,24 @@ class CustomFeature { } static async update(id, updates) { + // Normalize JSONB-like fields before constructing the query + const normalizeJsonb = (value) => { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + if (typeof value === 'string') { + try { return JSON.parse(value); } catch { + // Accept plain strings by storing as JSON string (quoted) + return String(value); + } + } + return value; + }; + if (updates && Object.prototype.hasOwnProperty.call(updates, 'business_rules')) { + updates.business_rules = normalizeJsonb(updates.business_rules); + } + if (updates && Object.prototype.hasOwnProperty.call(updates, 'technical_requirements')) { + updates.technical_requirements = normalizeJsonb(updates.technical_requirements); + } const fields = []; const values = []; let idx = 1; @@ -85,8 +145,14 @@ class CustomFeature { ]; for (const k of allowed) { if (updates[k] !== undefined) { - fields.push(`${k} = $${idx++}`); - values.push(updates[k]); + if (k === 'business_rules' || k === 'technical_requirements') { + fields.push(`${k} = $${idx++}::jsonb`); + const v = updates[k] === null ? null : JSON.stringify(updates[k]); + values.push(v); + } else { + fields.push(`${k} = $${idx++}`); + values.push(updates[k]); + } } } if (fields.length === 0) return await CustomFeature.getById(id); diff --git a/services/template-manager/src/routes/features.js b/services/template-manager/src/routes/features.js index af0083c..cd50675 100644 --- a/services/template-manager/src/routes/features.js +++ b/services/template-manager/src/routes/features.js @@ -367,25 +367,10 @@ router.put('/:id', async (req, res) => { let updated; if (isCustomFeature) { - // Update custom feature + // Update custom feature (only in custom_features table) updated = await CustomFeature.update(id, updateData); - - // Mirror update into template_features where feature_id = `custom_` - try { - const featureId = `custom_${id}`; - const mirroredExisting = await Feature.getByFeatureId(existing.template_id, featureId); - if (mirroredExisting) { - await Feature.update(mirroredExisting.id, { - name: updateData.name ?? mirroredExisting.name, - description: updateData.description ?? mirroredExisting.description, - complexity: updateData.complexity ?? mirroredExisting.complexity, - }); - } - } catch (mirrorErr) { - console.error('Failed to mirror custom feature update:', mirrorErr.message); - } } else { - // Update regular feature + // Update regular feature (only in template_features table) updated = await Feature.update(id, updateData); } @@ -427,6 +412,28 @@ router.delete('/:id', async (req, res) => { router.post('/custom', async (req, res) => { try { const data = req.body || {} + // Normalize and validate JSON-like fields to avoid invalid input syntax for type json + const tryParseJson = (value) => { + if (value === undefined || value === null || value === '') return null; + if (typeof value === 'string') { + const trimmed = value.trim(); + // Only attempt to parse if it looks like JSON; otherwise pass through as plain string + if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { + try { return JSON.parse(trimmed); } catch { + // If it looks like JSON but fails to parse, fallback to null so we don't send invalid jsonb + return null; + } + } + // Allow plain strings; model will stringify safely for jsonb columns + return trimmed; + } + return value; + } + + // Be lenient: normalize but do not reject if strings are not valid JSON + if (data.business_rules !== undefined) data.business_rules = tryParseJson(data.business_rules) + if (data.logic_rules !== undefined) data.logic_rules = tryParseJson(data.logic_rules) + if (data.technical_requirements !== undefined) data.technical_requirements = tryParseJson(data.technical_requirements) console.log('๐Ÿ” Custom feature creation request:', { template_id: data.template_id, name: data.name, complexity: data.complexity, description: data.description }) const required = ['template_id', 'name', 'complexity'] for (const f of required) { @@ -466,6 +473,9 @@ router.post('/custom', async (req, res) => { name: data.name, description: data.description, complexity: data.complexity, + // Persist incoming structured fields on the row as well + business_rules: data.business_rules ?? (data.logic_rules ? [{ requirement: 'Aggregated', rules: data.logic_rules }] : null), + technical_requirements: data.technical_requirements ?? null, approved: false, usage_count: 1, created_by_user_session: data.created_by_user_session, @@ -476,27 +486,27 @@ router.post('/custom', async (req, res) => { try { await AdminNotification.notifyNewFeature(created.id, created.name); } catch (e) { console.error('โš ๏ธ Failed to create admin notification:', e.message); } - try { - await Feature.create({ - template_id: data.template_id, - feature_id: data.id, - name: data.name, - description: data.description, - feature_type: 'custom', - complexity: data.complexity, - display_order: 999, - is_default: false, - created_by_user: true - }) - } catch (mirrorErr) { console.error('Failed to mirror custom feature into template_features:', mirrorErr.message) } + // Custom features are only stored in custom_features table, not mirrored to template_features - // Persist aggregated rules + // Persist aggregated rules (using actual custom feature ID) try { // Prefer structured business_rules; fallback to flat logic_rules const rules = (data.business_rules ?? data.logic_rules ?? []); - await FeatureBusinessRules.upsert(data.template_id, `custom_${created.id}`, rules); + if (Array.isArray(rules) && rules.length > 0) { + await FeatureBusinessRules.upsert(data.template_id, created.id, rules); + } else { + // If nothing to upsert, keep a copy on the custom_features row + try { await CustomFeature.update(created.id, { business_rules: rules }); } catch {} + } } catch (ruleErr) { - console.error('โš ๏ธ Failed to persist custom feature business rules:', ruleErr.message); + // Fallback: persist on the custom_features row so edit can still load them + console.error('โš ๏ธ Failed to persist custom feature business rules (feature_business_rules). Falling back to custom_features.business_rules:', ruleErr.message); + try { + const fallbackRules = (data.business_rules ?? data.logic_rules ?? []); + await CustomFeature.update(created.id, { business_rules: fallbackRules }); + } catch (fallbackErr) { + console.error('โš ๏ธ Fallback save to custom_features.business_rules also failed:', fallbackErr.message); + } } const response = { success: true, data: created, message: `Custom feature '${created.name}' created successfully and submitted for admin review` }; @@ -545,20 +555,8 @@ router.put('/custom/:id', async (req, res) => { if (!existing) return res.status(404).json({ success: false, error: 'Not found' }); const updates = req.body || {} const updated = await CustomFeature.update(id, updates); - // Mirror update into template_features where feature_id = `custom_` - try { - const featureId = `custom_${id}` - const mirroredExisting = await Feature.getByFeatureId(existing.template_id, featureId) - if (mirroredExisting) { - await Feature.update(mirroredExisting.id, { - name: updates.name ?? mirroredExisting.name, - description: updates.description ?? mirroredExisting.description, - complexity: updates.complexity ?? mirroredExisting.complexity, - }) - } - } catch (mirrorErr) { - console.error('Failed to mirror custom feature update:', mirrorErr.message) - } + + // Custom features are only stored in custom_features table, no mirroring needed res.json({ success: true, data: updated, message: `Custom feature '${updated.name}' updated successfully` }); } catch (e) { res.status(500).json({ success: false, error: 'Failed to update custom feature', message: e.message }); @@ -569,46 +567,24 @@ router.put('/custom/:id', async (req, res) => { router.delete('/custom/:id', async (req, res) => { try { const { id } = req.params; - // const rawId = String(id).replace(/^custom_/, '') - // Try deleting from custom_features first - let existing = await CustomFeature.getById(id); - if (existing) { - await CustomFeature.delete(id); - // Remove mirrored template_features with feature_id = `custom_` - try { - const featureId = id - const mirroredExisting = await Feature.getByFeatureId(existing.template_id, featureId) - if (mirroredExisting) { - await Feature.delete(mirroredExisting.id) - } - // Cleanup business rules if present - try { - await database.query('DELETE FROM feature_business_rules WHERE template_id = $1 AND feature_id = $2', [existing.template_id, featureId]) - } catch (cleanupErr) { console.error('Failed to cleanup business rules:', cleanupErr.message) } - } catch (mirrorErr) { - console.error('Failed to mirror custom feature delete:', mirrorErr.message) - } - return res.json({ success: true, message: `Custom feature '${existing.name}' deleted successfully` }); + // Find and delete from custom_features table + const existing = await CustomFeature.getById(id); + if (!existing) { + return res.status(404).json({ success: false, error: 'Not found', message: 'Custom feature not found' }); } - // Fallback: handle case where only mirrored template_features exists or client sent prefixed id + // Delete the custom feature + await CustomFeature.delete(id); + + // Cleanup business rules if present try { - const prefixed = id.startsWith('custom_') ? id : `custom_${rawId}` - const tf = await database.query('SELECT id, template_id, name FROM template_features WHERE feature_id = $1', [prefixed]) - if (tf.rows.length > 0) { - const row = tf.rows[0] - await Feature.delete(row.id) - try { - await database.query('DELETE FROM feature_business_rules WHERE template_id = $1 AND feature_id = $2', [row.template_id, prefixed]) - } catch (cleanupErr) { console.error('Failed to cleanup business rules:', cleanupErr.message) } - return res.json({ success: true, message: `Mirrored feature '${row.name}' deleted successfully` }) - } - } catch (fallbackErr) { - console.error('Fallback delete check failed:', fallbackErr.message) + await database.query('DELETE FROM feature_business_rules WHERE template_id = $1 AND feature_id = $2', [existing.template_id, id]) + } catch (cleanupErr) { + console.error('Failed to cleanup business rules:', cleanupErr.message) } - return res.status(404).json({ success: false, error: 'Not found', message: 'Custom feature not found' }); + return res.json({ success: true, message: `Custom feature '${existing.name}' deleted successfully` }); } catch (e) { res.status(500).json({ success: false, error: 'Failed to delete custom feature', message: e.message }); } diff --git a/services/template-manager/src/routes/templates.js b/services/template-manager/src/routes/templates.js index 10a20ee..6078b83 100644 --- a/services/template-manager/src/routes/templates.js +++ b/services/template-manager/src/routes/templates.js @@ -512,40 +512,85 @@ router.get('/:id([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/f console.log(`โœ… Template found: ${templateCheck.rows[0].title} (${templateCheck.rows[0].template_type})`); - let features = []; + // Fetch features from both tables for proper separation + console.log('๐Ÿ“‹ Fetching features from both template_features and custom_features tables'); - if (templateCheck.rows[0].template_type === 'custom') { - // For custom templates, get features from custom_features table - console.log('๐Ÿ“‹ Fetching features from custom_features table for custom template'); - const customFeaturesQuery = ` - SELECT - cf.id, - cf.template_id, - cf.name, - cf.description, - cf.complexity, - 'custom' as feature_type, - cf.created_at, - cf.updated_at, - cf.status, - cf.approved - FROM custom_features cf - WHERE cf.template_id = $1 - ORDER BY cf.created_at DESC - `; - const customFeaturesResult = await database.query(customFeaturesQuery, [id]); - features = customFeaturesResult.rows; - } else { - // For default templates, get features from template_features table - console.log('๐Ÿ“‹ Fetching features from template_features table for default template'); - features = await Feature.getByTemplateId(id); - } + // Get default/suggested features from template_features table + const defaultFeatures = await Feature.getByTemplateId(id); + console.log(`๐Ÿ“Š Found ${defaultFeatures.length} default/suggested features`); + + // Get custom features from custom_features table with business rules (if table exists) + // Some environments may not have run the feature_business_rules migration yet. Probe first. + const fbrExistsProbe = await database.query("SELECT to_regclass('public.feature_business_rules') AS tbl"); + const hasFbrTable = !!(fbrExistsProbe.rows && fbrExistsProbe.rows[0] && fbrExistsProbe.rows[0].tbl); + + const customFeaturesQuery = hasFbrTable + ? ` + SELECT + cf.id, + cf.template_id, + cf.name, + cf.description, + cf.complexity, + cf.business_rules, + cf.technical_requirements, + 'custom' as feature_type, + cf.created_at, + cf.updated_at, + cf.status, + cf.approved, + cf.usage_count, + 0 as user_rating, + false as is_default, + true as created_by_user, + fbr.business_rules as additional_business_rules + FROM custom_features cf + LEFT JOIN feature_business_rules fbr + ON cf.template_id = fbr.template_id + AND ( + fbr.feature_id = (cf.id::text) + OR fbr.feature_id = ('custom_' || cf.id::text) + ) + WHERE cf.template_id = $1 + ORDER BY cf.created_at DESC + ` + : ` + SELECT + cf.id, + cf.template_id, + cf.name, + cf.description, + cf.complexity, + cf.business_rules, + cf.technical_requirements, + 'custom' as feature_type, + cf.created_at, + cf.updated_at, + cf.status, + cf.approved, + cf.usage_count, + 0 as user_rating, + false as is_default, + true as created_by_user, + NULL::jsonb as additional_business_rules + FROM custom_features cf + WHERE cf.template_id = $1 + ORDER BY cf.created_at DESC + `; + const customFeaturesResult = await database.query(customFeaturesQuery, [id]); + const customFeatures = customFeaturesResult.rows; + console.log(`๐Ÿ“Š Found ${customFeatures.length} custom features`); + + // Combine both types of features + const features = [...defaultFeatures, ...customFeatures]; res.json({ success: true, data: features, count: features.length, - message: `Found ${features.length} features for ${templateCheck.rows[0].template_type} template`, + defaultFeaturesCount: defaultFeatures.length, + customFeaturesCount: customFeatures.length, + message: `Found ${defaultFeatures.length} default/suggested features and ${customFeatures.length} custom features`, templateInfo: templateCheck.rows[0] }); } catch (error) { diff --git a/services/web-dashboard/src/components/project-builder/BusinessQuestionsScreen.js b/services/web-dashboard/src/components/project-builder/BusinessQuestionsScreen.js index 25ad0dc..a1e9043 100644 --- a/services/web-dashboard/src/components/project-builder/BusinessQuestionsScreen.js +++ b/services/web-dashboard/src/components/project-builder/BusinessQuestionsScreen.js @@ -28,7 +28,7 @@ export default function BusinessQuestionsScreen() { console.log('๐Ÿš€ Generating comprehensive business questions for integrated system:', selectedFeatures); // Call the new comprehensive endpoint - const response = await fetch('http://localhost:8001/api/v1/generate-comprehensive-business-questions', { + const response = await fetch('http://localhost:8000/api/v1/generate-comprehensive-business-questions', { method: 'POST', headers: { 'Content-Type': 'application/json',