361 Zeilen
12 KiB
TypeScript
361 Zeilen
12 KiB
TypeScript
import Database from 'better-sqlite3'
|
|
import path from 'path'
|
|
import { randomBytes } from 'crypto'
|
|
import fs from 'fs'
|
|
import { Employee, User, SkillDefinition } from '@skillmate/shared'
|
|
import bcrypt from 'bcryptjs'
|
|
import { v4 as uuidv4 } from 'uuid'
|
|
import { FieldEncryption } from '../services/encryption'
|
|
|
|
// Get or generate database encryption key
|
|
const getDatabaseKey = (): string => {
|
|
let key = process.env.DATABASE_ENCRYPTION_KEY
|
|
|
|
if (!key) {
|
|
console.warn('⚠️ No DATABASE_ENCRYPTION_KEY found in environment variables.')
|
|
console.warn('⚠️ Generating a temporary key for development.')
|
|
console.warn('⚠️ For production, set DATABASE_ENCRYPTION_KEY in your .env file!')
|
|
|
|
// Generate and save a development key
|
|
const keyPath = path.join(process.cwd(), '.database.key')
|
|
if (fs.existsSync(keyPath)) {
|
|
key = fs.readFileSync(keyPath, 'utf8')
|
|
} else {
|
|
key = randomBytes(32).toString('hex')
|
|
fs.writeFileSync(keyPath, key, { mode: 0o600 }) // Restrictive permissions
|
|
console.log('💾 Development key saved to .database.key (add to .gitignore!)')
|
|
}
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
// Database path configuration
|
|
const getDbPath = (): string => {
|
|
const dbPath = process.env.DATABASE_PATH || (
|
|
process.env.NODE_ENV === 'production'
|
|
? path.join(process.cwd(), 'data', 'skillmate.encrypted.db')
|
|
: path.join(process.cwd(), 'skillmate.dev.encrypted.db')
|
|
)
|
|
|
|
// Ensure data directory exists for production
|
|
const dbDir = path.dirname(dbPath)
|
|
if (!fs.existsSync(dbDir)) {
|
|
fs.mkdirSync(dbDir, { recursive: true })
|
|
}
|
|
|
|
return dbPath
|
|
}
|
|
|
|
const dbPath = getDbPath()
|
|
const dbKey = getDatabaseKey()
|
|
|
|
// Create database connection with encryption support
|
|
export const db = new Database(dbPath)
|
|
|
|
// Enable better performance and data integrity
|
|
db.pragma('journal_mode = WAL')
|
|
db.pragma('foreign_keys = ON')
|
|
db.pragma('busy_timeout = 5000')
|
|
|
|
// Add encryption helper functions to database
|
|
export const encryptedDb = {
|
|
...db,
|
|
|
|
// Prepare statement with automatic encryption/decryption
|
|
prepareEncrypted(sql: string) {
|
|
return db.prepare(sql)
|
|
},
|
|
|
|
// Insert employee with encrypted fields
|
|
insertEmployee(employee: any) {
|
|
const encrypted = {
|
|
...employee,
|
|
email: FieldEncryption.encrypt(employee.email),
|
|
phone: FieldEncryption.encrypt(employee.phone),
|
|
mobile: FieldEncryption.encrypt(employee.mobile),
|
|
clearance_level: FieldEncryption.encrypt(employee.clearance_level),
|
|
clearance_valid_until: FieldEncryption.encrypt(employee.clearance_valid_until),
|
|
// Add search hashes for encrypted fields
|
|
email_hash: employee.email ? FieldEncryption.hash(employee.email) : null,
|
|
phone_hash: employee.phone ? FieldEncryption.hash(employee.phone) : null
|
|
}
|
|
|
|
return db.prepare(`
|
|
INSERT INTO employees (
|
|
id, first_name, last_name, employee_number, photo, position,
|
|
department, email, email_hash, phone, phone_hash, mobile, office, availability,
|
|
clearance_level, clearance_valid_until, clearance_issued_date,
|
|
created_at, updated_at, created_by
|
|
) VALUES (
|
|
@id, @first_name, @last_name, @employee_number, @photo, @position,
|
|
@department, @email, @email_hash, @phone, @phone_hash, @mobile, @office, @availability,
|
|
@clearance_level, @clearance_valid_until, @clearance_issued_date,
|
|
@created_at, @updated_at, @created_by
|
|
)
|
|
`).run(encrypted)
|
|
},
|
|
|
|
// Get employee with decrypted fields
|
|
getEmployee(id: string) {
|
|
const employee = db.prepare(`
|
|
SELECT * FROM employees WHERE id = ?
|
|
`).get(id) as any
|
|
|
|
if (!employee) return null
|
|
|
|
// Decrypt sensitive fields
|
|
return {
|
|
...employee,
|
|
email: FieldEncryption.decrypt(employee.email),
|
|
phone: FieldEncryption.decrypt(employee.phone),
|
|
mobile: FieldEncryption.decrypt(employee.mobile),
|
|
clearance_level: FieldEncryption.decrypt(employee.clearance_level),
|
|
clearance_valid_until: FieldEncryption.decrypt(employee.clearance_valid_until)
|
|
}
|
|
},
|
|
|
|
// Get all employees with decrypted fields (handle decryption failures)
|
|
getAllEmployees() {
|
|
const employees = db.prepare(`
|
|
SELECT * FROM employees ORDER BY last_name, first_name
|
|
`).all() as any[]
|
|
|
|
return employees.map(emp => {
|
|
const safeDecrypt = (field: any) => {
|
|
if (!field) return field
|
|
try {
|
|
return FieldEncryption.decrypt(field) || field
|
|
} catch (error) {
|
|
// For compatibility with old unencrypted data or different encryption keys
|
|
return field
|
|
}
|
|
}
|
|
|
|
return {
|
|
...emp,
|
|
email: safeDecrypt(emp.email),
|
|
phone: safeDecrypt(emp.phone),
|
|
mobile: safeDecrypt(emp.mobile),
|
|
clearance_level: safeDecrypt(emp.clearance_level),
|
|
clearance_valid_until: safeDecrypt(emp.clearance_valid_until)
|
|
}
|
|
})
|
|
},
|
|
|
|
// Search by encrypted field using hash
|
|
findByEmail(email: string) {
|
|
const emailHash = FieldEncryption.hash(email)
|
|
return db.prepare(`
|
|
SELECT * FROM employees WHERE email_hash = ?
|
|
`).get(emailHash)
|
|
}
|
|
}
|
|
|
|
export function initializeSecureDatabase() {
|
|
// Create updated employees table with hash fields for searching
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS employees (
|
|
id TEXT PRIMARY KEY,
|
|
first_name TEXT NOT NULL,
|
|
last_name TEXT NOT NULL,
|
|
employee_number TEXT UNIQUE NOT NULL,
|
|
photo TEXT,
|
|
position TEXT NOT NULL,
|
|
department TEXT NOT NULL,
|
|
email TEXT NOT NULL,
|
|
email_hash TEXT,
|
|
phone TEXT NOT NULL,
|
|
phone_hash TEXT,
|
|
mobile TEXT,
|
|
office TEXT,
|
|
availability TEXT NOT NULL,
|
|
clearance_level TEXT,
|
|
clearance_valid_until TEXT,
|
|
clearance_issued_date TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
created_by TEXT NOT NULL,
|
|
updated_by TEXT
|
|
)
|
|
`)
|
|
|
|
// Add indexes for hash fields
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_employees_email_hash ON employees(email_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
|
|
`)
|
|
|
|
// Users table with encrypted email
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
username TEXT UNIQUE NOT NULL,
|
|
email TEXT NOT NULL,
|
|
email_hash TEXT,
|
|
password TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
|
|
employee_id TEXT,
|
|
last_login TEXT,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(email_hash)
|
|
)
|
|
`)
|
|
|
|
// Create index for email hash
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
|
|
`)
|
|
|
|
// Skills table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS skills (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
description TEXT,
|
|
requires_certification INTEGER DEFAULT 0,
|
|
expires_after INTEGER
|
|
)
|
|
`)
|
|
|
|
|
|
|
|
// Employee skills junction table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS employee_skills (
|
|
employee_id TEXT NOT NULL,
|
|
skill_id TEXT NOT NULL,
|
|
level TEXT,
|
|
verified INTEGER DEFAULT 0,
|
|
verified_by TEXT,
|
|
verified_date TEXT,
|
|
PRIMARY KEY (employee_id, skill_id),
|
|
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
|
|
)
|
|
`)
|
|
|
|
// Language skills table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS language_skills (
|
|
id TEXT PRIMARY KEY,
|
|
employee_id TEXT NOT NULL,
|
|
language TEXT NOT NULL,
|
|
proficiency TEXT NOT NULL,
|
|
certified INTEGER DEFAULT 0,
|
|
certificate_type TEXT,
|
|
is_native INTEGER DEFAULT 0,
|
|
can_interpret INTEGER DEFAULT 0,
|
|
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
|
|
)
|
|
`)
|
|
|
|
// Specializations table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS specializations (
|
|
id TEXT PRIMARY KEY,
|
|
employee_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
|
|
)
|
|
`)
|
|
|
|
// Audit Log for security tracking
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS security_audit_log (
|
|
id TEXT PRIMARY KEY,
|
|
entity_type TEXT NOT NULL,
|
|
entity_id TEXT NOT NULL,
|
|
action TEXT NOT NULL CHECK(action IN ('create', 'read', 'update', 'delete', 'login', 'logout', 'failed_login')),
|
|
user_id TEXT,
|
|
changes TEXT,
|
|
timestamp TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
risk_level TEXT CHECK(risk_level IN ('low', 'medium', 'high', 'critical'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_entity ON security_audit_log(entity_type, entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_user ON security_audit_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_timestamp ON security_audit_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_security_audit_risk ON security_audit_log(risk_level);
|
|
`)
|
|
|
|
// System settings table
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS system_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
description TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
updated_by TEXT
|
|
)
|
|
`)
|
|
|
|
// Insert default system settings
|
|
const settingsExist = db.prepare('SELECT key FROM system_settings WHERE key = ?').get('email_notifications_enabled')
|
|
if (!settingsExist) {
|
|
const now = new Date().toISOString()
|
|
db.prepare(`
|
|
INSERT INTO system_settings (key, value, description, updated_at, updated_by)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
`).run('email_notifications_enabled', 'false', 'Enable/disable email notifications for new user passwords', now, 'system')
|
|
}
|
|
|
|
// Create indexes for better performance
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_employees_availability ON employees(availability);
|
|
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
|
|
CREATE INDEX IF NOT EXISTS idx_employee_skills_employee ON employee_skills(employee_id);
|
|
CREATE INDEX IF NOT EXISTS idx_employee_skills_skill ON employee_skills(skill_id);
|
|
CREATE INDEX IF NOT EXISTS idx_language_skills_employee ON language_skills(employee_id);
|
|
CREATE INDEX IF NOT EXISTS idx_specializations_employee ON specializations(employee_id);
|
|
`)
|
|
|
|
// Controlled vocabulary (used to store skill categories/subcategories names)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
|
|
id TEXT PRIMARY KEY,
|
|
category TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
description TEXT,
|
|
is_active INTEGER DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(category, value)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
|
|
CREATE INDEX IF NOT EXISTS idx_vocab_value ON controlled_vocabulary(value);
|
|
`)
|
|
|
|
// Create default admin user if not exists
|
|
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
|
|
if (!adminExists) {
|
|
const hashedPassword = bcrypt.hashSync('admin123', 12)
|
|
const now = new Date().toISOString()
|
|
const adminEmail = 'admin@skillmate.local'
|
|
|
|
db.prepare(`
|
|
INSERT INTO users (id, username, email, email_hash, password, role, is_active, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
uuidv4(),
|
|
'admin',
|
|
FieldEncryption.encrypt(adminEmail),
|
|
FieldEncryption.hash(adminEmail),
|
|
hashedPassword,
|
|
'admin',
|
|
1,
|
|
now,
|
|
now
|
|
)
|
|
|
|
console.log('🔐 Default admin user created with password: admin123')
|
|
console.log('⚠️ Please change this password immediately!')
|
|
}
|
|
}
|
|
|
|
// db is already exported above via export const db = new Database(dbPath)
|