So mit neuen UI Ideen und so
Dieser Commit ist enthalten in:
1286
backend/package-lock.json
generiert
1286
backend/package-lock.json
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
43
backend/scripts/migrations/0001_users_email_encrypt.js
Normale Datei
43
backend/scripts/migrations/0001_users_email_encrypt.js
Normale Datei
@ -0,0 +1,43 @@
|
||||
const CryptoJS = require('crypto-js')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const FIELD_ENCRYPTION_KEY = process.env.FIELD_ENCRYPTION_KEY || 'dev_field_key_change_in_production_32chars_min!'
|
||||
|
||||
function encrypt(text) {
|
||||
if (!text) return null
|
||||
try {
|
||||
return CryptoJS.AES.encrypt(text, FIELD_ENCRYPTION_KEY).toString()
|
||||
} catch (e) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function hash(text) {
|
||||
if (!text) return null
|
||||
return crypto.createHash('sha256').update(String(text).toLowerCase()).digest('hex')
|
||||
}
|
||||
|
||||
module.exports.up = function up(db) {
|
||||
// Ensure users table has email_hash column
|
||||
try {
|
||||
db.exec('ALTER TABLE users ADD COLUMN email_hash TEXT')
|
||||
} catch {}
|
||||
// Populate encryption/hash where missing
|
||||
const users = db.prepare('SELECT id, email FROM users').all()
|
||||
const update = db.prepare('UPDATE users SET email = ?, email_hash = ? WHERE id = ?')
|
||||
const tx = db.transaction(() => {
|
||||
for (const u of users) {
|
||||
const hasEncryptedMarker = typeof u.email === 'string' && u.email.includes('U2FsdGVkX1')
|
||||
const plainEmail = u.email
|
||||
const encrypted = hasEncryptedMarker ? u.email : encrypt(plainEmail)
|
||||
const hashed = hash(plainEmail)
|
||||
update.run(encrypted, hashed, u.id)
|
||||
}
|
||||
})
|
||||
tx()
|
||||
// Add unique constraint index for email_hash if not exists
|
||||
try {
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_hash_unique ON users(email_hash)')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
54
backend/scripts/run-migrations.js
Normale Datei
54
backend/scripts/run-migrations.js
Normale Datei
@ -0,0 +1,54 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const Database = require('better-sqlite3')
|
||||
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(process.cwd(), 'skillmate.dev.encrypted.db')
|
||||
const db = new Database(dbPath)
|
||||
|
||||
function ensureSchemaTable() {
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`)
|
||||
}
|
||||
|
||||
function getApplied() {
|
||||
try {
|
||||
const rows = db.prepare('SELECT id FROM schema_version').all()
|
||||
return new Set(rows.map(r => r.id))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function applyMigration(file) {
|
||||
const migration = require(file)
|
||||
const id = path.basename(file)
|
||||
const tx = db.transaction(() => {
|
||||
migration.up(db)
|
||||
db.prepare('INSERT INTO schema_version (id, applied_at) VALUES (?, ?)').run(id, new Date().toISOString())
|
||||
})
|
||||
tx()
|
||||
console.log('Applied migration:', id)
|
||||
}
|
||||
|
||||
function main() {
|
||||
ensureSchemaTable()
|
||||
const applied = getApplied()
|
||||
const dir = path.join(__dirname, 'migrations')
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.log('No migrations directory found, skipping.')
|
||||
process.exit(0)
|
||||
}
|
||||
const files = fs.readdirSync(dir)
|
||||
.filter(f => f.endsWith('.js'))
|
||||
.sort()
|
||||
.map(f => path.join(dir, f))
|
||||
for (const file of files) {
|
||||
const id = path.basename(file)
|
||||
if (!applied.has(id)) {
|
||||
applyMigration(file)
|
||||
}
|
||||
}
|
||||
console.log('Migrations complete.')
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
43
backend/src/config/appConfig.ts
Normale Datei
43
backend/src/config/appConfig.ts
Normale Datei
@ -0,0 +1,43 @@
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
// Load environment variables early
|
||||
dotenv.config()
|
||||
|
||||
export interface AppConfig {
|
||||
nodeEnv: string
|
||||
port: number
|
||||
jwtSecret: string | null
|
||||
email: {
|
||||
host?: string
|
||||
port?: number
|
||||
user?: string
|
||||
pass?: string
|
||||
secure?: boolean
|
||||
from?: string
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): AppConfig {
|
||||
return {
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: Number(process.env.PORT || 3004),
|
||||
jwtSecret: process.env.JWT_SECRET || null,
|
||||
email: {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: process.env.EMAIL_PORT ? Number(process.env.EMAIL_PORT) : undefined,
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
secure: process.env.EMAIL_SECURE === 'true',
|
||||
from: process.env.EMAIL_FROM,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function assertProdSecretsSet(cfg: AppConfig) {
|
||||
if (cfg.nodeEnv === 'production') {
|
||||
if (!cfg.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be set in production')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
317
backend/src/repositories/employeeRepository.ts
Normale Datei
317
backend/src/repositories/employeeRepository.ts
Normale Datei
@ -0,0 +1,317 @@
|
||||
import { db, encryptedDb } from '../config/secureDatabase'
|
||||
import { Employee } from '@skillmate/shared'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export interface EmployeeInput {
|
||||
firstName: string
|
||||
lastName: string
|
||||
employeeNumber?: string | null
|
||||
photo?: string | null
|
||||
position?: string
|
||||
department: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
mobile?: string | null
|
||||
office?: string | null
|
||||
availability?: string
|
||||
clearance?: { level?: string; validUntil?: string | Date | null; issuedDate?: string | Date | null } | null
|
||||
skills?: Array<{ id: string; level?: any; verified?: boolean; verifiedBy?: string | null; verifiedDate?: string | Date | null }>
|
||||
languages?: Array<{ code: string; level: string }>
|
||||
specializations?: string[]
|
||||
}
|
||||
|
||||
export function getAllWithDetails(): Employee[] {
|
||||
const emps = (encryptedDb.getAllEmployees() as any[]) || []
|
||||
if (emps.length === 0) return []
|
||||
const ids = emps.map((e: any) => e.id)
|
||||
const placeholders = ids.map(() => '?').join(',')
|
||||
|
||||
// Batch fetch related data
|
||||
const skillsRows = db.prepare(`
|
||||
SELECT es.employee_id, 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 IN (${placeholders})
|
||||
`).all(...ids) as any[]
|
||||
|
||||
const langRows = db.prepare(`
|
||||
SELECT employee_id, language, proficiency, certified, certificate_type, is_native, can_interpret
|
||||
FROM language_skills
|
||||
WHERE employee_id IN (${placeholders})
|
||||
`).all(...ids) as any[]
|
||||
|
||||
const specRows = db.prepare(`
|
||||
SELECT employee_id, name FROM specializations WHERE employee_id IN (${placeholders})
|
||||
`).all(...ids) as any[]
|
||||
|
||||
const skillsByEmp = new Map<string, any[]>()
|
||||
for (const r of skillsRows) {
|
||||
if (!skillsByEmp.has(r.employee_id)) skillsByEmp.set(r.employee_id, [])
|
||||
skillsByEmp.get(r.employee_id)!.push(r)
|
||||
}
|
||||
const langsByEmp = new Map<string, any[]>()
|
||||
for (const r of langRows) {
|
||||
if (!langsByEmp.has(r.employee_id)) langsByEmp.set(r.employee_id, [])
|
||||
langsByEmp.get(r.employee_id)!.push(r)
|
||||
}
|
||||
const specsByEmp = new Map<string, string[]>()
|
||||
for (const r of specRows) {
|
||||
if (!specsByEmp.has(r.employee_id)) specsByEmp.set(r.employee_id, [])
|
||||
specsByEmp.get(r.employee_id)!.push(r.name)
|
||||
}
|
||||
|
||||
const mapProf = (p: string): 'basic' | 'fluent' | 'native' | 'business' => {
|
||||
const m: Record<string, any> = { 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 m[p] || 'basic'
|
||||
}
|
||||
|
||||
return emps.map((emp: any) => {
|
||||
const s = skillsByEmp.get(emp.id) || []
|
||||
const l = langsByEmp.get(emp.id) || []
|
||||
const sp = specsByEmp.get(emp.id) || []
|
||||
return {
|
||||
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: s.map((r: any) => ({ id: r.id, name: r.name, category: r.category, level: r.level, verified: Boolean(r.verified), verifiedBy: r.verified_by, verifiedDate: r.verified_date ? new Date(r.verified_date) : undefined })),
|
||||
languages: l.map((r: any) => ({ code: r.language, level: mapProf(r.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: sp,
|
||||
createdAt: new Date(emp.created_at),
|
||||
updatedAt: new Date(emp.updated_at),
|
||||
createdBy: emp.created_by,
|
||||
updatedBy: emp.updated_by
|
||||
} as Employee
|
||||
})
|
||||
}
|
||||
|
||||
export function getByIdWithDetails(id: string): Employee | null {
|
||||
const emp = encryptedDb.getEmployee(id) as any
|
||||
if (!emp) return null
|
||||
return buildEmployeeWithDetails(emp)
|
||||
}
|
||||
|
||||
function buildEmployeeWithDetails(emp: any): Employee {
|
||||
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 mapProf = (p: string): 'basic' | 'fluent' | 'native' | 'business' => {
|
||||
const m: Record<string, any> = { 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 m[p] || 'basic'
|
||||
}
|
||||
|
||||
return {
|
||||
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: mapProf(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
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmployee(input: EmployeeInput, actorUserId: string): { id: string } {
|
||||
const now = new Date().toISOString()
|
||||
const employeeId = uuidv4()
|
||||
const position = input.position || 'Mitarbeiter'
|
||||
const phone = input.phone || 'Nicht angegeben'
|
||||
const availability = input.availability || 'available'
|
||||
const employeeNumber = input.employeeNumber || `EMP${Date.now()}`
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
// Uniqueness check for employee number
|
||||
const existingEmployee = db.prepare('SELECT id FROM employees WHERE employee_number = ?').get(employeeNumber)
|
||||
if (existingEmployee) {
|
||||
const err: any = new Error('Employee number already exists')
|
||||
err.statusCode = 409
|
||||
throw err
|
||||
}
|
||||
|
||||
encryptedDb.insertEmployee({
|
||||
id: employeeId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
employee_number: employeeNumber,
|
||||
photo: input.photo || null,
|
||||
position,
|
||||
department: input.department,
|
||||
email: input.email,
|
||||
phone,
|
||||
mobile: input.mobile || null,
|
||||
office: input.office || null,
|
||||
availability,
|
||||
clearance_level: input.clearance?.level || null,
|
||||
clearance_valid_until: input.clearance?.validUntil || null,
|
||||
clearance_issued_date: input.clearance?.issuedDate || null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created_by: actorUserId,
|
||||
updated_by: actorUserId,
|
||||
})
|
||||
|
||||
if (input.skills && input.skills.length > 0) {
|
||||
const stmt = 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 s of input.skills) {
|
||||
const exists = checkSkillExists.get(s.id)
|
||||
if (!exists) continue
|
||||
stmt.run(employeeId, s.id, typeof s.level === 'number' ? s.level : (parseInt(String(s.level)) || null), s.verified ? 1 : 0, s.verifiedBy || null, s.verifiedDate || null)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.languages && input.languages.length > 0) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO language_skills (id, employee_id, language, proficiency, certified, certificate_type, is_native, can_interpret)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
for (const l of input.languages) {
|
||||
stmt.run(uuidv4(), employeeId, l.code, l.level, 0, null, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.specializations && input.specializations.length > 0) {
|
||||
const stmt = db.prepare('INSERT INTO specializations (id, employee_id, name) VALUES (?, ?, ?)')
|
||||
for (const name of input.specializations) {
|
||||
stmt.run(uuidv4(), employeeId, name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tx()
|
||||
return { id: employeeId }
|
||||
}
|
||||
|
||||
export function updateEmployee(id: string, input: EmployeeInput, actorUserId: string) {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
const err: any = new Error('Employee not found')
|
||||
err.statusCode = 404
|
||||
throw err
|
||||
}
|
||||
|
||||
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(
|
||||
input.firstName, input.lastName, input.position || 'Mitarbeiter', input.department,
|
||||
// encryption handled in secure db layer caller; here store encrypted values already in input? Route prepares with FieldEncryption
|
||||
input.email, // already encrypted by route layer
|
||||
null, // email_hash set by route if needed
|
||||
input.phone || '',
|
||||
null, // phone_hash set by route if needed
|
||||
input.mobile || null,
|
||||
input.office || null,
|
||||
input.availability || 'available',
|
||||
input.clearance?.level || null,
|
||||
input.clearance?.validUntil || null,
|
||||
input.clearance?.issuedDate || null,
|
||||
now,
|
||||
actorUserId,
|
||||
id
|
||||
)
|
||||
|
||||
if (input.skills) {
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(id)
|
||||
if (input.skills.length > 0) {
|
||||
const insert = 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 s of input.skills) {
|
||||
const exists = checkSkillExists.get(s.id)
|
||||
if (!exists) continue
|
||||
insert.run(
|
||||
id,
|
||||
s.id,
|
||||
typeof s.level === 'number' ? s.level : (parseInt(String(s.level)) || null),
|
||||
s.verified ? 1 : 0,
|
||||
s.verifiedBy || null,
|
||||
s.verifiedDate || null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
tx()
|
||||
}
|
||||
|
||||
export function deleteEmployee(id: string) {
|
||||
const tx = db.transaction(() => {
|
||||
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
|
||||
if (!existing) {
|
||||
const err: any = new Error('Employee not found')
|
||||
err.statusCode = 404
|
||||
throw err
|
||||
}
|
||||
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)
|
||||
})
|
||||
tx()
|
||||
}
|
||||
35
backend/src/services/auditService.ts
Normale Datei
35
backend/src/services/auditService.ts
Normale Datei
@ -0,0 +1,35 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { db } from '../config/secureDatabase'
|
||||
import { logger } from '../utils/logger'
|
||||
import type { Request } from 'express'
|
||||
|
||||
export function logSecurityAudit(
|
||||
action: 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'failed_login',
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
userId: string,
|
||||
req: Request,
|
||||
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 as any).ip || (req as any).connection?.remoteAddress,
|
||||
req.get('user-agent'),
|
||||
riskLevel
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Failed to log security audit:', error)
|
||||
}
|
||||
}
|
||||
|
||||
123
backend/src/services/sync/applier.ts
Normale Datei
123
backend/src/services/sync/applier.ts
Normale Datei
@ -0,0 +1,123 @@
|
||||
import { db } from '../../config/database'
|
||||
|
||||
export async function applyChanges(payload: any) {
|
||||
const { type, action, data } = payload
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
await syncEmployee(action, data)
|
||||
break
|
||||
case 'skills':
|
||||
await syncSkill(action, data)
|
||||
break
|
||||
case 'users':
|
||||
await syncUser(action, data)
|
||||
break
|
||||
case 'settings':
|
||||
await syncSettings(action, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function 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
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
async function 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 || null, data.requiresCertification ? 1 : 0, data.expiresAfter || null)
|
||||
break
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE skills SET name = COALESCE(?, name), category = COALESCE(?, category), description = COALESCE(?, description)
|
||||
WHERE id = ?
|
||||
`).run(data.name || null, data.category || null, data.description || null, data.id)
|
||||
break
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM employee_skills WHERE skill_id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM skills WHERE id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUser(action: string, data: any) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
db.prepare(`
|
||||
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(data.id, data.username, data.email, data.password, data.role, data.employeeId || null, 1, data.createdAt, data.updatedAt)
|
||||
break
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE users SET username = ?, email = ?, role = ?, employee_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(data.username, data.email, data.role, data.employeeId || null, data.updatedAt, data.id)
|
||||
break
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSettings(action: string, data: any) {
|
||||
if (action === 'update') {
|
||||
for (const [key, value] of Object.entries(data || {})) {
|
||||
db.prepare(`INSERT INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
||||
.run(key, String(value), new Date().toISOString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
60
backend/src/services/sync/queueStore.ts
Normale Datei
60
backend/src/services/sync/queueStore.ts
Normale Datei
@ -0,0 +1,60 @@
|
||||
import { db } from '../../config/database'
|
||||
|
||||
export const queueStore = {
|
||||
getPending() {
|
||||
return db.prepare(`
|
||||
SELECT * FROM sync_log
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`).all() as any[]
|
||||
},
|
||||
markCompleted(id: string) {
|
||||
db.prepare(`UPDATE sync_log SET status = 'completed', completed_at = ? WHERE id = ?`).run(new Date().toISOString(), id)
|
||||
},
|
||||
markFailed(id: string, message: string) {
|
||||
db.prepare(`UPDATE sync_log SET status = 'failed', error_message = ? WHERE id = ?`).run(message, id)
|
||||
},
|
||||
updateMetadata(nodeId: string, result: { success: boolean; syncedItems: number; conflicts: any[]; errors: any[] }) {
|
||||
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
|
||||
)
|
||||
}
|
||||
},
|
||||
getSyncSettings() {
|
||||
const settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
|
||||
return settings || { autoSyncInterval: 'disabled', conflictResolution: 'admin' }
|
||||
},
|
||||
getNodeInfo(nodeId: string) {
|
||||
return db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
17
backend/src/services/sync/transport.ts
Normale Datei
17
backend/src/services/sync/transport.ts
Normale Datei
@ -0,0 +1,17 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export async function sendToNode(targetNode: any, payload: any, nodeId: string) {
|
||||
const response = await axios.post(
|
||||
`http://${targetNode.ip_address}:${targetNode.port}/api/sync/receive`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${targetNode.api_key}`,
|
||||
'X-Node-Id': nodeId
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
69
backend/src/usecases/auth/loginUser.ts
Normale Datei
69
backend/src/usecases/auth/loginUser.ts
Normale Datei
@ -0,0 +1,69 @@
|
||||
import { db } from '../../src/config/secureDatabase'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { FieldEncryption } from '../../src/services/encryption'
|
||||
import { User, LoginResponse } from '@skillmate/shared'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
|
||||
|
||||
export async function loginUser(identifier: string, password: string): Promise<LoginResponse> {
|
||||
let userRow: any
|
||||
if (identifier.includes('@')) {
|
||||
const emailHash = FieldEncryption.hash(identifier)
|
||||
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)
|
||||
} else {
|
||||
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(identifier)
|
||||
}
|
||||
|
||||
if (!userRow) {
|
||||
const e: any = new Error('Invalid credentials')
|
||||
e.statusCode = 401
|
||||
throw e
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, userRow.password)
|
||||
if (!isValidPassword) {
|
||||
const e: any = new Error('Invalid credentials')
|
||||
e.statusCode = 401
|
||||
throw e
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
{ user },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
)
|
||||
|
||||
return {
|
||||
user,
|
||||
token: {
|
||||
accessToken: token,
|
||||
expiresIn: 86400,
|
||||
tokenType: 'Bearer'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
backend/src/usecases/employees.ts
Normale Datei
109
backend/src/usecases/employees.ts
Normale Datei
@ -0,0 +1,109 @@
|
||||
import { createEmployee as repoCreate, updateEmployee as repoUpdate, deleteEmployee as repoDelete, getAllWithDetails, getByIdWithDetails } from '../repositories/employeeRepository'
|
||||
import { syncService } from '../services/syncService'
|
||||
import { logSecurityAudit } from '../services/auditService'
|
||||
import type { Request } from 'express'
|
||||
|
||||
export function listEmployeesUC() {
|
||||
return getAllWithDetails()
|
||||
}
|
||||
|
||||
export function getEmployeeUC(id: string) {
|
||||
return getByIdWithDetails(id)
|
||||
}
|
||||
|
||||
export async function createEmployeeUC(req: Request, body: any, actorUserId: string) {
|
||||
const result = repoCreate({
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
employeeNumber: body.employeeNumber,
|
||||
photo: body.photo,
|
||||
position: body.position,
|
||||
department: body.department,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
mobile: body.mobile,
|
||||
office: body.office,
|
||||
availability: body.availability,
|
||||
clearance: body.clearance,
|
||||
skills: body.skills || [],
|
||||
languages: body.languages || [],
|
||||
specializations: body.specializations || [],
|
||||
}, actorUserId)
|
||||
|
||||
logSecurityAudit('create', 'employees', result.id, actorUserId, req, 'medium')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const newEmployee = {
|
||||
id: result.id,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
employeeNumber: body.employeeNumber || null,
|
||||
photo: body.photo || null,
|
||||
position: body.position || 'Mitarbeiter',
|
||||
department: body.department,
|
||||
email: body.email,
|
||||
phone: body.phone || 'Nicht angegeben',
|
||||
mobile: body.mobile || null,
|
||||
office: body.office || null,
|
||||
availability: body.availability || 'available',
|
||||
clearance: body.clearance,
|
||||
skills: body.skills || [],
|
||||
languages: body.languages || [],
|
||||
specializations: body.specializations || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
}
|
||||
|
||||
syncService.queueSync('employees', 'create', newEmployee).catch(() => {})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function updateEmployeeUC(req: Request, id: string, body: any, actorUserId: string) {
|
||||
repoUpdate(id, {
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
position: body.position,
|
||||
department: body.department,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
mobile: body.mobile,
|
||||
office: body.office,
|
||||
availability: body.availability,
|
||||
clearance: body.clearance,
|
||||
skills: body.skills,
|
||||
languages: body.languages,
|
||||
specializations: body.specializations,
|
||||
}, actorUserId)
|
||||
|
||||
logSecurityAudit('update', 'employees', id, actorUserId, req, 'medium')
|
||||
|
||||
const updatedEmployee = {
|
||||
id,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
position: body.position,
|
||||
department: body.department,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
mobile: body.mobile || null,
|
||||
office: body.office || null,
|
||||
availability: body.availability,
|
||||
clearance: body.clearance,
|
||||
skills: body.skills,
|
||||
languages: body.languages,
|
||||
specializations: body.specializations,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: actorUserId
|
||||
}
|
||||
|
||||
syncService.queueSync('employees', 'update', updatedEmployee).catch(() => {})
|
||||
}
|
||||
|
||||
export async function deleteEmployeeUC(req: Request, id: string, actorUserId: string) {
|
||||
repoDelete(id)
|
||||
logSecurityAudit('delete', 'employees', id, actorUserId, req, 'high')
|
||||
syncService.queueSync('employees', 'delete', { id }).catch(() => {})
|
||||
}
|
||||
|
||||
48
backend/src/usecases/users.ts
Normale Datei
48
backend/src/usecases/users.ts
Normale Datei
@ -0,0 +1,48 @@
|
||||
import { db } from '../config/secureDatabase'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { FieldEncryption } from '../services/encryption'
|
||||
|
||||
export async function createUserUC(input: { username: string; email: string; password: string; role: string; employeeId?: string | null }, actorRole: string) {
|
||||
const assignedRole = (actorRole === 'admin') ? input.role : 'user'
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(input.username)
|
||||
if (existingUser) {
|
||||
const e: any = new Error('Username already exists')
|
||||
e.statusCode = 409
|
||||
throw e
|
||||
}
|
||||
const emailHash = FieldEncryption.hash(input.email)
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email_hash = ?').get(emailHash)
|
||||
if (existingEmail) {
|
||||
const e: any = new Error('Email already exists')
|
||||
e.statusCode = 409
|
||||
throw e
|
||||
}
|
||||
const hashedPassword = bcrypt.hashSync(input.password, 12)
|
||||
const now = new Date().toISOString()
|
||||
const { v4: uuidv4 } = await import('uuid')
|
||||
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,
|
||||
input.username,
|
||||
FieldEncryption.encrypt(input.email),
|
||||
emailHash,
|
||||
hashedPassword,
|
||||
assignedRole,
|
||||
input.employeeId || null,
|
||||
1,
|
||||
now,
|
||||
now
|
||||
)
|
||||
return { id: userId, role: assignedRole }
|
||||
}
|
||||
|
||||
export function updateUserRoleUC(id: string, role: 'admin' | 'superuser' | 'user') {
|
||||
db.prepare(`
|
||||
UPDATE users SET role = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(role, new Date().toISOString(), id)
|
||||
}
|
||||
|
||||
23
backend/src/validation/employeeValidators.ts
Normale Datei
23
backend/src/validation/employeeValidators.ts
Normale Datei
@ -0,0 +1,23 @@
|
||||
import { body } from 'express-validator'
|
||||
|
||||
export const createEmployeeValidators = [
|
||||
body('firstName').notEmpty().trim().escape(),
|
||||
body('lastName').notEmpty().trim().escape(),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('department').notEmpty().trim().escape(),
|
||||
body('position').optional().trim().escape(),
|
||||
body('phone').optional().trim(),
|
||||
body('employeeNumber').optional().trim(),
|
||||
]
|
||||
|
||||
export const updateEmployeeValidators = [
|
||||
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'])
|
||||
]
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren