Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-20 21:31:04 +02:00
Commit 6b9b6d4f20
1821 geänderte Dateien mit 348527 neuen und 0 gelöschten Zeilen

444
backend/src/config/database.ts Normale Datei
Datei anzeigen

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

Datei anzeigen

@ -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
Datei anzeigen

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

Datei anzeigen

@ -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()
}
}

Datei anzeigen

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

Datei anzeigen

@ -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()
}
}

Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

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

Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

@ -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
Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -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'
]

Datei anzeigen

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

Datei anzeigen

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

Datei anzeigen

@ -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
Datei anzeigen

@ -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()
)
}))
}