Update changes
Dieser Commit ist enthalten in:
@ -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)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
158
backend/src/routes/officialTitles.ts
Normale Datei
158
backend/src/routes/officialTitles.ts
Normale Datei
@ -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
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
242
backend/src/routes/positions.ts
Normale Datei
242
backend/src/routes/positions.ts
Normale Datei
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
143
backend/src/utils/department.ts
Normale Datei
143
backend/src/utils/department.ts
Normale Datei
@ -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
53
backend/src/utils/html.ts
Normale Datei
@ -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
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren