Files
SkillMate/backend/src/routes/profiles.ts
Claude Project Manager 6b9b6d4f20 Initial commit
2025-09-20 21:31:04 +02:00

700 Zeilen
24 KiB
TypeScript

import { Router, Response, NextFunction } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { body, validationResult, query } from 'express-validator'
import { db } from '../config/database'
import { authenticate, authorize, AuthRequest } from '../middleware/auth'
// Profile Interface direkt definiert, da shared module noch nicht aktualisiert
interface Profile {
id: string
name: string
department?: string
location?: string
role?: string
contacts?: {
email?: string
phone?: string
teams?: string
}
domains?: string[]
tools?: string[]
methods?: string[]
industryKnowledge?: string[]
regulatory?: string[]
languages?: { code: string; level: string }[]
projects?: { title: string; role?: string; summary?: string; links?: string[] }[]
networks?: string[]
digitalSkills?: string[]
socialSkills?: string[]
jobCategory?: string
jobTitle?: string
jobDesc?: string
consentPublicProfile: boolean
consentSearchable: boolean
updatedAt: string
updatedBy: string
reviewDueAt?: string
}
const router = Router()
// Hilfsfunktion f\u00fcr Volltextsuche
function buildSearchVector(profile: any): string {
const parts = [
profile.name,
profile.role,
profile.jobTitle,
profile.jobDesc,
...(profile.domains || []),
...(profile.tools || []),
...(profile.methods || []),
...(profile.industryKnowledge || []),
...(profile.digitalSkills || [])
].filter(Boolean)
return parts.join(' ').toLowerCase()
}
// GET /api/profiles - Suche mit Facetten
router.get('/',
authenticate,
[
query('query').optional().trim(),
query('dept').optional(),
query('loc').optional(),
query('jobCat').optional(),
query('tools').optional(),
query('methods').optional(),
query('lang').optional(),
query('page').optional().isInt({ min: 1 }).toInt(),
query('pageSize').optional().isInt({ min: 1, max: 100 }).toInt(),
query('sort').optional().isIn(['recency', 'relevance'])
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid query parameters', details: errors.array() }
})
}
const {
query: searchQuery,
dept,
loc,
jobCat,
tools,
methods,
lang,
page = 1,
pageSize = 20,
sort = 'relevance'
} = req.query
// Basis-Query mit Consent-Pr\u00fcfung
let sql = `
SELECT DISTINCT p.*
FROM profiles p
WHERE (p.consent_searchable = 1 OR p.updated_by = ?)
`
const params: any[] = [req.user!.id]
// Volltextsuche
if (searchQuery) {
sql += ` AND p.search_vector LIKE ?`
params.push(`%${searchQuery}%`)
}
// Facettenfilter
if (dept) {
sql += ` AND p.department = ?`
params.push(dept)
}
if (loc) {
sql += ` AND p.location = ?`
params.push(loc)
}
if (jobCat) {
sql += ` AND p.job_category = ?`
params.push(jobCat)
}
// Tool-Filter
if (tools) {
const toolList = tools.toString().split(',')
sql += ` AND EXISTS (
SELECT 1 FROM profile_tools pt
WHERE pt.profile_id = p.id
AND pt.tool IN (${toolList.map(() => '?').join(',')})
)`
params.push(...toolList)
}
// Methoden-Filter
if (methods) {
const methodList = methods.toString().split(',')
sql += ` AND EXISTS (
SELECT 1 FROM profile_methods pm
WHERE pm.profile_id = p.id
AND pm.method IN (${methodList.map(() => '?').join(',')})
)`
params.push(...methodList)
}
// Sprachen-Filter
if (lang) {
const [langCode, level] = lang.toString().split(':')
sql += ` AND EXISTS (
SELECT 1 FROM profile_languages pl
WHERE pl.profile_id = p.id
AND pl.code = ?
${level ? 'AND pl.level = ?' : ''}
)`
params.push(langCode)
if (level) params.push(level)
}
// Sortierung
if (sort === 'recency') {
sql += ` ORDER BY p.updated_at DESC`
} else {
sql += ` ORDER BY p.updated_at DESC` // TODO: Ranking nach Relevanz
}
// Pagination
const offset = ((page as number) - 1) * (pageSize as number)
sql += ` LIMIT ? OFFSET ?`
params.push(pageSize, offset)
const profiles = db.prepare(sql).all(...params)
// Lade zus\u00e4tzliche Daten f\u00fcr jedes Profil
const enrichedProfiles = profiles.map((profile: any) => {
// Lade Arrays
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(profile.id).map((r: any) => r.domain)
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(profile.id).map((r: any) => r.tool)
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(profile.id).map((r: any) => r.method)
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(profile.id).map((r: any) => r.knowledge)
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(profile.id).map((r: any) => r.regulation)
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(profile.id).map((r: any) => r.network)
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(profile.id).map((r: any) => r.skill)
// Lade Sprachen
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(profile.id)
// Lade Projekte
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(profile.id)
for (const project of projects as any[]) {
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
delete project.id
}
return {
...profile,
contacts: {
email: profile.email,
phone: profile.phone,
teams: profile.teams_link
},
domains,
tools,
methods,
industryKnowledge,
regulatory,
networks,
digitalSkills,
socialSkills,
languages,
projects,
consentPublicProfile: Boolean(profile.consent_public_profile),
consentSearchable: Boolean(profile.consent_searchable)
}
})
res.json({
success: true,
data: enrichedProfiles,
meta: {
page,
pageSize,
total: (db.prepare('SELECT COUNT(DISTINCT id) as count FROM profiles WHERE consent_searchable = 1 OR updated_by = ?').get(req.user!.id) as any)?.count || 0
}
})
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/:id - Einzelnes Profil
router.get('/:id',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const profile = db.prepare(`
SELECT * FROM profiles WHERE id = ?
`).get(id) as any
if (!profile) {
return res.status(404).json({
success: false,
error: { message: 'Profile not found' }
})
}
// Pr\u00fcfe Zugriffsrechte
const isOwner = profile.updated_by === req.user!.id
const isAdmin = req.user!.role === 'admin'
if (!profile.consent_public_profile && !isOwner && !isAdmin) {
return res.status(403).json({
success: false,
error: { message: 'Access denied' }
})
}
// Lade zus\u00e4tzliche Daten
const domains = db.prepare('SELECT domain FROM profile_domains WHERE profile_id = ?').all(id).map((r: any) => r.domain)
const tools = db.prepare('SELECT tool FROM profile_tools WHERE profile_id = ?').all(id).map((r: any) => r.tool)
const methods = db.prepare('SELECT method FROM profile_methods WHERE profile_id = ?').all(id).map((r: any) => r.method)
const industryKnowledge = db.prepare('SELECT knowledge FROM profile_industry_knowledge WHERE profile_id = ?').all(id).map((r: any) => r.knowledge)
const regulatory = db.prepare('SELECT regulation FROM profile_regulatory WHERE profile_id = ?').all(id).map((r: any) => r.regulation)
const networks = db.prepare('SELECT network FROM profile_networks WHERE profile_id = ?').all(id).map((r: any) => r.network)
const digitalSkills = db.prepare('SELECT skill FROM profile_digital_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
const socialSkills = db.prepare('SELECT skill FROM profile_social_skills WHERE profile_id = ?').all(id).map((r: any) => r.skill)
const languages = db.prepare('SELECT code, level FROM profile_languages WHERE profile_id = ?').all(id)
const projects = db.prepare('SELECT id, title, role, summary FROM profile_projects WHERE profile_id = ?').all(id)
for (const project of projects as any[]) {
project.links = db.prepare('SELECT link FROM project_links WHERE project_id = ?').all(project.id).map((r: any) => r.link)
delete project.id
}
const enrichedProfile = {
...profile,
contacts: {
email: profile.email,
phone: profile.phone,
teams: profile.teams_link
},
domains,
tools,
methods,
industryKnowledge,
regulatory,
networks,
digitalSkills,
socialSkills,
languages,
projects,
consentPublicProfile: Boolean(profile.consent_public_profile),
consentSearchable: Boolean(profile.consent_searchable)
}
res.json({ success: true, data: enrichedProfile })
} catch (error) {
next(error)
}
}
)
// POST /api/profiles - Neues Profil erstellen
router.post('/',
authenticate,
[
body('name').notEmpty().trim(),
body('consentPublicProfile').isBoolean(),
body('consentSearchable').isBoolean()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
error: { message: 'Invalid input', details: errors.array() }
})
}
const profileId = uuidv4()
const now = new Date().toISOString()
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
const profileData = {
...req.body,
id: profileId,
created_at: now,
updated_at: now,
updated_by: req.user!.id,
review_due_at: reviewDueAt,
search_vector: buildSearchVector(req.body)
}
// Transaktion starten
const transaction = db.transaction(() => {
// Profil einf\u00fcgen
db.prepare(`
INSERT INTO profiles (
id, name, department, location, role, email, phone, teams_link,
job_category, job_title, job_desc,
consent_public_profile, consent_searchable,
created_at, updated_at, updated_by, review_due_at, search_vector
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
profileId,
profileData.name,
profileData.department || null,
profileData.location || null,
profileData.role || null,
profileData.contacts?.email || null,
profileData.contacts?.phone || null,
profileData.contacts?.teams || null,
profileData.jobCategory || null,
profileData.jobTitle || null,
profileData.jobDesc || null,
profileData.consentPublicProfile ? 1 : 0,
profileData.consentSearchable ? 1 : 0,
now,
now,
req.user!.id,
reviewDueAt,
profileData.search_vector
)
// Arrays einf\u00fcgen
const insertArray = (table: string, field: string, values: string[]) => {
if (values && values.length > 0) {
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
for (const value of values) {
stmt.run(profileId, value)
}
}
}
insertArray('profile_domains', 'domain', profileData.domains)
insertArray('profile_tools', 'tool', profileData.tools)
insertArray('profile_methods', 'method', profileData.methods)
insertArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
insertArray('profile_regulatory', 'regulation', profileData.regulatory)
insertArray('profile_networks', 'network', profileData.networks)
insertArray('profile_digital_skills', 'skill', profileData.digitalSkills)
insertArray('profile_social_skills', 'skill', profileData.socialSkills)
// Sprachen einf\u00fcgen
if (profileData.languages && profileData.languages.length > 0) {
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
for (const lang of profileData.languages) {
stmt.run(profileId, lang.code, lang.level)
}
}
// Projekte einf\u00fcgen
if (profileData.projects && profileData.projects.length > 0) {
for (const project of profileData.projects) {
const projectId = uuidv4()
db.prepare(`
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(projectId, profileId, project.title, project.role || null, project.summary || null, now)
if (project.links && project.links.length > 0) {
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
for (const link of project.links) {
linkStmt.run(projectId, link)
}
}
}
}
// Audit-Log
db.prepare(`
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'profile',
profileId,
'create',
req.user!.id,
JSON.stringify(profileData),
now
)
})
transaction()
res.status(201).json({
success: true,
data: { id: profileId },
message: 'Profile created successfully'
})
} catch (error) {
next(error)
}
}
)
// PUT /api/profiles/:id - Profil aktualisieren
router.put('/:id',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const now = new Date().toISOString()
const reviewDueAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
// Pr\u00fcfe ob Profil existiert und Berechtigungen
const existing = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any
if (!existing) {
return res.status(404).json({
success: false,
error: { message: 'Profile not found' }
})
}
const isOwner = existing.updated_by === req.user!.id
const isAdmin = req.user!.role === 'admin'
if (!isOwner && !isAdmin) {
return res.status(403).json({
success: false,
error: { message: 'Access denied' }
})
}
const profileData = {
...req.body,
updated_at: now,
updated_by: req.user!.id,
review_due_at: reviewDueAt,
search_vector: buildSearchVector(req.body)
}
// Transaktion f\u00fcr Update
const transaction = db.transaction(() => {
// Profil aktualisieren
db.prepare(`
UPDATE profiles SET
name = ?, department = ?, location = ?, role = ?,
email = ?, phone = ?, teams_link = ?,
job_category = ?, job_title = ?, job_desc = ?,
consent_public_profile = ?, consent_searchable = ?,
updated_at = ?, updated_by = ?, review_due_at = ?, search_vector = ?
WHERE id = ?
`).run(
profileData.name,
profileData.department || null,
profileData.location || null,
profileData.role || null,
profileData.contacts?.email || null,
profileData.contacts?.phone || null,
profileData.contacts?.teams || null,
profileData.jobCategory || null,
profileData.jobTitle || null,
profileData.jobDesc || null,
profileData.consentPublicProfile ? 1 : 0,
profileData.consentSearchable ? 1 : 0,
now,
req.user!.id,
reviewDueAt,
profileData.search_vector,
id
)
// Arrays aktualisieren (l\u00f6schen und neu einf\u00fcgen)
const updateArray = (table: string, field: string, values: string[]) => {
db.prepare(`DELETE FROM ${table} WHERE profile_id = ?`).run(id)
if (values && values.length > 0) {
const stmt = db.prepare(`INSERT INTO ${table} (profile_id, ${field}) VALUES (?, ?)`)
for (const value of values) {
stmt.run(id, value)
}
}
}
updateArray('profile_domains', 'domain', profileData.domains)
updateArray('profile_tools', 'tool', profileData.tools)
updateArray('profile_methods', 'method', profileData.methods)
updateArray('profile_industry_knowledge', 'knowledge', profileData.industryKnowledge)
updateArray('profile_regulatory', 'regulation', profileData.regulatory)
updateArray('profile_networks', 'network', profileData.networks)
updateArray('profile_digital_skills', 'skill', profileData.digitalSkills)
updateArray('profile_social_skills', 'skill', profileData.socialSkills)
// Sprachen aktualisieren
db.prepare('DELETE FROM profile_languages WHERE profile_id = ?').run(id)
if (profileData.languages && profileData.languages.length > 0) {
const stmt = db.prepare(`INSERT INTO profile_languages (profile_id, code, level) VALUES (?, ?, ?)`)
for (const lang of profileData.languages) {
stmt.run(id, lang.code, lang.level)
}
}
// Projekte aktualisieren
db.prepare('DELETE FROM profile_projects WHERE profile_id = ?').run(id)
if (profileData.projects && profileData.projects.length > 0) {
for (const project of profileData.projects) {
const projectId = uuidv4()
db.prepare(`
INSERT INTO profile_projects (id, profile_id, title, role, summary, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(projectId, id, project.title, project.role || null, project.summary || null, now)
if (project.links && project.links.length > 0) {
const linkStmt = db.prepare(`INSERT INTO project_links (project_id, link) VALUES (?, ?)`)
for (const link of project.links) {
linkStmt.run(projectId, link)
}
}
}
}
// Audit-Log
db.prepare(`
INSERT INTO audit_log (id, entity_type, entity_id, action, user_id, changes, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
uuidv4(),
'profile',
id,
'update',
req.user!.id,
JSON.stringify({ before: existing, after: profileData }),
now
)
})
transaction()
res.json({
success: true,
message: 'Profile updated successfully'
})
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/facets - Facettenwerte f\u00fcr Filter
router.get('/facets',
authenticate,
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const facets = {
departments: db.prepare('SELECT DISTINCT department FROM profiles WHERE department IS NOT NULL AND consent_searchable = 1 ORDER BY department').all().map((r: any) => r.department),
locations: db.prepare('SELECT DISTINCT location FROM profiles WHERE location IS NOT NULL AND consent_searchable = 1 ORDER BY location').all().map((r: any) => r.location),
jobCategories: db.prepare('SELECT DISTINCT job_category FROM profiles WHERE job_category IS NOT NULL AND consent_searchable = 1 ORDER BY job_category').all().map((r: any) => r.job_category),
tools: db.prepare('SELECT DISTINCT tool FROM profile_tools pt JOIN profiles p ON pt.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY tool').all().map((r: any) => r.tool),
methods: db.prepare('SELECT DISTINCT method FROM profile_methods pm JOIN profiles p ON pm.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY method').all().map((r: any) => r.method),
languages: db.prepare('SELECT DISTINCT code, level FROM profile_languages pl JOIN profiles p ON pl.profile_id = p.id WHERE p.consent_searchable = 1 ORDER BY code, level').all()
}
res.json({ success: true, data: facets })
} catch (error) {
next(error)
}
}
)
// GET /api/profiles/reminders/overdue - Veraltete Profile (Admin)
router.get('/reminders/overdue',
authenticate,
authorize('admin'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const now = new Date().toISOString()
const overdueProfiles = db.prepare(`
SELECT id, name, department, updated_at, review_due_at
FROM profiles
WHERE review_due_at < ?
ORDER BY review_due_at ASC
`).all(now)
res.json({ success: true, data: overdueProfiles })
} catch (error) {
next(error)
}
}
)
// POST /api/profiles/export - Export-Funktionalit\u00e4t
router.post('/export',
authenticate,
authorize('admin', 'superuser'),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { filter, format = 'json' } = req.body
// Baue Query basierend auf Filter
let sql = `SELECT * FROM profiles WHERE consent_searchable = 1`
const params: any[] = []
if (filter?.department) {
sql += ` AND department = ?`
params.push(filter.department)
}
if (filter?.location) {
sql += ` AND location = ?`
params.push(filter.location)
}
const profiles = db.prepare(sql).all(...params)
if (format === 'csv') {
// CSV-Export
const csv = [
'Name,Department,Location,Role,Email,Phone,Job Category,Job Title',
...profiles.map((p: any) =>
`"${p.name}","${p.department || ''}","${p.location || ''}","${p.role || ''}","${p.email || ''}","${p.phone || ''}","${p.job_category || ''}","${p.job_title || ''}"`
)
].join('\n')
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', 'attachment; filename=profiles.csv')
res.send(csv)
} else {
// JSON-Export
res.json({ success: true, data: profiles })
}
} catch (error) {
next(error)
}
}
)
// POST /api/profiles/tags/suggest - Autocomplete f\u00fcr Tags
router.post('/tags/suggest',
authenticate,
[
body('category').notEmpty(),
body('query').notEmpty()
],
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { category, query } = req.body
const suggestions = db.prepare(`
SELECT value, description
FROM controlled_vocabulary
WHERE category = ? AND value LIKE ? AND is_active = 1
ORDER BY value
LIMIT 10
`).all(category, `${query}%`)
res.json({ success: true, data: suggestions })
} catch (error) {
next(error)
}
}
)
export default router