Zwiscshenstand - laufende Version

Dieser Commit ist enthalten in:
Claude Project Manager
2025-09-27 13:11:39 +02:00
Ursprung 81e57ecff6
Commit 2689cd2d32
30 geänderte Dateien mit 1021 neuen und 459 gelöschten Zeilen

Datei anzeigen

@ -26,6 +26,7 @@
"multer": "^2.0.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"pdf-parse": "^1.1.1",
"sqlite3": "^5.1.6",
"uuid": "^9.0.1",
"winston": "^3.11.0"
@ -3862,6 +3863,12 @@
"node": ">=6.0.0"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/node-gyp": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz",
@ -4096,6 +4103,34 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.1.tgz",
"integrity": "sha512-v6ZJ/efsBpGrGGknjtq9J/oC8tZWq0KWL5vQrk2GlzLEQPUDB1ex+13Rmidl1neNN358Jn9EHZw5y07FFtaC7A==",
"license": "MIT",
"dependencies": {
"debug": "^3.1.0",
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
}
},
"node_modules/pdf-parse/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/pdf-parse/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",

Datei anzeigen

@ -9,7 +9,8 @@
"start": "node dist/index.js",
"reset-admin": "node scripts/reset-admin.js",
"migrate-users": "node scripts/migrate-users.js",
"seed-skills": "node scripts/seed-skills-from-frontend.js",
"seed-skills": "node scripts/seed-skills-from-shared.js",
"seed-skills-shared": "node scripts/seed-skills-from-shared.js",
"purge-users": "node scripts/purge-users.js"
},
"dependencies": {

Datei anzeigen

@ -0,0 +1,134 @@
// Seed controlled vocabulary (categories/subcategories) and skills
// from shared/skills.js (SKILL_HIERARCHY) into the local SQLite DB.
//
// Usage:
// cd backend
// node scripts/seed-skills-from-shared.js
//
// Optionally set DATABASE_PATH to seed a custom DB file.
const path = require('path')
const fs = require('fs')
const crypto = require('crypto')
const Database = require('better-sqlite3')
function getDbPath() {
if (process.env.DATABASE_PATH && process.env.DATABASE_PATH.length > 0) {
return process.env.DATABASE_PATH
}
// default dev DB in backend folder
return path.join(__dirname, '..', 'skillmate.dev.encrypted.db')
}
function ensureTables(db) {
// controlled_vocabulary (canonical definition from secureDatabase)
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
CREATE INDEX IF NOT EXISTS idx_vocab_value ON controlled_vocabulary(value);
`)
// skills (canonical definition from secureDatabase)
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
requires_certification INTEGER DEFAULT 0,
expires_after INTEGER
)
`)
}
function loadHierarchy() {
// Load from shared/skills.js
const skillsPath = path.join(__dirname, '..', '..', 'shared', 'skills.js')
if (!fs.existsSync(skillsPath)) {
throw new Error('shared/skills.js not found')
}
const mod = require(skillsPath)
if (!mod || !Array.isArray(mod.SKILL_HIERARCHY)) {
throw new Error('SKILL_HIERARCHY missing or invalid in shared/skills.js')
}
return mod.SKILL_HIERARCHY
}
function seed(db, hierarchy) {
const now = new Date().toISOString()
const insertVocab = db.prepare(`
INSERT OR IGNORE INTO controlled_vocabulary (id, category, value, description, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`)
const insertSkill = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`)
let catCount = 0
let subCount = 0
let skillCount = 0
for (const cat of hierarchy) {
const catId = String(cat.id)
const catName = String(cat.name || cat.id)
// category
const catPk = crypto.randomUUID()
const catRes = insertVocab.run(catPk, 'skill_category', catId, catName, 1, now)
if (catRes.changes > 0) catCount++
for (const sub of (cat.subcategories || [])) {
const subId = String(sub.id)
const subName = String(sub.name || sub.id)
const key = `${catId}.${subId}`
// subcategory
const subPk = crypto.randomUUID()
const subRes = insertVocab.run(subPk, 'skill_subcategory', key, subName, 1, now)
if (subRes.changes > 0) subCount++
for (const sk of (sub.skills || [])) {
const sId = `${key}.${sk.id}`
const sName = String(sk.name || sk.id)
const requires = (catId === 'certifications' || subId === 'weapons') ? 1 : 0
const expires = (catId === 'certifications') ? 36 : null
const sRes = insertSkill.run(sId, sName, key, null, requires, expires)
if (sRes.changes > 0) skillCount++
}
}
}
return { catCount, subCount, skillCount }
}
function main() {
const dbPath = getDbPath()
console.log('➡️ Seeding skills into DB:', dbPath)
const db = new Database(dbPath)
try {
ensureTables(db)
const hierarchy = loadHierarchy()
const { catCount, subCount, skillCount } = seed(db, hierarchy)
console.log(`✅ Seeding completed. Inserted: ${catCount} categories, ${subCount} subcategories, ${skillCount} skills.`)
console.log(' Re-open the Admin Panel to see Skills in hierarchy view.')
} catch (err) {
console.error('❌ Seeding failed:', err.message)
process.exitCode = 1
} finally {
try { db.close() } catch {}
}
}
main()

Datei anzeigen

@ -9,22 +9,30 @@ export { initializeSecureDatabase } from './secureDatabase'
// Export the secure database instance
export const db = secureDb
// IMPORTANT: secureDatabase.ts owns all securitysensitive base tables
// (users, employees, skills and their junctions, language_skills, specializations,
// controlled_vocabulary, system_settings, security_audit_log) including
// encrypted fields and indexes. This module defines only extended, nonsensitive
// schemas (profiles, workspaces/bookings, organizational structure, reminders, analytics).
export function initializeDatabase() {
// Users table
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'superuser', 'user')),
employee_id TEXT,
last_login TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
`)
// Lightweight schema guard (no mutations): warn if legacy/plain schemas are present
try {
const usersCols: any[] = db.prepare(`PRAGMA table_info(users)`).all() as any
const hasEmailHash = usersCols.some(c => c.name === 'email_hash')
if (!hasEmailHash) {
console.warn('[DB-Guard] users.email_hash missing. Ensure initializeSecureDatabase() runs first and legacy data is migrated.')
}
} catch {}
try {
const empCols: any[] = db.prepare(`PRAGMA table_info(employees)`).all() as any
const hasEmpEmailHash = empCols.some(c => c.name === 'email_hash')
const hasEmpPhoneHash = empCols.some(c => c.name === 'phone_hash')
if (!hasEmpEmailHash || !hasEmpPhoneHash) {
console.warn('[DB-Guard] employees hash columns missing. Ensure secure schema initialized and data migrated.')
}
} catch {}
// Profiles table (erweitert für Yellow Pages)
db.exec(`
@ -68,57 +76,7 @@ export function initializeDatabase() {
ON profiles(review_due_at);
`)
// Employees table (für Kompatibilität beibehalten)
db.exec(`
CREATE TABLE IF NOT EXISTS employees (
id TEXT PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
employee_number TEXT UNIQUE NOT NULL,
photo TEXT,
position TEXT NOT NULL,
department TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL,
mobile TEXT,
office TEXT,
availability TEXT NOT NULL,
clearance_level TEXT,
clearance_valid_until TEXT,
clearance_issued_date TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT
)
`)
// Skills table
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
requires_certification INTEGER DEFAULT 0,
expires_after INTEGER
)
`)
// Employee skills junction table
db.exec(`
CREATE TABLE IF NOT EXISTS employee_skills (
employee_id TEXT NOT NULL,
skill_id TEXT NOT NULL,
level TEXT,
verified INTEGER DEFAULT 0,
verified_by TEXT,
verified_date TEXT,
PRIMARY KEY (employee_id, skill_id),
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
)
`)
// Employees/Skills tables are defined in secureDatabase (encrypted & indexed)
// Profile Kompetenzen (Arrays als separate Tabellen)
db.exec(`
@ -210,62 +168,9 @@ export function initializeDatabase() {
);
`)
// Language skills table (für Kompatibilität)
db.exec(`
CREATE TABLE IF NOT EXISTS language_skills (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
language TEXT NOT NULL,
proficiency TEXT NOT NULL,
certified INTEGER DEFAULT 0,
certificate_type TEXT,
is_native INTEGER DEFAULT 0,
can_interpret INTEGER DEFAULT 0,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Language skills / Specializations are defined in secureDatabase
// Specializations table
db.exec(`
CREATE TABLE IF NOT EXISTS specializations (
id TEXT PRIMARY KEY,
employee_id TEXT NOT NULL,
name TEXT NOT NULL,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
)
`)
// Sync log table
db.exec(`
CREATE TABLE IF NOT EXISTS sync_log (
id TEXT PRIMARY KEY,
sync_time TEXT NOT NULL,
success INTEGER NOT NULL,
items_synced INTEGER,
error_message TEXT,
duration INTEGER
)
`)
// Create default admin user if not exists
const adminExists = db.prepare('SELECT id FROM users WHERE username = ?').get('admin')
if (!adminExists) {
const hashedPassword = bcrypt.hashSync('admin123', 10)
const now = new Date().toISOString()
db.prepare(`
INSERT INTO users (id, username, email, password, role, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'admin',
'admin@skillmate.local',
hashedPassword,
'admin',
1,
now,
now
)
}
// Sync tables are owned by SyncService initializer (avoid duplicate legacy schema)
// Workspace Management Tables
@ -432,9 +337,12 @@ export function initializeDatabase() {
FOREIGN KEY (unit_id) REFERENCES organizational_units(id) ON DELETE CASCADE,
UNIQUE(employee_id, unit_id, role)
);
CREATE INDEX IF NOT EXISTS idx_emp_units_employee ON employee_unit_assignments(employee_id);
CREATE INDEX IF NOT EXISTS idx_emp_units_unit ON employee_unit_assignments(unit_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_emp_units_primary_unique
ON employee_unit_assignments(employee_id)
WHERE is_primary = 1;
`)
// Special Positions (Personalrat, Beauftragte, etc.)
@ -538,29 +446,8 @@ export function initializeDatabase() {
CREATE INDEX IF NOT EXISTS idx_reminders_sent ON reminders(sent_at);
`)
// Kontrollierte Vokabulare/Tags
// Create indexes for better performance (only for tables defined here)
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
`)
// Create indexes for better performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_employees_availability ON employees(availability);
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
CREATE INDEX IF NOT EXISTS idx_employee_skills_employee ON employee_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_skills_skill ON employee_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_language_skills_employee ON language_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_specializations_employee ON specializations(employee_id);
CREATE INDEX IF NOT EXISTS idx_bookings_workspace ON bookings(workspace_id);
CREATE INDEX IF NOT EXISTS idx_bookings_user ON bookings(user_id);
CREATE INDEX IF NOT EXISTS idx_bookings_start_time ON bookings(start_time);

Datei anzeigen

@ -80,17 +80,19 @@ export const encryptedDb = {
email_hash: employee.email ? FieldEncryption.hash(employee.email) : null,
phone_hash: employee.phone ? FieldEncryption.hash(employee.phone) : null
}
return db.prepare(`
INSERT INTO employees (
id, first_name, last_name, employee_number, photo, position,
department, email, email_hash, phone, phone_hash, mobile, office, availability,
clearance_level, clearance_valid_until, clearance_issued_date,
primary_unit_id,
created_at, updated_at, created_by
) VALUES (
@id, @first_name, @last_name, @employee_number, @photo, @position,
@department, @email, @email_hash, @phone, @phone_hash, @mobile, @office, @availability,
@clearance_level, @clearance_valid_until, @clearance_issued_date,
@primary_unit_id,
@created_at, @updated_at, @created_by
)
`).run(encrypted)
@ -173,12 +175,24 @@ export function initializeSecureDatabase() {
clearance_level TEXT,
clearance_valid_until TEXT,
clearance_issued_date TEXT,
primary_unit_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT
)
`)
// Add primary_unit_id column if missing (legacy DBs)
try {
const cols: any[] = db.prepare(`PRAGMA table_info(employees)`).all() as any
const hasPrimaryUnitId = cols.some(c => c.name === 'primary_unit_id')
if (!hasPrimaryUnitId) {
db.exec(`ALTER TABLE employees ADD COLUMN primary_unit_id TEXT`)
}
} catch (e) {
// ignore
}
// Add indexes for hash fields
db.exec(`
@ -309,6 +323,7 @@ export function initializeSecureDatabase() {
db.exec(`
CREATE INDEX IF NOT EXISTS idx_employees_availability ON employees(availability);
CREATE INDEX IF NOT EXISTS idx_employees_department ON employees(department);
CREATE INDEX IF NOT EXISTS idx_employees_primary_unit ON employees(primary_unit_id);
CREATE INDEX IF NOT EXISTS idx_employee_skills_employee ON employee_skills(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_skills_skill ON employee_skills(skill_id);
CREATE INDEX IF NOT EXISTS idx_language_skills_employee ON language_skills(employee_id);

Datei anzeigen

@ -24,6 +24,7 @@ import employeeOrganizationRoutes from './routes/employeeOrganization'
import { errorHandler } from './middleware/errorHandler'
import { logger } from './utils/logger'
import { syncScheduler } from './services/syncScheduler'
import { ensureSkillsSeeded } from './services/skillSeeder'
dotenv.config()
@ -33,6 +34,8 @@ const PORT = process.env.PORT || 3004
// Initialize secure database (core tables) and extended schema (organization, deputies, etc.)
initializeSecureDatabase()
initializeDatabase()
// Ensure skills/categories exist for Admin Skill Management (idempotent)
ensureSkillsSeeded()
// Initialize sync scheduler
syncScheduler

Datei anzeigen

@ -66,7 +66,32 @@ function logSecurityAudit(
// Get all employees
router.get('/', authenticate, requirePermission('employees:read'), async (req: AuthRequest, res, next) => {
try {
const employees = encryptedDb.getAllEmployees()
const { unitId, includeDescendants } = (req.query || {}) as any
// Optional filter by unit (including descendants)
let allowedIds: Set<string> | null = null
if (unitId) {
const collectDescendants = (rootId: string): string[] => {
const ids: string[] = [rootId]
const stack: string[] = [rootId]
while (stack.length) {
const curr = stack.pop()!
const children = db.prepare('SELECT id FROM organizational_units WHERE parent_id = ?').all(curr) as any[]
for (const ch of children) {
ids.push(ch.id)
stack.push(ch.id)
}
}
return ids
}
const unitIds = includeDescendants === '1' || includeDescendants === 'true'
? collectDescendants(String(unitId))
: [String(unitId)]
const rows = db.prepare(`SELECT id FROM employees WHERE primary_unit_id IN (${unitIds.map(() => '?').join(',')})`).all(...unitIds) as any[]
allowedIds = new Set(rows.map(r => r.id))
}
const employees = encryptedDb.getAllEmployees().filter((e: any) => !allowedIds || allowedIds.has(e.id))
const employeesWithDetails = employees.map((emp: any) => {
// Get skills
@ -331,7 +356,8 @@ router.post('/',
const {
firstName, lastName, employeeNumber, photo, position = 'Mitarbeiter',
department, email, phone = 'Nicht angegeben', mobile, office, availability = 'available',
clearance, skills = [], languages = [], specializations = [], userRole, createUser
clearance, skills = [], languages = [], specializations = [], userRole, createUser,
primaryUnitId, assignmentRole
} = req.body
// Generate employee number if not provided
@ -363,11 +389,28 @@ router.post('/',
clearance_level: clearance?.level || null,
clearance_valid_until: clearance?.validUntil || null,
clearance_issued_date: clearance?.issuedDate || null,
primary_unit_id: primaryUnitId || null,
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' } })
}
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',
now, null, 1, now, now
)
}
// Insert skills (only if they exist in skills table)
if (skills && skills.length > 0) {
const insertSkill = db.prepare(`
@ -477,6 +520,7 @@ router.post('/',
office: office || null,
availability,
clearance,
primaryUnitId: primaryUnitId || null,
skills,
languages,
specializations,

Datei anzeigen

@ -0,0 +1,112 @@
import path from 'path'
import { db } from '../config/secureDatabase'
import { logger } from '../utils/logger'
type SkillHierarchy = Array<{
id: string
name: string
subcategories?: Array<{
id: string
name: string
skills?: Array<{ id: string; name: string }>
}>
}>
function loadSharedHierarchy(): SkillHierarchy | null {
try {
const sharedPath = path.join(__dirname, '../../../shared/skills.js')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(sharedPath)
if (mod && Array.isArray(mod.SKILL_HIERARCHY)) {
return mod.SKILL_HIERARCHY as SkillHierarchy
}
} catch (err) {
logger.warn('SkillSeeder: Could not load shared/skills.js. Skipping seeding.')
}
return null
}
export function ensureSkillsSeeded() {
try {
// Always ensure vocabulary tables exist (idempotent)
db.exec(`
CREATE TABLE IF NOT EXISTS controlled_vocabulary (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(category, value)
);
CREATE INDEX IF NOT EXISTS idx_vocab_category ON controlled_vocabulary(category);
CREATE INDEX IF NOT EXISTS idx_vocab_value ON controlled_vocabulary(value);
`)
db.exec(`
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
requires_certification INTEGER DEFAULT 0,
expires_after INTEGER
)
`)
const hierarchy = loadSharedHierarchy()
if (!hierarchy) return
const now = new Date().toISOString()
const insertVocab = db.prepare(`
INSERT OR IGNORE INTO controlled_vocabulary (id, category, value, description, is_active, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`)
const insertSkill = db.prepare(`
INSERT OR IGNORE INTO skills (id, name, category, description, requires_certification, expires_after)
VALUES (?, ?, ?, ?, ?, ?)
`)
let cats = 0, subs = 0, skills = 0
for (const cat of hierarchy) {
const catId = String(cat.id)
const catName = String(cat.name || cat.id)
// category
insertVocab.run(cryptoRandomUUID(), 'skill_category', catId, catName, 1, now)
cats++
for (const sub of (cat.subcategories || [])) {
const subId = String(sub.id)
const subName = String(sub.name || sub.id)
const key = `${catId}.${subId}`
insertVocab.run(cryptoRandomUUID(), 'skill_subcategory', key, subName, 1, now)
subs++
for (const sk of (sub.skills || [])) {
const sId = `${key}.${sk.id}`
const sName = String(sk.name || sk.id)
const requires = (catId === 'certifications' || subId === 'weapons') ? 1 : 0
const expires = (catId === 'certifications') ? 36 : null
insertSkill.run(sId, sName, key, null, requires, expires)
skills++
}
}
}
logger.info(`SkillSeeder: ensured ${cats} categories, ${subs} subcategories, ${skills} skills (idempotent).`)
} catch (err) {
logger.warn('SkillSeeder failed (non-fatal): ' + (err as Error).message)
}
}
function cryptoRandomUUID() {
// Node 18 has crypto.randomUUID, but keep a tiny fallback
try { return (require('crypto').randomUUID() as string) } catch {}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}