Dieser Commit ist enthalten in:
Claude Project Manager
2025-10-16 08:24:01 +02:00
Ursprung 01d0988515
Commit 4d509d255f
51 geänderte Dateien mit 5922 neuen und 1502 gelöschten Zeilen

Datei anzeigen

@ -3,32 +3,43 @@
const fs = require('fs')
const path = require('path')
const vm = require('vm')
const Database = require('better-sqlite3')
function parseFrontendHierarchy() {
function loadHierarchy() {
const sharedPath = path.join(process.cwd(), '..', 'shared', 'skills.js')
if (fs.existsSync(sharedPath)) {
const sharedModule = require(sharedPath)
if (Array.isArray(sharedModule?.SKILL_HIERARCHY)) {
return sharedModule.SKILL_HIERARCHY
}
throw new Error('SKILL_HIERARCHY missing or invalid in shared/skills.js')
}
const tsPath = path.join(process.cwd(), '..', 'frontend', 'src', 'data', 'skillCategories.ts')
if (!fs.existsSync(tsPath)) {
throw new Error('No skill hierarchy definition found in shared/skills.js or frontend/src/data/skillCategories.ts')
}
const src = fs.readFileSync(tsPath, 'utf8')
// Remove interface declarations and LANGUAGE_LEVELS export, keep the array literal
let code = src
.replace(/export interface[\s\S]*?\n\}/g, '')
.replace(/export const LANGUAGE_LEVELS[\s\S]*?\n\n/, '')
.replace(/export const SKILL_HIERARCHY:[^=]*=/, 'module.exports =')
const sandbox = { module: {}, exports: {} }
vm.createContext(sandbox)
vm.runInContext(code, sandbox)
return sandbox.module.exports || sandbox.exports
require('vm').runInNewContext(code, sandbox)
const hierarchy = sandbox.module?.exports || sandbox.exports
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
return hierarchy
}
function main() {
const dbPath = path.join(process.cwd(), 'skillmate.dev.encrypted.db')
const db = new Database(dbPath)
try {
const hierarchy = parseFrontendHierarchy()
if (!Array.isArray(hierarchy)) {
throw new Error('Parsed hierarchy is not an array')
}
const hierarchy = loadHierarchy()
const insert = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, expires_after)

Datei anzeigen

@ -207,6 +207,86 @@ export function initializeSecureDatabase() {
CREATE INDEX IF NOT EXISTS idx_employees_phone_hash ON employees(phone_hash);
`)
// Official titles catalog managed via admin panel
db.exec(`
CREATE TABLE IF NOT EXISTS official_titles (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
order_index INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_official_titles_label ON official_titles(label COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_official_titles_order ON official_titles(order_index);
`)
try {
const existingTitles = db.prepare('SELECT COUNT(*) as count FROM official_titles').get() as { count: number }
if (!existingTitles || existingTitles.count === 0) {
const defaults = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
`)
defaults.forEach((label, index) => {
insert.run(uuidv4(), label, index, now, now)
})
}
} catch (error) {
console.warn('Failed to seed official titles:', error)
}
// Position catalog managed via admin panel (optionally scoped per organisationseinheit)
db.exec(`
CREATE TABLE IF NOT EXISTS position_catalog (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
organization_unit_id TEXT,
order_index INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_position_catalog_unit ON position_catalog(organization_unit_id);
CREATE INDEX IF NOT EXISTS idx_position_catalog_order ON position_catalog(order_index);
CREATE UNIQUE INDEX IF NOT EXISTS idx_position_catalog_unique ON position_catalog(label COLLATE NOCASE, IFNULL(organization_unit_id, 'GLOBAL'));
`)
try {
const existingPositions = db.prepare('SELECT COUNT(*) as count FROM position_catalog WHERE organization_unit_id IS NULL').get() as { count: number }
if (!existingPositions || existingPositions.count === 0) {
const defaults = [
'Sachbearbeitung',
'stellvertretende Sachgebietsleitung',
'Sachgebietsleitung',
'Dezernatsleitung',
'Abteilungsleitung',
'Behördenleitung'
]
const now = new Date().toISOString()
const insert = db.prepare(`
INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
VALUES (?, ?, NULL, ?, 1, ?, ?)
`)
defaults.forEach((label, index) => {
insert.run(uuidv4(), label, index, now, now)
})
}
} catch (error) {
console.warn('Failed to seed position catalog:', error)
}
// Users table with encrypted email
db.exec(`
CREATE TABLE IF NOT EXISTS users (
@ -217,19 +297,37 @@ export function initializeSecureDatabase() {
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
power_unit_id TEXT,
power_function TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(email_hash)
UNIQUE(email_hash),
FOREIGN KEY(power_unit_id) REFERENCES organizational_units(id)
)
`)
// Create index for email hash
db.exec(`
CREATE INDEX IF NOT EXISTS idx_users_email_hash ON users(email_hash);
`)
// Ensure new power user columns exist (legacy migrations)
try {
const userCols: any[] = db.prepare(`PRAGMA table_info(users)`).all() as any
const hasPowerUnit = userCols.some(c => c.name === 'power_unit_id')
const hasPowerFunction = userCols.some(c => c.name === 'power_function')
if (!hasPowerUnit) {
db.exec(`ALTER TABLE users ADD COLUMN power_unit_id TEXT`)
}
if (!hasPowerFunction) {
db.exec(`ALTER TABLE users ADD COLUMN power_function TEXT`)
}
} catch (error) {
console.error('Failed to ensure power user columns:', error)
}
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (

Datei anzeigen

@ -16,6 +16,8 @@ import workspaceRoutes from './routes/workspaces'
import userRoutes from './routes/users'
import userAdminRoutes from './routes/usersAdmin'
import settingsRoutes from './routes/settings'
import officialTitlesRoutes from './routes/officialTitles'
import positionsRoutes from './routes/positions'
import organizationRoutes from './routes/organization'
import organizationImportRoutes from './routes/organizationImport'
import employeeOrganizationRoutes from './routes/employeeOrganization'
@ -65,6 +67,8 @@ 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/positions', positionsRoutes)
app.use('/api/official-titles', officialTitlesRoutes)
app.use('/api/organization', organizationRoutes)
app.use('/api/organization', organizationImportRoutes)
app.use('/api', employeeOrganizationRoutes)

Datei anzeigen

@ -2,10 +2,11 @@ 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 { db, encryptedDb } from '../config/secureDatabase'
import { User, LoginRequest, LoginResponse, POWER_FUNCTIONS } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { logger } from '../utils/logger'
import { emailService } from '../services/emailService'
const router = Router()
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'
@ -77,7 +78,20 @@ router.post('/login',
const now = new Date().toISOString()
db.prepare('UPDATE users SET last_login = ? WHERE id = ?').run(now, userRow.id)
// Enrich with power user meta (unit + function)
const power = db.prepare(`
SELECT u.power_unit_id as powerUnitId,
u.power_function as powerFunction,
ou.name as powerUnitName,
ou.type as powerUnitType
FROM users u
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
WHERE u.id = ?
`).get(userRow.id) as any
// Create user object without password (decrypt email)
const powerFunctionId = power?.powerFunction || null
const powerDefinition = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
const user: User = {
id: userRow.id,
username: userRow.username,
@ -87,7 +101,12 @@ router.post('/login',
lastLogin: new Date(now),
isActive: Boolean(userRow.is_active),
createdAt: new Date(userRow.created_at),
updatedAt: new Date(userRow.updated_at)
updatedAt: new Date(userRow.updated_at),
powerUnitId: power?.powerUnitId || null,
powerUnitName: power?.powerUnitName || null,
powerUnitType: power?.powerUnitType || null,
powerFunction: powerFunctionId,
canManageEmployees: userRow.role === 'admin' || (userRow.role === 'superuser' && Boolean(powerDefinition?.canManageEmployees))
}
// Generate token
@ -114,8 +133,87 @@ router.post('/login',
}
)
router.post('/forgot-password',
[
body('email').isEmail().normalizeEmail()
],
async (req: Request, res: Response, next: NextFunction) => {
const genericMessage = 'Falls die angegebene E-Mail im System hinterlegt ist, erhalten Sie in Kürze ein neues Passwort.'
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
// Immer generische Antwort senden, um keine Information preiszugeben
logger.warn('Forgot password request received with invalid email input')
return res.json({ success: true, message: genericMessage })
}
const { email } = req.body as { email: string }
const normalizedEmail = email.trim().toLowerCase()
const emailHash = FieldEncryption.hash(normalizedEmail)
const userRow = db.prepare(`
SELECT id, username, email, employee_id, is_active
FROM users
WHERE email_hash = ?
`).get(emailHash) as any
if (!userRow || !userRow.is_active) {
logger.info('Forgot password request for non-existing or inactive account')
return res.json({ success: true, message: genericMessage })
}
const decryptedEmail = FieldEncryption.decrypt(userRow.email) || normalizedEmail
let firstName: string | undefined
if (userRow.employee_id) {
try {
const employee = encryptedDb.getEmployee(userRow.employee_id)
if (employee?.first_name) {
firstName = employee.first_name
}
} catch (error) {
logger.warn(`Failed to resolve employee for password reset: ${error}`)
}
}
const emailNotificationsSetting = db.prepare('SELECT value FROM system_settings WHERE key = ?').get('email_notifications_enabled') as any
const emailNotificationsEnabled = emailNotificationsSetting?.value === 'true'
const canSendEmail = emailNotificationsEnabled && emailService.isServiceEnabled()
if (!canSendEmail) {
logger.warn('Password reset requested but email notifications are disabled or email service unavailable')
return res.json({ success: true, message: genericMessage })
}
const temporaryPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const sent = await emailService.sendInitialPassword(decryptedEmail, temporaryPassword, firstName)
if (!sent) {
logger.warn(`Password reset email could not be sent to ${decryptedEmail}`)
return res.json({ success: true, message: genericMessage })
}
const hashedPassword = await bcrypt.hash(temporaryPassword, 12)
const now = new Date().toISOString()
db.prepare(`
UPDATE users
SET password = ?, updated_at = ?
WHERE id = ?
`).run(hashedPassword, now, userRow.id)
logger.info(`Password reset processed for user ${userRow.username}`)
return res.json({ success: true, message: genericMessage })
} catch (error) {
logger.error('Error processing forgot password request:', error)
return res.json({ success: true, message: genericMessage })
}
}
)
router.post('/logout', (req, res) => {
res.json({ success: true, message: 'Logged out successfully' })
})
export default router
export default router

Datei anzeigen

@ -82,17 +82,24 @@ router.put('/employee/:employeeId/organization', authenticate, async (req: AuthR
assignmentId, employeeId, unitId, 'mitarbeiter',
now, 1, now, now
)
// Keep employees.primary_unit_id in sync for listings
db.prepare(`
UPDATE employees
SET primary_unit_id = ?, updated_at = ?
WHERE id = ?
`).run(unitId, now, employeeId)
}
// Update employee's department field for backward compatibility
if (unitId) {
const unitInfo = db.prepare('SELECT name FROM organizational_units WHERE id = ?').get(unitId) as any
const unitInfo = db.prepare('SELECT code, name FROM organizational_units WHERE id = ?').get(unitId) as any
if (unitInfo) {
db.prepare(`
UPDATE employees
SET department = ?, updated_at = ?
WHERE id = ?
`).run(unitInfo.name, now, employeeId)
`).run(unitInfo.code || unitInfo.name, now, employeeId)
}
} else {
// Clear department if no unit
@ -163,4 +170,4 @@ router.get('/unit/:unitId/employees', authenticate, async (req: AuthRequest, res
}
})
export default router
export default router

Datei anzeigen

@ -5,12 +5,99 @@ 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, EmployeeUnitRole } from '@skillmate/shared'
import { Employee, LanguageSkill, Skill, UserRole, EmployeeUnitRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { decodeHtmlEntities } from '../utils/html'
import { createDepartmentResolver } from '../utils/department'
const router = Router()
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function clearActiveDeputiesForPrincipal(principalId: string) {
const now = new Date()
const past = new Date(now.getTime() - 1000)
const pastSql = toSqlDateTime(past)
const nowSql = toSqlDateTime(now)
db.prepare(`
UPDATE deputy_assignments
SET valid_until = ?, updated_at = ?
WHERE principal_id = ?
AND valid_until >= datetime('now')
`).run(pastSql, nowSql, principalId)
db.prepare(`
DELETE FROM deputy_assignments
WHERE principal_id = ?
AND valid_from > datetime('now')
`).run(principalId)
}
const resolveDepartmentInfo = createDepartmentResolver(db)
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.deputy_id as deputyId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.deputy_id
WHERE da.principal_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(principalId) as any[]
return rows.map(row => ({
id: row.deputyId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.principal_id as principalId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.principal_id
WHERE da.deputy_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(deputyId) as any[]
return rows.map(row => ({
id: row.principalId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
// 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'> = {
@ -34,15 +121,65 @@ function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'nativ
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, official_title,
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
SELECT
e.id,
e.first_name,
e.last_name,
e.employee_number,
e.photo,
e.position,
e.official_title,
e.department,
e.email,
e.phone,
e.mobile,
e.office,
e.availability,
e.primary_unit_id as primaryUnitId,
ou.code as primaryUnitCode,
ou.name as primaryUnitName,
ou.description as primaryUnitDescription,
e.clearance_level,
e.clearance_valid_until,
e.clearance_issued_date,
e.created_at,
e.updated_at,
e.created_by,
e.updated_by
FROM employees e
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
ORDER BY e.last_name, e.first_name
`).all()
const employeesWithDetails = employees.map((emp: any) => {
const decodeValue = (val: string | null) => {
if (val === null || val === undefined) return undefined
return decodeHtmlEntities(val) ?? val
}
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
const decodedPosition = decodeValue(emp.position) || emp.position
const decodedOfficialTitle = decodeValue(emp.official_title)
const decodedDepartmentRaw = decodeValue(emp.department)
const decodedEmail = decodeValue(emp.email) || emp.email
const decodedPhone = decodeValue(emp.phone) || emp.phone
const decodedMobile = decodeValue(emp.mobile)
const decodedOffice = decodeValue(emp.office)
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primaryUnitId,
primaryUnitCode: emp.primaryUnitCode,
primaryUnitName: emp.primaryUnitName,
primaryUnitDescription: emp.primaryUnitDescription,
})
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
const departmentDescription = departmentInfo.description
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
// Get skills
const skills = db.prepare(`
SELECT s.id, s.name, s.category, es.level, es.verified, es.verified_by, es.verified_date
@ -65,17 +202,22 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
firstName: decodedFirstName,
lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
position: decodedPosition,
officialTitle: decodedOfficialTitle,
department: departmentLabel,
departmentDescription,
departmentTasks,
primaryUnitId: emp.primaryUnitId || undefined,
primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@ -99,7 +241,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -117,12 +261,34 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
const { id } = req.params
const emp = db.prepare(`
SELECT id, first_name, last_name, employee_number, photo, position, official_title,
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 = ?
SELECT
e.id,
e.first_name,
e.last_name,
e.employee_number,
e.photo,
e.position,
e.official_title,
e.department,
e.email,
e.phone,
e.mobile,
e.office,
e.availability,
e.primary_unit_id as primaryUnitId,
ou.code as primaryUnitCode,
ou.name as primaryUnitName,
ou.description as primaryUnitDescription,
e.clearance_level,
e.clearance_valid_until,
e.clearance_issued_date,
e.created_at,
e.updated_at,
e.created_by,
e.updated_by
FROM employees e
LEFT JOIN organizational_units ou ON ou.id = e.primary_unit_id
WHERE e.id = ?
`).get(id) as any
if (!emp) {
@ -152,19 +318,52 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const decodeValue = (val: string | null) => {
if (val === null || val === undefined) return undefined
return decodeHtmlEntities(val) ?? val
}
const decodedFirstName = decodeValue(emp.first_name) || emp.first_name
const decodedLastName = decodeValue(emp.last_name) || emp.last_name
const decodedPosition = decodeValue(emp.position) || emp.position
const decodedOfficialTitle = decodeValue(emp.official_title)
const decodedDepartmentRaw = decodeValue(emp.department)
const decodedEmail = decodeValue(emp.email) || emp.email
const decodedPhone = decodeValue(emp.phone) || emp.phone
const decodedMobile = decodeValue(emp.mobile)
const decodedOffice = decodeValue(emp.office)
const decodedPrimaryUnitCode = decodeValue(emp.primaryUnitCode) || undefined
const decodedPrimaryUnitName = decodeValue(emp.primaryUnitName) || undefined
const decodedPrimaryUnitDescription = decodeValue(emp.primaryUnitDescription) || undefined
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primaryUnitId,
primaryUnitCode: emp.primaryUnitCode,
primaryUnitName: emp.primaryUnitName,
primaryUnitDescription: emp.primaryUnitDescription,
})
const departmentLabel = departmentInfo.label || (decodedDepartmentRaw || '')
const departmentDescription = departmentInfo.description
const departmentTasks = departmentInfo.tasks || decodedPrimaryUnitDescription
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
lastName: emp.last_name,
firstName: decodedFirstName,
lastName: decodedLastName,
employeeNumber: emp.employee_number,
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
office: emp.office,
position: decodedPosition,
officialTitle: decodedOfficialTitle,
department: departmentLabel,
departmentDescription,
departmentTasks,
primaryUnitId: emp.primaryUnitId || undefined,
primaryUnitCode: decodedPrimaryUnitCode,
primaryUnitName: decodedPrimaryUnitName,
email: decodedEmail,
phone: decodedPhone,
mobile: decodedMobile,
office: decodedOffice,
availability: emp.availability,
skills: skills.map((s: any) => ({
id: s.id,
@ -188,7 +387,9 @@ router.get('/:id', authenticate, requirePermission('employees:read'), async (req
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
res.json({ success: true, data: employee })
@ -204,11 +405,17 @@ router.post('/',
[
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('employeeNumber').optional({ checkFalsy: true }).trim(),
body('position').optional({ checkFalsy: true }).trim(),
body('officialTitle').optional().trim(),
body('email').isEmail(),
body('department').notEmpty().trim(),
body('organizationUnitId').optional({ checkFalsy: true }).isUUID(),
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter'])
body('department').optional({ checkFalsy: true }).trim(),
body('phone').optional({ checkFalsy: true }).trim(),
body('mobile').optional({ checkFalsy: true }).trim(),
body('office').optional({ checkFalsy: true }).trim(),
body('organizationUnitId').notEmpty().isUUID(),
body('organizationRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -227,9 +434,16 @@ router.post('/',
firstName, lastName, employeeNumber, photo, position, officialTitle,
department, email, phone, mobile, office, availability,
clearance, skills, languages, specializations,
userRole, createUser, organizationUnitId, organizationRole
userRole, createUser, organizationUnitId: organizationUnitIdRaw, organizationRole, powerFunction
} = req.body
const organizationUnitId = typeof organizationUnitIdRaw === 'string' ? organizationUnitIdRaw : ''
const normalizedDepartment = typeof department === 'string' ? department.trim() : ''
if (!organizationUnitId) {
return res.status(400).json({ success: false, error: { message: 'Organisatorische Einheit ist erforderlich' } })
}
if (organizationRole && !organizationUnitId) {
return res.status(400).json({
success: false,
@ -237,20 +451,66 @@ router.post('/',
})
}
let resolvedDepartment = department
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
if (userRole === 'superuser' && !requestedPowerFunction) {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
}
if (userRole === 'superuser' && !organizationUnitId && req.user?.role !== 'superuser') {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
}
let resolvedDepartment = normalizedDepartment
let resolvedUnitId: string | null = null
let resolvedUnitRole: EmployeeUnitRole = 'mitarbeiter'
if (organizationUnitId) {
const unitRow = db.prepare('SELECT id, name FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; name: string } | undefined
const unitRow = db.prepare('SELECT id, code, name, type FROM organizational_units WHERE id = ? AND is_active = 1').get(organizationUnitId) as { id: string; code: string | null; name: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organization unit not found' } })
}
resolvedUnitId = unitRow.id
resolvedDepartment = unitRow.name
resolvedDepartment = unitRow.code || unitRow.name
if (organizationRole) {
resolvedUnitRole = organizationRole as EmployeeUnitRole
}
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({
success: false,
error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unitRow.type} zugeordnet werden` }
})
}
}
if (req.user?.role === 'superuser') {
const canManage = req.user.canManageEmployees
if (!canManage) {
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
}
if (!req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
}
if (organizationUnitId && organizationUnitId !== req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
}
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
if (!unitRow) {
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
}
resolvedUnitId = unitRow.id
resolvedDepartment = unitRow.code || unitRow.name
resolvedUnitRole = 'mitarbeiter'
}
if (!resolvedDepartment) {
resolvedDepartment = 'Noch nicht zugewiesen'
}
// Insert employee with default values for missing fields
@ -373,12 +633,35 @@ router.post('/',
// Encrypt email for user table storage
const encryptedEmail = FieldEncryption.encrypt(email)
const emailHash = FieldEncryption.hash(email)
let powerUnitForUser: string | null = null
let powerFunctionForUser: string | null = null
if (userRole === 'superuser') {
if (!resolvedUnitId || !requestedPowerFunction) {
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
}
powerUnitForUser = resolvedUnitId
powerFunctionForUser = requestedPowerFunction
}
db.prepare(`
INSERT INTO users (id, username, email, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId, email, encryptedEmail, hashedPassword, userRole, employeeId, 1, now, now
userId,
email,
encryptedEmail,
emailHash,
hashedPassword,
userRole,
employeeId,
powerUnitForUser,
powerFunctionForUser,
1,
now,
now
)
console.log(`User created for employee ${firstName} ${lastName} with role ${userRole}`)
@ -438,7 +721,7 @@ router.put('/:id',
body('department').notEmpty().trim(),
body('email').isEmail(),
body('phone').notEmpty().trim(),
body('availability').isIn(['available', 'parttime', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
body('availability').isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -459,7 +742,7 @@ router.put('/:id',
} = req.body
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@ -482,6 +765,10 @@ router.put('/:id',
now, req.user!.id, id
)
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
// Update skills
if (skills !== undefined) {
// Delete existing skills
@ -530,6 +817,10 @@ router.put('/:id',
await syncService.queueSync('employees', 'update', updatedEmployee)
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
res.json({
success: true,
message: 'Employee updated successfully'
@ -549,7 +840,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,

Datei anzeigen

@ -5,13 +5,99 @@ 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 { Employee, LanguageSkill, Skill, UserRole, EmployeeDeputySummary, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { syncService } from '../services/syncService'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
import { createDepartmentResolver } from '../utils/department'
const router = Router()
const resolveDepartmentInfo = createDepartmentResolver(db)
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function clearActiveDeputiesForPrincipal(principalId: string) {
const now = new Date()
const past = new Date(now.getTime() - 1000)
const pastSql = toSqlDateTime(past)
const nowSql = toSqlDateTime(now)
db.prepare(`
UPDATE deputy_assignments
SET valid_until = ?, updated_at = ?
WHERE principal_id = ?
AND valid_until >= datetime('now')
`).run(pastSql, nowSql, principalId)
db.prepare(`
DELETE FROM deputy_assignments
WHERE principal_id = ?
AND valid_from > datetime('now')
`).run(principalId)
}
function getActiveDeputies(principalId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.deputy_id as deputyId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.deputy_id
WHERE da.principal_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(principalId) as any[]
return rows.map(row => ({
id: row.deputyId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
function getActivePrincipals(deputyId: string): EmployeeDeputySummary[] {
const rows = db.prepare(`
SELECT
da.id as assignmentId,
da.principal_id as principalId,
e.first_name as firstName,
e.last_name as lastName,
e.availability as availability,
e.position as position
FROM deputy_assignments da
JOIN employees e ON e.id = da.principal_id
WHERE da.deputy_id = ?
AND da.valid_from <= datetime('now')
AND da.valid_until >= datetime('now')
ORDER BY e.last_name, e.first_name
`).all(deputyId) as any[]
return rows.map(row => ({
id: row.principalId,
assignmentId: row.assignmentId,
firstName: row.firstName,
lastName: row.lastName,
availability: row.availability,
position: row.position || undefined
}))
}
// Helper function to map old proficiency to new level
function mapProficiencyToLevel(proficiency: string): 'basic' | 'fluent' | 'native' | 'business' {
@ -114,6 +200,11 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@ -122,7 +213,9 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@ -150,7 +243,10 @@ router.get('/', authenticate, requirePermission('employees:read'), async (req: A
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
primaryUnitId: emp.primary_unit_id || undefined,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -197,6 +293,11 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
SELECT name FROM specializations WHERE employee_id = ?
`).all(emp.id).map((s: any) => s.name)
const departmentInfo = resolveDepartmentInfo({
department: emp.department,
primaryUnitId: emp.primary_unit_id,
})
const employee: Employee = {
id: emp.id,
firstName: emp.first_name,
@ -205,7 +306,9 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
photo: emp.photo,
position: emp.position,
officialTitle: emp.official_title || undefined,
department: emp.department,
department: departmentInfo.label || emp.department,
departmentDescription: departmentInfo.description,
departmentTasks: departmentInfo.tasks,
email: emp.email,
phone: emp.phone,
mobile: emp.mobile,
@ -233,7 +336,10 @@ router.get('/public', authenticate, async (req: AuthRequest, res, next) => {
createdAt: new Date(emp.created_at),
updatedAt: new Date(emp.updated_at),
createdBy: emp.created_by,
updatedBy: emp.updated_by
updatedBy: emp.updated_by,
primaryUnitId: emp.primary_unit_id || undefined,
currentDeputies: getActiveDeputies(emp.id),
represents: getActivePrincipals(emp.id)
}
return employee
@ -334,13 +440,16 @@ router.post('/',
authenticate,
requirePermission('employees:create'),
[
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(), // Optional
body('department').notEmpty().trim(),
body('position').optional().trim(), // Optional
body('phone').optional().trim(), // Optional - kann später ergänzt werden
body('employeeNumber').optional().trim() // Optional - wird automatisch generiert wenn leer
body('employeeNumber').optional().trim(), // Optional - wird automatisch generiert wenn leer
body('primaryUnitId').optional({ checkFalsy: true }).isUUID(),
body('assignmentRole').optional({ checkFalsy: true }).isIn(['leiter', 'stellvertreter', 'mitarbeiter', 'beauftragter']),
body('powerFunction').optional({ checkFalsy: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@ -358,10 +467,21 @@ router.post('/',
const {
firstName, lastName, employeeNumber, photo, position = 'Teammitglied', officialTitle,
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole
} = req.body
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole, powerFunction
} = req.body
const requestedPowerFunction = typeof powerFunction === 'string' && powerFunction.length > 0 ? powerFunction : null
const powerFunctionDef = requestedPowerFunction ? POWER_FUNCTIONS.find(def => def.id === requestedPowerFunction) : undefined
if (userRole === 'superuser' && !requestedPowerFunction) {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert die Auswahl einer Funktion' } })
}
if (userRole === 'superuser' && !primaryUnitId && req.user?.role !== 'superuser') {
return res.status(400).json({ success: false, error: { message: 'Poweruser erfordert eine Organisationseinheit' } })
}
// Generate employee number if not provided
const finalEmployeeNumber = employeeNumber || `EMP${Date.now()}`
@ -375,6 +495,48 @@ router.post('/',
})
}
let resolvedDepartment = department
let resolvedPrimaryUnitId: string | null = primaryUnitId || null
let resolvedAssignmentRole = assignmentRole || 'mitarbeiter'
if (primaryUnitId) {
const unit = db.prepare('SELECT id, type, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(primaryUnitId) as { id: string; type: OrganizationalUnitType; code: string | null; name: string } | undefined
if (!unit) {
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
}
if (requestedPowerFunction && powerFunctionDef && !powerFunctionDef.unitTypes.includes(unit.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${powerFunctionDef.label} kann nicht einer Einheit vom Typ ${unit.type} zugeordnet werden` } })
}
resolvedDepartment = unit.code || unit.name
resolvedPrimaryUnitId = unit.id
}
if (req.user?.role === 'superuser') {
const canManage = req.user.canManageEmployees
if (!canManage) {
return res.status(403).json({ success: false, error: { message: 'Keine Berechtigung zum Anlegen von Mitarbeitenden' } })
}
if (!req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Poweruser hat keine Organisationseinheit zugewiesen' } })
}
if (primaryUnitId && primaryUnitId !== req.user.powerUnitId) {
return res.status(403).json({ success: false, error: { message: 'Mitarbeitende dürfen nur im eigenen Bereich angelegt werden' } })
}
const unitRow = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ?').get(req.user.powerUnitId) as { id: string; code: string | null; name: string } | undefined
if (!unitRow) {
return res.status(403).json({ success: false, error: { message: 'Zugeordnete Organisationseinheit nicht gefunden' } })
}
resolvedPrimaryUnitId = unitRow.id
resolvedDepartment = unitRow.code || unitRow.name
resolvedAssignmentRole = 'mitarbeiter'
}
// Insert employee with encrypted fields
encryptedDb.insertEmployee({
id: employeeId,
@ -384,7 +546,7 @@ router.post('/',
photo: photo || null,
position,
official_title: officialTitle || null,
department,
department: resolvedDepartment,
email,
phone,
mobile: mobile || null,
@ -393,24 +555,20 @@ router.post('/',
clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null,
primary_unit_id: primaryUnitId || null,
primary_unit_id: resolvedPrimaryUnitId,
created_at: now,
updated_at: now,
created_by: req.user!.id
})
// Create primary assignment if provided
if (primaryUnitId) {
const unit = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(primaryUnitId)
if (!unit) {
return res.status(400).json({ success: false, error: { message: 'Invalid primary unit' } })
}
if (resolvedPrimaryUnitId) {
db.prepare(`
INSERT INTO employee_unit_assignments (
id, employee_id, unit_id, role, start_date, end_date, is_primary, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(), employeeId, primaryUnitId, assignmentRole || 'mitarbeiter',
uuidv4(), employeeId, resolvedPrimaryUnitId, resolvedAssignmentRole,
now, null, 1, now, now
)
}
@ -486,10 +644,20 @@ router.post('/',
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'
let powerUnitForUser: string | null = null
let powerFunctionForUser: string | null = null
if (assignedRole === 'superuser') {
if (!resolvedPrimaryUnitId || !requestedPowerFunction) {
throw new Error('Poweruser benötigt Organisationseinheit und Funktion')
}
powerUnitForUser = resolvedPrimaryUnitId
powerFunctionForUser = requestedPowerFunction
}
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
email,
@ -498,6 +666,8 @@ router.post('/',
hashedPassword,
assignedRole,
employeeId,
powerUnitForUser,
powerFunctionForUser,
1,
now,
now
@ -593,15 +763,15 @@ 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('officialTitle').optional().trim().escape(),
body('department').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('position').optional().trim(),
body('officialTitle').optional().trim(),
body('department').notEmpty().trim(),
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'])
body('availability').optional().isIn(['available', 'unavailable', 'busy', 'away', 'vacation', 'sick', 'training', 'operation'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
const transaction = db.transaction(() => {
@ -618,13 +788,13 @@ router.put('/:id',
const now = new Date().toISOString()
const {
firstName, lastName, position = 'Teammitglied', officialTitle, department, email, phone = 'Nicht angegeben',
firstName, lastName, position = 'Teammitglied', officialTitle, 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)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
@ -727,6 +897,10 @@ router.put('/:id',
logger.error('Failed to queue sync:', err)
})
if (availability === 'available') {
clearActiveDeputiesForPrincipal(id)
}
return res.json({
success: true,
message: 'Employee updated successfully'
@ -762,7 +936,7 @@ router.delete('/:id',
const { id } = req.params
// Check if employee exists
const existing = db.prepare('SELECT id FROM employees WHERE id = ?').get(id)
const existing = db.prepare('SELECT id, availability FROM employees WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,

Datei anzeigen

@ -0,0 +1,158 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { logger } from '../utils/logger'
const router = Router()
const mapRow = (row: any) => ({
id: row.id,
label: row.label,
orderIndex: row.order_index ?? row.orderIndex ?? 0,
isActive: row.is_active === undefined ? row.isActive ?? true : Boolean(row.is_active),
createdAt: row.created_at ?? row.createdAt,
updatedAt: row.updated_at ?? row.updatedAt,
})
// Public list for regular users (active titles only)
router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const titles = db.prepare(
'SELECT id, label, order_index, is_active FROM official_titles WHERE is_active = 1 ORDER BY order_index ASC, label COLLATE NOCASE ASC'
).all()
res.json({
success: true,
data: titles.map((row: any) => ({ id: row.id, label: row.label })),
})
} catch (error) {
next(error)
}
})
// Admin list with full metadata
router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const titles = db.prepare(
'SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles ORDER BY order_index ASC, label COLLATE NOCASE ASC'
).all()
res.json({ success: true, data: titles.map(mapRow) })
} catch (error) {
next(error)
}
})
router.post('/',
authenticate,
requirePermission('settings:update'),
[body('label').trim().notEmpty()],
(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 { label } = req.body as { label: string }
const now = new Date().toISOString()
const maxOrder = db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM official_titles').get() as { maxOrder: number }
const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
const id = uuidv4()
db.prepare(`
INSERT INTO official_titles (id, label, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
`).run(id, label.trim(), nextOrder, now, now)
logger.info(`Official title "${label}" created by user ${req.user?.username}`)
res.status(201).json({ success: true, data: { id, label: label.trim(), orderIndex: nextOrder, isActive: true } })
} catch (error) {
next(error)
}
}
)
router.put('/:id',
authenticate,
requirePermission('settings:update'),
[
body('label').optional().trim().notEmpty(),
body('isActive').optional().isBoolean(),
body('orderIndex').optional().isInt({ min: 0 }),
],
(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 { label, isActive, orderIndex } = req.body as { label?: string; isActive?: boolean; orderIndex?: number }
const now = new Date().toISOString()
const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
}
const fields: string[] = []
const values: any[] = []
if (label !== undefined) {
fields.push('label = ?')
values.push(label.trim())
}
if (isActive !== undefined) {
fields.push('is_active = ?')
values.push(isActive ? 1 : 0)
}
if (orderIndex !== undefined) {
fields.push('order_index = ?')
values.push(orderIndex)
}
if (fields.length === 0) {
return res.json({ success: true, message: 'No changes applied' })
}
fields.push('updated_at = ?')
values.push(now)
values.push(id)
db.prepare(`
UPDATE official_titles SET ${fields.join(', ')} WHERE id = ?
`).run(...values)
logger.info(`Official title ${id} updated by user ${req.user?.username}`)
const updated = db.prepare('SELECT id, label, order_index, is_active, created_at, updated_at FROM official_titles WHERE id = ?').get(id)
res.json({ success: true, data: mapRow(updated) })
} catch (error) {
next(error)
}
}
)
router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const existing = db.prepare('SELECT id FROM official_titles WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Official title not found' } })
}
db.prepare('DELETE FROM official_titles WHERE id = ?').run(id)
logger.info(`Official title ${id} deleted by user ${req.user?.username}`)
res.json({ success: true })
} catch (error) {
next(error)
}
})
export default router

Datei anzeigen

@ -5,15 +5,72 @@ import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import {
OrganizationalUnit,
OrganizationalUnitType,
EmployeeUnitAssignment,
SpecialPosition,
DeputyAssignment,
DeputyDelegation
} from '@skillmate/shared'
import { logger } from '../utils/logger'
import { decodeHtmlEntities } from '../utils/html'
const router = Router()
function toSqlDateTime(date: Date): string {
const pad = (value: number) => value.toString().padStart(2, '0')
const year = date.getUTCFullYear()
const month = pad(date.getUTCMonth() + 1)
const day = pad(date.getUTCDate())
const hours = pad(date.getUTCHours())
const minutes = pad(date.getUTCMinutes())
const seconds = pad(date.getUTCSeconds())
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
function normalizeAssignmentRange(validFrom: string, validUntil?: string | null) {
if (!validFrom) {
throw new Error('Startdatum ist erforderlich')
}
if (!validUntil) {
throw new Error('Enddatum ist erforderlich')
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(validFrom) || !/^\d{4}-\d{2}-\d{2}$/.test(validUntil)) {
throw new Error('Datumsangaben müssen im Format JJJJ-MM-TT erfolgen')
}
const startDate = new Date(`${validFrom}T00:00:00Z`)
if (Number.isNaN(startDate.getTime())) {
throw new Error('Ungültiges Startdatum')
}
const endDate = new Date(`${validUntil}T23:59:59Z`)
if (Number.isNaN(endDate.getTime())) {
throw new Error('Ungültiges Enddatum')
}
if (endDate.getTime() < startDate.getTime()) {
throw new Error('Enddatum muss nach dem Startdatum liegen')
}
const startSql = toSqlDateTime(startDate)
const endSql = toSqlDateTime(endDate)
return { startSql, endSql }
}
const PARENT_RULES: Record<OrganizationalUnitType, OrganizationalUnitType[] | null> = {
direktion: null,
abteilung: ['direktion'],
dezernat: ['abteilung'],
sachgebiet: ['dezernat', 'teildezernat'],
teildezernat: ['dezernat'],
ermittlungskommission: ['dezernat'],
fuehrungsstelle: ['abteilung'],
stabsstelle: ['direktion'],
sondereinheit: ['direktion', 'abteilung']
}
// Get all organizational units
router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
try {
@ -32,7 +89,14 @@ router.get('/units', authenticate, async (req: AuthRequest, res, next) => {
ORDER BY level, order_index, name
`).all()
res.json({ success: true, data: units })
const decodedUnits = units.map((unit: any) => ({
...unit,
code: decodeHtmlEntities(unit.code) ?? unit.code,
name: decodeHtmlEntities(unit.name) ?? unit.name,
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
}))
res.json({ success: true, data: decodedUnits })
} catch (error) {
logger.error('Error fetching organizational units:', error)
next(error)
@ -171,6 +235,18 @@ router.get('/hierarchy', authenticate, async (req: AuthRequest, res, next) => {
// Apply sorting from the top
rootUnits.forEach(sortTree)
const decodeTree = (node: any) => {
if (!node) return
node.code = decodeHtmlEntities(node.code) ?? node.code
node.name = decodeHtmlEntities(node.name) ?? node.name
node.description = decodeHtmlEntities(node.description) ?? (decodeHtmlEntities(node.name) ?? node.name)
if (Array.isArray(node.children)) {
node.children.forEach(decodeTree)
}
}
rootUnits.forEach(decodeTree)
res.json({ success: true, data: rootUnits })
} catch (error) {
logger.error('Error building organizational hierarchy:', error)
@ -218,11 +294,28 @@ router.get('/units/:id', authenticate, async (req: AuthRequest, res, next) => {
e.last_name, e.first_name
`).all(req.params.id)
res.json({
success: true,
const decodedUnit = {
...unit,
code: decodeHtmlEntities(unit.code) ?? unit.code,
name: decodeHtmlEntities(unit.name) ?? unit.name,
description: decodeHtmlEntities(unit.description) ?? (decodeHtmlEntities(unit.name) ?? unit.name)
}
const decodedEmployees = employees.map((emp: any) => ({
...emp,
firstName: decodeHtmlEntities(emp.firstName) ?? emp.firstName,
lastName: decodeHtmlEntities(emp.lastName) ?? emp.lastName,
position: decodeHtmlEntities(emp.position) ?? emp.position,
department: decodeHtmlEntities(emp.department) ?? emp.department,
email: decodeHtmlEntities(emp.email) ?? emp.email,
phone: decodeHtmlEntities(emp.phone) ?? emp.phone
}))
res.json({
success: true,
data: {
...unit,
employees
...decodedUnit,
employees: decodedEmployees
}
})
} catch (error) {
@ -237,7 +330,7 @@ router.post('/units',
[
body('code').notEmpty().trim(),
body('name').notEmpty().trim(),
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit']),
body('type').isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('level').isInt({ min: 0, max: 10 }),
body('parentId').optional({ checkFalsy: true }).isUUID()
],
@ -263,6 +356,22 @@ router.post('/units',
return res.status(400).json({ success: false, error: { message: 'Unit code already exists' } })
}
const unitType = type as OrganizationalUnitType
const requiredParents = PARENT_RULES[unitType] ?? null
let parentType: OrganizationalUnitType | null = null
if (parentId) {
const parentRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; type: OrganizationalUnitType } | undefined
if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
}
parentType = parentRow.type
if (requiredParents && requiredParents.length > 0 && !requiredParents.includes(parentType)) {
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
}
} else if (requiredParents && requiredParents.length > 0) {
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
}
// Get max order index for this level
const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM organizational_units WHERE level = ?').get(level) as any
const orderIndex = (maxOrder?.max || 0) + 1
@ -294,9 +403,13 @@ router.put('/units/:id',
authenticate,
[
param('id').isUUID(),
body('code').optional().isString().trim().notEmpty(),
body('name').optional().notEmpty().trim(),
body('type').optional().isIn(['direktion', 'abteilung', 'dezernat', 'sachgebiet', 'teildezernat', 'fuehrungsstelle', 'stabsstelle', 'sondereinheit', 'ermittlungskommission']),
body('description').optional(),
body('color').optional(),
body('hasFuehrungsstelle').optional().isBoolean().toBoolean(),
body('fuehrungsstelleName').optional().isString().trim(),
body('parentId').optional({ checkFalsy: true }).isUUID(),
body('level').optional().isInt({ min: 0, max: 10 }),
// allow updating persisted canvas positions from admin editor
@ -305,67 +418,126 @@ router.put('/units/:id',
],
async (req: AuthRequest, res: any, next: any) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, error: { message: 'Invalid input', details: errors.array() } })
}
if (req.user?.role !== 'admin') {
return res.status(403).json({ success: false, error: { message: 'Admin access required' } })
}
const { name, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const { code, name, type, description, color, hasFuehrungsstelle, fuehrungsstelleName, positionX, positionY, parentId, level } = req.body
const now = new Date().toISOString()
// Optional: validate parentId and avoid cycles
let newParentId: string | null | undefined = undefined
const existingUnit = db.prepare('SELECT id, type, parent_id as parentId FROM organizational_units WHERE id = ?').get(req.params.id) as { id: string; type: OrganizationalUnitType; parentId: string | null } | undefined
if (!existingUnit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
}
// Resolve target type
const targetType: OrganizationalUnitType = (type as OrganizationalUnitType) || existingUnit.type
// Determine final parent
let finalParentId: string | null = existingUnit.parentId || null
let finalParentType: OrganizationalUnitType | null = null
if (parentId !== undefined) {
if (parentId === null || parentId === '' ) {
newParentId = null
if (parentId === null || parentId === '') {
finalParentId = null
} else {
const parent = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(parentId)
if (!parent) {
const parentRow = db.prepare('SELECT id, parent_id as parentId, type FROM organizational_units WHERE id = ? AND is_active = 1').get(parentId) as { id: string; parentId: string | null; type: OrganizationalUnitType } | undefined
if (!parentRow) {
return res.status(404).json({ success: false, error: { message: 'Parent unit not found' } })
}
// cycle check: walk up from parent to root; id must not appear
// cycle check
const targetId = req.params.id
let cursor: any = parent
let cursor: any = parentRow
while (cursor && cursor.parentId) {
if (cursor.parentId === targetId) {
return res.status(400).json({ success: false, error: { message: 'Cyclic parent assignment is not allowed' } })
}
cursor = db.prepare('SELECT id, parent_id as parentId FROM organizational_units WHERE id = ?').get(cursor.parentId)
}
newParentId = parentId
finalParentId = parentRow.id
finalParentType = parentRow.type
}
}
const result = db.prepare(`
UPDATE organizational_units
SET name = COALESCE(?, name),
description = COALESCE(?, description),
color = COALESCE(?, color),
has_fuehrungsstelle = COALESCE(?, has_fuehrungsstelle),
fuehrungsstelle_name = COALESCE(?, fuehrungsstelle_name),
parent_id = COALESCE(?, parent_id),
level = COALESCE(?, level),
position_x = COALESCE(?, position_x),
position_y = COALESCE(?, position_y),
updated_at = ?
WHERE id = ?
`).run(
name || null,
description !== undefined ? description : null,
color || null,
hasFuehrungsstelle !== undefined ? (hasFuehrungsstelle ? 1 : 0) : null,
fuehrungsstelleName || null,
newParentId !== undefined ? newParentId : null,
level !== undefined ? Number(level) : null,
positionX !== undefined ? Math.round(Number(positionX)) : null,
positionY !== undefined ? Math.round(Number(positionY)) : null,
now,
req.params.id
)
if (result.changes === 0) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
if (finalParentId && !finalParentType) {
const parentRow = db.prepare('SELECT type FROM organizational_units WHERE id = ?').get(finalParentId) as { type: OrganizationalUnitType } | undefined
finalParentType = parentRow?.type ?? null
}
const allowedParents = PARENT_RULES[targetType] ?? null
if (allowedParents && allowedParents.length > 0) {
if (!finalParentId) {
return res.status(400).json({ success: false, error: { message: 'Dieser Einheitstyp benötigt eine übergeordnete Einheit' } })
}
if (!finalParentType || !allowedParents.includes(finalParentType)) {
return res.status(400).json({ success: false, error: { message: 'Ungültige übergeordnete Einheit für diesen Typ' } })
}
}
const updates: string[] = []
const params: any[] = []
if (code !== undefined) {
updates.push('code = ?')
params.push(code)
}
if (name !== undefined) {
updates.push('name = ?')
params.push(name)
}
if (type !== undefined) {
updates.push('type = ?')
params.push(type)
}
if (description !== undefined) {
updates.push('description = ?')
params.push(description)
}
if (color !== undefined) {
updates.push('color = ?')
params.push(color)
}
if (hasFuehrungsstelle !== undefined) {
updates.push('has_fuehrungsstelle = ?')
params.push(hasFuehrungsstelle ? 1 : 0)
}
if (fuehrungsstelleName !== undefined) {
updates.push('fuehrungsstelle_name = ?')
params.push(fuehrungsstelleName)
}
if (parentId !== undefined) {
updates.push('parent_id = ?')
params.push(finalParentId)
}
if (level !== undefined) {
updates.push('level = ?')
params.push(Number(level))
}
if (positionX !== undefined) {
updates.push('position_x = ?')
params.push(Math.round(Number(positionX)))
}
if (positionY !== undefined) {
updates.push('position_y = ?')
params.push(Math.round(Number(positionY)))
}
updates.push('updated_at = ?')
params.push(now)
if (updates.length === 0) {
return res.json({ success: true, message: 'No changes applied' })
}
params.push(req.params.id)
const stmt = db.prepare(`UPDATE organizational_units SET ${updates.join(', ')} WHERE id = ?`)
stmt.run(...params)
res.json({ success: true })
} catch (error) {
logger.error('Error updating organizational unit:', error)
@ -405,7 +577,7 @@ router.post('/assignments',
}
// Validate unit exists and is active
const unit = db.prepare('SELECT id FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId)
const unit = db.prepare('SELECT id, code, name FROM organizational_units WHERE id = ? AND is_active = 1').get(unitId) as { id: string; code?: string | null; name?: string | null } | undefined
if (!unit) {
return res.status(404).json({ success: false, error: { message: 'Unit not found' } })
}
@ -427,6 +599,23 @@ router.post('/assignments',
SET is_primary = 0, updated_at = ?
WHERE employee_id = ? AND end_date IS NULL
`).run(now, employeeId)
// Keep employees.primary_unit_id in sync for listings
db.prepare(`
UPDATE employees
SET primary_unit_id = ?, updated_at = ?
WHERE id = ?
`).run(unitId, now, employeeId)
// Update department text (backward compatibility for older UIs/exports)
const deptText = (unit.code && String(unit.code).trim().length > 0) ? unit.code : (unit.name || null)
if (deptText) {
db.prepare(`
UPDATE employees
SET department = ?, updated_at = ?
WHERE id = ?
`).run(deptText, now, employeeId)
}
}
db.prepare(`
@ -544,15 +733,39 @@ router.post('/deputies',
const now = new Date().toISOString()
const assignmentId = uuidv4()
// Check for conflicts
let range
try {
range = normalizeAssignmentRange(validFrom, validUntil)
} catch (error: any) {
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
}
const { startSql, endSql } = range
const conflict = db.prepare(`
SELECT id FROM deputy_assignments
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
FROM deputy_assignments
WHERE principal_id = ? AND deputy_id = ?
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
`).get(req.user.employeeId, deputyId, validFrom, validUntil, validFrom, validUntil)
AND valid_until >= ?
AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
db.prepare(`
UPDATE deputy_assignments
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
WHERE id = ?
`).run(
unitId || conflict.unitId || null,
startSql,
endSql,
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
now,
conflict.id
)
return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@ -563,7 +776,7 @@ router.post('/deputies',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
validFrom, validUntil, reason || null, canDelegate ? 1 : 0,
startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)
@ -581,7 +794,7 @@ router.post('/deputies/my',
[
body('deputyId').isUUID(),
body('validFrom').isISO8601(),
body('validUntil').optional({ nullable: true }).isISO8601(),
body('validUntil').isISO8601(),
body('unitId').optional({ nullable: true }).isUUID()
],
async (req: AuthRequest, res: any, next: any) => {
@ -599,15 +812,39 @@ router.post('/deputies/my',
const now = new Date().toISOString()
const assignmentId = uuidv4()
// Check for conflicts
let range
try {
range = normalizeAssignmentRange(validFrom, validUntil)
} catch (error: any) {
return res.status(400).json({ success: false, error: { message: error?.message || 'Invalid date range' } })
}
const { startSql, endSql } = range
const conflict = db.prepare(`
SELECT id FROM deputy_assignments
SELECT id, unit_id as unitId, reason as existingReason, can_delegate as existingCanDelegate
FROM deputy_assignments
WHERE principal_id = ? AND deputy_id = ?
AND ((valid_from BETWEEN ? AND ?) OR (valid_until BETWEEN ? AND ?))
`).get(req.user.employeeId, deputyId, validFrom, validUntil || validFrom, validFrom, validUntil || validFrom)
AND valid_until >= ?
AND valid_from <= ?
`).get(req.user.employeeId, deputyId, startSql, endSql) as any
if (conflict) {
return res.status(400).json({ success: false, error: { message: 'Conflicting assignment exists' } })
db.prepare(`
UPDATE deputy_assignments
SET unit_id = ?, valid_from = ?, valid_until = ?, reason = ?, can_delegate = ?, updated_at = ?
WHERE id = ?
`).run(
unitId || conflict.unitId || null,
startSql,
endSql,
reason !== undefined ? (reason || null) : (conflict.existingReason || null),
canDelegate !== undefined ? (canDelegate ? 1 : 0) : (conflict.existingCanDelegate ? 1 : 0),
now,
conflict.id
)
return res.json({ success: true, data: { id: conflict.id, updated: true } })
}
db.prepare(`
@ -618,7 +855,7 @@ router.post('/deputies/my',
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assignmentId, req.user.employeeId, deputyId, unitId || null,
validFrom, validUntil || validFrom, reason || null, canDelegate ? 1 : 0,
startSql, endSql, reason || null, canDelegate ? 1 : 0,
req.user.id, now, now
)

Datei anzeigen

@ -0,0 +1,242 @@
import { Router, Response, NextFunction } from 'express'
import { body, validationResult } from 'express-validator'
import { v4 as uuidv4 } from 'uuid'
import { db } from '../config/secureDatabase'
import { authenticate, AuthRequest } from '../middleware/auth'
import { requirePermission } from '../middleware/roleAuth'
import { logger } from '../utils/logger'
const router = Router()
interface PositionRow {
id: string
label: string
organization_unit_id: string | null
order_index?: number
is_active?: number | boolean
created_at?: string
updated_at?: string
}
const mapRow = (row: PositionRow) => ({
id: row.id,
label: row.label,
organizationUnitId: row.organization_unit_id || null,
orderIndex: row.order_index ?? 0,
isActive: row.is_active === undefined ? true : Boolean(row.is_active),
createdAt: row.created_at,
updatedAt: row.updated_at
})
const sanitizeUnitId = (value: unknown): string | null => {
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed || trimmed.toLowerCase() === 'null' || trimmed.toLowerCase() === 'global') {
return null
}
return trimmed
}
router.get('/', authenticate, (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const unitId = sanitizeUnitId(req.query.unitId)
const fetchGlobal = (): PositionRow[] => db.prepare(
`SELECT id, label, organization_unit_id, order_index, is_active
FROM position_catalog
WHERE is_active = 1 AND organization_unit_id IS NULL
ORDER BY order_index ASC, label COLLATE NOCASE ASC`
).all() as PositionRow[]
const fetchScoped = (orgId: string): PositionRow[] => db.prepare(
`SELECT id, label, organization_unit_id, order_index, is_active
FROM position_catalog
WHERE is_active = 1 AND organization_unit_id = ?
ORDER BY order_index ASC, label COLLATE NOCASE ASC`
).all(orgId) as PositionRow[]
let rows: PositionRow[]
if (unitId) {
const scoped = fetchScoped(unitId)
const global = fetchGlobal()
const seen = new Set<string>()
rows = [...scoped, ...global].filter((row) => {
const key = String(row.label || '').trim().toLowerCase()
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
} else {
rows = fetchGlobal()
}
res.json({
success: true,
data: rows.map((row) => ({
id: row.id,
label: row.label,
organizationUnitId: row.organization_unit_id || null
}))
})
} catch (error) {
next(error)
}
})
router.get('/admin', authenticate, requirePermission('settings:read'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const unitId = sanitizeUnitId(req.query.unitId)
const statement = unitId
? db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id = ? ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
: db.prepare(`SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE organization_unit_id IS NULL ORDER BY order_index ASC, label COLLATE NOCASE ASC`)
const rows = (unitId ? statement.all(unitId) : statement.all()) as PositionRow[]
res.json({ success: true, data: rows.map(mapRow) })
} catch (error) {
next(error)
}
})
router.post('/',
authenticate,
requirePermission('settings:update'),
[
body('label').trim().notEmpty(),
body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
],
(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 { label, organizationUnitId } = req.body as { label: string; organizationUnitId?: string | null }
const unitId = sanitizeUnitId(organizationUnitId)
const now = new Date().toISOString()
const id = uuidv4()
const maxOrder = unitId
? (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id = ?').get(unitId) as { maxOrder: number })
: (db.prepare('SELECT COALESCE(MAX(order_index), -1) AS maxOrder FROM position_catalog WHERE organization_unit_id IS NULL').get() as { maxOrder: number })
const nextOrder = (maxOrder?.maxOrder ?? -1) + 1
const insert = db.prepare(`
INSERT INTO position_catalog (id, label, organization_unit_id, order_index, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
`)
try {
insert.run(id, label.trim(), unitId, nextOrder, now, now)
} catch (error: any) {
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
}
throw error
}
logger.info(`Position "${label}" created by ${req.user?.username}${unitId ? ` for unit ${unitId}` : ''}`)
res.status(201).json({ success: true, data: { id, label: label.trim(), organizationUnitId: unitId, orderIndex: nextOrder, isActive: true } })
} catch (error) {
next(error)
}
}
)
router.put('/:id',
authenticate,
requirePermission('settings:update'),
[
body('label').optional().trim().notEmpty(),
body('isActive').optional().isBoolean(),
body('orderIndex').optional().isInt({ min: 0 }),
body('organizationUnitId').optional({ checkFalsy: true }).isString().trim()
],
(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 { label, isActive, orderIndex, organizationUnitId } = req.body as { label?: string; isActive?: boolean; orderIndex?: number; organizationUnitId?: string | null }
const unitId = organizationUnitId !== undefined ? sanitizeUnitId(organizationUnitId) : undefined
const now = new Date().toISOString()
const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
}
const fields: string[] = []
const values: any[] = []
if (label !== undefined) {
fields.push('label = ?')
values.push(label.trim())
}
if (isActive !== undefined) {
fields.push('is_active = ?')
values.push(isActive ? 1 : 0)
}
if (orderIndex !== undefined) {
fields.push('order_index = ?')
values.push(orderIndex)
}
if (unitId !== undefined) {
fields.push('organization_unit_id = ?')
values.push(unitId)
}
if (fields.length === 0) {
return res.json({ success: true, message: 'Keine Änderungen erforderlich.' })
}
fields.push('updated_at = ?')
values.push(now)
values.push(id)
const update = db.prepare(`
UPDATE position_catalog SET ${fields.join(', ')} WHERE id = ?
`)
try {
update.run(...values)
} catch (error: any) {
if (error?.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ success: false, error: { message: 'Position existiert bereits für diese Organisationseinheit.' } })
}
throw error
}
logger.info(`Position ${id} updated by ${req.user?.username}`)
const refreshed = db.prepare('SELECT id, label, organization_unit_id, order_index, is_active, created_at, updated_at FROM position_catalog WHERE id = ?').get(id) as PositionRow | undefined
res.json({ success: true, data: refreshed ? mapRow(refreshed) : null })
} catch (error) {
next(error)
}
}
)
router.delete('/:id', authenticate, requirePermission('settings:update'), (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const existing = db.prepare('SELECT id FROM position_catalog WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ success: false, error: { message: 'Positionseintrag nicht gefunden.' } })
}
db.prepare('DELETE FROM position_catalog WHERE id = ?').run(id)
logger.info(`Position ${id} deleted by ${req.user?.username}`)
res.json({ success: true })
} catch (error) {
next(error)
}
})
export default router

Datei anzeigen

@ -4,7 +4,7 @@ import bcrypt from 'bcrypt'
import { v4 as uuidv4 } from 'uuid'
import { db, encryptedDb } from '../config/secureDatabase'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
import { User, UserRole } from '@skillmate/shared'
import { User, UserRole, POWER_FUNCTIONS, OrganizationalUnitType } from '@skillmate/shared'
import { FieldEncryption } from '../services/encryption'
import { emailService } from '../services/emailService'
import { logger } from '../utils/logger'
@ -15,9 +15,23 @@ const router = Router()
router.get('/', authenticate, authorize('admin', 'superuser'), 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
SELECT
u.id,
u.username,
u.email,
u.role,
u.employee_id,
u.last_login,
u.is_active,
u.created_at,
u.updated_at,
u.power_unit_id,
u.power_function,
ou.name AS power_unit_name,
ou.type AS power_unit_type
FROM users u
LEFT JOIN organizational_units ou ON ou.id = u.power_unit_id
ORDER BY u.username
`).all() as any[]
// Decrypt email addresses (handle decryption failures)
@ -35,6 +49,9 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
}
}
const powerDef = POWER_FUNCTIONS.find(def => def.id === user.power_function)
const canManageEmployees = user.role === 'admin' || (user.role === 'superuser' && powerDef?.canManageEmployees)
return {
...user,
email: decryptedEmail,
@ -42,7 +59,12 @@ router.get('/', authenticate, authorize('admin', 'superuser'), async (req: AuthR
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
employeeId: user.employee_id,
powerUnitId: user.power_unit_id || null,
powerUnitName: user.power_unit_name || null,
powerUnitType: user.power_unit_type || null,
powerFunction: user.power_function || null,
canManageEmployees: Boolean(canManageEmployees)
}
})
@ -58,7 +80,9 @@ router.put('/:id/role',
authenticate,
authorize('admin'),
[
body('role').isIn(['admin', 'superuser', 'user'])
body('role').isIn(['admin', 'superuser', 'user']),
body('powerUnitId').optional({ nullable: true }).isUUID(),
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -71,7 +95,7 @@ router.put('/:id/role',
}
const { id } = req.params
const { role } = req.body
const { role, powerUnitId, powerFunction } = req.body as { role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check if user exists
const existingUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(id) as any
@ -90,11 +114,40 @@ router.put('/:id/role',
})
}
// Update role
let finalPowerUnit: string | null = null
let finalPowerFunction: string | null = null
if (role === 'superuser') {
if (!powerUnitId || !powerFunction) {
return res.status(400).json({
success: false,
error: { message: 'Poweruser requires organizational unit and Funktion' }
})
}
const functionDef = POWER_FUNCTIONS.find(def => def.id === powerFunction)
if (!functionDef) {
return res.status(400).json({ success: false, error: { message: 'Ungültige Poweruser-Funktion' } })
}
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
}
if (!functionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
}
finalPowerUnit = powerUnitId
finalPowerFunction = powerFunction
}
const now = new Date().toISOString()
db.prepare(`
UPDATE users SET role = ?, updated_at = ?
UPDATE users SET role = ?, power_unit_id = ?, power_function = ?, updated_at = ?
WHERE id = ?
`).run(role, new Date().toISOString(), id)
`).run(role, finalPowerUnit, finalPowerFunction, now, id)
logger.info(`User role updated: ${existingUser.username} -> ${role}`)
res.json({ success: true, message: 'Role updated successfully' })
@ -124,6 +177,13 @@ router.post('/bulk-create-from-employees',
}
const { employeeIds, role } = req.body as { employeeIds: string[]; role: UserRole }
if (role === 'superuser') {
return res.status(400).json({
success: false,
error: { message: 'Bulk-Erstellung für Poweruser wird nicht unterstützt. Bitte einzeln mit Organisationszuordnung anlegen.' }
})
}
const results: any[] = []
for (const employeeId of employeeIds) {
@ -155,8 +215,8 @@ router.post('/bulk-create-from-employees',
const userId = uuidv4()
db.prepare(`
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@ -165,6 +225,8 @@ router.post('/bulk-create-from-employees',
hashedPassword,
role,
employeeId,
null,
null,
1,
now,
now
@ -336,7 +398,9 @@ router.post('/create-from-employee',
[
body('employeeId').notEmpty().isString(),
body('username').optional().isString().isLength({ min: 3 }),
body('role').isIn(['admin', 'superuser', 'user'])
body('role').isIn(['admin', 'superuser', 'user']),
body('powerUnitId').optional({ nullable: true }).isUUID(),
body('powerFunction').optional({ nullable: true }).isIn(POWER_FUNCTIONS.map(f => f.id))
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
@ -348,7 +412,7 @@ router.post('/create-from-employee',
})
}
const { employeeId, username, role } = req.body
const { employeeId, username, role, powerUnitId, powerFunction } = req.body as { employeeId: string; username?: string; role: UserRole; powerUnitId?: string | null; powerFunction?: string | null }
// Check employee exists
const employee = encryptedDb.getEmployee(employeeId) as any
@ -381,6 +445,29 @@ router.post('/create-from-employee',
}
}
let resolvedPowerUnit: string | null = null
let resolvedPowerFunction: string | null = null
if (role === 'superuser') {
const powerFunctionId = typeof powerFunction === 'string' ? powerFunction : null
const functionDef = powerFunctionId ? POWER_FUNCTIONS.find(def => def.id === powerFunctionId) : undefined
if (!powerUnitId || !functionDef || !powerFunctionId) {
return res.status(400).json({ success: false, error: { message: 'Poweruser requires Organisationseinheit und Funktion' } })
}
const unitRow = db.prepare('SELECT id, type FROM organizational_units WHERE id = ?').get(powerUnitId) as { id: string; type: OrganizationalUnitType } | undefined
if (!unitRow) {
return res.status(404).json({ success: false, error: { message: 'Organisationseinheit nicht gefunden' } })
}
if (!functionDef.unitTypes.includes(unitRow.type)) {
return res.status(400).json({ success: false, error: { message: `Funktion ${functionDef.label} kann nicht der Einheit vom Typ ${unitRow.type} zugeordnet werden` } })
}
resolvedPowerUnit = powerUnitId
resolvedPowerFunction = powerFunctionId
}
// Generate temp password
const tempPassword = `Temp${Math.random().toString(36).slice(-8)}!@#`
const hashedPassword = await bcrypt.hash(tempPassword, 12)
@ -389,8 +476,8 @@ router.post('/create-from-employee',
// 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, username, email, email_hash, password, role, employee_id, power_unit_id, power_function, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
finalUsername,
@ -399,6 +486,8 @@ router.post('/create-from-employee',
hashedPassword,
role,
employeeId,
resolvedPowerUnit,
resolvedPowerFunction,
1,
now,
now

Datei anzeigen

@ -32,6 +32,7 @@ export class SyncScheduler {
}
private initialize() {
this.ensureSyncSettingsTable()
// Check current sync settings on startup
this.checkAndUpdateInterval()
@ -41,6 +42,44 @@ export class SyncScheduler {
}, 60000)
}
private ensureSyncSettingsTable() {
const now = new Date().toISOString()
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
)
`)
db.prepare(`
INSERT OR IGNORE 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'
)
}
private checkAndUpdateInterval() {
try {
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
@ -126,4 +165,4 @@ export class SyncScheduler {
}
}
export const syncScheduler = SyncScheduler.getInstance()
export const syncScheduler = SyncScheduler.getInstance()

Datei anzeigen

@ -0,0 +1,143 @@
import type * as BetterSqlite3 from 'better-sqlite3'
import { decodeHtmlEntities } from './html'
interface DepartmentSource {
department?: string | null
primaryUnitId?: string | null
primaryUnitCode?: string | null
primaryUnitName?: string | null
primaryUnitDescription?: string | null
}
export interface DepartmentInfo {
label: string
description?: string
tasks?: string
}
const decodeValue = (value?: string | null): string | undefined => {
if (value === null || value === undefined) return undefined
const decoded = decodeHtmlEntities(value)
const cleaned = (decoded ?? value ?? '').trim()
return cleaned.length > 0 ? cleaned : undefined
}
type SqliteDatabase = BetterSqlite3.Database
export const createDepartmentResolver = (db: SqliteDatabase) => {
interface UnitRow {
id: string
code?: string | null
name?: string | null
description?: string | null
parent_id?: string | null
}
const selectById = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE id = ?')
const selectByCode = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE code = ? COLLATE NOCASE')
const selectByName = db.prepare('SELECT id, code, name, description, parent_id FROM organizational_units WHERE name = ? COLLATE NOCASE')
const resolveUnitById = (id?: string | null): UnitRow | undefined => {
if (!id) return undefined
try {
return selectById.get(id) as UnitRow | undefined
} catch {
return undefined
}
}
const buildPath = (unit: UnitRow): string[] => {
const segments: string[] = []
let current: UnitRow | undefined = unit
let guard = 0
while (current && guard < 20) {
const segment = decodeValue(current.code) || decodeValue(current.name)
if (segment) {
segments.unshift(segment)
}
current = current.parent_id ? resolveUnitById(current.parent_id) : undefined
guard += 1
}
return segments
}
const resolve = (source: DepartmentSource): DepartmentInfo => {
const originalDepartment = decodeValue(source.department)
let label = decodeValue(source.primaryUnitCode) || originalDepartment || ''
let description = decodeValue(source.primaryUnitName)
let tasks = decodeValue(source.primaryUnitDescription)
let unitRow: UnitRow | undefined = resolveUnitById(source.primaryUnitId)
const codeCandidates = [
decodeValue(source.primaryUnitCode),
decodeValue(source.department),
].filter(Boolean) as string[]
if (!unitRow) {
for (const candidate of codeCandidates) {
try {
unitRow = selectByCode.get(candidate) as UnitRow | undefined
} catch {
unitRow = undefined
}
if (unitRow) break
}
}
if (!unitRow) {
const nameCandidates = [
decodeValue(source.primaryUnitName),
originalDepartment,
].filter(Boolean) as string[]
for (const candidate of nameCandidates) {
try {
unitRow = selectByName.get(candidate) as UnitRow | undefined
} catch {
unitRow = undefined
}
if (unitRow) break
}
}
if (unitRow) {
const unitCode = decodeValue(unitRow.code)
const unitName = decodeValue(unitRow.name)
const unitDescription = decodeValue(unitRow.description)
const pathSegments = buildPath(unitRow)
if (pathSegments.length > 0) {
label = pathSegments.join(' -> ')
} else if (!label && unitCode) {
label = unitCode
}
if (unitName && unitName !== label) {
description = unitName
}
if (unitDescription) {
tasks = unitDescription
} else if (!tasks && unitName && unitName !== label) {
tasks = unitName
}
}
if (!label) {
label = originalDepartment || ''
}
if (description && description === label) {
description = undefined
}
return {
label,
description,
tasks,
}
}
return resolve
}

53
backend/src/utils/html.ts Normale Datei
Datei anzeigen

@ -0,0 +1,53 @@
const namedEntities: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
nbsp: '\u00A0',
slash: '/',
sol: '/',
frasl: '/',
}
const decodeSinglePass = (input: string): string => {
return input.replace(/&(#x?[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity) => {
if (!entity) {
return match
}
if (entity[0] === '#') {
const isHex = entity[1]?.toLowerCase() === 'x'
const codePoint = isHex
? parseInt(entity.slice(2), 16)
: parseInt(entity.slice(1), 10)
if (!Number.isNaN(codePoint)) {
try {
return String.fromCodePoint(codePoint)
} catch {
return match
}
}
return match
}
const lowered = entity.toLowerCase()
if (namedEntities[lowered]) {
return namedEntities[lowered]
}
return match
})
}
export const decodeHtmlEntities = (value?: string | null): string | undefined => {
if (value === undefined || value === null) {
return undefined
}
let result = value
for (let i = 0; i < 3; i += 1) {
const decoded = decodeSinglePass(result)
if (decoded === result) {
break
}
result = decoded
}
return result
}

Datei anzeigen

@ -1,20 +1,20 @@
import { body } from 'express-validator'
export const createEmployeeValidators = [
body('firstName').notEmpty().trim().escape(),
body('lastName').notEmpty().trim().escape(),
body('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('department').notEmpty().trim().escape(),
body('position').optional().trim().escape(),
body('department').notEmpty().trim(),
body('position').optional().trim(),
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('firstName').notEmpty().trim(),
body('lastName').notEmpty().trim(),
body('position').optional().trim(),
body('department').notEmpty().trim(),
body('email').isEmail().normalizeEmail(),
body('phone').optional().trim(),
body('employeeNumber').optional().trim(),