Updated template manager and user-auth changes
This commit is contained in:
parent
2e972f1157
commit
80087e13ec
@ -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:
|
||||
|
||||
119
services/template-manager/package-lock.json
generated
119
services/template-manager/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
@ -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();
|
||||
|
||||
119
services/template-manager/src/models/admin_notification.js
Normal file
119
services/template-manager/src/models/admin_notification.js
Normal 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;
|
||||
@ -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;
|
||||
|
||||
398
services/template-manager/src/routes/admin.js
Normal file
398
services/template-manager/src/routes/admin.js
Normal 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;
|
||||
@ -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) {
|
||||
|
||||
237
services/template-manager/src/services/feature_similarity.js
Normal file
237
services/template-manager/src/services/feature_similarity.js
Normal 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;
|
||||
@ -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;
|
||||
Loading…
Reference in New Issue
Block a user