Initial commit
Dieser Commit ist enthalten in:
138
backend/src/services/emailService.ts
Normale Datei
138
backend/src/services/emailService.ts
Normale Datei
@ -0,0 +1,138 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface EmailConfig {
|
||||
host: string
|
||||
port: number
|
||||
secure: boolean
|
||||
auth: {
|
||||
user: string
|
||||
pass: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SendEmailOptions {
|
||||
to: string
|
||||
subject: string
|
||||
text?: string
|
||||
html?: string
|
||||
}
|
||||
|
||||
class EmailService {
|
||||
private transporter: nodemailer.Transporter | null = null
|
||||
private isEnabled: boolean = false
|
||||
|
||||
constructor() {
|
||||
this.initializeTransporter()
|
||||
}
|
||||
|
||||
private initializeTransporter() {
|
||||
const emailHost = process.env.EMAIL_HOST
|
||||
const emailPort = parseInt(process.env.EMAIL_PORT || '587')
|
||||
const emailUser = process.env.EMAIL_USER
|
||||
const emailPass = process.env.EMAIL_PASS
|
||||
const emailSecure = process.env.EMAIL_SECURE === 'true'
|
||||
|
||||
if (emailHost && emailUser && emailPass) {
|
||||
const config: EmailConfig = {
|
||||
host: emailHost,
|
||||
port: emailPort,
|
||||
secure: emailSecure,
|
||||
auth: {
|
||||
user: emailUser,
|
||||
pass: emailPass
|
||||
}
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport(config)
|
||||
this.isEnabled = true
|
||||
logger.info('Email service initialized successfully')
|
||||
} else {
|
||||
logger.warn('Email service not configured. Set EMAIL_HOST, EMAIL_USER, EMAIL_PASS environment variables.')
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(options: SendEmailOptions): Promise<boolean> {
|
||||
if (!this.isEnabled || !this.transporter) {
|
||||
logger.warn('Email service not enabled. Cannot send email.')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await this.transporter.sendMail({
|
||||
from: process.env.EMAIL_FROM || process.env.EMAIL_USER,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
text: options.text,
|
||||
html: options.html
|
||||
})
|
||||
|
||||
logger.info(`Email sent successfully to ${options.to}`, { messageId: info.messageId })
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to send email:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async sendInitialPassword(email: string, password: string, firstName?: string): Promise<boolean> {
|
||||
const subject = 'SkillMate - Ihr Zugangs-Passwort'
|
||||
const text = `Hallo${firstName ? ` ${firstName}` : ''},
|
||||
|
||||
Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:
|
||||
|
||||
E-Mail: ${email}
|
||||
Passwort: ${password}
|
||||
|
||||
Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.
|
||||
|
||||
Login-URL: ${process.env.FRONTEND_URL || 'http://localhost:5173/login'}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr SkillMate-Team`
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">SkillMate - Ihr Zugangs-Passwort</h2>
|
||||
|
||||
<p>Hallo${firstName ? ` ${firstName}` : ''},</p>
|
||||
|
||||
<p>Ihr SkillMate-Konto wurde erstellt. Hier sind Ihre Anmeldedaten:</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p><strong>E-Mail:</strong> ${email}</p>
|
||||
<p><strong>Passwort:</strong> <code style="background-color: #e5e7eb; padding: 2px 4px; border-radius: 4px;">${password}</code></p>
|
||||
</div>
|
||||
|
||||
<p>Bitte loggen Sie sich ein und ändern Sie Ihr Passwort bei der ersten Anmeldung.</p>
|
||||
|
||||
<p>
|
||||
<a href="${process.env.FRONTEND_URL || 'http://localhost:5173/login'}"
|
||||
style="background-color: #2563eb; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Jetzt einloggen
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #e5e7eb;">
|
||||
|
||||
<p style="color: #6b7280; font-size: 14px;">
|
||||
Mit freundlichen Grüßen,<br>
|
||||
Ihr SkillMate-Team
|
||||
</p>
|
||||
</div>`
|
||||
|
||||
return this.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
})
|
||||
}
|
||||
|
||||
isServiceEnabled(): boolean {
|
||||
return this.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService()
|
||||
110
backend/src/services/encryption.ts
Normale Datei
110
backend/src/services/encryption.ts
Normale Datei
@ -0,0 +1,110 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { randomBytes } from 'crypto'
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config()
|
||||
|
||||
// Generate a secure encryption key from environment or create a new one
|
||||
const getEncryptionKey = (): string => {
|
||||
let key = process.env.FIELD_ENCRYPTION_KEY
|
||||
|
||||
if (!key) {
|
||||
// Use the default development key
|
||||
key = 'dev_field_key_change_in_production_32chars_min!'
|
||||
console.warn('⚠️ No FIELD_ENCRYPTION_KEY found. Using default development key.')
|
||||
console.warn('⚠️ Set FIELD_ENCRYPTION_KEY in your .env file for production!')
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
const ENCRYPTION_KEY = getEncryptionKey()
|
||||
|
||||
export class FieldEncryption {
|
||||
/**
|
||||
* Encrypt sensitive field data
|
||||
*/
|
||||
static encrypt(text: string | null | undefined): string | null {
|
||||
if (!text) return null
|
||||
|
||||
try {
|
||||
const encrypted = CryptoJS.AES.encrypt(text, ENCRYPTION_KEY).toString()
|
||||
return encrypted
|
||||
} catch (error) {
|
||||
console.error('Encryption error:', error)
|
||||
throw new Error('Failed to encrypt data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive field data
|
||||
*/
|
||||
static decrypt(encryptedText: string | null | undefined): string | null {
|
||||
if (!encryptedText) return null
|
||||
|
||||
try {
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedText, ENCRYPTION_KEY)
|
||||
return decrypted.toString(CryptoJS.enc.Utf8)
|
||||
} catch (error) {
|
||||
console.error('Decryption error:', error)
|
||||
throw new Error('Failed to decrypt data')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt multiple fields in an object
|
||||
*/
|
||||
static encryptFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
fields: (keyof T)[]
|
||||
): T {
|
||||
const encrypted = { ...obj }
|
||||
|
||||
for (const field of fields) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = this.encrypt(encrypted[field] as string) as any
|
||||
}
|
||||
}
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt multiple fields in an object
|
||||
*/
|
||||
static decryptFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
fields: (keyof T)[]
|
||||
): T {
|
||||
const decrypted = { ...obj }
|
||||
|
||||
for (const field of fields) {
|
||||
if (decrypted[field]) {
|
||||
decrypted[field] = this.decrypt(decrypted[field] as string) as any
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash data for searching (one-way)
|
||||
*/
|
||||
static hash(text: string): string {
|
||||
return CryptoJS.SHA256(text.toLowerCase()).toString()
|
||||
}
|
||||
}
|
||||
|
||||
// List of sensitive fields that should be encrypted
|
||||
export const SENSITIVE_EMPLOYEE_FIELDS = [
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'clearance_level',
|
||||
'clearance_valid_until'
|
||||
]
|
||||
|
||||
export const SENSITIVE_USER_FIELDS = [
|
||||
'email'
|
||||
]
|
||||
138
backend/src/services/reminderService.ts
Normale Datei
138
backend/src/services/reminderService.ts
Normale Datei
@ -0,0 +1,138 @@
|
||||
import { db } from '../config/database'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { logger } from '../utils/logger'
|
||||
import * as cron from 'node-cron'
|
||||
|
||||
class ReminderService {
|
||||
private reminderJob: cron.ScheduledTask | null = null
|
||||
|
||||
constructor() {
|
||||
this.initializeScheduler()
|
||||
}
|
||||
|
||||
private initializeScheduler() {
|
||||
// T\u00e4glich um 9:00 Uhr pr\u00fcfen
|
||||
this.reminderJob = cron.schedule('0 9 * * *', () => {
|
||||
this.checkAndSendReminders()
|
||||
})
|
||||
|
||||
logger.info('Reminder scheduler initialized')
|
||||
}
|
||||
|
||||
async checkAndSendReminders() {
|
||||
try {
|
||||
const now = new Date()
|
||||
const oneYearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
// Finde Profile die aktualisiert werden m\u00fcssen
|
||||
const overdueProfiles = db.prepare(`
|
||||
SELECT id, name, email, updated_at, review_due_at
|
||||
FROM profiles
|
||||
WHERE review_due_at <= ? OR updated_at <= ?
|
||||
`).all(now.toISOString(), oneYearAgo)
|
||||
|
||||
for (const profile of overdueProfiles) {
|
||||
// Pr\u00fcfe ob bereits ein Reminder existiert
|
||||
const existingReminder = db.prepare(`
|
||||
SELECT id FROM reminders
|
||||
WHERE profile_id = ? AND type = 'annual_update'
|
||||
AND sent_at IS NULL
|
||||
`).get((profile as any).id)
|
||||
|
||||
if (!existingReminder) {
|
||||
// Erstelle neuen Reminder
|
||||
db.prepare(`
|
||||
INSERT INTO reminders (id, profile_id, type, message, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
uuidv4(),
|
||||
(profile as any).id,
|
||||
'annual_update',
|
||||
`Ihr Profil wurde seit ${(profile as any).updated_at} nicht mehr aktualisiert. Bitte \u00fcberpr\u00fcfen Sie Ihre Angaben.`,
|
||||
now.toISOString()
|
||||
)
|
||||
|
||||
// TODO: Email-Versand implementieren
|
||||
logger.info(`Reminder created for profile ${(profile as any).id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Markiere Profile als veraltet
|
||||
db.prepare(`
|
||||
UPDATE profiles
|
||||
SET review_due_at = ?
|
||||
WHERE updated_at <= ? AND review_due_at IS NULL
|
||||
`).run(
|
||||
new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(), // +30 Tage Grace Period
|
||||
oneYearAgo
|
||||
)
|
||||
|
||||
logger.info(`Reminder check completed. ${overdueProfiles.length} profiles need update.`)
|
||||
} catch (error) {
|
||||
logger.error('Error in reminder service:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async sendReminder(profileId: string, type: string, message: string) {
|
||||
try {
|
||||
const profile = db.prepare(`
|
||||
SELECT email, name FROM profiles WHERE id = ?
|
||||
`).get(profileId) as any
|
||||
|
||||
if (!profile) {
|
||||
logger.error(`Profile ${profileId} not found for reminder`)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Email-Service integrieren
|
||||
// await emailService.send({
|
||||
// to: (profile as any).email,
|
||||
// subject: 'SkillMate Profil-Aktualisierung',
|
||||
// body: message
|
||||
// })
|
||||
|
||||
// Markiere als gesendet
|
||||
db.prepare(`
|
||||
UPDATE reminders
|
||||
SET sent_at = ?
|
||||
WHERE profile_id = ? AND type = ? AND sent_at IS NULL
|
||||
`).run(new Date().toISOString(), profileId, type)
|
||||
|
||||
logger.info(`Reminder sent to ${(profile as any).email}`)
|
||||
} catch (error) {
|
||||
logger.error(`Error sending reminder to profile ${profileId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
async acknowledgeReminder(reminderId: string, userId: string) {
|
||||
db.prepare(`
|
||||
UPDATE reminders
|
||||
SET acknowledged_at = ?
|
||||
WHERE id = ?
|
||||
`).run(new Date().toISOString(), reminderId)
|
||||
|
||||
// Aktualisiere review_due_at f\u00fcr das Profil
|
||||
const reminder = db.prepare(`
|
||||
SELECT profile_id FROM reminders WHERE id = ?
|
||||
`).get(reminderId) as any
|
||||
|
||||
if (reminder) {
|
||||
const nextReviewDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
db.prepare(`
|
||||
UPDATE profiles
|
||||
SET review_due_at = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(nextReviewDate, new Date().toISOString(), userId, reminder.profile_id)
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.reminderJob) {
|
||||
this.reminderJob.stop()
|
||||
logger.info('Reminder scheduler stopped')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const reminderService = new ReminderService()
|
||||
129
backend/src/services/syncScheduler.ts
Normale Datei
129
backend/src/services/syncScheduler.ts
Normale Datei
@ -0,0 +1,129 @@
|
||||
import { syncService } from './syncService'
|
||||
import { db } from '../config/database'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface SyncInterval {
|
||||
value: string
|
||||
milliseconds: number
|
||||
}
|
||||
|
||||
const SYNC_INTERVALS: Record<string, SyncInterval> = {
|
||||
'5min': { value: '5min', milliseconds: 5 * 60 * 1000 },
|
||||
'15min': { value: '15min', milliseconds: 15 * 60 * 1000 },
|
||||
'30min': { value: '30min', milliseconds: 30 * 60 * 1000 },
|
||||
'1hour': { value: '1hour', milliseconds: 60 * 60 * 1000 },
|
||||
'daily': { value: 'daily', milliseconds: 24 * 60 * 60 * 1000 }
|
||||
}
|
||||
|
||||
export class SyncScheduler {
|
||||
private static instance: SyncScheduler
|
||||
private intervalId: NodeJS.Timeout | null = null
|
||||
private currentInterval: string = 'disabled'
|
||||
|
||||
private constructor() {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
static getInstance(): SyncScheduler {
|
||||
if (!SyncScheduler.instance) {
|
||||
SyncScheduler.instance = new SyncScheduler()
|
||||
}
|
||||
return SyncScheduler.instance
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
// Check current sync settings on startup
|
||||
this.checkAndUpdateInterval()
|
||||
|
||||
// Check for interval changes every minute
|
||||
setInterval(() => {
|
||||
this.checkAndUpdateInterval()
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
private checkAndUpdateInterval() {
|
||||
try {
|
||||
const settings = db.prepare('SELECT auto_sync_interval FROM sync_settings WHERE id = ?').get('default') as any
|
||||
const newInterval = settings?.auto_sync_interval || 'disabled'
|
||||
|
||||
if (newInterval !== this.currentInterval) {
|
||||
logger.info(`Sync interval changed from ${this.currentInterval} to ${newInterval}`)
|
||||
this.currentInterval = newInterval
|
||||
this.updateSchedule()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to check sync interval:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private updateSchedule() {
|
||||
// Clear existing interval
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId)
|
||||
this.intervalId = null
|
||||
}
|
||||
|
||||
// Set new interval if not disabled
|
||||
if (this.currentInterval !== 'disabled' && SYNC_INTERVALS[this.currentInterval]) {
|
||||
const { milliseconds } = SYNC_INTERVALS[this.currentInterval]
|
||||
|
||||
logger.info(`Starting automatic sync with interval: ${this.currentInterval}`)
|
||||
|
||||
// Run sync immediately
|
||||
this.runSync()
|
||||
|
||||
// Then schedule regular syncs
|
||||
this.intervalId = setInterval(() => {
|
||||
this.runSync()
|
||||
}, milliseconds)
|
||||
} else {
|
||||
logger.info('Automatic sync disabled')
|
||||
}
|
||||
}
|
||||
|
||||
private async runSync() {
|
||||
try {
|
||||
logger.info('Running scheduled sync...')
|
||||
|
||||
// Check if we're the admin node
|
||||
const nodeType = process.env.NODE_TYPE || 'local'
|
||||
if (nodeType === 'admin') {
|
||||
// Admin pushes to all local nodes
|
||||
await syncService.triggerSync()
|
||||
} else {
|
||||
// Local nodes sync with admin
|
||||
const adminNode = db.prepare(`
|
||||
SELECT id FROM network_nodes
|
||||
WHERE type = 'admin' AND is_online = 1
|
||||
LIMIT 1
|
||||
`).get() as any
|
||||
|
||||
if (adminNode) {
|
||||
await syncService.syncWithNode(adminNode.id)
|
||||
} else {
|
||||
logger.warn('No admin node available for sync')
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Scheduled sync completed')
|
||||
} catch (error) {
|
||||
logger.error('Scheduled sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Manual trigger for testing
|
||||
async triggerManualSync() {
|
||||
await this.runSync()
|
||||
}
|
||||
|
||||
// Get current status
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.currentInterval !== 'disabled',
|
||||
interval: this.currentInterval,
|
||||
nextRun: this.intervalId ? new Date(Date.now() + (SYNC_INTERVALS[this.currentInterval]?.milliseconds || 0)) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncScheduler = SyncScheduler.getInstance()
|
||||
573
backend/src/services/syncService.ts
Normale Datei
573
backend/src/services/syncService.ts
Normale Datei
@ -0,0 +1,573 @@
|
||||
import { db } from '../config/database'
|
||||
import axios from 'axios'
|
||||
import crypto from 'crypto'
|
||||
import { logger } from '../utils/logger'
|
||||
|
||||
interface SyncPayload {
|
||||
type: 'employees' | 'skills' | 'users' | 'settings'
|
||||
action: 'create' | 'update' | 'delete'
|
||||
data: any
|
||||
timestamp: string
|
||||
nodeId: string
|
||||
checksum: string
|
||||
}
|
||||
|
||||
interface SyncResult {
|
||||
success: boolean
|
||||
syncedItems: number
|
||||
conflicts: any[]
|
||||
errors: any[]
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private static instance: SyncService
|
||||
private syncQueue: SyncPayload[] = []
|
||||
private isSyncing = false
|
||||
|
||||
private constructor() {
|
||||
// Initialize sync tables
|
||||
this.initializeSyncTables()
|
||||
}
|
||||
|
||||
static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService()
|
||||
}
|
||||
return SyncService.instance
|
||||
}
|
||||
|
||||
private initializeSyncTables() {
|
||||
// Sync log table to track all sync operations
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id TEXT NOT NULL,
|
||||
sync_type TEXT NOT NULL,
|
||||
sync_action TEXT NOT NULL,
|
||||
entity_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
status TEXT CHECK(status IN ('pending', 'completed', 'failed', 'conflict')) NOT NULL,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)
|
||||
`)
|
||||
|
||||
// Conflict resolution table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
entity_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
local_data TEXT NOT NULL,
|
||||
remote_data TEXT NOT NULL,
|
||||
conflict_type TEXT NOT NULL,
|
||||
resolution_status TEXT CHECK(resolution_status IN ('pending', 'resolved', 'ignored')) DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
resolved_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Sync metadata table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
node_id TEXT PRIMARY KEY,
|
||||
last_sync_at TEXT,
|
||||
last_successful_sync TEXT,
|
||||
sync_version INTEGER DEFAULT 1,
|
||||
total_synced_items INTEGER DEFAULT 0,
|
||||
total_conflicts INTEGER DEFAULT 0,
|
||||
total_errors INTEGER DEFAULT 0
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
// Generate checksum for data integrity
|
||||
private generateChecksum(data: any): string {
|
||||
const hash = crypto.createHash('sha256')
|
||||
hash.update(JSON.stringify(data))
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
// Add item to sync queue
|
||||
async queueSync(type: SyncPayload['type'], action: SyncPayload['action'], data: any) {
|
||||
const nodeId = this.getNodeId()
|
||||
const payload: SyncPayload = {
|
||||
type,
|
||||
action,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
nodeId,
|
||||
checksum: this.generateChecksum(data)
|
||||
}
|
||||
|
||||
this.syncQueue.push(payload)
|
||||
|
||||
// Log to sync_log
|
||||
db.prepare(`
|
||||
INSERT INTO sync_log (
|
||||
id, node_id, sync_type, sync_action, entity_id, entity_type,
|
||||
payload, checksum, status, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
crypto.randomUUID(),
|
||||
nodeId,
|
||||
type,
|
||||
action,
|
||||
data.id || 'unknown',
|
||||
type,
|
||||
JSON.stringify(payload),
|
||||
payload.checksum,
|
||||
'pending',
|
||||
payload.timestamp
|
||||
)
|
||||
|
||||
// Trigger sync if auto-sync is enabled
|
||||
const syncSettings = this.getSyncSettings()
|
||||
if (syncSettings.autoSyncInterval !== 'disabled') {
|
||||
this.triggerSync()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with remote nodes
|
||||
async syncWithNode(targetNodeId: string): Promise<SyncResult> {
|
||||
const result: SyncResult = {
|
||||
success: false,
|
||||
syncedItems: 0,
|
||||
conflicts: [],
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const targetNode = this.getNodeInfo(targetNodeId)
|
||||
if (!targetNode) {
|
||||
throw new Error('Target node not found')
|
||||
}
|
||||
|
||||
// Get pending sync items
|
||||
const pendingItems = db.prepare(`
|
||||
SELECT * FROM sync_log
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`).all() as any[]
|
||||
|
||||
for (const item of pendingItems) {
|
||||
try {
|
||||
const payload = JSON.parse(item.payload)
|
||||
|
||||
// Send to target node
|
||||
const response = await axios.post(
|
||||
`http://${targetNode.ip_address}:${targetNode.port}/api/sync/receive`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${targetNode.api_key}`,
|
||||
'X-Node-Id': this.getNodeId()
|
||||
},
|
||||
timeout: 30000
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
// Mark as completed
|
||||
db.prepare(`
|
||||
UPDATE sync_log
|
||||
SET status = 'completed', completed_at = ?
|
||||
WHERE id = ?
|
||||
`).run(new Date().toISOString(), item.id)
|
||||
|
||||
result.syncedItems++
|
||||
} else if (response.data.conflict) {
|
||||
// Handle conflict
|
||||
this.handleConflict(item, response.data.conflictData)
|
||||
result.conflicts.push({
|
||||
itemId: item.id,
|
||||
conflict: response.data.conflictData
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Sync error for item ${item.id}:`, error)
|
||||
|
||||
// Mark as failed
|
||||
db.prepare(`
|
||||
UPDATE sync_log
|
||||
SET status = 'failed', error_message = ?
|
||||
WHERE id = ?
|
||||
`).run(error.message, item.id)
|
||||
|
||||
result.errors.push({
|
||||
itemId: item.id,
|
||||
error: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
this.updateSyncMetadata(targetNodeId, result)
|
||||
result.success = result.errors.length === 0
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Sync failed:', error)
|
||||
result.errors.push({ error: error.message })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Receive sync from remote node
|
||||
async receiveSync(payload: SyncPayload): Promise<any> {
|
||||
try {
|
||||
// Verify checksum
|
||||
const calculatedChecksum = this.generateChecksum(payload.data)
|
||||
if (calculatedChecksum !== payload.checksum) {
|
||||
throw new Error('Checksum mismatch - data integrity error')
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const conflict = await this.checkForConflicts(payload)
|
||||
if (conflict) {
|
||||
return {
|
||||
success: false,
|
||||
conflict: true,
|
||||
conflictData: conflict
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes based on type and action
|
||||
await this.applyChanges(payload)
|
||||
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
logger.error('Error receiving sync:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
private async checkForConflicts(payload: SyncPayload): Promise<any> {
|
||||
const { type, action, data } = payload
|
||||
|
||||
if (action === 'create') {
|
||||
// Check if entity already exists
|
||||
let exists = false
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
exists = !!db.prepare('SELECT id FROM employees WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'skills':
|
||||
exists = !!db.prepare('SELECT id FROM skills WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'users':
|
||||
exists = !!db.prepare('SELECT id FROM users WHERE id = ?').get(data.id)
|
||||
break
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return {
|
||||
type: 'already_exists',
|
||||
entityId: data.id,
|
||||
entityType: type
|
||||
}
|
||||
}
|
||||
} else if (action === 'update') {
|
||||
// Check if local version is newer
|
||||
let localEntity: any = null
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
localEntity = db.prepare('SELECT * FROM employees WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'skills':
|
||||
localEntity = db.prepare('SELECT * FROM skills WHERE id = ?').get(data.id)
|
||||
break
|
||||
case 'users':
|
||||
localEntity = db.prepare('SELECT * FROM users WHERE id = ?').get(data.id)
|
||||
break
|
||||
}
|
||||
|
||||
if (localEntity && new Date(localEntity.updated_at) > new Date(data.updatedAt)) {
|
||||
return {
|
||||
type: 'newer_version',
|
||||
entityId: data.id,
|
||||
entityType: type,
|
||||
localVersion: localEntity,
|
||||
remoteVersion: data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Apply changes to local database
|
||||
private async applyChanges(payload: SyncPayload) {
|
||||
const { type, action, data } = payload
|
||||
|
||||
switch (type) {
|
||||
case 'employees':
|
||||
await this.syncEmployee(action, data)
|
||||
break
|
||||
case 'skills':
|
||||
await this.syncSkill(action, data)
|
||||
break
|
||||
case 'users':
|
||||
await this.syncUser(action, data)
|
||||
break
|
||||
case 'settings':
|
||||
await this.syncSettings(action, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync employee data
|
||||
private async syncEmployee(action: string, data: any) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
db.prepare(`
|
||||
INSERT INTO employees (
|
||||
id, first_name, last_name, employee_number, photo, position,
|
||||
department, email, phone, mobile, office, availability,
|
||||
clearance_level, clearance_valid_until, clearance_issued_date,
|
||||
created_at, updated_at, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, data.firstName, data.lastName, data.employeeNumber,
|
||||
data.photo, data.position, data.department, data.email,
|
||||
data.phone, data.mobile, data.office, data.availability,
|
||||
data.clearance?.level, data.clearance?.validUntil,
|
||||
data.clearance?.issuedDate, data.createdAt, data.updatedAt,
|
||||
data.createdBy
|
||||
)
|
||||
|
||||
// Sync skills
|
||||
if (data.skills) {
|
||||
for (const skill of data.skills) {
|
||||
db.prepare(`
|
||||
INSERT INTO employee_skills (employee_id, skill_id, level, verified, verified_by, verified_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, skill.id, skill.level,
|
||||
skill.verified ? 1 : 0, skill.verifiedBy, skill.verifiedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE employees SET
|
||||
first_name = ?, last_name = ?, position = ?, department = ?,
|
||||
email = ?, phone = ?, mobile = ?, office = ?, availability = ?,
|
||||
updated_at = ?, updated_by = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
data.firstName, data.lastName, data.position, data.department,
|
||||
data.email, data.phone, data.mobile, data.office, data.availability,
|
||||
data.updatedAt, data.updatedBy, data.id
|
||||
)
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM employees WHERE id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM employee_skills WHERE employee_id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM language_skills WHERE employee_id = ?').run(data.id)
|
||||
db.prepare('DELETE FROM specializations WHERE employee_id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync skill data
|
||||
private async syncSkill(action: string, data: any) {
|
||||
switch (action) {
|
||||
case 'create':
|
||||
db.prepare(`
|
||||
INSERT INTO skills (id, name, category, description, requires_certification, expires_after)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.id, data.name, data.category, data.description,
|
||||
data.requiresCertification ? 1 : 0, data.expiresAfter
|
||||
)
|
||||
break
|
||||
|
||||
case 'update':
|
||||
db.prepare(`
|
||||
UPDATE skills SET name = ?, category = ?, description = ?
|
||||
WHERE id = ?
|
||||
`).run(data.name, data.category, data.description, data.id)
|
||||
break
|
||||
|
||||
case 'delete':
|
||||
db.prepare('DELETE FROM skills WHERE id = ?').run(data.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Sync user data
|
||||
private async syncUser(action: string, data: any) {
|
||||
// Implementation for user sync
|
||||
logger.info(`Syncing user: ${action}`, data)
|
||||
}
|
||||
|
||||
// Sync settings
|
||||
private async syncSettings(action: string, data: any) {
|
||||
// Implementation for settings sync
|
||||
logger.info(`Syncing settings: ${action}`, data)
|
||||
}
|
||||
|
||||
// Handle conflicts
|
||||
private handleConflict(syncItem: any, conflictData: any) {
|
||||
const conflictId = crypto.randomUUID()
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sync_conflicts (
|
||||
id, entity_id, entity_type, local_data, remote_data,
|
||||
conflict_type, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
conflictId,
|
||||
conflictData.entityId,
|
||||
conflictData.entityType,
|
||||
JSON.stringify(conflictData.localVersion || {}),
|
||||
JSON.stringify(conflictData.remoteVersion || {}),
|
||||
conflictData.type,
|
||||
new Date().toISOString()
|
||||
)
|
||||
|
||||
// Apply conflict resolution strategy
|
||||
const settings = this.getSyncSettings()
|
||||
if (settings.conflictResolution === 'admin') {
|
||||
// Admin wins - apply remote changes if from admin node
|
||||
const remoteNode = this.getNodeInfo(syncItem.node_id)
|
||||
if (remoteNode?.type === 'admin') {
|
||||
this.applyChanges(JSON.parse(syncItem.payload))
|
||||
}
|
||||
} else if (settings.conflictResolution === 'newest') {
|
||||
// Newest wins - compare timestamps
|
||||
const localTime = new Date(conflictData.localVersion?.updatedAt || 0)
|
||||
const remoteTime = new Date(conflictData.remoteVersion?.updatedAt || 0)
|
||||
if (remoteTime > localTime) {
|
||||
this.applyChanges(JSON.parse(syncItem.payload))
|
||||
}
|
||||
}
|
||||
// Manual resolution - do nothing, let admin resolve
|
||||
}
|
||||
|
||||
// Get sync settings
|
||||
private getSyncSettings(): any {
|
||||
const settings = db.prepare('SELECT * FROM sync_settings WHERE id = ?').get('default') as any
|
||||
return settings || {
|
||||
autoSyncInterval: 'disabled',
|
||||
conflictResolution: 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
// Get node information
|
||||
private getNodeInfo(nodeId: string): any {
|
||||
return db.prepare('SELECT * FROM network_nodes WHERE id = ?').get(nodeId)
|
||||
}
|
||||
|
||||
// Get current node ID
|
||||
private getNodeId(): string {
|
||||
// Get from environment or generate
|
||||
return process.env.NODE_ID || 'local-node'
|
||||
}
|
||||
|
||||
// Update sync metadata
|
||||
private updateSyncMetadata(nodeId: string, result: SyncResult) {
|
||||
const existing = db.prepare('SELECT * FROM sync_metadata WHERE node_id = ?').get(nodeId) as any
|
||||
|
||||
if (existing) {
|
||||
db.prepare(`
|
||||
UPDATE sync_metadata SET
|
||||
last_sync_at = ?,
|
||||
last_successful_sync = ?,
|
||||
total_synced_items = total_synced_items + ?,
|
||||
total_conflicts = total_conflicts + ?,
|
||||
total_errors = total_errors + ?
|
||||
WHERE node_id = ?
|
||||
`).run(
|
||||
new Date().toISOString(),
|
||||
result.success ? new Date().toISOString() : existing.last_successful_sync,
|
||||
result.syncedItems,
|
||||
result.conflicts.length,
|
||||
result.errors.length,
|
||||
nodeId
|
||||
)
|
||||
} else {
|
||||
db.prepare(`
|
||||
INSERT INTO sync_metadata (
|
||||
node_id, last_sync_at, last_successful_sync,
|
||||
total_synced_items, total_conflicts, total_errors
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nodeId,
|
||||
new Date().toISOString(),
|
||||
result.success ? new Date().toISOString() : null,
|
||||
result.syncedItems,
|
||||
result.conflicts.length,
|
||||
result.errors.length
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger sync process
|
||||
async triggerSync() {
|
||||
if (this.isSyncing) {
|
||||
logger.info('Sync already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
this.isSyncing = true
|
||||
|
||||
try {
|
||||
// Get all active nodes
|
||||
const nodes = db.prepare(`
|
||||
SELECT * FROM network_nodes
|
||||
WHERE is_online = 1 AND id != ?
|
||||
`).all(this.getNodeId()) as any[]
|
||||
|
||||
for (const node of nodes) {
|
||||
logger.info(`Syncing with node: ${node.name}`)
|
||||
await this.syncWithNode(node.id)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Sync trigger failed:', error)
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status
|
||||
getSyncStatus(): any {
|
||||
const pendingCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM sync_log WHERE status = ?'
|
||||
).get('pending') as any
|
||||
|
||||
const recentSync = db.prepare(`
|
||||
SELECT * FROM sync_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
`).all()
|
||||
|
||||
const conflicts = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM sync_conflicts
|
||||
WHERE resolution_status = ?
|
||||
`).get('pending') as any
|
||||
|
||||
return {
|
||||
pendingItems: pendingCount.count,
|
||||
recentSync,
|
||||
pendingConflicts: conflicts.count,
|
||||
isSyncing: this.isSyncing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const syncService = SyncService.getInstance()
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren