Updated template manager and user-auth changes

This commit is contained in:
tejas.prakash 2025-08-25 08:14:36 +05:30
parent 2e972f1157
commit 80087e13ec
12 changed files with 1202 additions and 28 deletions

View File

@ -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:

View File

@ -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",

View File

@ -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",

View File

@ -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'
}
});
});

View File

@ -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;

View File

@ -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);
// Execute the migration
await database.query(migrationSQL);
// Check if migration file exists
if (!fs.existsSync(migrationPath)) {
console.log(`Migration file not found: ${migrationFile}`);
continue;
}
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(`Running migration: ${migrationFile}`);
const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
// Execute the migration
await database.query(migrationSQL);
console.log(`Migration ${migrationFile} completed successfully!`);
}
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();

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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>} - 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<Object|null>} - 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<Object>} - 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>} - 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;

View File

@ -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;