From 80087e13ec75996c742324b17e29019bdb199e0f Mon Sep 17 00:00:00 2001 From: "tejas.prakash" Date: Mon, 25 Aug 2025 08:14:36 +0530 Subject: [PATCH] Updated template manager and user-auth changes --- docker-compose.yml | 2 +- services/template-manager/package-lock.json | 119 ++++++ services/template-manager/package.json | 1 + services/template-manager/src/app.js | 8 +- .../002_admin_approval_workflow.sql | 78 ++++ .../src/migrations/migrate.js | 44 +- .../src/models/admin_notification.js | 119 ++++++ .../src/models/custom_feature.js | 80 +++- services/template-manager/src/routes/admin.js | 398 ++++++++++++++++++ .../template-manager/src/routes/features.js | 94 ++++- .../src/services/feature_similarity.js | 237 +++++++++++ services/user-auth/src/routes/auth.js | 50 +++ 12 files changed, 1202 insertions(+), 28 deletions(-) create mode 100644 services/template-manager/src/migrations/002_admin_approval_workflow.sql create mode 100644 services/template-manager/src/models/admin_notification.js create mode 100644 services/template-manager/src/routes/admin.js create mode 100644 services/template-manager/src/services/feature_similarity.js diff --git a/docker-compose.yml b/docker-compose.yml index 2716c74..fda7aa7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -436,7 +436,7 @@ services: - rabbitmq_data:/var/lib/rabbitmq - rabbitmq_logs:/var/log/rabbitmq ports: - - "5672:5672" + - "5673:5672" - "15672:15672" - "15692:15692" networks: diff --git a/services/template-manager/package-lock.json b/services/template-manager/package-lock.json index a93c89e..e028aa0 100644 --- a/services/template-manager/package-lock.json +++ b/services/template-manager/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.18.0", "helmet": "^6.0.0", "joi": "^17.7.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.8.0", "redis": "^4.6.0", @@ -239,6 +240,12 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -421,6 +428,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -823,6 +839,109 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/services/template-manager/package.json b/services/template-manager/package.json index 97d70f0..fee23fb 100644 --- a/services/template-manager/package.json +++ b/services/template-manager/package.json @@ -15,6 +15,7 @@ "express": "^4.18.0", "helmet": "^6.0.0", "joi": "^17.7.0", + "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", "pg": "^8.8.0", "redis": "^4.6.0", diff --git a/services/template-manager/src/app.js b/services/template-manager/src/app.js index 5063531..c689f4f 100644 --- a/services/template-manager/src/app.js +++ b/services/template-manager/src/app.js @@ -11,6 +11,7 @@ const database = require('./config/database'); const templateRoutes = require('./routes/templates'); const featureRoutes = require('./routes/features'); const learningRoutes = require('./routes/learning'); +const adminRoutes = require('./routes/admin'); const app = express(); const PORT = process.env.PORT || 8009; @@ -26,6 +27,7 @@ app.use(express.urlencoded({ extended: true })); app.use('/api/templates', templateRoutes); app.use('/api/features', featureRoutes); app.use('/api/learning', learningRoutes); +app.use('/api/admin', adminRoutes); // Health check endpoint app.get('/health', (req, res) => { @@ -39,7 +41,8 @@ app.get('/health', (req, res) => { template_management: true, feature_learning: true, usage_tracking: true, - self_improving: true + self_improving: true, + admin_approval_workflow: true } }); }); @@ -53,7 +56,8 @@ app.get('/', (req, res) => { health: '/health', templates: '/api/templates', features: '/api/features', - learning: '/api/learning' + learning: '/api/learning', + admin: '/api/admin' } }); }); diff --git a/services/template-manager/src/migrations/002_admin_approval_workflow.sql b/services/template-manager/src/migrations/002_admin_approval_workflow.sql new file mode 100644 index 0000000..16fba72 --- /dev/null +++ b/services/template-manager/src/migrations/002_admin_approval_workflow.sql @@ -0,0 +1,78 @@ +-- Migration: Add Admin Approval for Custom Features +-- This migration adds admin approval workflow functionality to the existing template manager + +-- 1. Add status and admin fields to custom_features +-- First add the columns as nullable +ALTER TABLE custom_features + ADD COLUMN status VARCHAR(20) + CHECK (status IN ('pending', 'approved', 'rejected', 'duplicate')), + ADD COLUMN admin_notes TEXT, + ADD COLUMN admin_reviewed_at TIMESTAMP, + ADD COLUMN admin_reviewed_by VARCHAR(100), + ADD COLUMN canonical_feature_id UUID REFERENCES template_features(id) ON DELETE SET NULL, + ADD COLUMN similarity_score FLOAT; + +-- Set default values for existing rows +UPDATE custom_features +SET status = CASE + WHEN approved = true THEN 'approved' + ELSE 'pending' +END; + +-- Now alter the column to be NOT NULL +ALTER TABLE custom_features ALTER COLUMN status SET NOT NULL; +ALTER TABLE custom_features ALTER COLUMN status SET DEFAULT 'pending'; + +-- 2. Create a table for feature synonyms/aliases +CREATE TABLE feature_synonyms ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + feature_id UUID NOT NULL REFERENCES template_features(id) ON DELETE CASCADE, + synonym VARCHAR(200) NOT NULL, + created_by VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(synonym) +); + +-- 3. Add index for faster lookups +CREATE INDEX idx_custom_features_status ON custom_features(status); +CREATE INDEX idx_custom_features_created_at ON custom_features(created_at DESC); +CREATE INDEX idx_feature_synonyms_synonym ON feature_synonyms(synonym); + +-- 4. Admin notifications table +CREATE TABLE admin_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + reference_id UUID, + reference_type VARCHAR(50), + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + read_at TIMESTAMP +); + +-- 5. Create indexes for admin notifications +CREATE INDEX idx_admin_notifications_type ON admin_notifications(type); +CREATE INDEX idx_admin_notifications_is_read ON admin_notifications(is_read); +CREATE INDEX idx_admin_notifications_created_at ON admin_notifications(created_at DESC); + +-- 6. Update existing custom_features to have 'approved' status if they were previously approved +UPDATE custom_features +SET status = CASE + WHEN approved = true THEN 'approved' + ELSE 'pending' +END, +admin_reviewed_at = CASE + WHEN approved = true THEN created_at + ELSE NULL +END, +admin_reviewed_by = CASE + WHEN approved = true THEN 'system_migration' + ELSE NULL +END; + +-- 7. Insert success message +INSERT INTO templates (type, title, description, category) +VALUES ('_admin_workflow_migration', 'Admin Workflow Migration', 'Admin approval workflow schema created successfully', 'System') +ON CONFLICT (type) DO NOTHING; + +SELECT 'Admin approval workflow database schema created successfully!' as message; diff --git a/services/template-manager/src/migrations/migrate.js b/services/template-manager/src/migrations/migrate.js index 3580381..89e214c 100644 --- a/services/template-manager/src/migrations/migrate.js +++ b/services/template-manager/src/migrations/migrate.js @@ -7,36 +7,46 @@ async function runMigrations() { console.log('🚀 Starting Template Manager database migration...'); try { - // Read the SQL migration file - const migrationPath = path.join(__dirname, '001_initial_schema.sql'); - const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); + // Get all migration files in order + const migrationFiles = [ + '001_initial_schema.sql', + '002_admin_approval_workflow.sql' + ]; - console.log('📄 Running migration: 001_initial_schema.sql'); + for (const migrationFile of migrationFiles) { + const migrationPath = path.join(__dirname, migrationFile); + + // Check if migration file exists + if (!fs.existsSync(migrationPath)) { + console.log(`Migration file not found: ${migrationFile}`); + continue; + } + + console.log(`Running migration: ${migrationFile}`); + + const migrationSQL = fs.readFileSync(migrationPath, 'utf8'); + + // Execute the migration + await database.query(migrationSQL); + + console.log(`Migration ${migrationFile} completed successfully!`); + } - // Execute the migration - await database.query(migrationSQL); - - console.log('✅ Migration completed successfully!'); - console.log('📊 Database schema created:'); - console.log(' - templates table'); - console.log(' - template_features table'); - console.log(' - feature_usage table'); - console.log(' - custom_features table'); - console.log(' - indexes and triggers'); + console.log('All migrations completed successfully!'); // Verify tables were created const result = await database.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' - AND table_name IN ('templates', 'template_features', 'feature_usage', 'custom_features') + AND table_name IN ('templates', 'template_features', 'feature_usage', 'custom_features', 'feature_synonyms', 'admin_notifications') ORDER BY table_name `); - console.log('🔍 Verified tables:', result.rows.map(row => row.table_name)); + console.log('Verified tables:', result.rows.map(row => row.table_name)); } catch (error) { - console.error('❌ Migration failed:', error.message); + console.error('Migration failed:', error.message); process.exit(1); } finally { await database.close(); diff --git a/services/template-manager/src/models/admin_notification.js b/services/template-manager/src/models/admin_notification.js new file mode 100644 index 0000000..9d6aca9 --- /dev/null +++ b/services/template-manager/src/models/admin_notification.js @@ -0,0 +1,119 @@ +const database = require('../config/database'); +const { v4: uuidv4 } = require('uuid'); + +class AdminNotification { + constructor(data = {}) { + this.id = data.id; + this.type = data.type; + this.message = data.message; + this.reference_id = data.reference_id; + this.reference_type = data.reference_type; + this.is_read = data.is_read || false; + this.created_at = data.created_at; + this.read_at = data.read_at; + } + + static async create(data) { + const id = uuidv4(); + const query = ` + INSERT INTO admin_notifications ( + id, type, message, reference_id, reference_type, is_read + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + const values = [ + id, + data.type, + data.message, + data.reference_id || null, + data.reference_type || null, + data.is_read || false + ]; + const result = await database.query(query, values); + return new AdminNotification(result.rows[0]); + } + + static async getUnread(limit = 50) { + const query = ` + SELECT * FROM admin_notifications + WHERE is_read = false + ORDER BY created_at DESC + LIMIT $1 + `; + const result = await database.query(query, [limit]); + return result.rows.map(r => new AdminNotification(r)); + } + + static async getAll(limit = 100, offset = 0) { + const query = ` + SELECT * FROM admin_notifications + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `; + const result = await database.query(query, [limit, offset]); + return result.rows.map(r => new AdminNotification(r)); + } + + static async markAsRead(id) { + const query = ` + UPDATE admin_notifications + SET is_read = true, read_at = NOW() + WHERE id = $1 + RETURNING * + `; + const result = await database.query(query, [id]); + return result.rows.length ? new AdminNotification(result.rows[0]) : null; + } + + static async markAllAsRead() { + const query = ` + UPDATE admin_notifications + SET is_read = true, read_at = NOW() + WHERE is_read = false + `; + const result = await database.query(query); + return result.rowCount; + } + + static async getCounts() { + const query = ` + SELECT + COUNT(*) as total, + COUNT(CASE WHEN is_read = false THEN 1 END) as unread, + COUNT(CASE WHEN is_read = true THEN 1 END) as read + FROM admin_notifications + `; + const result = await database.query(query); + return result.rows[0]; + } + + static async deleteOld(daysOld = 30) { + const query = ` + DELETE FROM admin_notifications + WHERE created_at < NOW() - INTERVAL '${daysOld} days' + `; + const result = await database.query(query); + return result.rowCount; + } + + // Convenience methods for creating specific notification types + static async notifyNewFeature(featureId, featureName) { + return await AdminNotification.create({ + type: 'new_feature', + message: `New custom feature submitted: "${featureName}"`, + reference_id: featureId, + reference_type: 'custom_feature' + }); + } + + static async notifyFeatureReviewed(featureId, featureName, status) { + return await AdminNotification.create({ + type: 'feature_reviewed', + message: `Feature "${featureName}" has been ${status}`, + reference_id: featureId, + reference_type: 'custom_feature' + }); + } +} + +module.exports = AdminNotification; diff --git a/services/template-manager/src/models/custom_feature.js b/services/template-manager/src/models/custom_feature.js index 5e452a8..a4cda0b 100644 --- a/services/template-manager/src/models/custom_feature.js +++ b/services/template-manager/src/models/custom_feature.js @@ -15,6 +15,13 @@ class CustomFeature { this.created_by_user_session = data.created_by_user_session; this.created_at = data.created_at; this.updated_at = data.updated_at; + // Admin approval workflow fields + this.status = data.status || 'pending'; + this.admin_notes = data.admin_notes; + this.admin_reviewed_at = data.admin_reviewed_at; + this.admin_reviewed_by = data.admin_reviewed_by; + this.canonical_feature_id = data.canonical_feature_id; + this.similarity_score = data.similarity_score; } static async getByTemplateId(templateId) { @@ -37,8 +44,9 @@ class CustomFeature { const query = ` INSERT INTO custom_features ( id, template_id, name, description, complexity, - business_rules, technical_requirements, approved, usage_count, created_by_user_session - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + 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 + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING * `; const values = [ @@ -52,6 +60,12 @@ class CustomFeature { data.approved ?? false, data.usage_count ?? 1, data.created_by_user_session || null, + data.status || 'pending', + data.admin_notes || null, + data.admin_reviewed_at || null, + data.admin_reviewed_by || null, + data.canonical_feature_id || null, + data.similarity_score || null, ]; const result = await database.query(query, values); return new CustomFeature(result.rows[0]); @@ -61,7 +75,11 @@ class CustomFeature { const fields = []; const values = []; let idx = 1; - const allowed = ['name','description','complexity','business_rules','technical_requirements','approved','usage_count']; + const allowed = [ + 'name','description','complexity','business_rules','technical_requirements', + 'approved','usage_count','status','admin_notes','admin_reviewed_at', + 'admin_reviewed_by','canonical_feature_id','similarity_score' + ]; for (const k of allowed) { if (updates[k] !== undefined) { fields.push(`${k} = $${idx++}`); @@ -79,6 +97,62 @@ class CustomFeature { const result = await database.query('DELETE FROM custom_features WHERE id = $1', [id]); return result.rowCount > 0; } + + // Admin workflow methods + static async getPendingFeatures(limit = 50, offset = 0) { + const query = ` + SELECT cf.*, t.title as template_title + FROM custom_features cf + LEFT JOIN templates t ON cf.template_id = t.id + WHERE cf.status = 'pending' + ORDER BY cf.created_at ASC + LIMIT $1 OFFSET $2 + `; + const result = await database.query(query, [limit, offset]); + return result.rows.map(r => new CustomFeature(r)); + } + + static async getFeaturesByStatus(status, limit = 50, offset = 0) { + const query = ` + SELECT cf.*, t.title as template_title + FROM custom_features cf + LEFT JOIN templates t ON cf.template_id = t.id + WHERE cf.status = $1 + ORDER BY cf.created_at DESC + LIMIT $2 OFFSET $3 + `; + const result = await database.query(query, [status, limit, offset]); + return result.rows.map(r => new CustomFeature(r)); + } + + static async getFeatureStats() { + const query = ` + SELECT + status, + COUNT(*) as count + FROM custom_features + GROUP BY status + `; + const result = await database.query(query); + return result.rows; + } + + static async reviewFeature(id, reviewData) { + const { status, admin_notes, canonical_feature_id, admin_reviewed_by } = reviewData; + + const updates = { + status, + admin_notes, + admin_reviewed_at: new Date(), + admin_reviewed_by + }; + + if (canonical_feature_id) { + updates.canonical_feature_id = canonical_feature_id; + } + + return await CustomFeature.update(id, updates); + } } module.exports = CustomFeature; diff --git a/services/template-manager/src/routes/admin.js b/services/template-manager/src/routes/admin.js new file mode 100644 index 0000000..29d195c --- /dev/null +++ b/services/template-manager/src/routes/admin.js @@ -0,0 +1,398 @@ +const express = require('express'); +const router = express.Router(); +const CustomFeature = require('../models/custom_feature'); +const AdminNotification = require('../models/admin_notification'); +const FeatureSimilarityService = require('../services/feature_similarity'); +const jwt = require('jsonwebtoken'); +const Joi = require('joi'); + +// Initialize similarity service +const similarityService = new FeatureSimilarityService(); + +// Middleware to check if user is admin using JWT from Authorization header +const requireAdmin = (req, res, next) => { + try { + const authHeader = req.headers.authorization || ''; + if (!authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + success: false, + error: 'Authentication required', + message: 'Missing or invalid Authorization header' + }); + } + const token = authHeader.substring(7); + const decoded = jwt.verify( + token, + process.env.JWT_ACCESS_SECRET || 'access-secret-key-2024-tech4biz', + { issuer: 'tech4biz-auth', audience: 'tech4biz-users' } + ); + if (!decoded || decoded.role !== 'admin') { + return res.status(403).json({ + success: false, + error: 'Insufficient permissions', + message: 'Admin role required' + }); + } + req.user = decoded; + next(); + } catch (err) { + return res.status(401).json({ + success: false, + error: 'Invalid token', + message: 'Failed to authenticate admin' + }); + } +}; + +// Apply admin middleware to all routes +router.use(requireAdmin); + +// GET /api/admin/features/pending - Get pending features for review +router.get('/features/pending', async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 50; + const offset = parseInt(req.query.offset) || 0; + + console.log(`Admin: Fetching pending features (limit: ${limit}, offset: ${offset})`); + + const features = await CustomFeature.getPendingFeatures(limit, offset); + + res.json({ + success: true, + data: features, + count: features.length, + message: `Found ${features.length} pending features` + }); + } catch (error) { + console.error('Error fetching pending features:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to fetch pending features', + message: error.message + }); + } +}); + +// GET /api/admin/features/status/:status - Get features by status +router.get('/features/status/:status', async (req, res) => { + try { + const { status } = req.params; + const limit = parseInt(req.query.limit) || 50; + const offset = parseInt(req.query.offset) || 0; + + const validStatuses = ['pending', 'approved', 'rejected', 'duplicate']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ + success: false, + error: 'Invalid status', + message: `Status must be one of: ${validStatuses.join(', ')}` + }); + } + + console.log(`🔍 Admin: Fetching ${status} features (limit: ${limit}, offset: ${offset})`); + + const features = await CustomFeature.getFeaturesByStatus(status, limit, offset); + + res.json({ + success: true, + data: features, + count: features.length, + message: `Found ${features.length} ${status} features` + }); + } catch (error) { + console.error('❌ Error fetching features by status:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to fetch features by status', + message: error.message + }); + } +}); + +// GET /api/admin/features/stats - Get feature statistics +router.get('/features/stats', async (req, res) => { + try { + console.log('📊 Admin: Fetching feature statistics...'); + + const stats = await CustomFeature.getFeatureStats(); + const notificationCounts = await AdminNotification.getCounts(); + + res.json({ + success: true, + data: { + features: stats, + notifications: notificationCounts + }, + message: 'Feature statistics retrieved successfully' + }); + } catch (error) { + console.error('❌ Error fetching feature stats:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to fetch feature statistics', + message: error.message + }); + } +}); + +// POST /api/admin/features/:id/review - Review a feature +router.post('/features/:id/review', async (req, res) => { + try { + const { id } = req.params; + const { status, notes, canonical_feature_id, admin_reviewed_by } = req.body; + + // Validate input + const schema = Joi.object({ + status: Joi.string().valid('approved', 'rejected', 'duplicate').required(), + notes: Joi.string().optional(), + canonical_feature_id: Joi.string().uuid().optional(), + admin_reviewed_by: Joi.string().required() + }); + + const { error } = schema.validate(req.body); + if (error) { + return res.status(400).json({ + success: false, + error: 'Validation error', + message: error.details[0].message + }); + } + + // Validate canonical_feature_id is provided for duplicate status + if (status === 'duplicate' && !canonical_feature_id) { + return res.status(400).json({ + success: false, + error: 'Missing canonical feature ID', + message: 'Canonical feature ID is required when marking as duplicate' + }); + } + + console.log(`🔍 Admin: Reviewing feature ${id} with status: ${status}`); + + // Get the feature first to get its name for notification + const feature = await CustomFeature.getById(id); + if (!feature) { + return res.status(404).json({ + success: false, + error: 'Feature not found', + message: 'The specified feature does not exist' + }); + } + + // Review the feature + const reviewData = { + status, + admin_notes: notes, + canonical_feature_id, + admin_reviewed_by + }; + + const updatedFeature = await CustomFeature.reviewFeature(id, reviewData); + + // Create notification + await AdminNotification.notifyFeatureReviewed(id, feature.name, status); + + res.json({ + success: true, + data: updatedFeature, + message: `Feature "${feature.name}" has been ${status}` + }); + } catch (error) { + console.error('❌ Error reviewing feature:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to review feature', + message: error.message + }); + } +}); + +// GET /api/admin/features/similar - Find similar features +router.get('/features/similar', async (req, res) => { + try { + const { q: query, threshold = 0.7, limit = 5 } = req.query; + + if (!query) { + return res.status(400).json({ + success: false, + error: 'Query parameter required', + message: 'Please provide a query parameter "q"' + }); + } + + console.log(`🔍 Admin: Finding similar features for "${query}"`); + + const similarFeatures = await similarityService.findSimilarFeatures( + query, + parseFloat(threshold), + parseInt(limit) + ); + + res.json({ + success: true, + data: similarFeatures, + count: similarFeatures.length, + message: `Found ${similarFeatures.length} similar features` + }); + } catch (error) { + console.error('❌ Error finding similar features:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to find similar features', + message: error.message + }); + } +}); + +// POST /api/admin/features/:id/synonyms - Add feature synonym +router.post('/features/:id/synonyms', async (req, res) => { + try { + const { id } = req.params; + const { synonym, created_by } = req.body; + + // Validate input + const schema = Joi.object({ + synonym: Joi.string().min(1).max(200).required(), + created_by: Joi.string().optional() + }); + + const { error } = schema.validate(req.body); + if (error) { + return res.status(400).json({ + success: false, + error: 'Validation error', + message: error.details[0].message + }); + } + + console.log(`🔍 Admin: Adding synonym "${synonym}" to feature ${id}`); + + const newSynonym = await similarityService.addSynonym(id, synonym, created_by || 'admin'); + + res.json({ + success: true, + data: newSynonym, + message: `Synonym "${synonym}" added successfully` + }); + } catch (error) { + console.error('❌ Error adding synonym:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to add synonym', + message: error.message + }); + } +}); + +// GET /api/admin/features/:id/synonyms - Get feature synonyms +router.get('/features/:id/synonyms', async (req, res) => { + try { + const { id } = req.params; + + console.log(`🔍 Admin: Getting synonyms for feature ${id}`); + + const synonyms = await similarityService.getSynonyms(id); + + res.json({ + success: true, + data: synonyms, + count: synonyms.length, + message: `Found ${synonyms.length} synonyms` + }); + } catch (error) { + console.error('❌ Error getting synonyms:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to get synonyms', + message: error.message + }); + } +}); + +// GET /api/admin/notifications - Get admin notifications +router.get('/notifications', async (req, res) => { + try { + const { unread_only = 'false' } = req.query; + const limit = parseInt(req.query.limit) || 50; + const offset = parseInt(req.query.offset) || 0; + + console.log(`🔍 Admin: Fetching notifications (unread_only: ${unread_only})`); + + let notifications; + if (unread_only === 'true') { + notifications = await AdminNotification.getUnread(limit); + } else { + notifications = await AdminNotification.getAll(limit, offset); + } + + res.json({ + success: true, + data: notifications, + count: notifications.length, + message: `Found ${notifications.length} notifications` + }); + } catch (error) { + console.error('❌ Error fetching notifications:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to fetch notifications', + message: error.message + }); + } +}); + +// POST /api/admin/notifications/:id/read - Mark notification as read +router.post('/notifications/:id/read', async (req, res) => { + try { + const { id } = req.params; + + console.log(`🔍 Admin: Marking notification ${id} as read`); + + const notification = await AdminNotification.markAsRead(id); + + if (!notification) { + return res.status(404).json({ + success: false, + error: 'Notification not found', + message: 'The specified notification does not exist' + }); + } + + res.json({ + success: true, + data: notification, + message: 'Notification marked as read' + }); + } catch (error) { + console.error('❌ Error marking notification as read:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to mark notification as read', + message: error.message + }); + } +}); + +// POST /api/admin/notifications/read-all - Mark all notifications as read +router.post('/notifications/read-all', async (req, res) => { + try { + console.log('🔍 Admin: Marking all notifications as read'); + + const count = await AdminNotification.markAllAsRead(); + + res.json({ + success: true, + data: { count }, + message: `${count} notifications marked as read` + }); + } catch (error) { + console.error('❌ Error marking all notifications as read:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to mark all notifications as read', + message: error.message + }); + } +}); + +module.exports = router; diff --git a/services/template-manager/src/routes/features.js b/services/template-manager/src/routes/features.js index 9ae05ac..5ff3b23 100644 --- a/services/template-manager/src/routes/features.js +++ b/services/template-manager/src/routes/features.js @@ -2,8 +2,13 @@ const express = require('express'); const router = express.Router(); const Feature = require('../models/feature'); const CustomFeature = require('../models/custom_feature'); +const AdminNotification = require('../models/admin_notification'); +const FeatureSimilarityService = require('../services/feature_similarity'); const { v4: uuidv4 } = require('uuid'); +// Initialize similarity service +const similarityService = new FeatureSimilarityService(); + // GET /api/features/popular - Get popular features across all templates router.get('/popular', async (req, res) => { try { @@ -82,6 +87,43 @@ router.get('/search', async (req, res) => { } }); +// GET /api/features/similar - Find similar features +router.get('/similar', async (req, res) => { + try { + const { q: query, threshold = 0.7, limit = 5 } = req.query; + + if (!query) { + return res.status(400).json({ + success: false, + error: 'Query parameter required', + message: 'Please provide a query parameter "q"' + }); + } + + console.log(`🔍 Finding similar features for "${query}"`); + + const similarFeatures = await similarityService.findSimilarFeatures( + query, + parseFloat(threshold), + parseInt(limit) + ); + + res.json({ + success: true, + data: similarFeatures, + count: similarFeatures.length, + message: `Found ${similarFeatures.length} similar features` + }); + } catch (error) { + console.error('❌ Error finding similar features:', error.message); + res.status(500).json({ + success: false, + error: 'Failed to find similar features', + message: error.message + }); + } +}); + // GET /api/features/type/:type - Get features by type router.get('/type/:type', async (req, res) => { try { @@ -379,6 +421,24 @@ router.post('/custom', async (req, res) => { if (!validComplexity.includes(data.complexity)) { return res.status(400).json({ success: false, error: 'Invalid complexity' }) } + + // Check for similar features before creating + let similarityInfo = null; + try { + const duplicateCheck = await similarityService.checkForDuplicates(data.name, 0.8); + if (duplicateCheck.isDuplicate) { + similarityInfo = { + isDuplicate: true, + canonicalFeature: duplicateCheck.canonicalFeature, + similarityScore: duplicateCheck.similarityScore, + matchType: duplicateCheck.matchType + }; + } + } catch (similarityError) { + console.error('Error checking for duplicates:', similarityError.message); + // Continue with feature creation even if similarity check fails + } + const created = await CustomFeature.create({ template_id: data.template_id, name: data.name, @@ -389,7 +449,18 @@ router.post('/custom', async (req, res) => { approved: false, usage_count: 1, created_by_user_session: data.created_by_user_session, + status: 'pending', + similarity_score: similarityInfo?.similarityScore || null, + canonical_feature_id: similarityInfo?.canonicalFeature?.id || null, }) + + // Create admin notification for new feature + try { + await AdminNotification.notifyNewFeature(created.id, created.name); + } catch (notificationError) { + console.error('⚠️ Failed to create admin notification:', notificationError.message); + } + // Mirror into template_features with stable feature_id try { await Feature.create({ @@ -404,11 +475,24 @@ router.post('/custom', async (req, res) => { created_by_user: true }) } catch (mirrorErr) { - console.error('⚠️ Failed to mirror custom feature into template_features:', mirrorErr.message) + console.error('Failed to mirror custom feature into template_features:', mirrorErr.message) } - return res.status(201).json({ success: true, data: created, message: `Custom feature '${created.name}' created successfully` }) + + const response = { + success: true, + data: created, + message: `Custom feature '${created.name}' created successfully and submitted for admin review` + }; + + // Include similarity info in response if duplicates were found + if (similarityInfo) { + response.similarityInfo = similarityInfo; + response.message += '. Similar features were found and will be reviewed by admin.'; + } + + return res.status(201).json(response); } catch (e) { - console.error('❌ Error creating custom feature:', e.message) + console.error('Error creating custom feature:', e.message) return res.status(500).json({ success: false, error: 'Failed to create custom feature', message: e.message }) } }) @@ -462,7 +546,7 @@ router.put('/custom/:id', async (req, res) => { }) } } catch (mirrorErr) { - console.error('⚠️ Failed to mirror custom feature update:', mirrorErr.message) + console.error('Failed to mirror custom feature update:', mirrorErr.message) } res.json({ success: true, data: updated, message: `Custom feature '${updated.name}' updated successfully` }); } catch (e) { @@ -485,7 +569,7 @@ router.delete('/custom/:id', async (req, res) => { await Feature.delete(mirroredExisting.id) } } catch (mirrorErr) { - console.error('⚠️ Failed to mirror custom feature delete:', mirrorErr.message) + console.error('Failed to mirror custom feature delete:', mirrorErr.message) } res.json({ success: true, message: `Custom feature '${existing.name}' deleted successfully` }); } catch (e) { diff --git a/services/template-manager/src/services/feature_similarity.js b/services/template-manager/src/services/feature_similarity.js new file mode 100644 index 0000000..07857e6 --- /dev/null +++ b/services/template-manager/src/services/feature_similarity.js @@ -0,0 +1,237 @@ +const database = require('../config/database'); + +/** + * Feature Similarity Service + * Handles duplicate detection and similarity scoring for custom features + */ +class FeatureSimilarityService { + constructor() { + this.database = database; + } + + /** + * Normalize text for comparison + * @param {string} text - Text to normalize + * @returns {string} - Normalized text + */ + normalizeText(text) { + if (!text) return ''; + return text.toLowerCase() + .replace(/\s+/g, ' ') + .trim() + .replace(/[^\w\s]/g, ''); // Remove special characters + } + + /** + * Calculate similarity score between two strings using Levenshtein distance + * @param {string} str1 - First string + * @param {string} str2 - Second string + * @returns {number} - Similarity score between 0 and 1 + */ + calculateSimilarity(str1, str2) { + if (!str1 || !str2) return 0; + + const normalized1 = this.normalizeText(str1); + const normalized2 = this.normalizeText(str2); + + if (normalized1 === normalized2) return 1.0; + + const longer = normalized1.length > normalized2.length ? normalized1 : normalized2; + const shorter = normalized1.length > normalized2.length ? normalized2 : normalized1; + + if (longer.length === 0) return 1.0; + + const distance = this.levenshteinDistance(longer, shorter); + return (longer.length - distance) / longer.length; + } + + /** + * Calculate Levenshtein distance between two strings + * @param {string} str1 - First string + * @param {string} str2 - Second string + * @returns {number} - Levenshtein distance + */ + levenshteinDistance(str1, str2) { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + /** + * Find features similar to the given name + * @param {string} name - Feature name to search for + * @param {number} threshold - Minimum similarity score (default: 0.7) + * @param {number} limit - Maximum number of results (default: 5) + * @returns {Promise} - Array of similar features + */ + async findSimilarFeatures(name, threshold = 0.7, limit = 5) { + try { + const normalizedName = this.normalizeText(name); + + // 1. Check exact matches first + const exactMatches = await this.database.query(` + SELECT + tf.id, + tf.name, + 'exact' as match_type, + 1.0 as score, + tf.feature_type, + tf.complexity + FROM template_features tf + WHERE LOWER(tf.name) = $1 + UNION + SELECT + tf.id, + tf.name, + 'synonym' as match_type, + 0.9 as score, + tf.feature_type, + tf.complexity + FROM template_features tf + JOIN feature_synonyms fs ON tf.id = fs.feature_id + WHERE LOWER(fs.synonym) = $1 + LIMIT $2 + `, [normalizedName, limit]); + + if (exactMatches.rows.length > 0) { + return exactMatches.rows; + } + + // 2. Find fuzzy matches + const allFeatures = await this.database.query(` + SELECT + tf.id, + tf.name, + tf.feature_type, + tf.complexity + FROM template_features tf + UNION + SELECT + tf.id, + tf.name, + tf.feature_type, + tf.complexity + FROM template_features tf + JOIN feature_synonyms fs ON tf.id = fs.feature_id + `); + + const matches = []; + for (const feature of allFeatures.rows) { + const score = this.calculateSimilarity(name, feature.name); + + if (score >= threshold) { + matches.push({ + ...feature, + match_type: 'fuzzy', + score: Math.round(score * 100) / 100 // Round to 2 decimal places + }); + } + } + + // Sort by score descending and limit results + return matches + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + } catch (error) { + console.error('Error finding similar features:', error); + throw new Error('Failed to find similar features'); + } + } + + /** + * Check if a feature name is a duplicate + * @param {string} name - Feature name to check + * @param {number} threshold - Similarity threshold (default: 0.8) + * @returns {Promise} - Duplicate info or null + */ + async checkForDuplicates(name, threshold = 0.8) { + try { + const similarFeatures = await this.findSimilarFeatures(name, threshold, 1); + + if (similarFeatures.length > 0) { + const bestMatch = similarFeatures[0]; + return { + isDuplicate: true, + canonicalFeature: bestMatch, + similarityScore: bestMatch.score, + matchType: bestMatch.match_type + }; + } + + return { isDuplicate: false }; + } catch (error) { + console.error('Error checking for duplicates:', error); + throw new Error('Failed to check for duplicates'); + } + } + + /** + * Add a synonym for a feature + * @param {string} featureId - Feature ID + * @param {string} synonym - Synonym to add + * @param {string} createdBy - User who created the synonym + * @returns {Promise} - Created synonym + */ + async addSynonym(featureId, synonym, createdBy = 'admin') { + try { + const result = await this.database.query(` + INSERT INTO feature_synonyms (feature_id, synonym, created_by) + VALUES ($1, $2, $3) + RETURNING * + `, [featureId, synonym, createdBy]); + + return result.rows[0]; + } catch (error) { + if (error.code === '23505') { // Unique constraint violation + throw new Error('Synonym already exists'); + } + console.error('Error adding synonym:', error); + throw new Error('Failed to add synonym'); + } + } + + /** + * Get all synonyms for a feature + * @param {string} featureId - Feature ID + * @returns {Promise} - Array of synonyms + */ + async getSynonyms(featureId) { + try { + const result = await this.database.query(` + SELECT * FROM feature_synonyms + WHERE feature_id = $1 + ORDER BY created_at DESC + `, [featureId]); + + return result.rows; + } catch (error) { + console.error('Error getting synonyms:', error); + throw new Error('Failed to get synonyms'); + } + } +} + +module.exports = FeatureSimilarityService; diff --git a/services/user-auth/src/routes/auth.js b/services/user-auth/src/routes/auth.js index 9f97dad..6b3821b 100644 --- a/services/user-auth/src/routes/auth.js +++ b/services/user-auth/src/routes/auth.js @@ -486,4 +486,54 @@ router.post('/admin/cleanup', authenticateToken, requireAdmin, async (req, res) } }); +// PUT /api/auth/admin/users/:id/role - Update a user's role (Admin only) +router.put('/admin/users/:id/role', authenticateToken, requireAdmin, async (req, res) => { + try { + const { id } = req.params; + const { role } = req.body; + const allowed = ['user', 'admin']; + if (!allowed.includes(role)) { + return res.status(400).json({ + success: false, + error: 'Invalid role', + message: `Role must be one of: ${allowed.join(', ')}` + }); + } + + const user = await User.findById(id); + if (!user) { + return res.status(404).json({ success: false, error: 'User not found' }); + } + + await require('../config/database').query( + 'UPDATE users SET role = $1, updated_at = NOW() WHERE id = $2', + [role, id] + ); + + const updated = await User.findById(id); + res.json({ success: true, data: updated.toJSON(), message: 'User role updated' }); + } catch (error) { + console.error('Failed to update user role:', error.message); + res.status(500).json({ success: false, error: 'Failed to update role', message: error.message }); + } +}); + +// GET /api/auth/admin/users - List users (Admin only) +router.get('/admin/users', authenticateToken, requireAdmin, async (req, res) => { + try { + const limit = parseInt(req.query.limit) || 25; + const offset = parseInt(req.query.offset) || 0; + const db = require('../config/database'); + const result = await db.query( + `SELECT id, username, email, first_name, last_name, role, email_verified, is_active, created_at, updated_at + FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + [limit, offset] + ); + res.json({ success: true, data: result.rows, count: result.rows.length, message: 'Users retrieved' }); + } catch (error) { + console.error('Failed to list users:', error.message); + res.status(500).json({ success: false, error: 'Failed to list users', message: error.message }); + } +}); + module.exports = router; \ No newline at end of file