Initial commit
Dieser Commit ist enthalten in:
444
backend/src/config/database.ts
Normale Datei
444
backend/src/config/database.ts
Normale Datei
@ -0,0 +1,444 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import { Employee, User, SkillDefinition } from '@skillmate/shared'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db as secureDb, encryptedDb, initializeSecureDatabase } from './secureDatabase'
|
||||
export { initializeSecureDatabase } from './secureDatabase'
|
||||
|
||||
// Export the secure database instance
|
||||
export const db = secureDb
|
||||
|
||||
export function initializeDatabase() {
|
||||
// Users table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
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
|
||||
)
|
||||
`)
|
||||
|
||||
// Profiles table (erweitert für Yellow Pages)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
department TEXT,
|
||||
location TEXT,
|
||||
role TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
teams_link TEXT,
|
||||
job_category TEXT CHECK(job_category IN ('Technik', 'IT & Digitalisierung', 'Verwaltung', 'F&E', 'Kommunikation & HR', 'Produktion', 'Sonstiges')),
|
||||
job_title TEXT,
|
||||
job_desc TEXT,
|
||||
consent_public_profile INTEGER DEFAULT 0,
|
||||
consent_searchable INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL,
|
||||
review_due_at TEXT,
|
||||
search_vector TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Volltext-Index für Suche
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_search
|
||||
ON profiles(search_vector);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_department
|
||||
ON profiles(department);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_location
|
||||
ON profiles(location);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_job_category
|
||||
ON profiles(job_category);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_review_due
|
||||
ON profiles(review_due_at);
|
||||
`)
|
||||
|
||||
// Employees table (für Kompatibilität beibehalten)
|
||||
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,
|
||||
phone TEXT NOT NULL,
|
||||
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
|
||||
)
|
||||
`)
|
||||
|
||||
// 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
|
||||
)
|
||||
`)
|
||||
|
||||
// Profile Kompetenzen (Arrays als separate Tabellen)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS profile_domains (
|
||||
profile_id TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, domain),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_tools (
|
||||
profile_id TEXT NOT NULL,
|
||||
tool TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, tool),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_methods (
|
||||
profile_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, method),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_industry_knowledge (
|
||||
profile_id TEXT NOT NULL,
|
||||
knowledge TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, knowledge),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_regulatory (
|
||||
profile_id TEXT NOT NULL,
|
||||
regulation TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, regulation),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_networks (
|
||||
profile_id TEXT NOT NULL,
|
||||
network TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, network),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_digital_skills (
|
||||
profile_id TEXT NOT NULL,
|
||||
skill TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, skill),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_social_skills (
|
||||
profile_id TEXT NOT NULL,
|
||||
skill TEXT NOT NULL,
|
||||
PRIMARY KEY (profile_id, skill),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
// Profile Sprachen
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS profile_languages (
|
||||
profile_id TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('basic', 'fluent', 'native', 'business')),
|
||||
PRIMARY KEY (profile_id, code),
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
// Profile Projekte
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS profile_projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
role TEXT,
|
||||
summary TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS project_links (
|
||||
project_id TEXT NOT NULL,
|
||||
link TEXT NOT NULL,
|
||||
PRIMARY KEY (project_id, link),
|
||||
FOREIGN KEY (project_id) REFERENCES profile_projects(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
|
||||
// Language skills table (für Kompatibilität)
|
||||
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
|
||||
)
|
||||
`)
|
||||
|
||||
// Sync log table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
sync_time TEXT NOT NULL,
|
||||
success INTEGER NOT NULL,
|
||||
items_synced INTEGER,
|
||||
error_message TEXT,
|
||||
duration INTEGER
|
||||
)
|
||||
`)
|
||||
|
||||
// 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', 10)
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, password, role, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
'admin',
|
||||
'admin@skillmate.local',
|
||||
hashedPassword,
|
||||
'admin',
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
// Workspace Management Tables
|
||||
|
||||
// Workspaces (Desks, Meeting Rooms, etc.)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS workspaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('desk', 'meeting_room', 'phone_booth', 'parking', 'locker')),
|
||||
floor TEXT NOT NULL,
|
||||
building TEXT,
|
||||
capacity INTEGER DEFAULT 1,
|
||||
equipment TEXT, -- JSON array of equipment
|
||||
position_x INTEGER, -- For floor plan visualization
|
||||
position_y INTEGER,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Bookings table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bookings (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
employee_id TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('confirmed', 'cancelled', 'completed', 'no_show')),
|
||||
check_in_time TEXT,
|
||||
check_out_time TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Recurring bookings
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recurring_bookings (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
employee_id TEXT NOT NULL,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT NOT NULL,
|
||||
time_start TEXT NOT NULL,
|
||||
time_end TEXT NOT NULL,
|
||||
days_of_week TEXT NOT NULL, -- JSON array of days [1,2,3,4,5] for Mon-Fri
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id)
|
||||
)
|
||||
`)
|
||||
|
||||
// Booking rules and restrictions
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS booking_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_type TEXT,
|
||||
max_duration_hours INTEGER,
|
||||
max_advance_days INTEGER,
|
||||
min_advance_hours INTEGER,
|
||||
max_bookings_per_user_per_day INTEGER,
|
||||
max_bookings_per_user_per_week INTEGER,
|
||||
auto_release_minutes INTEGER, -- Auto-release if not checked in
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Analytics data
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS workspace_analytics (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
total_bookings INTEGER DEFAULT 0,
|
||||
total_hours_booked REAL DEFAULT 0,
|
||||
utilization_rate REAL DEFAULT 0,
|
||||
no_show_count INTEGER DEFAULT 0,
|
||||
unique_users INTEGER DEFAULT 0,
|
||||
peak_hour INTEGER,
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(id),
|
||||
UNIQUE(workspace_id, date)
|
||||
)
|
||||
`)
|
||||
|
||||
// Floor plans
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS floor_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
floor TEXT NOT NULL,
|
||||
building TEXT,
|
||||
image_url TEXT,
|
||||
svg_data TEXT, -- SVG data for interactive floor plan
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Audit Log für Änderungsverfolgung
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK(action IN ('create', 'update', 'delete')),
|
||||
user_id TEXT NOT NULL,
|
||||
changes TEXT, -- JSON mit Änderungen
|
||||
timestamp TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log(entity_type, entity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||
`)
|
||||
|
||||
// Reminder-System
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('annual_update', 'overdue', 'custom')),
|
||||
message TEXT,
|
||||
sent_at TEXT,
|
||||
acknowledged_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_profile ON reminders(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_sent ON reminders(sent_at);
|
||||
`)
|
||||
|
||||
// Kontrollierte Vokabulare/Tags
|
||||
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 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);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_workspace ON bookings(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_user ON bookings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_start_time ON bookings(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_analytics_date ON workspace_analytics(date);
|
||||
`)
|
||||
}
|
||||
360
backend/src/config/secureDatabase.ts
Normale Datei
360
backend/src/config/secureDatabase.ts
Normale Datei
@ -0,0 +1,360 @@
|
||||
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)
|
||||
73
backend/src/index.ts
Normale Datei
73
backend/src/index.ts
Normale Datei
@ -0,0 +1,73 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import { initializeSecureDatabase } from './config/secureDatabase'
|
||||
import authRoutes from './routes/auth'
|
||||
import employeeRoutes from './routes/employeesSecure'
|
||||
import profileRoutes from './routes/profiles'
|
||||
import skillRoutes from './routes/skills'
|
||||
import syncRoutes from './routes/sync'
|
||||
import uploadRoutes from './routes/upload'
|
||||
import networkRoutes from './routes/network'
|
||||
import workspaceRoutes from './routes/workspaces'
|
||||
import userRoutes from './routes/users'
|
||||
import userAdminRoutes from './routes/usersAdmin'
|
||||
import settingsRoutes from './routes/settings'
|
||||
// import bookingRoutes from './routes/bookings' // Temporär deaktiviert wegen TS-Fehlern
|
||||
// import analyticsRoutes from './routes/analytics' // Temporär deaktiviert
|
||||
import { errorHandler } from './middleware/errorHandler'
|
||||
import { logger } from './utils/logger'
|
||||
import { syncScheduler } from './services/syncScheduler'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3004
|
||||
|
||||
// Initialize secure database
|
||||
initializeSecureDatabase()
|
||||
|
||||
// Initialize sync scheduler
|
||||
syncScheduler
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
// Erlaube Bilder/Downloads aus diesem Server auch für andere Ports (5173/5174)
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
}))
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
// Static file serving for uploads
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes)
|
||||
app.use('/api/employees', employeeRoutes)
|
||||
app.use('/api/profiles', profileRoutes)
|
||||
app.use('/api/skills', skillRoutes)
|
||||
app.use('/api/sync', syncRoutes)
|
||||
app.use('/api/upload', uploadRoutes)
|
||||
app.use('/api/network', networkRoutes)
|
||||
app.use('/api/workspaces', workspaceRoutes)
|
||||
app.use('/api/users', userRoutes)
|
||||
app.use('/api/admin/users', userAdminRoutes)
|
||||
app.use('/api/admin/settings', settingsRoutes)
|
||||
// app.use('/api/bookings', bookingRoutes) // Temporär deaktiviert
|
||||
// app.use('/api/analytics', analyticsRoutes) // Temporär deaktiviert
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
||||
})
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler)
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Backend server running on port ${PORT}`)
|
||||
})// restart trigger
|
||||
53
backend/src/middleware/auth.ts
Normale Datei
53
backend/src/middleware/auth.ts
Normale Datei
@ -0,0 +1,53 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: User
|
||||
}
|
||||
|
||||
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const token = req.headers.authorization?.split(' ')[1]
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { message: 'No token provided' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as any
|
||||
req.user = decoded.user
|
||||
next()
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid token' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const authenticate = authenticateToken;
|
||||
|
||||
export function authorize(...roles: UserRole[]) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { message: 'Not authenticated' }
|
||||
})
|
||||
}
|
||||
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Insufficient permissions' }
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
38
backend/src/middleware/errorHandler.ts
Normale Datei
38
backend/src/middleware/errorHandler.ts
Normale Datei
@ -0,0 +1,38 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
export interface ApiError extends Error {
|
||||
statusCode?: number
|
||||
details?: any
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: ApiError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
logger.error({
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
body: req.body,
|
||||
params: req.params,
|
||||
query: req.query
|
||||
})
|
||||
|
||||
const statusCode = err.statusCode || 500
|
||||
const message = err.message || 'Internal Server Error'
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
message,
|
||||
...(process.env.NODE_ENV === 'development' && {
|
||||
stack: err.stack,
|
||||
details: err.details
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
93
backend/src/middleware/roleAuth.ts
Normale Datei
93
backend/src/middleware/roleAuth.ts
Normale Datei
@ -0,0 +1,93 @@
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import { UserRole, ROLE_PERMISSIONS } from '@skillmate/shared'
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: UserRole
|
||||
employeeId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPermission(userRole: UserRole, permission: string): boolean {
|
||||
const rolePermissions = ROLE_PERMISSIONS[userRole] || []
|
||||
return rolePermissions.includes(permission)
|
||||
}
|
||||
|
||||
export function requirePermission(permission: string) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
}
|
||||
|
||||
if (!hasPermission(req.user.role, permission)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Insufficient permissions. Required: ${permission}`
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRole(roles: UserRole | UserRole[]) {
|
||||
const allowedRoles = Array.isArray(roles) ? roles : [roles]
|
||||
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Access denied. Required role: ${allowedRoles.join(' or ')}`
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdminPanel() {
|
||||
return requirePermission('admin:panel:access')
|
||||
}
|
||||
|
||||
export function canEditEmployee(req: AuthRequest, targetEmployeeId: string): boolean {
|
||||
if (!req.user) return false
|
||||
|
||||
// Admins can edit anyone
|
||||
if (req.user.role === 'admin') return true
|
||||
|
||||
// Superusers can edit any employee
|
||||
if (req.user.role === 'superuser') return true
|
||||
|
||||
// Users can only edit their own profile (if linked to employee)
|
||||
if (req.user.role === 'user' && req.user.employeeId === targetEmployeeId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function requireEditPermission(getTargetId: (req: AuthRequest) => string) {
|
||||
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
}
|
||||
|
||||
const targetId = getTargetId(req)
|
||||
|
||||
if (!canEditEmployee(req, targetId)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: 'You can only edit your own profile'
|
||||
})
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
235
backend/src/routes/analytics.ts
Normale Datei
235
backend/src/routes/analytics.ts
Normale Datei
@ -0,0 +1,235 @@
|
||||
import { Router } from 'express'
|
||||
import { db } from '../config/database'
|
||||
import { authenticateToken, AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get workspace utilization analytics
|
||||
router.get('/workspace-utilization', authenticateToken, (req: AuthRequest, res) => {
|
||||
if (!['admin', 'superuser'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { from_date, to_date, workspace_id, workspace_type } = req.query
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
wa.*,
|
||||
w.name as workspace_name,
|
||||
w.type as workspace_type,
|
||||
w.floor,
|
||||
w.building
|
||||
FROM workspace_analytics wa
|
||||
JOIN workspaces w ON wa.workspace_id = w.id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
if (from_date) {
|
||||
query += ' AND wa.date >= ?'
|
||||
params.push(from_date)
|
||||
}
|
||||
|
||||
if (to_date) {
|
||||
query += ' AND wa.date <= ?'
|
||||
params.push(to_date)
|
||||
}
|
||||
|
||||
if (workspace_id) {
|
||||
query += ' AND wa.workspace_id = ?'
|
||||
params.push(workspace_id)
|
||||
}
|
||||
|
||||
if (workspace_type) {
|
||||
query += ' AND w.type = ?'
|
||||
params.push(workspace_type)
|
||||
}
|
||||
|
||||
query += ' ORDER BY wa.date DESC'
|
||||
|
||||
const analytics = db.prepare(query).all(...params)
|
||||
|
||||
res.json(analytics)
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch analytics' })
|
||||
}
|
||||
})
|
||||
|
||||
// Get overall statistics
|
||||
router.get('/overview', authenticateToken, (req: AuthRequest, res) => {
|
||||
if (!['admin', 'superuser'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { from_date = new Date().toISOString().split('T')[0], to_date } = req.query
|
||||
|
||||
// Total workspaces by type
|
||||
const workspaceStats = db.prepare(`
|
||||
SELECT type, COUNT(*) as count
|
||||
FROM workspaces
|
||||
WHERE is_active = 1
|
||||
GROUP BY type
|
||||
`).all()
|
||||
|
||||
// Booking statistics
|
||||
const bookingStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total_bookings,
|
||||
COUNT(DISTINCT user_id) as unique_users,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_bookings,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_bookings,
|
||||
SUM(CASE WHEN status = 'no_show' THEN 1 ELSE 0 END) as no_shows
|
||||
FROM bookings
|
||||
WHERE start_time >= ?
|
||||
${to_date ? 'AND end_time <= ?' : ''}
|
||||
`).get(from_date, ...(to_date ? [to_date] : []))
|
||||
|
||||
// Average utilization by workspace type
|
||||
const utilizationByType = db.prepare(`
|
||||
SELECT
|
||||
w.type,
|
||||
AVG(wa.utilization_rate) as avg_utilization,
|
||||
AVG(wa.total_hours_booked) as avg_hours_booked
|
||||
FROM workspace_analytics wa
|
||||
JOIN workspaces w ON wa.workspace_id = w.id
|
||||
WHERE wa.date >= ?
|
||||
${to_date ? 'AND wa.date <= ?' : ''}
|
||||
GROUP BY w.type
|
||||
`).all(from_date, ...(to_date ? [to_date] : []))
|
||||
|
||||
// Popular workspaces
|
||||
const popularWorkspaces = db.prepare(`
|
||||
SELECT
|
||||
w.id,
|
||||
w.name,
|
||||
w.type,
|
||||
w.floor,
|
||||
COUNT(b.id) as booking_count,
|
||||
AVG(
|
||||
CAST((julianday(b.end_time) - julianday(b.start_time)) * 24 AS REAL)
|
||||
) as avg_duration_hours
|
||||
FROM workspaces w
|
||||
JOIN bookings b ON w.id = b.workspace_id
|
||||
WHERE b.start_time >= ?
|
||||
${to_date ? 'AND b.end_time <= ?' : ''}
|
||||
AND b.status != 'cancelled'
|
||||
GROUP BY w.id, w.name, w.type, w.floor
|
||||
ORDER BY booking_count DESC
|
||||
LIMIT 10
|
||||
`).all(from_date, ...(to_date ? [to_date] : []))
|
||||
|
||||
// Peak hours analysis
|
||||
const peakHours = db.prepare(`
|
||||
SELECT
|
||||
CAST(strftime('%H', start_time) AS INTEGER) as hour,
|
||||
COUNT(*) as booking_count
|
||||
FROM bookings
|
||||
WHERE start_time >= ?
|
||||
${to_date ? 'AND end_time <= ?' : ''}
|
||||
AND status != 'cancelled'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`).all(from_date, ...(to_date ? [to_date] : []))
|
||||
|
||||
res.json({
|
||||
workspace_stats: workspaceStats,
|
||||
booking_stats: bookingStats,
|
||||
utilization_by_type: utilizationByType,
|
||||
popular_workspaces: popularWorkspaces,
|
||||
peak_hours: peakHours,
|
||||
date_range: {
|
||||
from: from_date,
|
||||
to: to_date || new Date().toISOString().split('T')[0]
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching overview:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch overview' })
|
||||
}
|
||||
})
|
||||
|
||||
// Update analytics data (should be run by a scheduled job)
|
||||
router.post('/update', authenticateToken, (req: AuthRequest, res) => {
|
||||
if (req.user!.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const date = req.body.date || new Date().toISOString().split('T')[0]
|
||||
|
||||
// Calculate analytics for each workspace
|
||||
const workspaces = db.prepare('SELECT id FROM workspaces WHERE is_active = 1').all()
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const dayStart = `${date}T00:00:00.000Z`
|
||||
const dayEnd = `${date}T23:59:59.999Z`
|
||||
|
||||
// Get booking statistics for the day
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total_bookings,
|
||||
COUNT(DISTINCT user_id) as unique_users,
|
||||
SUM(
|
||||
CASE
|
||||
WHEN status = 'no_show' OR (status = 'confirmed' AND check_in_time IS NULL AND end_time < ?)
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
) as no_show_count,
|
||||
SUM(
|
||||
CAST((julianday(end_time) - julianday(start_time)) * 24 AS REAL)
|
||||
) as total_hours_booked
|
||||
FROM bookings
|
||||
WHERE workspace_id = ?
|
||||
AND start_time >= ? AND start_time <= ?
|
||||
AND status != 'cancelled'
|
||||
`).get(new Date().toISOString(), (workspace as any).id, dayStart, dayEnd)
|
||||
|
||||
// Calculate peak hour
|
||||
const peakHour = db.prepare(`
|
||||
SELECT
|
||||
CAST(strftime('%H', start_time) AS INTEGER) as hour,
|
||||
COUNT(*) as count
|
||||
FROM bookings
|
||||
WHERE workspace_id = ?
|
||||
AND start_time >= ? AND start_time <= ?
|
||||
AND status != 'cancelled'
|
||||
GROUP BY hour
|
||||
ORDER BY count DESC
|
||||
LIMIT 1
|
||||
`).get((workspace as any).id, dayStart, dayEnd)
|
||||
|
||||
// Calculate utilization rate (assuming 10 hour work day)
|
||||
const workHoursPerDay = 10
|
||||
const utilizationRate = (stats as any).total_hours_booked / workHoursPerDay
|
||||
|
||||
// Insert or update analytics record
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO workspace_analytics (
|
||||
id, workspace_id, date, total_bookings, total_hours_booked,
|
||||
utilization_rate, no_show_count, unique_users, peak_hour
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
`${(workspace as any).id}-${date}`,
|
||||
(workspace as any).id,
|
||||
date,
|
||||
(stats as any).total_bookings || 0,
|
||||
(stats as any).total_hours_booked || 0,
|
||||
utilizationRate || 0,
|
||||
(stats as any).no_show_count || 0,
|
||||
(stats as any).unique_users || 0,
|
||||
(peakHour as any)?.hour || null
|
||||
)
|
||||
}
|
||||
|
||||
res.json({ message: 'Analytics updated successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error updating analytics:', error)
|
||||
res.status(500).json({ error: 'Failed to update analytics' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
121
backend/src/routes/auth.ts
Normale Datei
121
backend/src/routes/auth.ts
Normale Datei
@ -0,0 +1,121 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { User, LoginRequest, LoginResponse } from '@skillmate/shared'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
|
||||
router.post('/login',
|
||||
[
|
||||
body('username').optional().notEmpty().trim(),
|
||||
body('email').optional().isEmail().normalizeEmail(),
|
||||
body('password').notEmpty()
|
||||
],
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { username, email, password } = req.body
|
||||
|
||||
// Determine login identifier (email takes precedence)
|
||||
const loginIdentifier = email || username
|
||||
if (!loginIdentifier) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Either username or email is required' }
|
||||
})
|
||||
}
|
||||
|
||||
let userRow: any
|
||||
|
||||
// Try to find by email first (if looks like email), then by username
|
||||
if (loginIdentifier.includes('@')) {
|
||||
// Login with email
|
||||
const emailHash = FieldEncryption.hash(loginIdentifier)
|
||||
userRow = db.prepare(`
|
||||
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email_hash = ? AND is_active = 1
|
||||
`).get(emailHash) as any
|
||||
} else {
|
||||
// Login with username
|
||||
userRow = db.prepare(`
|
||||
SELECT id, username, email, password, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = ? AND is_active = 1
|
||||
`).get(loginIdentifier) as any
|
||||
}
|
||||
|
||||
if (!userRow) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid credentials' }
|
||||
})
|
||||
}
|
||||
|
||||
// Check password
|
||||
const isValidPassword = await bcrypt.compare(password, userRow.password)
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid credentials' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update last login
|
||||
const now = new Date().toISOString()
|
||||
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
|
||||
|
||||
// Create user object without password (decrypt email)
|
||||
const user: User = {
|
||||
id: userRow.id,
|
||||
username: userRow.username,
|
||||
email: FieldEncryption.decrypt(userRow.email) || '',
|
||||
role: userRow.role,
|
||||
employeeId: userRow.employee_id,
|
||||
lastLogin: new Date(now),
|
||||
isActive: Boolean(userRow.is_active),
|
||||
createdAt: new Date(userRow.created_at),
|
||||
updatedAt: new Date(userRow.updated_at)
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ user },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
)
|
||||
|
||||
const response: LoginResponse = {
|
||||
user,
|
||||
token: {
|
||||
accessToken: token,
|
||||
expiresIn: 86400,
|
||||
tokenType: 'Bearer'
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`User ${loginIdentifier} logged in successfully`)
|
||||
res.json({ success: true, data: response })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
res.json({ success: true, message: 'Logged out successfully' })
|
||||
})
|
||||
|
||||
export default router
|
||||
330
backend/src/routes/bookings.ts
Normale Datei
330
backend/src/routes/bookings.ts
Normale Datei
@ -0,0 +1,330 @@
|
||||
import { Router, Response } from 'express'
|
||||
import { db } from '../config/database'
|
||||
import { authenticateToken, AuthRequest } from '../middleware/auth'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Booking, BookingRequest } from '@skillmate/shared'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get user's bookings
|
||||
router.get('/my-bookings', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { status, from_date, to_date } = req.query
|
||||
let query = `
|
||||
SELECT b.*, w.name as workspace_name, w.type as workspace_type,
|
||||
w.floor, w.building, e.first_name, e.last_name, e.photo
|
||||
FROM bookings b
|
||||
JOIN workspaces w ON b.workspace_id = w.id
|
||||
JOIN employees e ON b.employee_id = e.id
|
||||
WHERE b.user_id = ?
|
||||
`
|
||||
const params: any[] = [req.user!.id]
|
||||
|
||||
if (status) {
|
||||
query += ' AND b.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
if (from_date) {
|
||||
query += ' AND b.start_time >= ?'
|
||||
params.push(from_date)
|
||||
}
|
||||
|
||||
if (to_date) {
|
||||
query += ' AND b.end_time <= ?'
|
||||
params.push(to_date)
|
||||
}
|
||||
|
||||
query += ' ORDER BY b.start_time DESC'
|
||||
|
||||
const bookings = db.prepare(query).all(...params)
|
||||
|
||||
res.json(bookings)
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch bookings' })
|
||||
}
|
||||
})
|
||||
|
||||
// Get all bookings (admin/superuser)
|
||||
router.get('/', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
if (!['admin', 'superuser'].includes(req.user!.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' })
|
||||
}
|
||||
|
||||
try {
|
||||
const { workspace_id, user_id, status, from_date, to_date } = req.query
|
||||
let query = `
|
||||
SELECT b.*, w.name as workspace_name, w.type as workspace_type,
|
||||
w.floor, w.building, e.first_name, e.last_name, e.photo,
|
||||
u.username
|
||||
FROM bookings b
|
||||
JOIN workspaces w ON b.workspace_id = w.id
|
||||
JOIN employees e ON b.employee_id = e.id
|
||||
JOIN users u ON b.user_id = u.id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
if (workspace_id) {
|
||||
query += ' AND b.workspace_id = ?'
|
||||
params.push(workspace_id)
|
||||
}
|
||||
|
||||
if (user_id) {
|
||||
query += ' AND b.user_id = ?'
|
||||
params.push(user_id)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query += ' AND b.status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
if (from_date) {
|
||||
query += ' AND b.start_time >= ?'
|
||||
params.push(from_date)
|
||||
}
|
||||
|
||||
if (to_date) {
|
||||
query += ' AND b.end_time <= ?'
|
||||
params.push(to_date)
|
||||
}
|
||||
|
||||
query += ' ORDER BY b.start_time DESC'
|
||||
|
||||
const bookings = db.prepare(query).all(...params)
|
||||
|
||||
res.json(bookings)
|
||||
} catch (error) {
|
||||
console.error('Error fetching bookings:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch bookings' })
|
||||
}
|
||||
})
|
||||
|
||||
// Create booking
|
||||
router.post('/', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const bookingRequest: BookingRequest = req.body
|
||||
const { workspace_id, start_time, end_time, notes, recurring } = bookingRequest
|
||||
|
||||
if (!workspace_id || !start_time || !end_time) {
|
||||
return res.status(400).json({ error: 'Missing required fields' })
|
||||
}
|
||||
|
||||
// Check if workspace exists and is active
|
||||
const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ? AND is_active = 1').get(workspace_id)
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: 'Workspace not found or inactive' })
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflict = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM bookings
|
||||
WHERE workspace_id = ? AND status = 'confirmed'
|
||||
AND (
|
||||
(start_time <= ? AND end_time > ?)
|
||||
OR (start_time < ? AND end_time >= ?)
|
||||
OR (start_time >= ? AND end_time <= ?)
|
||||
)
|
||||
`).get(workspace_id, start_time, start_time, end_time, end_time, start_time, end_time)
|
||||
|
||||
if ((conflict as any).count > 0) {
|
||||
return res.status(409).json({ error: 'Time slot already booked' })
|
||||
}
|
||||
|
||||
// Check booking rules
|
||||
const rules = db.prepare('SELECT * FROM booking_rules WHERE workspace_type = ?').get((workspace as any).type)
|
||||
if (rules) {
|
||||
// Validate against rules
|
||||
const startDate = new Date(start_time)
|
||||
const endDate = new Date(end_time)
|
||||
const now = new Date()
|
||||
|
||||
// Check max duration
|
||||
if ((rules as any).max_duration_hours) {
|
||||
const durationHours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60)
|
||||
if (durationHours > (rules as any).max_duration_hours) {
|
||||
return res.status(400).json({
|
||||
error: `Maximum booking duration is ${(rules as any).max_duration_hours} hours`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check advance booking limits
|
||||
if ((rules as any).max_advance_days) {
|
||||
const daysInAdvance = (startDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (daysInAdvance > (rules as any).max_advance_days) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot book more than ${(rules as any).max_advance_days} days in advance`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if ((rules as any).min_advance_hours) {
|
||||
const hoursInAdvance = (startDate.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
if (hoursInAdvance < (rules as any).min_advance_hours) {
|
||||
return res.status(400).json({
|
||||
error: `Must book at least ${(rules as any).min_advance_hours} hours in advance`
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bookingId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO bookings (
|
||||
id, workspace_id, user_id, employee_id, start_time, end_time,
|
||||
status, notes, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
bookingId, workspace_id, req.user!.id, req.user!.employeeId,
|
||||
start_time, end_time, 'confirmed', notes, now, now
|
||||
)
|
||||
|
||||
// Handle recurring bookings
|
||||
if (recurring) {
|
||||
const recurringId = uuidv4()
|
||||
const startDate = new Date(start_time)
|
||||
const startTimeOnly = startDate.toTimeString().substring(0, 8)
|
||||
const endTimeOnly = new Date(end_time).toTimeString().substring(0, 8)
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO recurring_bookings (
|
||||
id, workspace_id, user_id, employee_id, start_date, end_date,
|
||||
time_start, time_end, days_of_week, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
recurringId, workspace_id, req.user!.id, req.user!.employeeId,
|
||||
start_time, recurring.end_date, startTimeOnly, endTimeOnly,
|
||||
JSON.stringify(recurring.days_of_week), now
|
||||
)
|
||||
|
||||
// Create individual bookings for the recurring series
|
||||
// This is a simplified version - in production you'd want a background job
|
||||
createRecurringBookings(recurringId, bookingRequest, req.user)
|
||||
}
|
||||
|
||||
const newBooking = db.prepare(`
|
||||
SELECT b.*, w.name as workspace_name, w.type as workspace_type
|
||||
FROM bookings b
|
||||
JOIN workspaces w ON b.workspace_id = w.id
|
||||
WHERE b.id = ?
|
||||
`).get(bookingId)
|
||||
|
||||
res.status(201).json(newBooking)
|
||||
} catch (error) {
|
||||
console.error('Error creating booking:', error)
|
||||
res.status(500).json({ error: 'Failed to create booking' })
|
||||
}
|
||||
})
|
||||
|
||||
// Check in to booking
|
||||
router.post('/:id/check-in', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user!.id)
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({ error: 'Booking not found' })
|
||||
}
|
||||
|
||||
if ((booking as any).status !== 'confirmed') {
|
||||
return res.status(400).json({ error: 'Booking is not confirmed' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const startTime = new Date((booking as any).start_time)
|
||||
|
||||
// Allow check-in 15 minutes before start time
|
||||
const earliestCheckIn = new Date(startTime.getTime() - 15 * 60 * 1000)
|
||||
|
||||
if (now < earliestCheckIn) {
|
||||
return res.status(400).json({ error: 'Too early to check in' })
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bookings
|
||||
SET check_in_time = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(now.toISOString(), now.toISOString(), req.params.id)
|
||||
|
||||
res.json({ message: 'Checked in successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error checking in:', error)
|
||||
res.status(500).json({ error: 'Failed to check in' })
|
||||
}
|
||||
})
|
||||
|
||||
// Check out from booking
|
||||
router.post('/:id/check-out', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ? AND user_id = ?')
|
||||
.get(req.params.id, req.user!.id)
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({ error: 'Booking not found' })
|
||||
}
|
||||
|
||||
if (!(booking as any).check_in_time) {
|
||||
return res.status(400).json({ error: 'Not checked in' })
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bookings
|
||||
SET check_out_time = ?, status = 'completed', updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(now, now, req.params.id)
|
||||
|
||||
res.json({ message: 'Checked out successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error checking out:', error)
|
||||
res.status(500).json({ error: 'Failed to check out' })
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel booking
|
||||
router.post('/:id/cancel', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(req.params.id)
|
||||
|
||||
if (!booking) {
|
||||
return res.status(404).json({ error: 'Booking not found' })
|
||||
}
|
||||
|
||||
// Users can only cancel their own bookings, admins can cancel any
|
||||
if ((booking as any).user_id !== req.user!.id && req.user!.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Not authorized to cancel this booking' })
|
||||
}
|
||||
|
||||
if ((booking as any).status !== 'confirmed') {
|
||||
return res.status(400).json({ error: 'Booking is not confirmed' })
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE bookings
|
||||
SET status = 'cancelled', updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(now, req.params.id)
|
||||
|
||||
res.json({ message: 'Booking cancelled successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error cancelling booking:', error)
|
||||
res.status(500).json({ error: 'Failed to cancel booking' })
|
||||
}
|
||||
})
|
||||
|
||||
// Helper function to create recurring bookings
|
||||
function createRecurringBookings(recurringId: string, request: BookingRequest, user: any) {
|
||||
// This is a simplified implementation
|
||||
// In production, this should be handled by a background job
|
||||
// to avoid timeout issues with many bookings
|
||||
}
|
||||
|
||||
export default router
|
||||
561
backend/src/routes/employees.ts
Normale Datei
561
backend/src/routes/employees.ts
Normale Datei
@ -0,0 +1,561 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
|
||||
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Helper function to map old proficiency to new level
|
||||
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
|
||||
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
|
||||
'A1': 'basic',
|
||||
'A2': 'basic',
|
||||
'B1': 'business',
|
||||
'B2': 'business',
|
||||
'C1': 'fluent',
|
||||
'C2': 'fluent',
|
||||
'Muttersprache': 'native',
|
||||
'native': 'native',
|
||||
'fluent': 'fluent',
|
||||
'advanced': 'business',
|
||||
'intermediate': 'business',
|
||||
'basic': 'basic'
|
||||
}
|
||||
return mapping[proficiency] || 'basic'
|
||||
}
|
||||
|
||||
// Get all employees
|
||||
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = db.prepare(`
|
||||
SELECT id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM employees
|
||||
ORDER BY last_name, first_name
|
||||
`).all()
|
||||
|
||||
const employeesWithDetails = employees.map((emp: any) => {
|
||||
// Get skills
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
FROM employee_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get languages
|
||||
const languages = db.prepare(`
|
||||
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get specializations
|
||||
const specializations = db.prepare(`
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
employeeNumber: emp.employee_number,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
level: s.level,
|
||||
verified: Boolean(s.verified),
|
||||
verifiedBy: s.verified_by,
|
||||
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
|
||||
})),
|
||||
languages: languages.map((l: any) => ({
|
||||
code: l.language, // Map language to code for new interface
|
||||
level: mapProficiencyToLevel(l.proficiency) // Map old proficiency to new level
|
||||
})),
|
||||
clearance: emp.clearance_level ? {
|
||||
level: emp.clearance_level,
|
||||
validUntil: new Date(emp.clearance_valid_until),
|
||||
issuedDate: new Date(emp.clearance_issued_date)
|
||||
} : undefined,
|
||||
specializations,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
}
|
||||
|
||||
return employee
|
||||
})
|
||||
|
||||
res.json({ success: true, data: employeesWithDetails })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get employee by ID
|
||||
router.get('/:id', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const emp = db.prepare(`
|
||||
SELECT id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
FROM employees
|
||||
WHERE id = ?
|
||||
`).get(id) as any
|
||||
|
||||
if (!emp) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Get skills
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
FROM employee_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get languages
|
||||
const languages = db.prepare(`
|
||||
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get specializations
|
||||
const specializations = db.prepare(`
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
employeeNumber: emp.employee_number,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
level: s.level,
|
||||
verified: Boolean(s.verified),
|
||||
verifiedBy: s.verified_by,
|
||||
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
|
||||
})),
|
||||
languages: languages.map((l: any) => ({
|
||||
code: l.language, // Map language to code for new interface
|
||||
level: mapProficiencyToLevel(l.proficiency) // Map old proficiency to new level
|
||||
})),
|
||||
clearance: emp.clearance_level ? {
|
||||
level: emp.clearance_level,
|
||||
validUntil: new Date(emp.clearance_valid_until),
|
||||
issuedDate: new Date(emp.clearance_issued_date)
|
||||
} : undefined,
|
||||
specializations,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
}
|
||||
|
||||
res.json({ success: true, data: employee })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create employee (admin/poweruser only)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requirePermission('employees:create'),
|
||||
[
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('email').isEmail(),
|
||||
body('department').notEmpty().trim()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const employeeId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const {
|
||||
firstName, lastName, employeeNumber, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance, skills, languages, specializations, userRole, createUser
|
||||
} = req.body
|
||||
|
||||
// Insert employee with default values for missing fields
|
||||
db.prepare(`
|
||||
INSERT INTO employees (
|
||||
id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
employeeId,
|
||||
firstName,
|
||||
lastName,
|
||||
employeeNumber || null,
|
||||
photo || null,
|
||||
position || 'Mitarbeiter', // Default position
|
||||
department,
|
||||
email,
|
||||
phone || 'Nicht angegeben', // Default phone
|
||||
mobile || null,
|
||||
office || null,
|
||||
availability || 'available', // Default availability
|
||||
clearance?.level || null,
|
||||
clearance?.validUntil || null,
|
||||
clearance?.issuedDate || null,
|
||||
now, now, req.user!.id
|
||||
)
|
||||
|
||||
// Insert skills
|
||||
if (skills && skills.length > 0) {
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const skill of skills) {
|
||||
insertSkill.run(
|
||||
employeeId,
|
||||
skill.id,
|
||||
skill.level || null,
|
||||
skill.verified ? 1 : 0,
|
||||
skill.verifiedBy || null,
|
||||
skill.verifiedDate || null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert languages
|
||||
if (languages && languages.length > 0) {
|
||||
const insertLang = db.prepare(`
|
||||
INSERT INTO language_skills (
|
||||
id, employee_id, language, proficiency, certified,
|
||||
certificate_type, is_native, can_interpret
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const lang of languages) {
|
||||
insertLang.run(
|
||||
uuidv4(),
|
||||
employeeId,
|
||||
lang.language,
|
||||
lang.proficiency,
|
||||
lang.certified ? 1 : 0,
|
||||
lang.certificateType || null,
|
||||
lang.isNative ? 1 : 0,
|
||||
lang.canInterpret ? 1 : 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert specializations
|
||||
if (specializations && specializations.length > 0) {
|
||||
const insertSpec = db.prepare(`
|
||||
INSERT INTO specializations (id, employee_id, name)
|
||||
VALUES (?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const spec of specializations) {
|
||||
insertSpec.run(uuidv4(), employeeId, spec)
|
||||
}
|
||||
}
|
||||
|
||||
// Queue sync for new employee
|
||||
const newEmployee = {
|
||||
id: employeeId,
|
||||
firstName,
|
||||
lastName,
|
||||
employeeNumber: employeeNumber || null,
|
||||
photo: photo || null,
|
||||
position: position || 'Mitarbeiter',
|
||||
department,
|
||||
email,
|
||||
phone: phone || 'Nicht angegeben',
|
||||
mobile: mobile || null,
|
||||
office: office || null,
|
||||
availability: availability || 'available',
|
||||
clearance,
|
||||
skills: skills || [],
|
||||
languages: languages || [],
|
||||
specializations: specializations || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: req.user!.id
|
||||
}
|
||||
|
||||
// Create user account if requested
|
||||
let userId = null
|
||||
let temporaryPassword = null
|
||||
if (createUser && userRole) {
|
||||
try {
|
||||
userId = uuidv4()
|
||||
// Generate a secure temporary password
|
||||
temporaryPassword = `TempPass${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = await bcrypt.hash(temporaryPassword, 10)
|
||||
|
||||
// Encrypt email for user table storage
|
||||
const encryptedEmail = FieldEncryption.encrypt(email)
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
|
||||
)
|
||||
|
||||
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
|
||||
} catch (userError) {
|
||||
console.error('Error creating user account:', userError)
|
||||
// Continue without failing the employee creation
|
||||
temporaryPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
await syncService.queueSync('employees', 'create', newEmployee)
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: employeeId,
|
||||
userId: userId,
|
||||
temporaryPassword: temporaryPassword
|
||||
},
|
||||
message: `Employee created successfully${createUser ? ' with user account' : ''}`
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update employee (admin/poweruser only)
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
requireEditPermission(req => req.params.id),
|
||||
[
|
||||
body('firstName').notEmpty().trim(),
|
||||
body('lastName').notEmpty().trim(),
|
||||
body('position').notEmpty().trim(),
|
||||
body('department').notEmpty().trim(),
|
||||
body('email').isEmail(),
|
||||
body('phone').notEmpty().trim(),
|
||||
body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const {
|
||||
firstName, lastName, position, department, email, phone,
|
||||
mobile, office, availability, clearance, skills, languages, specializations
|
||||
} = req.body
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update employee
|
||||
db.prepare(`
|
||||
UPDATE employees SET
|
||||
first_name = ?, last_name = ?, position = ?, department = ?,
|
||||
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
|
||||
clearance_level = ?, clearance_valid_until = ?, clearance_issued_date = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
firstName, lastName, position, department,
|
||||
email, phone, mobile || null, office || null, availability,
|
||||
clearance?.level || null, clearance?.validUntil || null, clearance?.issuedDate || null,
|
||||
now, req.user!.id, id
|
||||
)
|
||||
|
||||
// Update skills
|
||||
if (skills !== undefined) {
|
||||
// Delete existing skills
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
|
||||
|
||||
// Insert new skills
|
||||
if (skills.length > 0) {
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const skill of skills) {
|
||||
insertSkill.run(
|
||||
id,
|
||||
skill.id,
|
||||
skill.level || null,
|
||||
skill.verified ? 1 : 0,
|
||||
skill.verifiedBy || null,
|
||||
skill.verifiedDate || null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue sync for updated employee
|
||||
const updatedEmployee = {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
position,
|
||||
department,
|
||||
email,
|
||||
phone,
|
||||
mobile: mobile || null,
|
||||
office: office || null,
|
||||
availability,
|
||||
clearance,
|
||||
skills,
|
||||
languages,
|
||||
specializations,
|
||||
updatedAt: now,
|
||||
updatedBy: req.user!.id
|
||||
}
|
||||
|
||||
await syncService.queueSync('employees', 'update', updatedEmployee)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Employee updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete employee (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requirePermission('employees:delete'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Delete employee and related data
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM employees WHERE id = ?').run(id)
|
||||
|
||||
// Queue sync for deleted employee
|
||||
await syncService.queueSync('employees', 'delete', { id })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Employee deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Search employees by skills
|
||||
router.post('/search', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { skills, category } = req.body
|
||||
|
||||
let query = `
|
||||
SELECT DISTINCT e.id, e.first_name, e.last_name, e.employee_number,
|
||||
e.position, e.department, e.availability
|
||||
FROM employees e
|
||||
JOIN employee_skills es ON e.id = es.employee_id
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
const params: any[] = []
|
||||
|
||||
if (skills && skills.length > 0) {
|
||||
const placeholders = skills.map(() => '?').join(',')
|
||||
query += ` AND s.name IN (${placeholders})`
|
||||
params.push(...skills)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
query += ` AND s.category = ?`
|
||||
params.push(category)
|
||||
}
|
||||
|
||||
query += ` ORDER BY e.last_name, e.first_name`
|
||||
|
||||
const results = db.prepare(query).all(...params)
|
||||
|
||||
res.json({ success: true, data: results })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
813
backend/src/routes/employeesSecure.ts
Normale Datei
813
backend/src/routes/employeesSecure.ts
Normale Datei
@ -0,0 +1,813 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireEditPermission } from '../middleware/roleAuth'
|
||||
import { Employee, LanguageSkill, Skill, UserRole } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { emailService } from '../services/emailService'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Helper function to map old proficiency to new level
|
||||
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
|
||||
const mapping: Record<string, 'basic' | 'fluent' | 'native' | 'business'> = {
|
||||
'A1': 'basic',
|
||||
'A2': 'basic',
|
||||
'B1': 'business',
|
||||
'B2': 'business',
|
||||
'C1': 'fluent',
|
||||
'C2': 'fluent',
|
||||
'Muttersprache': 'native',
|
||||
'native': 'native',
|
||||
'fluent': 'fluent',
|
||||
'advanced': 'business',
|
||||
'intermediate': 'business',
|
||||
'basic': 'basic'
|
||||
}
|
||||
return mapping[proficiency] || 'basic'
|
||||
}
|
||||
|
||||
// Log security audit events
|
||||
function logSecurityAudit(
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
userId: string,
|
||||
req: AuthRequest,
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical' = 'low'
|
||||
) {
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO security_audit_log (
|
||||
id, entity_type, entity_id, action, user_id,
|
||||
timestamp, ip_address, user_agent, risk_level
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
entityType,
|
||||
entityId,
|
||||
action,
|
||||
userId,
|
||||
new Date().toISOString(),
|
||||
req.ip || req.connection.remoteAddress,
|
||||
req.get('user-agent'),
|
||||
riskLevel
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to log security audit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all employees
|
||||
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const employees = encryptedDb.getAllEmployees()
|
||||
|
||||
const employeesWithDetails = employees.map((emp: any) => {
|
||||
// Get skills
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
FROM employee_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get languages
|
||||
const languages = db.prepare(`
|
||||
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get specializations
|
||||
const specializations = db.prepare(`
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
level: s.level,
|
||||
verified: Boolean(s.verified),
|
||||
verifiedBy: s.verified_by,
|
||||
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
|
||||
})),
|
||||
languages: languages.map((l: any) => ({
|
||||
code: l.language,
|
||||
level: mapProficiencyToLevel(l.proficiency)
|
||||
})),
|
||||
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
|
||||
level: emp.clearance_level,
|
||||
validUntil: new Date(emp.clearance_valid_until),
|
||||
issuedDate: new Date(emp.clearance_issued_date)
|
||||
} : undefined,
|
||||
specializations,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
}
|
||||
|
||||
return employee
|
||||
})
|
||||
|
||||
// Log read access
|
||||
logSecurityAudit('read', 'employees', 'all', req.user!.id, req, 'low')
|
||||
|
||||
res.json({ success: true, data: employeesWithDetails })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching employees:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Public employees for frontend: only those with a linked user (excluding admin)
|
||||
router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
// Get allowed employee IDs from users table (exclude admin, only active accounts)
|
||||
const allowedUserLinks = db.prepare(`
|
||||
SELECT employee_id FROM users
|
||||
WHERE employee_id IS NOT NULL AND username <> 'admin' AND is_active = 1
|
||||
`).all() as any[]
|
||||
const allowedIds = new Set((allowedUserLinks || []).map((u: any) => u.employee_id))
|
||||
|
||||
const employees = encryptedDb.getAllEmployees()
|
||||
.filter((emp: any) => allowedIds.has(emp.id))
|
||||
|
||||
const employeesWithDetails = employees.map((emp: any) => {
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
FROM employee_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
const languages = db.prepare(`
|
||||
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
const specializations = db.prepare(`
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
level: s.level,
|
||||
verified: Boolean(s.verified),
|
||||
verifiedBy: s.verified_by,
|
||||
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
|
||||
})),
|
||||
languages: languages.map((l: any) => ({
|
||||
code: l.language,
|
||||
level: mapProficiencyToLevel(l.proficiency)
|
||||
})),
|
||||
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
|
||||
level: emp.clearance_level,
|
||||
validUntil: new Date(emp.clearance_valid_until),
|
||||
issuedDate: new Date(emp.clearance_issued_date)
|
||||
} : undefined,
|
||||
specializations,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
}
|
||||
|
||||
return employee
|
||||
})
|
||||
|
||||
res.json({ success: true, data: employeesWithDetails })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching public employees:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get employee by ID
|
||||
router.get('/:id', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const emp = encryptedDb.getEmployee(id)
|
||||
|
||||
if (!emp) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Get skills
|
||||
const skills = db.prepare(`
|
||||
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
|
||||
FROM employee_skills es
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE es.employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get languages
|
||||
const languages = db.prepare(`
|
||||
SELECT language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id = ?
|
||||
`).all(emp.id)
|
||||
|
||||
// Get specializations
|
||||
const specializations = db.prepare(`
|
||||
SELECT name FROM specializations WHERE employee_id = ?
|
||||
`).all(emp.id).map((s: any) => s.name)
|
||||
|
||||
const employee: Employee = {
|
||||
id: emp.id,
|
||||
firstName: emp.first_name,
|
||||
lastName: emp.last_name,
|
||||
employeeNumber: (emp.employee_number && !String(emp.employee_number).startsWith('EMP')) ? emp.employee_number : undefined,
|
||||
photo: emp.photo,
|
||||
position: emp.position,
|
||||
department: emp.department,
|
||||
email: emp.email,
|
||||
phone: emp.phone,
|
||||
mobile: emp.mobile,
|
||||
office: emp.office,
|
||||
availability: emp.availability,
|
||||
skills: skills.map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
level: s.level,
|
||||
verified: Boolean(s.verified),
|
||||
verifiedBy: s.verified_by,
|
||||
verifiedDate: s.verified_date ? new Date(s.verified_date) : undefined
|
||||
})),
|
||||
languages: languages.map((l: any) => ({
|
||||
code: l.language,
|
||||
level: mapProficiencyToLevel(l.proficiency)
|
||||
})),
|
||||
clearance: emp.clearance_level && emp.clearance_valid_until && emp.clearance_issued_date ? {
|
||||
level: emp.clearance_level,
|
||||
validUntil: new Date(emp.clearance_valid_until),
|
||||
issuedDate: new Date(emp.clearance_issued_date)
|
||||
} : undefined,
|
||||
specializations,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
}
|
||||
|
||||
// Log read access
|
||||
logSecurityAudit('read', 'employees', id, req.user!.id, req, 'low')
|
||||
|
||||
res.json({ success: true, data: employee })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching employee:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create employee (admin/poweruser only)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requirePermission('employees:create'),
|
||||
[
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(), // Optional
|
||||
body('phone').optional().trim(), // Optional - kann später ergänzt werden
|
||||
body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction = db.transaction(() => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const employeeId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const {
|
||||
firstName, lastName, employeeNumber, photo, position = 'Mitarbeiter',
|
||||
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
|
||||
clearance, skills = [], languages = [], specializations = [], userRole, createUser
|
||||
} = req.body
|
||||
|
||||
// Generate employee number if not provided
|
||||
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
|
||||
|
||||
// Check if employee number already exists
|
||||
const existingEmployee = db.prepare('SELECT id FROM employees WHERE employee_number = ?').get(finalEmployeeNumber)
|
||||
if (existingEmployee) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: { message: 'Employee number already exists' }
|
||||
})
|
||||
}
|
||||
|
||||
// Insert employee with encrypted fields
|
||||
encryptedDb.insertEmployee({
|
||||
id: employeeId,
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
employee_number: finalEmployeeNumber,
|
||||
photo: photo || null,
|
||||
position,
|
||||
department,
|
||||
email,
|
||||
phone,
|
||||
mobile: mobile || null,
|
||||
office: office || null,
|
||||
availability,
|
||||
clearance_level: clearance?.level || null,
|
||||
clearance_valid_until: clearance?.validUntil || null,
|
||||
clearance_issued_date: clearance?.issuedDate || null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_by: req.user!.id
|
||||
})
|
||||
|
||||
// Insert skills (only if they exist in skills table)
|
||||
if (skills && skills.length > 0) {
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
|
||||
|
||||
for (const skill of skills) {
|
||||
// Check if skill exists before inserting
|
||||
const skillExists = checkSkillExists.get(skill.id)
|
||||
if (skillExists) {
|
||||
insertSkill.run(
|
||||
employeeId,
|
||||
skill.id,
|
||||
skill.level || null,
|
||||
skill.verified ? 1 : 0,
|
||||
skill.verifiedBy || null,
|
||||
skill.verifiedDate || null
|
||||
)
|
||||
} else {
|
||||
logger.warn(`Skill with ID ${skill.id} does not exist in skills table, skipping`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert languages
|
||||
if (languages && languages.length > 0) {
|
||||
const insertLang = db.prepare(`
|
||||
INSERT INTO language_skills (
|
||||
id, employee_id, language, proficiency, certified,
|
||||
certificate_type, is_native, can_interpret
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const lang of languages) {
|
||||
insertLang.run(
|
||||
uuidv4(),
|
||||
employeeId,
|
||||
lang.code || lang.language,
|
||||
lang.level || lang.proficiency || 'basic',
|
||||
lang.certified ? 1 : 0,
|
||||
lang.certificateType || null,
|
||||
lang.isNative ? 1 : 0,
|
||||
lang.canInterpret ? 1 : 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert specializations
|
||||
if (specializations && specializations.length > 0) {
|
||||
const insertSpec = db.prepare(`
|
||||
INSERT INTO specializations (id, employee_id, name)
|
||||
VALUES (?, ?, ?)
|
||||
`)
|
||||
|
||||
for (const spec of specializations) {
|
||||
insertSpec.run(uuidv4(), employeeId, spec)
|
||||
}
|
||||
}
|
||||
|
||||
// Create user account if requested
|
||||
let userId = null
|
||||
let tempPassword = null
|
||||
if (createUser) {
|
||||
userId = uuidv4()
|
||||
tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = bcrypt.hashSync(tempPassword, 12)
|
||||
// Enforce role policy: only admins may assign roles; others default to 'user'
|
||||
const assignedRole = req.user?.role === 'admin' && userRole ? userRole : 'user'
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
email,
|
||||
FieldEncryption.encrypt(email),
|
||||
FieldEncryption.hash(email),
|
||||
hashedPassword,
|
||||
assignedRole,
|
||||
employeeId,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
logger.info(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
|
||||
}
|
||||
|
||||
// Log creation
|
||||
logSecurityAudit('create', 'employees', employeeId, req.user!.id, req, 'medium')
|
||||
|
||||
// Queue sync for new employee
|
||||
const newEmployee = {
|
||||
id: employeeId,
|
||||
firstName,
|
||||
lastName,
|
||||
employeeNumber: finalEmployeeNumber,
|
||||
photo: photo || null,
|
||||
position,
|
||||
department,
|
||||
email,
|
||||
phone,
|
||||
mobile: mobile || null,
|
||||
office: office || null,
|
||||
availability,
|
||||
clearance,
|
||||
skills,
|
||||
languages,
|
||||
specializations,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: req.user!.id
|
||||
}
|
||||
|
||||
syncService.queueSync('employees', 'create', newEmployee).catch(err => {
|
||||
logger.error('Failed to queue sync:', err)
|
||||
})
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: employeeId,
|
||||
userId: userId,
|
||||
temporaryPassword: tempPassword
|
||||
},
|
||||
message: `Employee created successfully${createUser ? ' with user account' : ''}`
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error creating employee:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const result = transaction()
|
||||
|
||||
// Send email after successful transaction (if user was created)
|
||||
const { createUser, userRole, email, firstName } = req.body
|
||||
if (createUser && userRole && result && (result as any).data?.userId && (result as any).data?.temporaryPassword) {
|
||||
// Check if email notifications are enabled
|
||||
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
|
||||
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
|
||||
|
||||
// Send initial password via email if notifications are enabled
|
||||
if (emailNotificationsEnabled && emailService.isServiceEnabled()) {
|
||||
const emailSent = await emailService.sendInitialPassword(email, (result as any).data.temporaryPassword, firstName)
|
||||
if (emailSent) {
|
||||
logger.info(`Initial password email sent to ${email}`)
|
||||
} else {
|
||||
logger.warn(`Failed to send initial password email to ${email}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error: any) {
|
||||
logger.error('Transaction failed:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to create employee',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update employee
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
requireEditPermission(req => req.params.id),
|
||||
[
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('phone').optional().trim(),
|
||||
body('employeeNumber').optional().trim(),
|
||||
body('availability').optional().isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction = db.transaction(() => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const {
|
||||
firstName, lastName, position = 'Mitarbeiter', department, email, phone = 'Nicht angegeben',
|
||||
mobile, office, availability = 'available', clearance, skills, languages, specializations,
|
||||
employeeNumber
|
||||
} = req.body
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update employee with encrypted fields
|
||||
db.prepare(`
|
||||
UPDATE employees SET
|
||||
first_name = ?, last_name = ?, position = ?, department = ?,
|
||||
email = ?, email_hash = ?, phone = ?, phone_hash = ?,
|
||||
mobile = ?, office = ?, availability = ?,
|
||||
clearance_level = ?, clearance_valid_until = ?, clearance_issued_date = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
firstName, lastName, position, department,
|
||||
FieldEncryption.encrypt(email),
|
||||
FieldEncryption.hash(email),
|
||||
FieldEncryption.encrypt(phone || ''),
|
||||
FieldEncryption.hash(phone || ''),
|
||||
mobile ? FieldEncryption.encrypt(mobile) : null,
|
||||
office || null,
|
||||
availability,
|
||||
clearance?.level || null,
|
||||
clearance?.validUntil || null,
|
||||
clearance?.issuedDate || null,
|
||||
now,
|
||||
req.user!.id,
|
||||
id
|
||||
)
|
||||
|
||||
// Optionally update employee number (NW-Kennung) with uniqueness check
|
||||
if (employeeNumber && typeof employeeNumber === 'string') {
|
||||
const existsNumber = db.prepare('SELECT id FROM employees WHERE employee_number = ? AND id <> ?').get(employeeNumber, id)
|
||||
if (existsNumber) {
|
||||
return res.status(409).json({ success: false, error: { message: 'Employee number already exists' } })
|
||||
}
|
||||
db.prepare('UPDATE employees SET employee_number = ?, updated_at = ?, updated_by = ? WHERE id = ?')
|
||||
.run(employeeNumber, now, req.user!.id, id)
|
||||
}
|
||||
|
||||
// Update skills
|
||||
if (skills !== undefined) {
|
||||
// Delete existing skills
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
|
||||
|
||||
// Insert new skills
|
||||
if (skills.length > 0) {
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
const checkSkillExists = db.prepare('SELECT id FROM skills WHERE id = ?')
|
||||
|
||||
for (const skill of skills) {
|
||||
const exists = checkSkillExists.get(skill.id)
|
||||
if (!exists) {
|
||||
logger.warn(`Skill with ID ${skill.id} does not exist in skills table, skipping`)
|
||||
continue
|
||||
}
|
||||
insertSkill.run(
|
||||
id,
|
||||
skill.id,
|
||||
typeof skill.level === 'number' ? skill.level : (parseInt(skill.level) || null),
|
||||
skill.verified ? 1 : 0,
|
||||
skill.verifiedBy || null,
|
||||
skill.verifiedDate || null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log update
|
||||
logSecurityAudit('update', 'employees', id, req.user!.id, req, 'medium')
|
||||
|
||||
// Queue sync for updated employee
|
||||
const updatedEmployee = {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
position,
|
||||
department,
|
||||
email,
|
||||
phone,
|
||||
mobile: mobile || null,
|
||||
office: office || null,
|
||||
availability,
|
||||
clearance,
|
||||
skills,
|
||||
languages,
|
||||
specializations,
|
||||
updatedAt: now,
|
||||
updatedBy: req.user!.id
|
||||
}
|
||||
|
||||
syncService.queueSync('employees', 'update', updatedEmployee).catch(err => {
|
||||
logger.error('Failed to queue sync:', err)
|
||||
})
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Employee updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating employee:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
return transaction()
|
||||
} catch (error: any) {
|
||||
logger.error('Transaction failed:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to update employee',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete employee (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requirePermission('employees:delete'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const transaction = db.transaction(() => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// Check if employee exists
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Delete employee and related data
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(id)
|
||||
db.prepare('DELETE FROM employees WHERE id = ?').run(id)
|
||||
|
||||
// Log deletion
|
||||
logSecurityAudit('delete', 'employees', id, req.user!.id, req, 'high')
|
||||
|
||||
// Queue sync for deleted employee
|
||||
syncService.queueSync('employees', 'delete', { id }).catch(err => {
|
||||
logger.error('Failed to queue sync:', err)
|
||||
})
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Employee deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error deleting employee:', error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
return transaction()
|
||||
} catch (error: any) {
|
||||
logger.error('Transaction failed:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Failed to delete employee',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Search employees by skills
|
||||
router.post('/search', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { skills, category } = req.body
|
||||
|
||||
let query = `
|
||||
SELECT DISTINCT e.id, e.first_name, e.last_name, e.employee_number,
|
||||
e.position, e.department, e.availability,
|
||||
e.email, e.phone, e.mobile
|
||||
FROM employees e
|
||||
JOIN employee_skills es ON e.id = es.employee_id
|
||||
JOIN skills s ON es.skill_id = s.id
|
||||
WHERE 1=1
|
||||
`
|
||||
|
||||
const params: any[] = []
|
||||
|
||||
if (skills && skills.length > 0) {
|
||||
const placeholders = skills.map(() => '?').join(',')
|
||||
query += ` AND s.name IN (${placeholders})`
|
||||
params.push(...skills)
|
||||
}
|
||||
|
||||
if (category) {
|
||||
query += ` AND s.category = ?`
|
||||
params.push(category)
|
||||
}
|
||||
|
||||
query += ` ORDER BY e.last_name, e.first_name`
|
||||
|
||||
const results = db.prepare(query).all(...params)
|
||||
|
||||
// Decrypt sensitive fields in results safely
|
||||
const decryptedResults = results.map((emp: any) => {
|
||||
const safeDecrypt = (v: any) => {
|
||||
try { return v ? FieldEncryption.decrypt(v) : null } catch { return null }
|
||||
}
|
||||
return {
|
||||
...emp,
|
||||
email: safeDecrypt(emp.email),
|
||||
phone: safeDecrypt(emp.phone),
|
||||
mobile: safeDecrypt(emp.mobile),
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: decryptedResults })
|
||||
} catch (error) {
|
||||
logger.error('Error searching employees:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
338
backend/src/routes/network.ts
Normale Datei
338
backend/src/routes/network.ts
Normale Datei
@ -0,0 +1,338 @@
|
||||
import { Router } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import crypto from 'crypto'
|
||||
import { syncScheduler } from '../services/syncScheduler'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Initialize network nodes table if not exists
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS network_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
ip_address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
api_key TEXT NOT NULL UNIQUE,
|
||||
type TEXT CHECK(type IN ('admin', 'local')) NOT NULL,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
last_sync TEXT,
|
||||
last_ping TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Initialize sync settings table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
auto_sync_interval TEXT,
|
||||
conflict_resolution TEXT CHECK(conflict_resolution IN ('admin', 'newest', 'manual')),
|
||||
sync_employees INTEGER DEFAULT 1,
|
||||
sync_skills INTEGER DEFAULT 1,
|
||||
sync_users INTEGER DEFAULT 1,
|
||||
sync_settings INTEGER DEFAULT 0,
|
||||
bandwidth_limit INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Get all network nodes
|
||||
router.get('/nodes', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const nodes = db.prepare(`
|
||||
SELECT * FROM network_nodes ORDER BY type DESC, name ASC
|
||||
`).all()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: nodes.map((node: any) => ({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
location: node.location,
|
||||
ipAddress: node.ip_address,
|
||||
port: node.port,
|
||||
apiKey: node.api_key,
|
||||
type: node.type,
|
||||
isOnline: Boolean(node.is_online),
|
||||
lastSync: node.last_sync ? new Date(node.last_sync) : null,
|
||||
lastPing: node.last_ping ? new Date(node.last_ping) : null,
|
||||
createdAt: new Date(node.created_at),
|
||||
updatedAt: new Date(node.updated_at)
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create new network node
|
||||
router.post('/nodes', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { name, location, ipAddress, port, type } = req.body
|
||||
const nodeId = uuidv4()
|
||||
const apiKey = generateApiKey()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO network_nodes (
|
||||
id, name, location, ip_address, port, api_key, type,
|
||||
is_online, created_at, updated_at, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nodeId, name, location || null, ipAddress, port, apiKey, type,
|
||||
0, now, now, req.user!.id
|
||||
)
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
id: nodeId,
|
||||
apiKey: apiKey
|
||||
},
|
||||
message: 'Network node created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update network node
|
||||
router.put('/nodes/:id', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, location, ipAddress, port } = req.body
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE network_nodes
|
||||
SET name = ?, location = ?, ip_address = ?, port = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name, location || null, ipAddress, port,
|
||||
now, req.user!.id, id
|
||||
)
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Network node not found' }
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Network node updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Delete network node
|
||||
router.delete('/nodes/:id', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const result = db.prepare('DELETE FROM network_nodes WHERE id = ?').run(id)
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Network node not found' }
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Network node deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Ping network node
|
||||
router.post('/nodes/:id/ping', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const node = db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(id) as any
|
||||
|
||||
if (!node) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Network node not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Implement actual ping logic
|
||||
// For now, simulate ping
|
||||
const isOnline = Math.random() > 0.2 // 80% chance of being online
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE network_nodes
|
||||
SET is_online = ?, last_ping = ?
|
||||
WHERE id = ?
|
||||
`).run(isOnline ? 1 : 0, now, id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
isOnline,
|
||||
lastPing: now,
|
||||
responseTime: isOnline ? Math.floor(Math.random() * 100) + 10 : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync settings
|
||||
router.get('/sync-settings', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
let settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
|
||||
|
||||
if (!settings) {
|
||||
// Create default settings
|
||||
const now = new Date().toISOString()
|
||||
db.prepare(`
|
||||
INSERT INTO sync_settings (
|
||||
id, auto_sync_interval, conflict_resolution,
|
||||
sync_employees, sync_skills, sync_users, sync_settings,
|
||||
bandwidth_limit, updated_at, updated_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'default', 'disabled', 'admin',
|
||||
1, 1, 1, 0,
|
||||
null, now, 'system'
|
||||
)
|
||||
|
||||
settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
autoSyncInterval: settings.auto_sync_interval,
|
||||
conflictResolution: settings.conflict_resolution,
|
||||
syncEmployees: Boolean(settings.sync_employees),
|
||||
syncSkills: Boolean(settings.sync_skills),
|
||||
syncUsers: Boolean(settings.sync_users),
|
||||
syncSettings: Boolean(settings.sync_settings),
|
||||
bandwidthLimit: settings.bandwidth_limit
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update sync settings
|
||||
router.put('/sync-settings', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const {
|
||||
autoSyncInterval,
|
||||
conflictResolution,
|
||||
syncEmployees,
|
||||
syncSkills,
|
||||
syncUsers,
|
||||
syncSettings,
|
||||
bandwidthLimit
|
||||
} = req.body
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE sync_settings
|
||||
SET auto_sync_interval = ?, conflict_resolution = ?,
|
||||
sync_employees = ?, sync_skills = ?, sync_users = ?, sync_settings = ?,
|
||||
bandwidth_limit = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
autoSyncInterval,
|
||||
conflictResolution,
|
||||
syncEmployees ? 1 : 0,
|
||||
syncSkills ? 1 : 0,
|
||||
syncUsers ? 1 : 0,
|
||||
syncSettings ? 1 : 0,
|
||||
bandwidthLimit || null,
|
||||
now,
|
||||
req.user!.id,
|
||||
'default'
|
||||
)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync settings updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger manual sync
|
||||
router.post('/sync/trigger', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { nodeIds } = req.body
|
||||
|
||||
// TODO: Implement actual sync logic
|
||||
// For now, simulate sync process
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
// Update specific nodes
|
||||
const placeholders = nodeIds.map(() => '?').join(',')
|
||||
db.prepare(`
|
||||
UPDATE network_nodes
|
||||
SET last_sync = ?
|
||||
WHERE id IN (${placeholders})
|
||||
`).run(now, ...nodeIds)
|
||||
} else {
|
||||
// Update all nodes
|
||||
db.prepare(`
|
||||
UPDATE network_nodes
|
||||
SET last_sync = ?
|
||||
`).run(now)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync triggered successfully',
|
||||
data: {
|
||||
syncedAt: now,
|
||||
nodeCount: nodeIds?.length || 'all'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync scheduler status
|
||||
router.get('/sync-status', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const status = syncScheduler.getStatus()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: status
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
function generateApiKey(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export default router
|
||||
700
backend/src/routes/profiles.ts
Normale Datei
700
backend/src/routes/profiles.ts
Normale Datei
@ -0,0 +1,700 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { body, validationResult, query } from 'express-validator'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
// Profile Interface direkt definiert, da shared module noch nicht aktualisiert
|
||||
interface Profile {
|
||||
id: string
|
||||
name: string
|
||||
department?: string
|
||||
location?: string
|
||||
role?: string
|
||||
contacts?: {
|
||||
email?: string
|
||||
phone?: string
|
||||
teams?: string
|
||||
}
|
||||
domains?: string[]
|
||||
tools?: string[]
|
||||
methods?: string[]
|
||||
industryKnowledge?: string[]
|
||||
regulatory?: string[]
|
||||
languages?: { code: string; level: string }[]
|
||||
projects?: { title: string; role?: string; summary?: string; links?: string[] }[]
|
||||
networks?: string[]
|
||||
digitalSkills?: string[]
|
||||
socialSkills?: string[]
|
||||
jobCategory?: string
|
||||
jobTitle?: string
|
||||
jobDesc?: string
|
||||
consentPublicProfile: boolean
|
||||
consentSearchable: boolean
|
||||
updatedAt: string
|
||||
updatedBy: string
|
||||
reviewDueAt?: string
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Hilfsfunktion f\u00fcr Volltextsuche
|
||||
function buildSearchVector(profile: any): string {
|
||||
const parts = [
|
||||
profile.name,
|
||||
profile.role,
|
||||
profile.jobTitle,
|
||||
profile.jobDesc,
|
||||
...(profile.domains || []),
|
||||
...(profile.tools || []),
|
||||
...(profile.methods || []),
|
||||
...(profile.industryKnowledge || []),
|
||||
...(profile.digitalSkills || [])
|
||||
].filter(Boolean)
|
||||
|
||||
return parts.join(' ').toLowerCase()
|
||||
}
|
||||
|
||||
// GET /api/profiles - Suche mit Facetten
|
||||
router.get('/',
|
||||
authenticate,
|
||||
[
|
||||
query('query').optional().trim(),
|
||||
query('dept').optional(),
|
||||
query('loc').optional(),
|
||||
query('jobCat').optional(),
|
||||
query('tools').optional(),
|
||||
query('methods').optional(),
|
||||
query('lang').optional(),
|
||||
query('page').optional().isInt({ min: 1 }).toInt(),
|
||||
query('pageSize').optional().isInt({ min: 1, max: 100 }).toInt(),
|
||||
query('sort').optional().isIn(['recency', 'relevance'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid query parameters', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const {
|
||||
query: searchQuery,
|
||||
dept,
|
||||
loc,
|
||||
jobCat,
|
||||
tools,
|
||||
methods,
|
||||
lang,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sort = 'relevance'
|
||||
} = req.query
|
||||
|
||||
// Basis-Query mit Consent-Pr\u00fcfung
|
||||
let sql = `
|
||||
SELECT DISTINCT p.*
|
||||
FROM profiles p
|
||||
WHERE (p.consent_searchable = 1 OR p.updated_by = ?)
|
||||
`
|
||||
const params: any[] = [req.user!.id]
|
||||
|
||||
// Volltextsuche
|
||||
if (searchQuery) {
|
||||
sql += ` AND p.search_vector LIKE ?`
|
||||
params.push(`%${searchQuery}%`)
|
||||
}
|
||||
|
||||
// Facettenfilter
|
||||
if (dept) {
|
||||
sql += ` AND p.department = ?`
|
||||
params.push(dept)
|
||||
}
|
||||
|
||||
if (loc) {
|
||||
sql += ` AND p.location = ?`
|
||||
params.push(loc)
|
||||
}
|
||||
|
||||
if (jobCat) {
|
||||
sql += ` AND p.job_category = ?`
|
||||
params.push(jobCat)
|
||||
}
|
||||
|
||||
// Tool-Filter
|
||||
if (tools) {
|
||||
const toolList = tools.toString().split(',')
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM profile_tools pt
|
||||
WHERE pt.profile_id = p.id
|
||||
AND pt.tool IN (${toolList.map(() => '?').join(',')})
|
||||
)`
|
||||
params.push(...toolList)
|
||||
}
|
||||
|
||||
// Methoden-Filter
|
||||
if (methods) {
|
||||
const methodList = methods.toString().split(',')
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM profile_methods pm
|
||||
WHERE pm.profile_id = p.id
|
||||
AND pm.method IN (${methodList.map(() => '?').join(',')})
|
||||
)`
|
||||
params.push(...methodList)
|
||||
}
|
||||
|
||||
// Sprachen-Filter
|
||||
if (lang) {
|
||||
const [langCode, level] = lang.toString().split(':')
|
||||
sql += ` AND EXISTS (
|
||||
SELECT 1 FROM profile_languages pl
|
||||
WHERE pl.profile_id = p.id
|
||||
AND pl.code = ?
|
||||
${level ? 'AND pl.level = ?' : ''}
|
||||
)`
|
||||
params.push(langCode)
|
||||
if (level) params.push(level)
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
if (sort === 'recency') {
|
||||
sql += ` ORDER BY p.updated_at DESC`
|
||||
} else {
|
||||
sql += ` ORDER BY p.updated_at DESC` // TODO: Ranking nach Relevanz
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const offset = ((page as number) - 1) * (pageSize as number)
|
||||
sql += ` LIMIT ? OFFSET ?`
|
||||
params.push(pageSize, offset)
|
||||
|
||||
const profiles = db.prepare(sql).all(...params)
|
||||
|
||||
// Lade zus\u00e4tzliche Daten f\u00fcr jedes Profil
|
||||
const enrichedProfiles = profiles.map((profile: any) => {
|
||||
// Lade Arrays
|
||||
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(profile.id).map((r: any) => r.domain)
|
||||
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(profile.id).map((r: any) => r.tool)
|
||||
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(profile.id).map((r: any) => r.method)
|
||||
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(profile.id).map((r: any) => r.knowledge)
|
||||
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(profile.id).map((r: any) => r.regulation)
|
||||
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(profile.id).map((r: any) => r.network)
|
||||
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
|
||||
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
|
||||
|
||||
// Lade Sprachen
|
||||
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(profile.id)
|
||||
|
||||
// Lade Projekte
|
||||
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(profile.id)
|
||||
for (const project of projects as any[]) {
|
||||
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
|
||||
delete project.id
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
contacts: {
|
||||
email: profile.email,
|
||||
phone: profile.phone,
|
||||
teams: profile.teams_link
|
||||
},
|
||||
domains,
|
||||
tools,
|
||||
methods,
|
||||
industryKnowledge,
|
||||
regulatory,
|
||||
networks,
|
||||
digitalSkills,
|
||||
socialSkills,
|
||||
languages,
|
||||
projects,
|
||||
consentPublicProfile: Boolean(profile.consent_public_profile),
|
||||
consentSearchable: Boolean(profile.consent_searchable)
|
||||
}
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: enrichedProfiles,
|
||||
meta: {
|
||||
page,
|
||||
pageSize,
|
||||
total: (db.prepare('SELECT COUNT(DISTINCT id) as count FROM profiles WHERE consent_searchable = 1 OR updated_by = ?').get(req.user!.id) as any)?.count || 0
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// GET /api/profiles/:id - Einzelnes Profil
|
||||
router.get('/:id',
|
||||
authenticate,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const profile = db.prepare(`
|
||||
SELECT * FROM profiles WHERE id = ?
|
||||
`).get(id) as any
|
||||
|
||||
if (!profile) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Profile not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Pr\u00fcfe Zugriffsrechte
|
||||
const isOwner = profile.updated_by === req.user!.id
|
||||
const isAdmin = req.user!.role === 'admin'
|
||||
|
||||
if (!profile.consent_public_profile && !isOwner && !isAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Access denied' }
|
||||
})
|
||||
}
|
||||
|
||||
// Lade zus\u00e4tzliche Daten
|
||||
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(id).map((r: any) => r.domain)
|
||||
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(id).map((r: any) => r.tool)
|
||||
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(id).map((r: any) => r.method)
|
||||
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(id).map((r: any) => r.knowledge)
|
||||
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(id).map((r: any) => r.regulation)
|
||||
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(id).map((r: any) => r.network)
|
||||
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
|
||||
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
|
||||
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(id)
|
||||
|
||||
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(id)
|
||||
for (const project of projects as any[]) {
|
||||
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
|
||||
delete project.id
|
||||
}
|
||||
|
||||
const enrichedProfile = {
|
||||
...profile,
|
||||
contacts: {
|
||||
email: profile.email,
|
||||
phone: profile.phone,
|
||||
teams: profile.teams_link
|
||||
},
|
||||
domains,
|
||||
tools,
|
||||
methods,
|
||||
industryKnowledge,
|
||||
regulatory,
|
||||
networks,
|
||||
digitalSkills,
|
||||
socialSkills,
|
||||
languages,
|
||||
projects,
|
||||
consentPublicProfile: Boolean(profile.consent_public_profile),
|
||||
consentSearchable: Boolean(profile.consent_searchable)
|
||||
}
|
||||
|
||||
res.json({ success: true, data: enrichedProfile })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// POST /api/profiles - Neues Profil erstellen
|
||||
router.post('/',
|
||||
authenticate,
|
||||
[
|
||||
body('name').notEmpty().trim(),
|
||||
body('consentPublicProfile').isBoolean(),
|
||||
body('consentSearchable').isBoolean()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const profileId = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
const profileData = {
|
||||
...req.body,
|
||||
id: profileId,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
updated_by: req.user!.id,
|
||||
review_due_at: reviewDueAt,
|
||||
search_vector: buildSearchVector(req.body)
|
||||
}
|
||||
|
||||
// Transaktion starten
|
||||
const transaction = db.transaction(() => {
|
||||
// Profil einf\u00fcgen
|
||||
db.prepare(`
|
||||
INSERT INTO profiles (
|
||||
id, name, department, location, role, email, phone, teams_link,
|
||||
job_category, job_title, job_desc,
|
||||
consent_public_profile, consent_searchable,
|
||||
created_at, updated_at, updated_by, review_due_at, search_vector
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
profileId,
|
||||
profileData.name,
|
||||
profileData.department || null,
|
||||
profileData.location || null,
|
||||
profileData.role || null,
|
||||
profileData.contacts?.email || null,
|
||||
profileData.contacts?.phone || null,
|
||||
profileData.contacts?.teams || null,
|
||||
profileData.jobCategory || null,
|
||||
profileData.jobTitle || null,
|
||||
profileData.jobDesc || null,
|
||||
profileData.consentPublicProfile ? 1 : 0,
|
||||
profileData.consentSearchable ? 1 : 0,
|
||||
now,
|
||||
now,
|
||||
req.user!.id,
|
||||
reviewDueAt,
|
||||
profileData.search_vector
|
||||
)
|
||||
|
||||
// Arrays einf\u00fcgen
|
||||
const insertArray = (table: string, field: string, values: string[]) => {
|
||||
if (values && values.length > 0) {
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
|
||||
for (const value of values) {
|
||||
stmt.run(profileId, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
insertArray('profile_domains', 'domain', profileData.domains)
|
||||
insertArray('profile_tools', 'tool', profileData.tools)
|
||||
insertArray('profile_methods', 'method', profileData.methods)
|
||||
insertArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
|
||||
insertArray('profile_regulatory', 'regulation', profileData.regulatory)
|
||||
insertArray('profile_networks', 'network', profileData.networks)
|
||||
insertArray('profile_digital_skills', 'skill', profileData.digitalSkills)
|
||||
insertArray('profile_social_skills', 'skill', profileData.socialSkills)
|
||||
|
||||
// Sprachen einf\u00fcgen
|
||||
if (profileData.languages && profileData.languages.length > 0) {
|
||||
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
|
||||
for (const lang of profileData.languages) {
|
||||
stmt.run(profileId, lang.code, lang.level)
|
||||
}
|
||||
}
|
||||
|
||||
// Projekte einf\u00fcgen
|
||||
if (profileData.projects && profileData.projects.length > 0) {
|
||||
for (const project of profileData.projects) {
|
||||
const projectId = uuidv4()
|
||||
db.prepare(`
|
||||
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(projectId, profileId, project.title, project.role || null, project.summary || null, now)
|
||||
|
||||
if (project.links && project.links.length > 0) {
|
||||
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
|
||||
for (const link of project.links) {
|
||||
linkStmt.run(projectId, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-Log
|
||||
db.prepare(`
|
||||
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
'profile',
|
||||
profileId,
|
||||
'create',
|
||||
req.user!.id,
|
||||
JSON.stringify(profileData),
|
||||
now
|
||||
)
|
||||
})
|
||||
|
||||
transaction()
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { id: profileId },
|
||||
message: 'Profile created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// PUT /api/profiles/:id - Profil aktualisieren
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const now = new Date().toISOString()
|
||||
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
// Pr\u00fcfe ob Profil existiert und Berechtigungen
|
||||
const existing = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Profile not found' }
|
||||
})
|
||||
}
|
||||
|
||||
const isOwner = existing.updated_by === req.user!.id
|
||||
const isAdmin = req.user!.role === 'admin'
|
||||
|
||||
if (!isOwner && !isAdmin) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Access denied' }
|
||||
})
|
||||
}
|
||||
|
||||
const profileData = {
|
||||
...req.body,
|
||||
updated_at: now,
|
||||
updated_by: req.user!.id,
|
||||
review_due_at: reviewDueAt,
|
||||
search_vector: buildSearchVector(req.body)
|
||||
}
|
||||
|
||||
// Transaktion f\u00fcr Update
|
||||
const transaction = db.transaction(() => {
|
||||
// Profil aktualisieren
|
||||
db.prepare(`
|
||||
UPDATE profiles SET
|
||||
name = ?, department = ?, location = ?, role = ?,
|
||||
email = ?, phone = ?, teams_link = ?,
|
||||
job_category = ?, job_title = ?, job_desc = ?,
|
||||
consent_public_profile = ?, consent_searchable = ?,
|
||||
updated_at = ?, updated_by = ?, review_due_at = ?, search_vector = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
profileData.name,
|
||||
profileData.department || null,
|
||||
profileData.location || null,
|
||||
profileData.role || null,
|
||||
profileData.contacts?.email || null,
|
||||
profileData.contacts?.phone || null,
|
||||
profileData.contacts?.teams || null,
|
||||
profileData.jobCategory || null,
|
||||
profileData.jobTitle || null,
|
||||
profileData.jobDesc || null,
|
||||
profileData.consentPublicProfile ? 1 : 0,
|
||||
profileData.consentSearchable ? 1 : 0,
|
||||
now,
|
||||
req.user!.id,
|
||||
reviewDueAt,
|
||||
profileData.search_vector,
|
||||
id
|
||||
)
|
||||
|
||||
// Arrays aktualisieren (l\u00f6schen und neu einf\u00fcgen)
|
||||
const updateArray = (table: string, field: string, values: string[]) => {
|
||||
db.prepare(`DELETE FROM ${table} WHERE profile_id = ?`).run(id)
|
||||
if (values && values.length > 0) {
|
||||
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
|
||||
for (const value of values) {
|
||||
stmt.run(id, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateArray('profile_domains', 'domain', profileData.domains)
|
||||
updateArray('profile_tools', 'tool', profileData.tools)
|
||||
updateArray('profile_methods', 'method', profileData.methods)
|
||||
updateArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
|
||||
updateArray('profile_regulatory', 'regulation', profileData.regulatory)
|
||||
updateArray('profile_networks', 'network', profileData.networks)
|
||||
updateArray('profile_digital_skills', 'skill', profileData.digitalSkills)
|
||||
updateArray('profile_social_skills', 'skill', profileData.socialSkills)
|
||||
|
||||
// Sprachen aktualisieren
|
||||
db.prepare('DELETE FROM profile_languages WHERE profile_id = ?').run(id)
|
||||
if (profileData.languages && profileData.languages.length > 0) {
|
||||
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
|
||||
for (const lang of profileData.languages) {
|
||||
stmt.run(id, lang.code, lang.level)
|
||||
}
|
||||
}
|
||||
|
||||
// Projekte aktualisieren
|
||||
db.prepare('DELETE FROM profile_projects WHERE profile_id = ?').run(id)
|
||||
if (profileData.projects && profileData.projects.length > 0) {
|
||||
for (const project of profileData.projects) {
|
||||
const projectId = uuidv4()
|
||||
db.prepare(`
|
||||
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(projectId, id, project.title, project.role || null, project.summary || null, now)
|
||||
|
||||
if (project.links && project.links.length > 0) {
|
||||
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
|
||||
for (const link of project.links) {
|
||||
linkStmt.run(projectId, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-Log
|
||||
db.prepare(`
|
||||
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
'profile',
|
||||
id,
|
||||
'update',
|
||||
req.user!.id,
|
||||
JSON.stringify({ before: existing, after: profileData }),
|
||||
now
|
||||
)
|
||||
})
|
||||
|
||||
transaction()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Profile updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// GET /api/profiles/facets - Facettenwerte f\u00fcr Filter
|
||||
router.get('/facets',
|
||||
authenticate,
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const facets = {
|
||||
departments: db.prepare('SELECT DISTINCT department FROM profiles WHERE department IS NOT NULL AND consent_searchable = 1 ORDER BY department').all().map((r: any) => r.department),
|
||||
locations: db.prepare('SELECT DISTINCT location FROM profiles WHERE location IS NOT NULL AND consent_searchable = 1 ORDER BY location').all().map((r: any) => r.location),
|
||||
jobCategories: db.prepare('SELECT DISTINCT job_category FROM profiles WHERE job_category IS NOT NULL AND consent_searchable = 1 ORDER BY job_category').all().map((r: any) => r.job_category),
|
||||
tools: db.prepare('SELECT DISTINCT tool FROM profile_tools pt JOIN profiles p ON pt.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY tool').all().map((r: any) => r.tool),
|
||||
methods: db.prepare('SELECT DISTINCT method FROM profile_methods pm JOIN profiles p ON pm.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY method').all().map((r: any) => r.method),
|
||||
languages: db.prepare('SELECT DISTINCT code, level FROM profile_languages pl JOIN profiles p ON pl.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY code, level').all()
|
||||
}
|
||||
|
||||
res.json({ success: true, data: facets })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// GET /api/profiles/reminders/overdue - Veraltete Profile (Admin)
|
||||
router.get('/reminders/overdue',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const overdueProfiles = db.prepare(`
|
||||
SELECT id, name, department, updated_at, review_due_at
|
||||
FROM profiles
|
||||
WHERE review_due_at < ?
|
||||
ORDER BY review_due_at ASC
|
||||
`).all(now)
|
||||
|
||||
res.json({ success: true, data: overdueProfiles })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// POST /api/profiles/export - Export-Funktionalit\u00e4t
|
||||
router.post('/export',
|
||||
authenticate,
|
||||
authorize('admin', 'superuser'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { filter, format = 'json' } = req.body
|
||||
|
||||
// Baue Query basierend auf Filter
|
||||
let sql = `SELECT * FROM profiles WHERE consent_searchable = 1`
|
||||
const params: any[] = []
|
||||
|
||||
if (filter?.department) {
|
||||
sql += ` AND department = ?`
|
||||
params.push(filter.department)
|
||||
}
|
||||
|
||||
if (filter?.location) {
|
||||
sql += ` AND location = ?`
|
||||
params.push(filter.location)
|
||||
}
|
||||
|
||||
const profiles = db.prepare(sql).all(...params)
|
||||
|
||||
if (format === 'csv') {
|
||||
// CSV-Export
|
||||
const csv = [
|
||||
'Name,Department,Location,Role,Email,Phone,Job Category,Job Title',
|
||||
...profiles.map((p: any) =>
|
||||
`"${p.name}","${p.department || ''}","${p.location || ''}","${p.role || ''}","${p.email || ''}","${p.phone || ''}","${p.job_category || ''}","${p.job_title || ''}"`
|
||||
)
|
||||
].join('\n')
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv')
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=profiles.csv')
|
||||
res.send(csv)
|
||||
} else {
|
||||
// JSON-Export
|
||||
res.json({ success: true, data: profiles })
|
||||
}
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// POST /api/profiles/tags/suggest - Autocomplete f\u00fcr Tags
|
||||
router.post('/tags/suggest',
|
||||
authenticate,
|
||||
[
|
||||
body('category').notEmpty(),
|
||||
body('query').notEmpty()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { category, query } = req.body
|
||||
|
||||
const suggestions = db.prepare(`
|
||||
SELECT value, description
|
||||
FROM controlled_vocabulary
|
||||
WHERE category = ? AND value LIKE ? AND is_active = 1
|
||||
ORDER BY value
|
||||
LIMIT 10
|
||||
`).all(category, `${query}%`)
|
||||
|
||||
res.json({ success: true, data: suggestions })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
87
backend/src/routes/settings.ts
Normale Datei
87
backend/src/routes/settings.ts
Normale Datei
@ -0,0 +1,87 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission } from '../middleware/roleAuth'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get system settings
|
||||
router.get('/', authenticate, requirePermission('settings:read'), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const settings = db.prepare('SELECT key, value, description FROM system_settings').all() as any[]
|
||||
|
||||
const settingsMap = settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = {
|
||||
value: setting.value === 'true' || setting.value === 'false' ? setting.value === 'true' : setting.value,
|
||||
description: setting.description
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
res.json({ success: true, data: settingsMap })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching settings:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update system setting
|
||||
router.put('/:key',
|
||||
authenticate,
|
||||
requirePermission('settings:update'),
|
||||
[
|
||||
body('value').notEmpty()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { key } = req.params
|
||||
const { value } = req.body
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Convert boolean to string for storage
|
||||
const stringValue = typeof value === 'boolean' ? value.toString() : value
|
||||
|
||||
// Check if setting exists
|
||||
const existingSetting = db.prepare('SELECT key FROM system_settings WHERE key = ?').get(key)
|
||||
|
||||
if (existingSetting) {
|
||||
// Update existing setting
|
||||
db.prepare(`
|
||||
UPDATE system_settings SET value = ?, updated_at = ?, updated_by = ?
|
||||
WHERE key = ?
|
||||
`).run(stringValue, now, req.user!.id, key)
|
||||
|
||||
logger.info(`System setting ${key} updated to ${stringValue} by user ${req.user!.username}`)
|
||||
} else {
|
||||
// Create new setting
|
||||
db.prepare(`
|
||||
INSERT INTO system_settings (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(key, stringValue, now, req.user!.id)
|
||||
|
||||
logger.info(`System setting ${key} created with value ${stringValue} by user ${req.user!.username}`)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Setting updated successfully',
|
||||
data: { key, value: stringValue }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error updating setting:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
508
backend/src/routes/skills.ts
Normale Datei
508
backend/src/routes/skills.ts
Normale Datei
@ -0,0 +1,508 @@
|
||||
import { Router } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { DEFAULT_SKILLS } from '@skillmate/shared'
|
||||
import { syncService } from '../services/syncService'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get all skills
|
||||
router.get('/', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const skills = db.prepare(`
|
||||
SELECT id, name, category, description, requires_certification, expires_after
|
||||
FROM skills
|
||||
ORDER BY category, name
|
||||
`).all()
|
||||
|
||||
res.json({ success: true, data: skills })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get skills hierarchy (categories -> subcategories -> skills)
|
||||
router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
// Ensure vocabulary table exists (defensive)
|
||||
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)
|
||||
);
|
||||
`)
|
||||
|
||||
let cats = [] as any[]
|
||||
let subs = [] as any[]
|
||||
try {
|
||||
cats = db.prepare(`
|
||||
SELECT value AS id, COALESCE(description, value) AS name
|
||||
FROM controlled_vocabulary
|
||||
WHERE category = 'skill_category'
|
||||
ORDER BY id
|
||||
`).all() as any[]
|
||||
|
||||
subs = db.prepare(`
|
||||
SELECT value AS key, COALESCE(description, value) AS name
|
||||
FROM controlled_vocabulary
|
||||
WHERE category = 'skill_subcategory'
|
||||
ORDER BY key
|
||||
`).all() as any[]
|
||||
} catch {}
|
||||
|
||||
const subByCat: Record<string, { id: string; name: string }[]> = {}
|
||||
for (const s of subs) {
|
||||
const [catId, subId] = String(s.key).split('.')
|
||||
if (!catId || !subId) continue
|
||||
if (!subByCat[catId]) subByCat[catId] = []
|
||||
subByCat[catId].push({ id: subId, name: s.name })
|
||||
}
|
||||
|
||||
const skills = db.prepare(`
|
||||
SELECT id, name, category, description FROM skills
|
||||
`).all() as any[]
|
||||
const byKey: Record<string, { id: string; name: string; description?: string | null }[]> = {}
|
||||
for (const s of skills) {
|
||||
const key = s.category
|
||||
if (!key) continue
|
||||
if (!byKey[key]) byKey[key] = []
|
||||
byKey[key].push({ id: s.id, name: s.name, description: s.description || null })
|
||||
}
|
||||
|
||||
let hierarchy = cats.map(cat => ({
|
||||
id: cat.id,
|
||||
name: cat.name,
|
||||
subcategories: (subByCat[cat.id] || []).map(sc => ({
|
||||
id: sc.id,
|
||||
name: sc.name,
|
||||
skills: byKey[`${cat.id}.${sc.id}`] || []
|
||||
}))
|
||||
}))
|
||||
|
||||
// Fallback: if vocabulary is empty, derive from existing skills
|
||||
if (hierarchy.length === 0) {
|
||||
const seenCats = new Map<string, { id: string; name: string; subs: Map<string, { id: string; name: string; skills: any[] }> }>()
|
||||
for (const s of skills) {
|
||||
const [catId, subId] = String(s.category || '').split('.')
|
||||
if (!catId || !subId) continue
|
||||
if (!seenCats.has(catId)) seenCats.set(catId, { id: catId, name: catId, subs: new Map() })
|
||||
const cat = seenCats.get(catId)!
|
||||
if (!cat.subs.has(subId)) cat.subs.set(subId, { id: subId, name: subId, skills: [] })
|
||||
cat.subs.get(subId)!.skills.push({ id: s.id, name: s.name, description: s.description || null })
|
||||
}
|
||||
hierarchy = Array.from(seenCats.values()).map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
subcategories: Array.from(c.subs.values())
|
||||
}))
|
||||
}
|
||||
|
||||
res.json({ success: true, data: hierarchy })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize default skills (admin only)
|
||||
router.post('/initialize',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const insertSkill = db.prepare(`
|
||||
INSERT OR IGNORE INTO skills (id, name, category, description, requires_certification, expires_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
|
||||
let count = 0
|
||||
for (const [category, skills] of Object.entries(DEFAULT_SKILLS)) {
|
||||
for (const skillName of skills) {
|
||||
const result = insertSkill.run(
|
||||
uuidv4(),
|
||||
skillName,
|
||||
category,
|
||||
null,
|
||||
category === 'certificates' || category === 'weapons' ? 1 : 0,
|
||||
category === 'certificates' ? 36 : null // 3 years for certificates
|
||||
)
|
||||
if (result.changes > 0) count++
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${count} skills initialized successfully`
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Create custom skill (admin/poweruser only)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
authorize('admin', 'superuser'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id, name, category, description, requiresCertification, expiresAfter } = req.body
|
||||
|
||||
// Optional custom id to keep stable
|
||||
let skillId = id && typeof id === 'string' && id.length > 0 ? id : uuidv4()
|
||||
const exists = db.prepare('SELECT id FROM skills WHERE id = ?').get(skillId)
|
||||
if (exists) {
|
||||
return res.status(409).json({ success: false, error: { message: 'Skill ID already exists' } })
|
||||
}
|
||||
|
||||
// Validate category refers to existing subcategory
|
||||
if (!category || String(category).indexOf('.') === -1) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Category must be catId.subId' } })
|
||||
}
|
||||
const subExists = db
|
||||
.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?')
|
||||
.get('skill_subcategory', category)
|
||||
if (!subExists) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Unknown subcategory' } })
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
skillId,
|
||||
name,
|
||||
category,
|
||||
description || null,
|
||||
requiresCertification ? 1 : 0,
|
||||
expiresAfter || null
|
||||
)
|
||||
|
||||
// Queue sync for new skill
|
||||
const newSkill = {
|
||||
id: skillId,
|
||||
name,
|
||||
category,
|
||||
description: description || null,
|
||||
requiresCertification: requiresCertification || false,
|
||||
expiresAfter: expiresAfter || null
|
||||
}
|
||||
|
||||
await syncService.queueSync('skills', 'create', newSkill)
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { id: skillId },
|
||||
message: 'Skill created successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update skill (admin only)
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, category, description } = req.body
|
||||
|
||||
// Check if skill exists
|
||||
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Skill not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Validate new category if provided
|
||||
if (category) {
|
||||
if (String(category).indexOf('.') === -1) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Category must be catId.subId' } })
|
||||
}
|
||||
const subExists = db
|
||||
.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?')
|
||||
.get('skill_subcategory', category)
|
||||
if (!subExists) {
|
||||
return res.status(400).json({ success: false, error: { message: 'Unknown subcategory' } })
|
||||
}
|
||||
}
|
||||
|
||||
// Update skill (partial)
|
||||
db.prepare(`
|
||||
UPDATE skills SET
|
||||
name = COALESCE(?, name),
|
||||
category = COALESCE(?, category),
|
||||
description = COALESCE(?, description)
|
||||
WHERE id = ?
|
||||
`).run(name || null, category || null, (description ?? null), id)
|
||||
|
||||
// Queue sync for updated skill
|
||||
const updatedSkill = {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
description: description || null
|
||||
}
|
||||
|
||||
await syncService.queueSync('skills', 'update', updatedSkill)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Skill updated successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete skill (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Skill not found' } })
|
||||
}
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(id)
|
||||
db.prepare('DELETE FROM skills WHERE id = ?').run(id)
|
||||
await syncService.queueSync('skills', 'delete', { id })
|
||||
res.json({ success: true, message: 'Skill deleted successfully' })
|
||||
} catch (error) { next(error) }
|
||||
}
|
||||
)
|
||||
|
||||
// Category management
|
||||
router.post('/categories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id, name } = req.body
|
||||
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
|
||||
const now = new Date().toISOString()
|
||||
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', id)
|
||||
if (exists) return res.status(409).json({ success: false, error: { message: 'Category already exists' } })
|
||||
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(uuidv4(), 'skill_category', id, name, 1, now)
|
||||
res.status(201).json({ success: true, message: 'Category created' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.put('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const { name } = req.body
|
||||
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
|
||||
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
|
||||
.run(name, 'skill_category', catId)
|
||||
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
|
||||
res.json({ success: true, message: 'Category updated' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.delete('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const tx = db.transaction(() => {
|
||||
const subs = db.prepare('SELECT value FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').all('skill_subcategory', `${catId}.%`) as any[]
|
||||
for (const s of subs) {
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(s.value)
|
||||
db.prepare('DELETE FROM skills WHERE category = ?').run(s.value)
|
||||
}
|
||||
db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').run('skill_subcategory', `${catId}.%`)
|
||||
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_category', catId)
|
||||
return del.changes
|
||||
})
|
||||
const changes = tx()
|
||||
if (!changes) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
|
||||
res.json({ success: true, message: 'Category deleted' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Subcategory management
|
||||
router.post('/categories/:catId/subcategories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const { id, name } = req.body
|
||||
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
|
||||
const cat = db.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', catId)
|
||||
if (!cat) return res.status(400).json({ success: false, error: { message: 'Category does not exist' } })
|
||||
const key = `${catId}.${id}`
|
||||
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_subcategory', key)
|
||||
if (exists) return res.status(409).json({ success: false, error: { message: 'Subcategory already exists' } })
|
||||
const now = new Date().toISOString()
|
||||
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(uuidv4(), 'skill_subcategory', key, name, 1, now)
|
||||
res.status(201).json({ success: true, message: 'Subcategory created' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.put('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId, subId } = req.params
|
||||
const { name } = req.body
|
||||
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
|
||||
const key = `${catId}.${subId}`
|
||||
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
|
||||
.run(name, 'skill_subcategory', key)
|
||||
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
|
||||
res.json({ success: true, message: 'Subcategory updated' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.delete('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId, subId } = req.params
|
||||
const key = `${catId}.${subId}`
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(key)
|
||||
db.prepare('DELETE FROM skills WHERE category = ?').run(key)
|
||||
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_subcategory', key)
|
||||
return del.changes
|
||||
})
|
||||
const changes = tx()
|
||||
if (!changes) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
|
||||
res.json({ success: true, message: 'Subcategory deleted' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Delete skill (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// Check if skill exists
|
||||
const existing = db.prepare('SELECT id FROM skills WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Skill not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Delete skill and related data
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(id)
|
||||
db.prepare('DELETE FROM skills WHERE id = ?').run(id)
|
||||
|
||||
// Queue sync for deleted skill
|
||||
await syncService.queueSync('skills', 'delete', { id })
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Skill deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Category management
|
||||
router.post('/categories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id, name } = req.body
|
||||
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
|
||||
const now = new Date().toISOString()
|
||||
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?')
|
||||
.get('skill_category', id)
|
||||
if (exists) return res.status(409).json({ success: false, error: { message: 'Category already exists' } })
|
||||
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(uuidv4(), 'skill_category', id, name, 1, now)
|
||||
res.status(201).json({ success: true, message: 'Category created' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.put('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const { name } = req.body
|
||||
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
|
||||
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
|
||||
.run(name, 'skill_category', catId)
|
||||
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
|
||||
res.json({ success: true, message: 'Category updated' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.delete('/categories/:catId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const tx = db.transaction(() => {
|
||||
// Delete skills under all subcategories
|
||||
const subs = db.prepare('SELECT value FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').all('skill_subcategory', `${catId}.%`) as any[]
|
||||
for (const s of subs) {
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(s.value)
|
||||
db.prepare('DELETE FROM skills WHERE category = ?').run(s.value)
|
||||
}
|
||||
db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value LIKE ?').run('skill_subcategory', `${catId}.%`)
|
||||
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_category', catId)
|
||||
return del.changes
|
||||
})
|
||||
const changes = tx()
|
||||
if (!changes) return res.status(404).json({ success: false, error: { message: 'Category not found' } })
|
||||
res.json({ success: true, message: 'Category deleted' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
// Subcategory management
|
||||
router.post('/categories/:catId/subcategories', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId } = req.params
|
||||
const { id, name } = req.body
|
||||
if (!id || !name) return res.status(400).json({ success: false, error: { message: 'id and name required' } })
|
||||
const cat = db.prepare('SELECT 1 FROM controlled_vocabulary WHERE category = ? AND value = ?').get('skill_category', catId)
|
||||
if (!cat) return res.status(400).json({ success: false, error: { message: 'Category does not exist' } })
|
||||
const key = `${catId}.${id}`
|
||||
const exists = db.prepare('SELECT id FROM controlled_vocabulary WHERE category = ? AND value = ?')
|
||||
.get('skill_subcategory', key)
|
||||
if (exists) return res.status(409).json({ success: false, error: { message: 'Subcategory already exists' } })
|
||||
const now = new Date().toISOString()
|
||||
db.prepare('INSERT INTO controlled_vocabulary (id, category, value, description, is_active, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(uuidv4(), 'skill_subcategory', key, name, 1, now)
|
||||
res.status(201).json({ success: true, message: 'Subcategory created' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.put('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId, subId } = req.params
|
||||
const { name } = req.body
|
||||
if (!name) return res.status(400).json({ success: false, error: { message: 'name required' } })
|
||||
const key = `${catId}.${subId}`
|
||||
const updated = db.prepare('UPDATE controlled_vocabulary SET description = ? WHERE category = ? AND value = ?')
|
||||
.run(name, 'skill_subcategory', key)
|
||||
if (updated.changes === 0) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
|
||||
res.json({ success: true, message: 'Subcategory updated' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
router.delete('/categories/:catId/subcategories/:subId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { catId, subId } = req.params
|
||||
const key = `${catId}.${subId}`
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id IN (SELECT id FROM skills WHERE category = ?)').run(key)
|
||||
db.prepare('DELETE FROM skills WHERE category = ?').run(key)
|
||||
const del = db.prepare('DELETE FROM controlled_vocabulary WHERE category = ? AND value = ?').run('skill_subcategory', key)
|
||||
return del.changes
|
||||
})
|
||||
const changes = tx()
|
||||
if (!changes) return res.status(404).json({ success: false, error: { message: 'Subcategory not found' } })
|
||||
res.json({ success: true, message: 'Subcategory deleted' })
|
||||
} catch (error) { next(error) }
|
||||
})
|
||||
|
||||
export default router
|
||||
123
backend/src/routes/sync.ts
Normale Datei
123
backend/src/routes/sync.ts
Normale Datei
@ -0,0 +1,123 @@
|
||||
import { Router } from 'express'
|
||||
import { authenticate, authorize } from '../middleware/auth'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { logger } from '../utils/logger'
|
||||
import type { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Receive sync data from another node
|
||||
router.post('/receive', authenticate, async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const nodeId = req.headers['x-node-id'] as string
|
||||
if (!nodeId) {
|
||||
return res.status(400).json({
|
||||
error: { message: 'Missing node ID in headers' }
|
||||
})
|
||||
}
|
||||
|
||||
const result = await syncService.receiveSync(req.body)
|
||||
|
||||
res.json(result)
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync status
|
||||
router.get('/status', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const status = syncService.getSyncStatus()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: status
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Trigger manual sync
|
||||
router.post('/trigger', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
// Run sync in background
|
||||
syncService.triggerSync().catch(error => {
|
||||
logger.error('Background sync failed:', error)
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync triggered successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync with specific node
|
||||
router.post('/sync/:nodeId', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { nodeId } = req.params
|
||||
const result = await syncService.syncWithNode(nodeId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync conflicts
|
||||
router.get('/conflicts', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { db } = await import('../config/database')
|
||||
|
||||
const conflicts = db.prepare(`
|
||||
SELECT * FROM sync_conflicts
|
||||
WHERE resolution_status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
`).all()
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: conflicts
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Resolve sync conflict
|
||||
router.post('/conflicts/:conflictId/resolve', authenticate, authorize('admin'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { conflictId } = req.params
|
||||
const { resolution, data } = req.body
|
||||
const { db } = await import('../config/database')
|
||||
|
||||
// Update conflict status
|
||||
db.prepare(`
|
||||
UPDATE sync_conflicts
|
||||
SET resolution_status = 'resolved',
|
||||
resolved_by = ?,
|
||||
resolved_at = ?
|
||||
WHERE id = ?
|
||||
`).run(req.user!.id, new Date().toISOString(), conflictId)
|
||||
|
||||
// Apply resolution if needed
|
||||
if (resolution === 'apply' && data) {
|
||||
await syncService.receiveSync(data)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Conflict resolved successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
151
backend/src/routes/upload.ts
Normale Datei
151
backend/src/routes/upload.ts
Normale Datei
@ -0,0 +1,151 @@
|
||||
import { Router } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import { db } from '../config/database'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Ensure upload directory exists
|
||||
const uploadDir = path.join(__dirname, '../../uploads/photos')
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Configure multer for photo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir)
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname)
|
||||
const filename = `${uuidv4()}${ext}`
|
||||
cb(null, filename)
|
||||
}
|
||||
})
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase())
|
||||
const mimetype = allowedTypes.test(file.mimetype)
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true)
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Upload employee photo
|
||||
// Only the user themself may change their own photo (no admin/superuser override)
|
||||
router.post('/employee-photo/:employeeId',
|
||||
authenticate,
|
||||
(req: AuthRequest, res, next) => {
|
||||
if (!req.user || req.user.employeeId !== req.params.employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Only the profile owner may change the photo' } })
|
||||
}
|
||||
next()
|
||||
},
|
||||
upload.single('photo'),
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'No file uploaded' }
|
||||
})
|
||||
}
|
||||
|
||||
const { employeeId } = req.params
|
||||
|
||||
// Check if employee exists
|
||||
const employee = db.prepare('SELECT id, photo FROM employees WHERE id = ?').get(employeeId) as any
|
||||
if (!employee) {
|
||||
// Delete uploaded file
|
||||
fs.unlinkSync(req.file.path)
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Delete old photo if exists
|
||||
if (employee.photo) {
|
||||
const oldPhotoPath = path.join(uploadDir, path.basename(employee.photo))
|
||||
if (fs.existsSync(oldPhotoPath)) {
|
||||
fs.unlinkSync(oldPhotoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Update employee photo URL
|
||||
const photoUrl = `/uploads/photos/${req.file.filename}`
|
||||
db.prepare('UPDATE employees SET photo = ?, updated_at = ?, updated_by = ? WHERE id = ?')
|
||||
.run(photoUrl, new Date().toISOString(), req.user!.id, employeeId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { photoUrl },
|
||||
message: 'Photo uploaded successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
// Clean up uploaded file on error
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path)
|
||||
}
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete employee photo
|
||||
router.delete('/employee-photo/:employeeId',
|
||||
authenticate,
|
||||
(req: AuthRequest, res, next) => {
|
||||
if (!req.user || req.user.employeeId !== req.params.employeeId) {
|
||||
return res.status(403).json({ success: false, error: { message: 'Only the profile owner may delete the photo' } })
|
||||
}
|
||||
next()
|
||||
},
|
||||
async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { employeeId } = req.params
|
||||
|
||||
// Get employee photo
|
||||
const employee = db.prepare('SELECT id, photo FROM employees WHERE id = ?').get(employeeId) as any
|
||||
if (!employee) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'Employee not found' }
|
||||
})
|
||||
}
|
||||
|
||||
if (employee.photo) {
|
||||
const photoPath = path.join(uploadDir, path.basename(employee.photo))
|
||||
if (fs.existsSync(photoPath)) {
|
||||
fs.unlinkSync(photoPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear photo from database
|
||||
db.prepare('UPDATE employees SET photo = NULL, updated_at = ?, updated_by = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), req.user!.id, employeeId)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Photo deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
311
backend/src/routes/users.ts
Normale Datei
311
backend/src/routes/users.ts
Normale Datei
@ -0,0 +1,311 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { db } from '../config/database'
|
||||
import { authenticate, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission, requireAdminPanel } from '../middleware/roleAuth'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`).all()
|
||||
|
||||
const formattedUsers: User[] = users.map((user: any) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role as UserRole,
|
||||
employeeId: user.employee_id || undefined,
|
||||
lastLogin: user.last_login ? new Date(user.last_login) : undefined,
|
||||
isActive: Boolean(user.is_active),
|
||||
createdAt: new Date(user.created_at),
|
||||
updatedAt: new Date(user.updated_at)
|
||||
}))
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: formattedUsers
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Get user by ID
|
||||
router.get('/:id', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(id)
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
const userRecord = user as any
|
||||
const formattedUser: User = {
|
||||
id: userRecord.id,
|
||||
username: userRecord.username,
|
||||
email: userRecord.email,
|
||||
role: userRecord.role as UserRole,
|
||||
employeeId: userRecord.employee_id || undefined,
|
||||
lastLogin: userRecord.last_login ? new Date(userRecord.last_login) : undefined,
|
||||
isActive: Boolean(userRecord.is_active),
|
||||
createdAt: new Date(userRecord.created_at),
|
||||
updatedAt: new Date(userRecord.updated_at)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: formattedUser
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Create new user (admin only)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requirePermission('users:create'),
|
||||
[
|
||||
body('username').notEmpty().trim().isLength({ min: 3 }),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('role').isIn(['admin', 'superuser', 'user']),
|
||||
body('employeeId').optional().isString()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors.array()
|
||||
})
|
||||
}
|
||||
|
||||
const { username, email, password, role, employeeId } = req.body
|
||||
|
||||
// Check if username already exists
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(username)
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'Username already exists' })
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
|
||||
if (existingEmail) {
|
||||
return res.status(409).json({ error: 'Email already exists' })
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = bcrypt.hashSync(password, 10)
|
||||
const now = new Date().toISOString()
|
||||
const userId = uuidv4()
|
||||
|
||||
// Enforce role policy: only admins can set role other than 'user'
|
||||
const assignedRole = (req.user?.role === 'admin') ? role : 'user'
|
||||
|
||||
// Insert user
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
username,
|
||||
email,
|
||||
hashedPassword,
|
||||
assignedRole,
|
||||
employeeId || null,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
const newUser: User = {
|
||||
id: userId,
|
||||
username,
|
||||
email,
|
||||
role: assignedRole as UserRole,
|
||||
employeeId: employeeId || undefined,
|
||||
isActive: true,
|
||||
createdAt: new Date(now),
|
||||
updatedAt: new Date(now)
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: newUser
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update user (admin only)
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
[
|
||||
body('username').optional().trim().isLength({ min: 3 }),
|
||||
body('email').optional().isEmail().normalizeEmail(),
|
||||
body('password').optional().isLength({ min: 6 }),
|
||||
body('role').optional().isIn(['admin', 'superuser', 'user']),
|
||||
body('employeeId').optional().isString(),
|
||||
body('isActive').optional().isBoolean()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors.array()
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { username, email, password, role, employeeId, isActive } = req.body
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(id)
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
// Prepare update fields
|
||||
const updates = []
|
||||
const values = []
|
||||
|
||||
if (username) {
|
||||
// Check if new username is already taken (by another user)
|
||||
const usernameCheck = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, id)
|
||||
if (usernameCheck) {
|
||||
return res.status(409).json({ error: 'Username already exists' })
|
||||
}
|
||||
updates.push('username = ?')
|
||||
values.push(username)
|
||||
}
|
||||
|
||||
if (email) {
|
||||
// Check if new email is already taken (by another user)
|
||||
const emailCheck = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email, id)
|
||||
if (emailCheck) {
|
||||
return res.status(409).json({ error: 'Email already exists' })
|
||||
}
|
||||
updates.push('email = ?')
|
||||
values.push(email)
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const hashedPassword = bcrypt.hashSync(password, 10)
|
||||
updates.push('password = ?')
|
||||
values.push(hashedPassword)
|
||||
}
|
||||
|
||||
if (role) {
|
||||
updates.push('role = ?')
|
||||
values.push(role)
|
||||
}
|
||||
|
||||
if (employeeId !== undefined) {
|
||||
updates.push('employee_id = ?')
|
||||
values.push(employeeId || null)
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
updates.push('is_active = ?')
|
||||
values.push(isActive ? 1 : 0)
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'No fields to update' })
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?')
|
||||
values.push(new Date().toISOString())
|
||||
values.push(id)
|
||||
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`).run(...values)
|
||||
|
||||
// Return updated user
|
||||
const updatedUser = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`).get(id)
|
||||
|
||||
const updatedRecord = updatedUser as any
|
||||
const formattedUser: User = {
|
||||
id: updatedRecord.id,
|
||||
username: updatedRecord.username,
|
||||
email: updatedRecord.email,
|
||||
role: updatedRecord.role as UserRole,
|
||||
employeeId: updatedRecord.employee_id || undefined,
|
||||
lastLogin: updatedRecord.last_login ? new Date(updatedRecord.last_login) : undefined,
|
||||
isActive: Boolean(updatedRecord.is_active),
|
||||
createdAt: new Date(updatedRecord.created_at),
|
||||
updatedAt: new Date(updatedRecord.updated_at)
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: formattedUser
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete user (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requirePermission('users:delete'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// Prevent deleting self
|
||||
if (req.user?.id === id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' })
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT * FROM users WHERE id = ?').get(id)
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
// Delete user
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully'
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
510
backend/src/routes/usersAdmin.ts
Normale Datei
510
backend/src/routes/usersAdmin.ts
Normale Datei
@ -0,0 +1,510 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
|
||||
import { requirePermission } from '../middleware/roleAuth'
|
||||
import { User, UserRole } from '@skillmate/shared'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
import { emailService } from '../services/emailService'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get all users (admin only)
|
||||
router.get('/', authenticate, requirePermission('users:read'), async (req: AuthRequest, res, next) => {
|
||||
try {
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, email, role, employee_id, last_login, is_active, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY username
|
||||
`).all() as any[]
|
||||
|
||||
// Decrypt email addresses (handle decryption failures)
|
||||
const decryptedUsers = users.map(user => {
|
||||
let decryptedEmail = user.email
|
||||
|
||||
// Check if email looks encrypted (encrypted strings are typically longer and contain special characters)
|
||||
if (user.email && user.email.includes('U2FsdGVkX1')) {
|
||||
try {
|
||||
decryptedEmail = FieldEncryption.decrypt(user.email) || user.email
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to decrypt email for user ${user.username}, using raw value`)
|
||||
// For compatibility with old unencrypted data or different encryption keys
|
||||
decryptedEmail = user.email
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
email: decryptedEmail,
|
||||
isActive: Boolean(user.is_active),
|
||||
lastLogin: user.last_login ? new Date(user.last_login) : null,
|
||||
createdAt: new Date(user.created_at),
|
||||
updatedAt: new Date(user.updated_at),
|
||||
employeeId: user.employee_id
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: decryptedUsers })
|
||||
} catch (error) {
|
||||
logger.error('Error fetching users:', error)
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update user role (admin only)
|
||||
router.put('/:id/role',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
[
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { role } = req.body
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'User not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent changing admin user role
|
||||
if (existingUser.username === 'admin' && role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Cannot change admin user role' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update role
|
||||
db.prepare(`
|
||||
UPDATE users SET role = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(role, new Date().toISOString(), id)
|
||||
|
||||
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
|
||||
res.json({ success: true, message: 'Role updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('Error updating user role:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk create users from employees
|
||||
router.post('/bulk-create-from-employees',
|
||||
authenticate,
|
||||
requirePermission('users:create'),
|
||||
[
|
||||
body('employeeIds').isArray({ min: 1 }),
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
|
||||
const results: any[] = []
|
||||
|
||||
for (const employeeId of employeeIds) {
|
||||
try {
|
||||
const emp = encryptedDb.getEmployee(employeeId) as any
|
||||
if (!emp) {
|
||||
results.push({ employeeId, status: 'skipped', reason: 'employee_not_found' })
|
||||
continue
|
||||
}
|
||||
|
||||
const existsByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
|
||||
if (existsByEmployee) {
|
||||
results.push({ employeeId, status: 'skipped', reason: 'user_exists' })
|
||||
continue
|
||||
}
|
||||
|
||||
const baseUsername = (emp.email ? String(emp.email).split('@')[0] : `${emp.first_name}.${emp.last_name}`)
|
||||
.toLowerCase().replace(/\s+/g, '')
|
||||
let finalUsername = baseUsername
|
||||
let i = 1
|
||||
while (db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)) {
|
||||
finalUsername = `${baseUsername}${i++}`
|
||||
}
|
||||
|
||||
const email: string | null = emp.email || null
|
||||
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 12)
|
||||
const now = new Date().toISOString()
|
||||
const userId = uuidv4()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
finalUsername,
|
||||
email ? FieldEncryption.encrypt(email) : null,
|
||||
email ? FieldEncryption.hash(email) : null,
|
||||
hashedPassword,
|
||||
role,
|
||||
employeeId,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
results.push({ employeeId, status: 'created', username: finalUsername, temporaryPassword: tempPassword })
|
||||
} catch (err: any) {
|
||||
logger.error('Bulk create error for employee ' + employeeId, err)
|
||||
results.push({ employeeId, status: 'error', reason: err?.message || 'unknown_error' })
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(201).json({ success: true, data: { results } })
|
||||
} catch (error) {
|
||||
logger.error('Error in bulk create from employees:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update user status (admin only)
|
||||
router.put('/:id/status',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
[
|
||||
body('isActive').isBoolean()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { isActive } = req.body
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'User not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent deactivating admin user
|
||||
if (existingUser.username === 'admin' && !isActive) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Cannot deactivate admin user' }
|
||||
})
|
||||
}
|
||||
|
||||
// Update status
|
||||
db.prepare(`
|
||||
UPDATE users SET is_active = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(isActive ? 1 : 0, new Date().toISOString(), id)
|
||||
|
||||
logger.info(`User status updated: ${existingUser.username} -> ${isActive ? 'active' : 'inactive'}`)
|
||||
res.json({ success: true, message: 'Status updated successfully' })
|
||||
} catch (error) {
|
||||
logger.error('Error updating user status:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Reset user password (admin only)
|
||||
router.post('/:id/reset-password',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
[
|
||||
body('newPassword').optional().isLength({ min: 8 })
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { newPassword } = req.body
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'User not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Generate password
|
||||
const password = newPassword || `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = await bcrypt.hash(password, 12)
|
||||
|
||||
// Update password
|
||||
db.prepare(`
|
||||
UPDATE users SET password = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(hashedPassword, new Date().toISOString(), id)
|
||||
|
||||
logger.info(`Password reset for user: ${existingUser.username}`)
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password reset successfully',
|
||||
data: { temporaryPassword: newPassword ? undefined : password }
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error resetting password:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Delete user (admin only)
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requirePermission('users:delete'),
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { message: 'User not found' }
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent deleting admin user
|
||||
if (existingUser.username === 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { message: 'Cannot delete admin user' }
|
||||
})
|
||||
}
|
||||
|
||||
// Delete user
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id)
|
||||
|
||||
logger.info(`User deleted: ${existingUser.username}`)
|
||||
res.json({ success: true, message: 'User deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error('Error deleting user:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
// Create user account from existing employee (admin only)
|
||||
router.post('/create-from-employee',
|
||||
authenticate,
|
||||
requirePermission('users:create'),
|
||||
[
|
||||
body('employeeId').notEmpty().isString(),
|
||||
body('username').optional().isString().isLength({ min: 3 }),
|
||||
body('role').isIn(['admin', 'superuser', 'user'])
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { employeeId, username, role } = req.body
|
||||
|
||||
// Check employee exists
|
||||
const employee = encryptedDb.getEmployee(employeeId) as any
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { message: 'Employee not found' } })
|
||||
}
|
||||
|
||||
// Check if a user is already linked to this employee
|
||||
const existingByEmployee = db.prepare('SELECT id FROM users WHERE employee_id = ?').get(employeeId)
|
||||
if (existingByEmployee) {
|
||||
return res.status(409).json({ success: false, error: { message: 'User already exists for this employee' } })
|
||||
}
|
||||
|
||||
// Determine email and username
|
||||
const email: string | null = employee.email || null
|
||||
const finalUsername = username || (email ? String(email).split('@')[0] : `${employee.first_name}.${employee.last_name}`)
|
||||
|
||||
// Check username uniqueness
|
||||
const usernameExists = db.prepare('SELECT id FROM users WHERE username = ?').get(finalUsername)
|
||||
if (usernameExists) {
|
||||
return res.status(409).json({ success: false, error: { message: 'Username already exists' } })
|
||||
}
|
||||
|
||||
// Check email uniqueness if available
|
||||
if (email) {
|
||||
const emailHash = FieldEncryption.hash(email)
|
||||
const emailExists = db.prepare('SELECT id FROM users WHERE email_hash = ?').get(emailHash)
|
||||
if (emailExists) {
|
||||
return res.status(409).json({ success: false, error: { message: 'Email already used by another account' } })
|
||||
}
|
||||
}
|
||||
|
||||
// Generate temp password
|
||||
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 12)
|
||||
const now = new Date().toISOString()
|
||||
const userId = uuidv4()
|
||||
|
||||
// Insert user with encrypted email
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
finalUsername,
|
||||
email ? FieldEncryption.encrypt(email) : null,
|
||||
email ? FieldEncryption.hash(email) : null,
|
||||
hashedPassword,
|
||||
role,
|
||||
employeeId,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
|
||||
logger.info(`User created from employee ${employeeId}: ${finalUsername} (${role})`)
|
||||
return res.status(201).json({ success: true, data: { id: userId, username: finalUsername, temporaryPassword: tempPassword } })
|
||||
} catch (error) {
|
||||
logger.error('Error creating user from employee:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Purge users: keep only admin and one specified email (admin only)
|
||||
router.post('/purge',
|
||||
authenticate,
|
||||
authorize('admin'),
|
||||
[
|
||||
body('email').isEmail()
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { email } = req.body as { email: string }
|
||||
const emailHash = FieldEncryption.hash(email)
|
||||
|
||||
const total = (db.prepare('SELECT COUNT(*) as c FROM users').get() as any).c || 0
|
||||
const keep = db.prepare('SELECT id, username FROM users WHERE username = ? OR email_hash = ?').all('admin', emailHash) as any[]
|
||||
|
||||
const delStmt = db.prepare('DELETE FROM users WHERE username <> ? AND (email_hash IS NULL OR email_hash <> ?)')
|
||||
const info = delStmt.run('admin', emailHash)
|
||||
|
||||
logger.warn(`User purge executed by ${req.user?.username}. Kept ${keep.length}, deleted ${info.changes}.`)
|
||||
return res.json({ success: true, data: { total, kept: keep.length, deleted: info.changes } })
|
||||
} catch (error) {
|
||||
logger.error('Error purging users:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Send temporary password via email to user's email
|
||||
router.post('/:id/send-temp-password',
|
||||
authenticate,
|
||||
requirePermission('users:update'),
|
||||
[
|
||||
body('password').notEmpty().isString().isLength({ min: 6 })
|
||||
],
|
||||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const errors = validationResult(req)
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { message: 'Invalid input', details: errors.array() }
|
||||
})
|
||||
}
|
||||
|
||||
const { id } = req.params
|
||||
const { password } = req.body as { password: string }
|
||||
|
||||
// Load user
|
||||
const user = db.prepare('SELECT id, username, email, employee_id FROM users WHERE id = ?').get(id) as any
|
||||
if (!user) {
|
||||
return res.status(404).json({ success: false, error: { message: 'User not found' } })
|
||||
}
|
||||
|
||||
// Decrypt email
|
||||
const email = user.email ? FieldEncryption.decrypt(user.email) : null
|
||||
if (!email) {
|
||||
return res.status(400).json({ success: false, error: { message: 'User has no email address' } })
|
||||
}
|
||||
|
||||
// Optional: get first name for nicer email copy
|
||||
let firstName: string | undefined = undefined
|
||||
if (user.employee_id) {
|
||||
const emp = encryptedDb.getEmployee(user.employee_id)
|
||||
if (emp && emp.first_name) firstName = emp.first_name
|
||||
}
|
||||
|
||||
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
|
||||
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
|
||||
|
||||
if (!emailNotificationsEnabled || !emailService.isServiceEnabled()) {
|
||||
return res.status(503).json({ success: false, error: { message: 'Email service not enabled' } })
|
||||
}
|
||||
|
||||
const sent = await emailService.sendInitialPassword(email, password, firstName)
|
||||
if (!sent) {
|
||||
return res.status(500).json({ success: false, error: { message: 'Failed to send email' } })
|
||||
}
|
||||
|
||||
logger.info(`Temporary password email sent to ${email} for user ${user.username}`)
|
||||
return res.json({ success: true, message: 'Temporary password email sent' })
|
||||
} catch (error) {
|
||||
logger.error('Error sending temporary password email:', error)
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
299
backend/src/routes/workspaces.ts
Normale Datei
299
backend/src/routes/workspaces.ts
Normale Datei
@ -0,0 +1,299 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import { db } from '../config/database'
|
||||
import { authenticateToken, AuthRequest } from '../middleware/auth'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Workspace, WorkspaceFilter } from '@skillmate/shared'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Get all workspaces with optional filters
|
||||
router.get('/', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const filters: WorkspaceFilter = req.query
|
||||
let query = 'SELECT * FROM workspaces WHERE is_active = 1'
|
||||
const params: any[] = []
|
||||
|
||||
if (filters.type) {
|
||||
query += ' AND type = ?'
|
||||
params.push(filters.type)
|
||||
}
|
||||
|
||||
if (filters.floor) {
|
||||
query += ' AND floor = ?'
|
||||
params.push(filters.floor)
|
||||
}
|
||||
|
||||
if (filters.building) {
|
||||
query += ' AND building = ?'
|
||||
params.push(filters.building)
|
||||
}
|
||||
|
||||
if (filters.min_capacity) {
|
||||
query += ' AND capacity >= ?'
|
||||
params.push(filters.min_capacity)
|
||||
}
|
||||
|
||||
query += ' ORDER BY floor, name'
|
||||
|
||||
const workspaces = db.prepare(query).all(...params)
|
||||
|
||||
// Parse equipment JSON
|
||||
const parsedWorkspaces = workspaces.map((ws: any) => ({
|
||||
...ws,
|
||||
equipment: ws.equipment ? JSON.parse(ws.equipment) : [],
|
||||
is_active: Boolean(ws.is_active)
|
||||
}))
|
||||
|
||||
res.json(parsedWorkspaces)
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspaces:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch workspaces' })
|
||||
}
|
||||
})
|
||||
|
||||
// Get available workspaces for a time range
|
||||
router.post('/availability', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { start_time, end_time, type, capacity } = req.body
|
||||
|
||||
if (!start_time || !end_time) {
|
||||
return res.status(400).json({ error: 'Start and end time required' })
|
||||
}
|
||||
|
||||
let query = `
|
||||
SELECT w.* FROM workspaces w
|
||||
WHERE w.is_active = 1
|
||||
AND w.id NOT IN (
|
||||
SELECT DISTINCT workspace_id FROM bookings
|
||||
WHERE status = 'confirmed'
|
||||
AND (
|
||||
(start_time <= ? AND end_time > ?)
|
||||
OR (start_time < ? AND end_time >= ?)
|
||||
OR (start_time >= ? AND end_time <= ?)
|
||||
)
|
||||
)
|
||||
`
|
||||
const params = [start_time, start_time, end_time, end_time, start_time, end_time]
|
||||
|
||||
if (type) {
|
||||
query += ' AND w.type = ?'
|
||||
params.push(type)
|
||||
}
|
||||
|
||||
if (capacity) {
|
||||
query += ' AND w.capacity >= ?'
|
||||
params.push(capacity)
|
||||
}
|
||||
|
||||
query += ' ORDER BY w.floor, w.name'
|
||||
|
||||
const availableWorkspaces = db.prepare(query).all(...params)
|
||||
|
||||
// Parse equipment JSON
|
||||
const parsedWorkspaces = availableWorkspaces.map((ws: any) => ({
|
||||
...ws,
|
||||
equipment: ws.equipment ? JSON.parse(ws.equipment) : [],
|
||||
is_active: Boolean(ws.is_active)
|
||||
}))
|
||||
|
||||
res.json(parsedWorkspaces)
|
||||
} catch (error) {
|
||||
console.error('Error checking availability:', error)
|
||||
res.status(500).json({ error: 'Failed to check availability' })
|
||||
}
|
||||
})
|
||||
|
||||
// Get workspace by ID
|
||||
router.get('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const workspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
|
||||
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: 'Workspace not found' })
|
||||
}
|
||||
|
||||
// Parse equipment JSON
|
||||
const parsedWorkspace = {
|
||||
...(workspace as any),
|
||||
equipment: (workspace as any).equipment ? JSON.parse((workspace as any).equipment) : [],
|
||||
is_active: Boolean((workspace as any).is_active)
|
||||
}
|
||||
|
||||
res.json(parsedWorkspace)
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspace:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch workspace' })
|
||||
}
|
||||
})
|
||||
|
||||
// Create new workspace (admin only)
|
||||
router.post('/', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
floor,
|
||||
building,
|
||||
capacity = 1,
|
||||
equipment = [],
|
||||
position_x,
|
||||
position_y
|
||||
} = req.body
|
||||
|
||||
if (!name || !type || !floor) {
|
||||
return res.status(400).json({ error: 'Name, type, and floor are required' })
|
||||
}
|
||||
|
||||
const id = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO workspaces (
|
||||
id, name, type, floor, building, capacity, equipment,
|
||||
position_x, position_y, is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id, name, type, floor, building, capacity,
|
||||
JSON.stringify(equipment), position_x, position_y,
|
||||
1, now, now
|
||||
)
|
||||
|
||||
const newWorkspace = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(id)
|
||||
|
||||
res.status(201).json({
|
||||
...(newWorkspace as any),
|
||||
equipment: JSON.parse((newWorkspace as any).equipment || '[]'),
|
||||
is_active: Boolean((newWorkspace as any).is_active)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error creating workspace:', error)
|
||||
res.status(500).json({ error: 'Failed to create workspace' })
|
||||
}
|
||||
})
|
||||
|
||||
// Update workspace (admin only)
|
||||
router.put('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' })
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
floor,
|
||||
building,
|
||||
capacity,
|
||||
equipment,
|
||||
position_x,
|
||||
position_y,
|
||||
is_active
|
||||
} = req.body
|
||||
|
||||
const existing = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Workspace not found' })
|
||||
}
|
||||
|
||||
const updates = []
|
||||
const params = []
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.push('name = ?')
|
||||
params.push(name)
|
||||
}
|
||||
if (type !== undefined) {
|
||||
updates.push('type = ?')
|
||||
params.push(type)
|
||||
}
|
||||
if (floor !== undefined) {
|
||||
updates.push('floor = ?')
|
||||
params.push(floor)
|
||||
}
|
||||
if (building !== undefined) {
|
||||
updates.push('building = ?')
|
||||
params.push(building)
|
||||
}
|
||||
if (capacity !== undefined) {
|
||||
updates.push('capacity = ?')
|
||||
params.push(capacity)
|
||||
}
|
||||
if (equipment !== undefined) {
|
||||
updates.push('equipment = ?')
|
||||
params.push(JSON.stringify(equipment))
|
||||
}
|
||||
if (position_x !== undefined) {
|
||||
updates.push('position_x = ?')
|
||||
params.push(position_x)
|
||||
}
|
||||
if (position_y !== undefined) {
|
||||
updates.push('position_y = ?')
|
||||
params.push(position_y)
|
||||
}
|
||||
if (is_active !== undefined) {
|
||||
updates.push('is_active = ?')
|
||||
params.push(is_active ? 1 : 0)
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?')
|
||||
params.push(new Date().toISOString())
|
||||
|
||||
params.push(req.params.id)
|
||||
|
||||
db.prepare(`
|
||||
UPDATE workspaces
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = ?
|
||||
`).run(...params)
|
||||
|
||||
const updated = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id)
|
||||
|
||||
res.json({
|
||||
...(updated as any),
|
||||
equipment: JSON.parse((updated as any).equipment || '[]'),
|
||||
is_active: Boolean((updated as any).is_active)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error updating workspace:', error)
|
||||
res.status(500).json({ error: 'Failed to update workspace' })
|
||||
}
|
||||
})
|
||||
|
||||
// Delete workspace (admin only)
|
||||
router.delete('/:id', authenticateToken, (req: AuthRequest, res: Response) => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' })
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if workspace has active bookings
|
||||
const activeBookings = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM bookings
|
||||
WHERE workspace_id = ? AND status = 'confirmed'
|
||||
AND end_time > ?
|
||||
`).get(req.params.id, new Date().toISOString())
|
||||
|
||||
if ((activeBookings as any).count > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete workspace with active bookings'
|
||||
})
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active = 0
|
||||
db.prepare(`
|
||||
UPDATE workspaces SET is_active = 0, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(new Date().toISOString(), req.params.id)
|
||||
|
||||
res.json({ message: 'Workspace deleted successfully' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting workspace:', error)
|
||||
res.status(500).json({ error: 'Failed to delete workspace' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
138
backend/src/services/emailService.ts
Normale Datei
138
backend/src/services/emailService.ts
Normale Datei
@ -0,0 +1,138 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface EmailConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
auth: {
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string
|
||||
subject: string
|
||||
text?: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
private transporter: nodemailer.Transporter | null = null
|
||||
private isEnabled: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.initializeTransporter()
|
||||
}
|
||||
|
||||
private initializeTransporter() {
|
||||
const emailHost = process.env.EMAIL_HOST
|
||||
const emailPort = parseInt(process.env.EMAIL_PORT || '587')
|
||||
const emailUser = process.env.EMAIL_USER
|
||||
const emailPass = process.env.EMAIL_PASS
|
||||
const emailSecure = process.env.EMAIL_SECURE === 'true'
|
||||
|
||||
if (emailHost && emailUser && emailPass) {
|
||||
const config: EmailConfig = {
|
||||
host: emailHost,
|
||||
port: emailPort,
|
||||
secure: emailSecure,
|
||||
auth: {
|
||||
user: emailUser,
|
||||
pass: emailPass
|
||||
}
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport(config)
|
||||
this.isEnabled = true
|
||||
logger.info('Email service initialized successfully')
|
||||
} else {
|
||||
logger.warn('Email service not configured. Set EMAIL_HOST, EMAIL_USER, EMAIL_PASS environment variables.')
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(options: SendEmailOptions): Promise<boolean> {
|
||||
if (!this.isEnabled || !this.transporter) {
|
||||
logger.warn('Email service not enabled. Cannot send email.')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM || process.env.EMAIL_USER,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html
|
||||
})
|
||||
|
||||
logger.info(`Email sent successfully to ${options.to}`, { messageId: info.messageId })
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to send email:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendInitialPassword(email: string, password: string, firstName?: string): Promise<boolean> {
|
||||
const subject = 'SkillMate - Ihr Zugangs-Passwort'
|
||||
const text = `Hallo${firstName ? ` ${firstName}` : ''},
|
||||
|
||||
Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:
|
||||
|
||||
E-Mail: ${email}
|
||||
Passwort: ${password}
|
||||
|
||||
Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.
|
||||
|
||||
Login-URL: ${process.env.FRONTEND_URL || 'http://localhost:5173/login'}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr SkillMate-Team`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">SkillMate - Ihr Zugangs-Passwort</h2>
|
||||
|
||||
<p>Hallo${firstName ? ` ${firstName}` : ''},</p>
|
||||
|
||||
<p>Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p><strong>E-Mail:</strong> ${email}</p>
|
||||
<p><strong>Passwort:</strong> <code style="background-color: #e5e7eb; padding: 2px 4px; border-radius: 4px;">${password}</code></p>
|
||||
</div>
|
||||
|
||||
<p>Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.</p>
|
||||
|
||||
<p>
|
||||
<a href="${process.env.FRONTEND_URL || 'http://localhost:5173/login'}"
|
||||
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
Mit freundlichen Grüßen,<br>
|
||||
Ihr SkillMate-Team
|
||||
</p>
|
||||
</div>`
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
})
|
||||
}
|
||||
|
||||
isServiceEnabled(): boolean {
|
||||
return this.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService()
|
||||
110
backend/src/services/encryption.ts
Normale Datei
110
backend/src/services/encryption.ts
Normale Datei
@ -0,0 +1,110 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { randomBytes } from 'crypto'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
|
||||
// Generate a secure encryption key from environment or create a new one
|
||||
const getEncryptionKey = (): string => {
|
||||
let key = process.env.FIELD_ENCRYPTION_KEY
|
||||
|
||||
if (!key) {
|
||||
// Use the default development key
|
||||
key = 'dev_field_key_change_in_production_32chars_min!'
|
||||
console.warn('⚠️ No FIELD_ENCRYPTION_KEY found. Using default development key.')
|
||||
console.warn('⚠️ Set FIELD_ENCRYPTION_KEY in your .env file for production!')
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY = getEncryptionKey()
|
||||
|
||||
export class FieldEncryption {
|
||||
/**
|
||||
* Encrypt sensitive field data
|
||||
*/
|
||||
static encrypt(text: string | null | undefined): string | null {
|
||||
if (!text) return null
|
||||
|
||||
try {
|
||||
const encrypted = CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString()
|
||||
return encrypted
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error)
|
||||
throw new Error('Failed to encrypt data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive field data
|
||||
*/
|
||||
static decrypt(encryptedText: string | null | undefined): string | null {
|
||||
if (!encryptedText) return null
|
||||
|
||||
try {
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedText, ENCRYPTION_KEY)
|
||||
return decrypted.toString(CryptoJS.enc.Utf8)
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error)
|
||||
throw new Error('Failed to decrypt data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt multiple fields in an object
|
||||
*/
|
||||
static encryptFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
fields: (keyof T)[]
|
||||
): T {
|
||||
const encrypted = { ...obj }
|
||||
|
||||
for (const field of fields) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = this.encrypt(encrypted[field] as string) as any
|
||||
}
|
||||
}
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt multiple fields in an object
|
||||
*/
|
||||
static decryptFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
fields: (keyof T)[]
|
||||
): T {
|
||||
const decrypted = { ...obj }
|
||||
|
||||
for (const field of fields) {
|
||||
if (decrypted[field]) {
|
||||
decrypted[field] = this.decrypt(decrypted[field] as string) as any
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data for searching (one-way)
|
||||
*/
|
||||
static hash(text: string): string {
|
||||
return CryptoJS.SHA256(text.toLowerCase()).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// List of sensitive fields that should be encrypted
|
||||
export const SENSITIVE_EMPLOYEE_FIELDS = [
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'clearance_level',
|
||||
'clearance_valid_until'
|
||||
]
|
||||
|
||||
export const SENSITIVE_USER_FIELDS = [
|
||||
'email'
|
||||
]
|
||||
138
backend/src/services/reminderService.ts
Normale Datei
138
backend/src/services/reminderService.ts
Normale Datei
@ -0,0 +1,138 @@
|
||||
import { db } from '../config/database'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { logger } from '../utils/logger'
|
||||
import * as cron from 'node-cron'
|
||||
|
||||
class ReminderService {
|
||||
private reminderJob: cron.ScheduledTask | null = null
|
||||
|
||||
constructor() {
|
||||
this.initializeScheduler()
|
||||
}
|
||||
|
||||
private initializeScheduler() {
|
||||
// T\u00e4glich um 9:00 Uhr pr\u00fcfen
|
||||
this.reminderJob = cron.schedule('0 9 * * *', () => {
|
||||
this.checkAndSendReminders()
|
||||
})
|
||||
|
||||
logger.info('Reminder scheduler initialized')
|
||||
}
|
||||
|
||||
async checkAndSendReminders() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
// Finde Profile die aktualisiert werden m\u00fcssen
|
||||
const overdueProfiles = db.prepare(`
|
||||
SELECT id, name, email, updated_at, review_due_at
|
||||
FROM profiles
|
||||
WHERE review_due_at <= ? OR updated_at <= ?
|
||||
`).all(now.toISOString(), oneYearAgo)
|
||||
|
||||
for (const profile of overdueProfiles) {
|
||||
// Pr\u00fcfe ob bereits ein Reminder existiert
|
||||
const existingReminder = db.prepare(`
|
||||
SELECT id FROM reminders
|
||||
WHERE profile_id = ? AND type = 'annual_update'
|
||||
AND sent_at IS NULL
|
||||
`).get((profile as any).id)
|
||||
|
||||
if (!existingReminder) {
|
||||
// Erstelle neuen Reminder
|
||||
db.prepare(`
|
||||
INSERT INTO reminders (id, profile_id, type, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
(profile as any).id,
|
||||
'annual_update',
|
||||
`Ihr Profil wurde seit ${(profile as any).updated_at} nicht mehr aktualisiert. Bitte \u00fcberpr\u00fcfen Sie Ihre Angaben.`,
|
||||
now.toISOString()
|
||||
)
|
||||
|
||||
// TODO: Email-Versand implementieren
|
||||
logger.info(`Reminder created for profile ${(profile as any).id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Markiere Profile als veraltet
|
||||
db.prepare(`
|
||||
UPDATE profiles
|
||||
SET review_due_at = ?
|
||||
WHERE updated_at <= ? AND review_due_at IS NULL
|
||||
`).run(
|
||||
new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), // +30 Tage Grace Period
|
||||
oneYearAgo
|
||||
)
|
||||
|
||||
logger.info(`Reminder check completed. ${overdueProfiles.length} profiles need update.`)
|
||||
} catch (error) {
|
||||
logger.error('Error in reminder service:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async sendReminder(profileId: string, type: string, message: string) {
|
||||
try {
|
||||
const profile = db.prepare(`
|
||||
SELECT email, name FROM profiles WHERE id = ?
|
||||
`).get(profileId) as any
|
||||
|
||||
if (!profile) {
|
||||
logger.error(`Profile ${profileId} not found for reminder`)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Email-Service integrieren
|
||||
// await emailService.send({
|
||||
// to: (profile as any).email,
|
||||
// subject: 'SkillMate Profil-Aktualisierung',
|
||||
// body: message
|
||||
// })
|
||||
|
||||
// Markiere als gesendet
|
||||
db.prepare(`
|
||||
UPDATE reminders
|
||||
SET sent_at = ?
|
||||
WHERE profile_id = ? AND type = ? AND sent_at IS NULL
|
||||
`).run(new Date().toISOString(), profileId, type)
|
||||
|
||||
logger.info(`Reminder sent to ${(profile as any).email}`)
|
||||
} catch (error) {
|
||||
logger.error(`Error sending reminder to profile ${profileId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async acknowledgeReminder(reminderId: string, userId: string) {
|
||||
db.prepare(`
|
||||
UPDATE reminders
|
||||
SET acknowledged_at = ?
|
||||
WHERE id = ?
|
||||
`).run(new Date().toISOString(), reminderId)
|
||||
|
||||
// Aktualisiere review_due_at f\u00fcr das Profil
|
||||
const reminder = db.prepare(`
|
||||
SELECT profile_id FROM reminders WHERE id = ?
|
||||
`).get(reminderId) as any
|
||||
|
||||
if (reminder) {
|
||||
const nextReviewDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE profiles
|
||||
SET review_due_at = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(nextReviewDate, new Date().toISOString(), userId, reminder.profile_id)
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.reminderJob) {
|
||||
this.reminderJob.stop()
|
||||
logger.info('Reminder scheduler stopped')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reminderService = new ReminderService()
|
||||
129
backend/src/services/syncScheduler.ts
Normale Datei
129
backend/src/services/syncScheduler.ts
Normale Datei
@ -0,0 +1,129 @@
|
||||
import { syncService } from './syncService'
|
||||
import { db } from '../config/database'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface SyncInterval {
|
||||
value: string
|
||||
milliseconds: number
|
||||
}
|
||||
|
||||
const SYNC_INTERVALS: Record<string, SyncInterval> = {
|
||||
'5min': { value: '5min', milliseconds: 5 * 60 * 1000 },
|
||||
'15min': { value: '15min', milliseconds: 15 * 60 * 1000 },
|
||||
'30min': { value: '30min', milliseconds: 30 * 60 * 1000 },
|
||||
'1hour': { value: '1hour', milliseconds: 60 * 60 * 1000 },
|
||||
'daily': { value: 'daily', milliseconds: 24 * 60 * 60 * 1000 }
|
||||
}
|
||||
|
||||
export class SyncScheduler {
|
||||
private static instance: SyncScheduler
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
private currentInterval: string = 'disabled'
|
||||
|
||||
private constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
static getInstance(): SyncScheduler {
|
||||
if (!SyncScheduler.instance) {
|
||||
SyncScheduler.instance = new SyncScheduler()
|
||||
}
|
||||
return SyncScheduler.instance
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
// Check current sync settings on startup
|
||||
this.checkAndUpdateInterval()
|
||||
|
||||
// Check for interval changes every minute
|
||||
setInterval(() => {
|
||||
this.checkAndUpdateInterval()
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
private checkAndUpdateInterval() {
|
||||
try {
|
||||
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
|
||||
const newInterval = settings?.auto_sync_interval || 'disabled'
|
||||
|
||||
if (newInterval !== this.currentInterval) {
|
||||
logger.info(`Sync interval changed from ${this.currentInterval} to ${newInterval}`)
|
||||
this.currentInterval = newInterval
|
||||
this.updateSchedule()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check sync interval:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private updateSchedule() {
|
||||
// Clear existing interval
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
|
||||
// Set new interval if not disabled
|
||||
if (this.currentInterval !== 'disabled' && SYNC_INTERVALS[this.currentInterval]) {
|
||||
const { milliseconds } = SYNC_INTERVALS[this.currentInterval]
|
||||
|
||||
logger.info(`Starting automatic sync with interval: ${this.currentInterval}`)
|
||||
|
||||
// Run sync immediately
|
||||
this.runSync()
|
||||
|
||||
// Then schedule regular syncs
|
||||
this.intervalId = setInterval(() => {
|
||||
this.runSync()
|
||||
}, milliseconds)
|
||||
} else {
|
||||
logger.info('Automatic sync disabled')
|
||||
}
|
||||
}
|
||||
|
||||
private async runSync() {
|
||||
try {
|
||||
logger.info('Running scheduled sync...')
|
||||
|
||||
// Check if we're the admin node
|
||||
const nodeType = process.env.NODE_TYPE || 'local'
|
||||
if (nodeType === 'admin') {
|
||||
// Admin pushes to all local nodes
|
||||
await syncService.triggerSync()
|
||||
} else {
|
||||
// Local nodes sync with admin
|
||||
const adminNode = db.prepare(`
|
||||
SELECT id FROM network_nodes
|
||||
WHERE type = 'admin' AND is_online = 1
|
||||
LIMIT 1
|
||||
`).get() as any
|
||||
|
||||
if (adminNode) {
|
||||
await syncService.syncWithNode(adminNode.id)
|
||||
} else {
|
||||
logger.warn('No admin node available for sync')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Scheduled sync completed')
|
||||
} catch (error) {
|
||||
logger.error('Scheduled sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Manual trigger for testing
|
||||
async triggerManualSync() {
|
||||
await this.runSync()
|
||||
}
|
||||
|
||||
// Get current status
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.currentInterval !== 'disabled',
|
||||
interval: this.currentInterval,
|
||||
nextRun: this.intervalId ? new Date(Date.now() + (SYNC_INTERVALS[this.currentInterval]?.milliseconds || 0)) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncScheduler = SyncScheduler.getInstance()
|
||||
573
backend/src/services/syncService.ts
Normale Datei
573
backend/src/services/syncService.ts
Normale Datei
@ -0,0 +1,573 @@
|
||||
import { db } from '../config/database'
|
||||
import axios from 'axios'
|
||||
import crypto from 'crypto'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface SyncPayload {
|
||||
type: 'employees' | 'skills' | 'users' | 'settings'
|
||||
action: 'create' | 'update' | 'delete'
|
||||
data: any
|
||||
timestamp: string
|
||||
nodeId: string
|
||||
checksum: string
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean
|
||||
syncedItems: number
|
||||
conflicts: any[]
|
||||
errors: any[]
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private static instance: SyncService
|
||||
private syncQueue: SyncPayload[] = []
|
||||
private isSyncing = false
|
||||
|
||||
private constructor() {
|
||||
// Initialize sync tables
|
||||
this.initializeSyncTables()
|
||||
}
|
||||
|
||||
static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService()
|
||||
}
|
||||
return SyncService.instance
|
||||
}
|
||||
|
||||
private initializeSyncTables() {
|
||||
// Sync log table to track all sync operations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
sync_type TEXT NOT NULL,
|
||||
sync_action TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
status TEXT CHECK(status IN ('pending', 'completed', 'failed', 'conflict')) NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Conflict resolution table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
local_data TEXT NOT NULL,
|
||||
remote_data TEXT NOT NULL,
|
||||
conflict_type TEXT NOT NULL,
|
||||
resolution_status TEXT CHECK(resolution_status IN ('pending', 'resolved', 'ignored')) DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
resolved_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Sync metadata table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
last_sync_at TEXT,
|
||||
last_successful_sync TEXT,
|
||||
sync_version INTEGER DEFAULT 1,
|
||||
total_synced_items INTEGER DEFAULT 0,
|
||||
total_conflicts INTEGER DEFAULT 0,
|
||||
total_errors INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
// Generate checksum for data integrity
|
||||
private generateChecksum(data: any): string {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(JSON.stringify(data))
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
// Add item to sync queue
|
||||
async queueSync(type: SyncPayload['type'], action: SyncPayload['action'], data: any) {
|
||||
const nodeId = this.getNodeId()
|
||||
const payload: SyncPayload = {
|
||||
type,
|
||||
action,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
nodeId,
|
||||
checksum: this.generateChecksum(data)
|
||||
}
|
||||
|
||||
this.syncQueue.push(payload)
|
||||
|
||||
// Log to sync_log
|
||||
db.prepare(`
|
||||
INSERT INTO sync_log (
|
||||
id, node_id, sync_type, sync_action, entity_id, entity_type,
|
||||
payload, checksum, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
crypto.randomUUID(),
|
||||
nodeId,
|
||||
type,
|
||||
action,
|
||||
data.id || 'unknown',
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
payload.checksum,
|
||||
'pending',
|
||||
payload.timestamp
|
||||
)
|
||||
|
||||
// Trigger sync if auto-sync is enabled
|
||||
const syncSettings = this.getSyncSettings()
|
||||
if (syncSettings.autoSyncInterval !== 'disabled') {
|
||||
this.triggerSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with remote nodes
|
||||
async syncWithNode(targetNodeId: string): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: false,
|
||||
syncedItems: 0,
|
||||
conflicts: [],
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const targetNode = this.getNodeInfo(targetNodeId)
|
||||
if (!targetNode) {
|
||||
throw new Error('Target node not found')
|
||||
}
|
||||
|
||||
// Get pending sync items
|
||||
const pendingItems = db.prepare(`
|
||||
SELECT * FROM sync_log
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`).all() as any[]
|
||||
|
||||
for (const item of pendingItems) {
|
||||
try {
|
||||
const payload = JSON.parse(item.payload)
|
||||
|
||||
// Send to target node
|
||||
const response = await axios.post(
|
||||
`http://${targetNode.ip_address}:${targetNode.port}/api/sync/receive`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${targetNode.api_key}`,
|
||||
'X-Node-Id': this.getNodeId()
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
// Mark as completed
|
||||
db.prepare(`
|
||||
UPDATE sync_log
|
||||
SET status = 'completed', completed_at = ?
|
||||
WHERE id = ?
|
||||
`).run(new Date().toISOString(), item.id)
|
||||
|
||||
result.syncedItems++
|
||||
} else if (response.data.conflict) {
|
||||
// Handle conflict
|
||||
this.handleConflict(item, response.data.conflictData)
|
||||
result.conflicts.push({
|
||||
itemId: item.id,
|
||||
conflict: response.data.conflictData
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Sync error for item ${item.id}:`, error)
|
||||
|
||||
// Mark as failed
|
||||
db.prepare(`
|
||||
UPDATE sync_log
|
||||
SET status = 'failed', error_message = ?
|
||||
WHERE id = ?
|
||||
`).run(error.message, item.id)
|
||||
|
||||
result.errors.push({
|
||||
itemId: item.id,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
this.updateSyncMetadata(targetNodeId, result)
|
||||
result.success = result.errors.length === 0
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Sync failed:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Receive sync from remote node
|
||||
async receiveSync(payload: SyncPayload): Promise<any> {
|
||||
try {
|
||||
// Verify checksum
|
||||
const calculatedChecksum = this.generateChecksum(payload.data)
|
||||
if (calculatedChecksum !== payload.checksum) {
|
||||
throw new Error('Checksum mismatch - data integrity error')
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflict = await this.checkForConflicts(payload)
|
||||
if (conflict) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictData: conflict
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes based on type and action
|
||||
await this.applyChanges(payload)
|
||||
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logger.error('Error receiving sync:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
private async checkForConflicts(payload: SyncPayload): Promise<any> {
|
||||
const { type, action, data } = payload
|
||||
|
||||
if (action === 'create') {
|
||||
// Check if entity already exists
|
||||
let exists = false
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
exists = !!db.prepare('SELECT id FROM employees WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'skills':
|
||||
exists = !!db.prepare('SELECT id FROM skills WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'users':
|
||||
exists = !!db.prepare('SELECT id FROM users WHERE id = ?').get(data.id)
|
||||
break
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return {
|
||||
type: 'already_exists',
|
||||
entityId: data.id,
|
||||
entityType: type
|
||||
}
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
// Check if local version is newer
|
||||
let localEntity: any = null
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
localEntity = db.prepare('SELECT * FROM employees WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'skills':
|
||||
localEntity = db.prepare('SELECT * FROM skills WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'users':
|
||||
localEntity = db.prepare('SELECT * FROM users WHERE id = ?').get(data.id)
|
||||
break
|
||||
}
|
||||
|
||||
if (localEntity && new Date(localEntity.updated_at) > new Date(data.updatedAt)) {
|
||||
return {
|
||||
type: 'newer_version',
|
||||
entityId: data.id,
|
||||
entityType: type,
|
||||
localVersion: localEntity,
|
||||
remoteVersion: data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Apply changes to local database
|
||||
private async applyChanges(payload: SyncPayload) {
|
||||
const { type, action, data } = payload
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
await this.syncEmployee(action, data)
|
||||
break
|
||||
case 'skills':
|
||||
await this.syncSkill(action, data)
|
||||
break
|
||||
case 'users':
|
||||
await this.syncUser(action, data)
|
||||
break
|
||||
case 'settings':
|
||||
await this.syncSettings(action, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync employee data
|
||||
private async syncEmployee(action: string, data: any) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
db.prepare(`
|
||||
INSERT INTO employees (
|
||||
id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, data.firstName, data.lastName, data.employeeNumber,
|
||||
data.photo, data.position, data.department, data.email,
|
||||
data.phone, data.mobile, data.office, data.availability,
|
||||
data.clearance?.level, data.clearance?.validUntil,
|
||||
data.clearance?.issuedDate, data.createdAt, data.updatedAt,
|
||||
data.createdBy
|
||||
)
|
||||
|
||||
// Sync skills
|
||||
if (data.skills) {
|
||||
for (const skill of data.skills) {
|
||||
db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, skill.id, skill.level,
|
||||
skill.verified ? 1 : 0, skill.verifiedBy, skill.verifiedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE employees SET
|
||||
first_name = ?, last_name = ?, position = ?, department = ?,
|
||||
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.firstName, data.lastName, data.position, data.department,
|
||||
data.email, data.phone, data.mobile, data.office, data.availability,
|
||||
data.updatedAt, data.updatedBy, data.id
|
||||
)
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM employees WHERE id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync skill data
|
||||
private async syncSkill(action: string, data: any) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
db.prepare(`
|
||||
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, data.name, data.category, data.description,
|
||||
data.requiresCertification ? 1 : 0, data.expiresAfter
|
||||
)
|
||||
break
|
||||
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE skills SET name = ?, category = ?, description = ?
|
||||
WHERE id = ?
|
||||
`).run(data.name, data.category, data.description, data.id)
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM skills WHERE id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync user data
|
||||
private async syncUser(action: string, data: any) {
|
||||
// Implementation for user sync
|
||||
logger.info(`Syncing user: ${action}`, data)
|
||||
}
|
||||
|
||||
// Sync settings
|
||||
private async syncSettings(action: string, data: any) {
|
||||
// Implementation for settings sync
|
||||
logger.info(`Syncing settings: ${action}`, data)
|
||||
}
|
||||
|
||||
// Handle conflicts
|
||||
private handleConflict(syncItem: any, conflictData: any) {
|
||||
const conflictId = crypto.randomUUID()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sync_conflicts (
|
||||
id, entity_id, entity_type, local_data, remote_data,
|
||||
conflict_type, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
conflictId,
|
||||
conflictData.entityId,
|
||||
conflictData.entityType,
|
||||
JSON.stringify(conflictData.localVersion || {}),
|
||||
JSON.stringify(conflictData.remoteVersion || {}),
|
||||
conflictData.type,
|
||||
new Date().toISOString()
|
||||
)
|
||||
|
||||
// Apply conflict resolution strategy
|
||||
const settings = this.getSyncSettings()
|
||||
if (settings.conflictResolution === 'admin') {
|
||||
// Admin wins - apply remote changes if from admin node
|
||||
const remoteNode = this.getNodeInfo(syncItem.node_id)
|
||||
if (remoteNode?.type === 'admin') {
|
||||
this.applyChanges(JSON.parse(syncItem.payload))
|
||||
}
|
||||
} else if (settings.conflictResolution === 'newest') {
|
||||
// Newest wins - compare timestamps
|
||||
const localTime = new Date(conflictData.localVersion?.updatedAt || 0)
|
||||
const remoteTime = new Date(conflictData.remoteVersion?.updatedAt || 0)
|
||||
if (remoteTime > localTime) {
|
||||
this.applyChanges(JSON.parse(syncItem.payload))
|
||||
}
|
||||
}
|
||||
// Manual resolution - do nothing, let admin resolve
|
||||
}
|
||||
|
||||
// Get sync settings
|
||||
private getSyncSettings(): any {
|
||||
const settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
|
||||
return settings || {
|
||||
autoSyncInterval: 'disabled',
|
||||
conflictResolution: 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
// Get node information
|
||||
private getNodeInfo(nodeId: string): any {
|
||||
return db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(nodeId)
|
||||
}
|
||||
|
||||
// Get current node ID
|
||||
private getNodeId(): string {
|
||||
// Get from environment or generate
|
||||
return process.env.NODE_ID || 'local-node'
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
private updateSyncMetadata(nodeId: string, result: SyncResult) {
|
||||
const existing = db.prepare('SELECT * FROM sync_metadata WHERE node_id = ?').get(nodeId) as any
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
UPDATE sync_metadata SET
|
||||
last_sync_at = ?,
|
||||
last_successful_sync = ?,
|
||||
total_synced_items = total_synced_items + ?,
|
||||
total_conflicts = total_conflicts + ?,
|
||||
total_errors = total_errors + ?
|
||||
WHERE node_id = ?
|
||||
`).run(
|
||||
new Date().toISOString(),
|
||||
result.success ? new Date().toISOString() : existing.last_successful_sync,
|
||||
result.syncedItems,
|
||||
result.conflicts.length,
|
||||
result.errors.length,
|
||||
nodeId
|
||||
)
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO sync_metadata (
|
||||
node_id, last_sync_at, last_successful_sync,
|
||||
total_synced_items, total_conflicts, total_errors
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nodeId,
|
||||
new Date().toISOString(),
|
||||
result.success ? new Date().toISOString() : null,
|
||||
result.syncedItems,
|
||||
result.conflicts.length,
|
||||
result.errors.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger sync process
|
||||
async triggerSync() {
|
||||
if (this.isSyncing) {
|
||||
logger.info('Sync already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
this.isSyncing = true
|
||||
|
||||
try {
|
||||
// Get all active nodes
|
||||
const nodes = db.prepare(`
|
||||
SELECT * FROM network_nodes
|
||||
WHERE is_online = 1 AND id != ?
|
||||
`).all(this.getNodeId()) as any[]
|
||||
|
||||
for (const node of nodes) {
|
||||
logger.info(`Syncing with node: ${node.name}`)
|
||||
await this.syncWithNode(node.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Sync trigger failed:', error)
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status
|
||||
getSyncStatus(): any {
|
||||
const pendingCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM sync_log WHERE status = ?'
|
||||
).get('pending') as any
|
||||
|
||||
const recentSync = db.prepare(`
|
||||
SELECT * FROM sync_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
const conflicts = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM sync_conflicts
|
||||
WHERE resolution_status = ?
|
||||
`).get('pending') as any
|
||||
|
||||
return {
|
||||
pendingItems: pendingCount.count,
|
||||
recentSync,
|
||||
pendingConflicts: conflicts.count,
|
||||
isSyncing: this.isSyncing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncService = SyncService.getInstance()
|
||||
33
backend/src/utils/logger.ts
Normale Datei
33
backend/src/utils/logger.ts
Normale Datei
@ -0,0 +1,33 @@
|
||||
import winston from 'winston'
|
||||
import path from 'path'
|
||||
|
||||
const logDir = process.env.LOG_PATH || path.join(process.cwd(), 'logs')
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'skillmate-backend' },
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'error.log'),
|
||||
level: 'error'
|
||||
}),
|
||||
new winston.transports.File({
|
||||
filename: path.join(logDir, 'combined.log')
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}))
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren