Dieser Commit ist enthalten in:
Claude Project Manager
2025-12-28 21:36:45 +00:00
Commit ab1e5be9a9
146 geänderte Dateien mit 65525 neuen und 0 gelöschten Zeilen

549
backend/services/gitService.js Normale Datei
Datei anzeigen

@ -0,0 +1,549 @@
/**
* TASKMATE - Git Service
* =======================
* Lokale Git-Operationen über child_process
*/
const { execSync, exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const logger = require('../utils/logger');
/**
* Konvertiert einen Windows-Pfad zu einem Docker-Container-Pfad
* z.B. "D:\Projekte\MyApp" -> "/mnt/d/Projekte/MyApp"
*/
function windowsToContainerPath(windowsPath) {
if (!windowsPath) return null;
// Bereits ein Container-Pfad?
if (windowsPath.startsWith('/mnt/')) {
return windowsPath;
}
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
const normalized = windowsPath.replace(/\\/g, '/');
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
if (match) {
const drive = match[1].toLowerCase();
const restPath = match[2];
return `/mnt/${drive}/${restPath}`;
}
return windowsPath;
}
/**
* Konvertiert einen Docker-Container-Pfad zu einem Windows-Pfad
* z.B. "/mnt/d/Projekte/MyApp" -> "D:\Projekte\MyApp"
*/
function containerToWindowsPath(containerPath) {
if (!containerPath) return null;
const match = containerPath.match(/^\/mnt\/([a-z])\/(.*)$/);
if (match) {
const drive = match[1].toUpperCase();
const restPath = match[2].replace(/\//g, '\\');
return `${drive}:\\${restPath}`;
}
return containerPath;
}
/**
* Prüft, ob ein Pfad existiert und erreichbar ist
*/
function isPathAccessible(localPath) {
const containerPath = windowsToContainerPath(localPath);
try {
// Prüfe ob das übergeordnete Verzeichnis existiert
const parentDir = path.dirname(containerPath);
return fs.existsSync(parentDir);
} catch (error) {
logger.error('Pfadzugriff fehlgeschlagen:', error);
return false;
}
}
/**
* Prüft, ob ein Verzeichnis ein Git-Repository ist
*/
function isGitRepository(localPath) {
const containerPath = windowsToContainerPath(localPath);
try {
const gitDir = path.join(containerPath, '.git');
return fs.existsSync(gitDir);
} catch (error) {
return false;
}
}
/**
* Führt einen Git-Befehl aus
*/
function execGitCommand(command, cwd, options = {}) {
const containerPath = windowsToContainerPath(cwd);
const timeout = options.timeout || 60000; // 60 Sekunden Standard-Timeout
try {
const result = execSync(command, {
cwd: containerPath,
encoding: 'utf8',
timeout,
maxBuffer: 10 * 1024 * 1024, // 10 MB
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0', // Keine interaktiven Prompts
GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=no'
}
});
return { success: true, output: result.trim() };
} catch (error) {
logger.error(`Git-Befehl fehlgeschlagen: ${command}`, error.message);
return {
success: false,
error: error.message,
stderr: error.stderr?.toString() || ''
};
}
}
/**
* Repository klonen
*/
async function cloneRepository(repoUrl, localPath, options = {}) {
const containerPath = windowsToContainerPath(localPath);
const branch = options.branch || 'main';
// Prüfe, ob das Zielverzeichnis bereits existiert
if (fs.existsSync(containerPath)) {
if (isGitRepository(localPath)) {
return { success: false, error: 'Verzeichnis enthält bereits ein Git-Repository' };
}
// Verzeichnis existiert, aber ist kein Git-Repo - prüfe ob leer
const files = fs.readdirSync(containerPath);
if (files.length > 0) {
return { success: false, error: 'Verzeichnis ist nicht leer' };
}
} else {
// Erstelle Verzeichnis
fs.mkdirSync(containerPath, { recursive: true });
}
// Gitea-Token für Authentifizierung hinzufügen
let authUrl = repoUrl;
const giteaToken = process.env.GITEA_TOKEN;
if (giteaToken && repoUrl.includes('gitea')) {
// Füge Token zur URL hinzu: https://token@gitea.example.com/...
authUrl = repoUrl.replace('https://', `https://${giteaToken}@`);
}
const command = `git clone --branch ${branch} "${authUrl}" "${containerPath}"`;
try {
execSync(command, {
encoding: 'utf8',
timeout: 300000, // 5 Minuten für Clone
maxBuffer: 50 * 1024 * 1024, // 50 MB
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
logger.info(`Repository geklont: ${repoUrl} -> ${localPath}`);
return { success: true, message: 'Repository erfolgreich geklont' };
} catch (error) {
logger.error('Clone fehlgeschlagen:', error.message);
// Bereinige bei Fehler
try {
if (fs.existsSync(containerPath)) {
fs.rmSync(containerPath, { recursive: true, force: true });
}
} catch (e) {
// Ignorieren
}
return { success: false, error: error.message };
}
}
/**
* Änderungen ziehen (Pull)
*/
function pullChanges(localPath, options = {}) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const branch = options.branch || '';
const command = branch ? `git pull origin ${branch}` : 'git pull';
return execGitCommand(command, localPath);
}
/**
* Änderungen hochladen (Push)
*/
function pushChanges(localPath, options = {}) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const branch = options.branch || '';
const command = branch ? `git push origin ${branch}` : 'git push';
return execGitCommand(command, localPath, { timeout: 120000 });
}
/**
* Git-Status abrufen
*/
function getStatus(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git status --porcelain', localPath);
if (!result.success) {
return result;
}
const lines = result.output.split('\n').filter(l => l.trim());
const changes = lines.map(line => {
const status = line.substring(0, 2);
const file = line.substring(3);
return { status, file };
});
// Zusätzliche Infos
const branchResult = execGitCommand('git branch --show-current', localPath);
const aheadBehindResult = execGitCommand('git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"', localPath);
let ahead = 0;
let behind = 0;
if (aheadBehindResult.success) {
const parts = aheadBehindResult.output.split(/\s+/);
ahead = parseInt(parts[0]) || 0;
behind = parseInt(parts[1]) || 0;
}
return {
success: true,
branch: branchResult.success ? branchResult.output : 'unknown',
changes,
hasChanges: changes.length > 0,
ahead,
behind,
isClean: changes.length === 0 && ahead === 0
};
}
/**
* Alle Änderungen stagen
*/
function stageAll(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand('git add -A', localPath);
}
/**
* Commit erstellen
*/
function commit(localPath, message) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
if (!message || message.trim() === '') {
return { success: false, error: 'Commit-Nachricht erforderlich' };
}
// Escape für Shell
const escapedMessage = message.replace(/"/g, '\\"');
return execGitCommand(`git commit -m "${escapedMessage}"`, localPath);
}
/**
* Commit-Historie abrufen
*/
function getCommitHistory(localPath, limit = 20) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const format = '%H|%h|%an|%ae|%at|%s';
const result = execGitCommand(`git log -${limit} --format="${format}"`, localPath);
if (!result.success) {
return result;
}
const commits = result.output.split('\n').filter(l => l.trim()).map(line => {
const [hash, shortHash, author, email, timestamp, subject] = line.split('|');
return {
hash,
shortHash,
author,
email,
date: new Date(parseInt(timestamp) * 1000).toISOString(),
message: subject
};
});
return { success: true, commits };
}
/**
* Branches auflisten
*/
function getBranches(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git branch -a', localPath);
if (!result.success) {
return result;
}
const branches = result.output.split('\n').filter(l => l.trim()).map(line => {
const isCurrent = line.startsWith('*');
const name = line.replace(/^\*?\s+/, '').trim();
const isRemote = name.startsWith('remotes/');
return { name, isCurrent, isRemote };
});
return { success: true, branches };
}
/**
* Branch wechseln
*/
function checkoutBranch(localPath, branch) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand(`git checkout ${branch}`, localPath);
}
/**
* Fetch von Remote
*/
function fetchRemote(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand('git fetch --all', localPath, { timeout: 120000 });
}
/**
* Remote-URL abrufen
*/
function getRemoteUrl(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git remote get-url origin', localPath);
if (result.success) {
// Entferne Token aus URL für Anzeige
let url = result.output;
url = url.replace(/https:\/\/[^@]+@/, 'https://');
return { success: true, url };
}
return result;
}
/**
* Git Repository initialisieren
*/
function initRepository(localPath, options = {}) {
const containerPath = windowsToContainerPath(localPath);
const branch = options.branch || 'main';
// Prüfe ob bereits ein Git-Repo existiert
if (isGitRepository(localPath)) {
return { success: true, message: 'Git-Repository existiert bereits' };
}
// Prüfe ob Verzeichnis existiert
if (!fs.existsSync(containerPath)) {
return { success: false, error: 'Verzeichnis existiert nicht' };
}
// Git init ausführen
const initResult = execGitCommand(`git init -b ${branch}`, localPath);
if (!initResult.success) {
return initResult;
}
logger.info(`Git-Repository initialisiert: ${localPath}`);
return { success: true, message: 'Git-Repository initialisiert' };
}
/**
* Remote hinzufügen oder aktualisieren
*/
function setRemote(localPath, remoteUrl, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
// Gitea-Token für Authentifizierung hinzufügen
let authUrl = remoteUrl;
const giteaToken = process.env.GITEA_TOKEN;
if (giteaToken && (remoteUrl.includes('gitea') || remoteUrl.includes('aegis-sight'))) {
authUrl = remoteUrl.replace('https://', `https://${giteaToken}@`);
}
// Prüfe ob Remote bereits existiert
const checkResult = execGitCommand(`git remote get-url ${remoteName}`, localPath);
if (checkResult.success) {
// Remote existiert - aktualisieren
const result = execGitCommand(`git remote set-url ${remoteName} "${authUrl}"`, localPath);
if (result.success) {
logger.info(`Remote '${remoteName}' aktualisiert: ${remoteUrl}`);
return { success: true, message: 'Remote aktualisiert' };
}
return result;
} else {
// Remote existiert nicht - hinzufügen
const result = execGitCommand(`git remote add ${remoteName} "${authUrl}"`, localPath);
if (result.success) {
logger.info(`Remote '${remoteName}' hinzugefügt: ${remoteUrl}`);
return { success: true, message: 'Remote hinzugefügt' };
}
return result;
}
}
/**
* Prüft ob ein Remote existiert
*/
function hasRemote(localPath, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return false;
}
const result = execGitCommand(`git remote get-url ${remoteName}`, localPath);
return result.success;
}
/**
* Initialen Push mit Upstream-Tracking
*/
function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
// Aktuellen Branch ermitteln falls nicht angegeben
if (!branch) {
const branchResult = execGitCommand('git branch --show-current', localPath);
branch = branchResult.success && branchResult.output ? branchResult.output : 'main';
}
// Prüfe ob Commits existieren
const logResult = execGitCommand('git rev-parse HEAD', localPath);
if (!logResult.success) {
// Keine Commits - erstelle einen initialen Commit
const statusResult = execGitCommand('git status --porcelain', localPath);
if (statusResult.success && statusResult.output.trim()) {
// Es gibt Dateien - stage und commit
const stageResult = stageAll(localPath);
if (!stageResult.success) {
return { success: false, error: 'Staging fehlgeschlagen: ' + stageResult.error };
}
const commitResult = commit(localPath, 'Initial commit');
if (!commitResult.success) {
return { success: false, error: 'Initial commit fehlgeschlagen: ' + commitResult.error };
}
logger.info('Initialer Commit erstellt vor Push');
} else {
return { success: false, error: 'Keine Commits und keine Dateien zum Committen vorhanden' };
}
}
// Push mit -u für Upstream-Tracking
return execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
}
/**
* Repository für Gitea vorbereiten (init, remote, initial commit)
*/
function prepareForGitea(localPath, remoteUrl, options = {}) {
const branch = options.branch || 'main';
const containerPath = windowsToContainerPath(localPath);
// 1. Prüfe ob Git-Repo existiert, wenn nicht initialisieren
if (!isGitRepository(localPath)) {
const initResult = initRepository(localPath, { branch });
if (!initResult.success) {
return initResult;
}
}
// 2. Remote hinzufügen/aktualisieren
const remoteResult = setRemote(localPath, remoteUrl);
if (!remoteResult.success) {
return remoteResult;
}
// 3. Prüfe ob Commits existieren
const logResult = execGitCommand('git rev-parse HEAD', localPath);
if (!logResult.success) {
// Keine Commits - erstelle initialen Commit wenn Dateien vorhanden
const statusResult = execGitCommand('git status --porcelain', localPath);
if (statusResult.success && statusResult.output.trim()) {
// Es gibt Dateien - stage und commit
const stageResult = stageAll(localPath);
if (!stageResult.success) {
return stageResult;
}
const commitResult = commit(localPath, 'Initial commit');
if (!commitResult.success) {
return commitResult;
}
}
}
logger.info(`Repository für Gitea vorbereitet: ${localPath} -> ${remoteUrl}`);
return { success: true, message: 'Repository für Gitea vorbereitet' };
}
module.exports = {
windowsToContainerPath,
containerToWindowsPath,
isPathAccessible,
isGitRepository,
cloneRepository,
pullChanges,
pushChanges,
getStatus,
stageAll,
commit,
getCommitHistory,
getBranches,
checkoutBranch,
fetchRemote,
getRemoteUrl,
initRepository,
setRemote,
hasRemote,
pushWithUpstream,
prepareForGitea
};

Datei anzeigen

@ -0,0 +1,300 @@
/**
* TASKMATE - Gitea Service
* =========================
* Integration mit Gitea API
*/
const logger = require('../utils/logger');
const GITEA_URL = process.env.GITEA_URL || 'https://gitea-undso.aegis-sight.de';
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const GITEA_ORG = process.env.GITEA_ORG || 'AegisSight'; // Standard-Organisation für neue Repos
/**
* Basis-Fetch für Gitea API
*/
async function giteaFetch(endpoint, options = {}) {
if (!GITEA_TOKEN) {
throw new Error('Gitea-Token nicht konfiguriert');
}
const url = `${GITEA_URL}/api/v1${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers
}
});
if (!response.ok) {
const errorText = await response.text();
logger.error(`Gitea API Fehler: ${response.status} - ${errorText}`);
throw new Error(`Gitea API Fehler: ${response.status}`);
}
return response.json();
}
/**
* Alle Repositories der Organisation abrufen
*/
async function listRepositories(options = {}) {
try {
const page = options.page || 1;
const limit = options.limit || 50;
// Repositories der Organisation abrufen
const repos = await giteaFetch(`/orgs/${GITEA_ORG}/repos?page=${page}&limit=${limit}`);
return {
success: true,
repositories: repos.map(repo => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
description: repo.description || '',
cloneUrl: repo.clone_url,
htmlUrl: repo.html_url,
defaultBranch: repo.default_branch,
private: repo.private,
fork: repo.fork,
stars: repo.stars_count,
forks: repo.forks_count,
updatedAt: repo.updated_at,
createdAt: repo.created_at
}))
};
} catch (error) {
logger.error('Fehler beim Abrufen der Repositories:', error);
return { success: false, error: error.message };
}
}
/**
* Repository-Details abrufen
*/
async function getRepository(owner, repo) {
try {
const repoData = await giteaFetch(`/repos/${owner}/${repo}`);
return {
success: true,
repository: {
id: repoData.id,
name: repoData.name,
fullName: repoData.full_name,
owner: repoData.owner.login,
description: repoData.description || '',
cloneUrl: repoData.clone_url,
htmlUrl: repoData.html_url,
defaultBranch: repoData.default_branch,
private: repoData.private,
fork: repoData.fork,
stars: repoData.stars_count,
forks: repoData.forks_count,
size: repoData.size,
updatedAt: repoData.updated_at,
createdAt: repoData.created_at
}
};
} catch (error) {
logger.error(`Fehler beim Abrufen des Repositories ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Branches eines Repositories abrufen
*/
async function getRepositoryBranches(owner, repo) {
try {
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches`);
return {
success: true,
branches: branches.map(branch => ({
name: branch.name,
commit: branch.commit?.id,
protected: branch.protected
}))
};
} catch (error) {
logger.error(`Fehler beim Abrufen der Branches für ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Commits eines Repositories abrufen
*/
async function getRepositoryCommits(owner, repo, options = {}) {
try {
const page = options.page || 1;
const limit = options.limit || 20;
const branch = options.branch || '';
let endpoint = `/repos/${owner}/${repo}/commits?page=${page}&limit=${limit}`;
if (branch) {
endpoint += `&sha=${branch}`;
}
const commits = await giteaFetch(endpoint);
return {
success: true,
commits: commits.map(commit => ({
sha: commit.sha,
shortSha: commit.sha.substring(0, 7),
message: commit.commit.message,
author: commit.commit.author.name,
email: commit.commit.author.email,
date: commit.commit.author.date,
htmlUrl: commit.html_url
}))
};
} catch (error) {
logger.error(`Fehler beim Abrufen der Commits für ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Neues Repository in der Organisation erstellen
*/
async function createRepository(name, options = {}) {
try {
// Repository unter der Organisation erstellen
const repoData = await giteaFetch(`/orgs/${GITEA_ORG}/repos`, {
method: 'POST',
body: JSON.stringify({
name,
description: options.description || '',
private: options.private !== false,
auto_init: options.autoInit !== false,
default_branch: options.defaultBranch || 'main',
readme: options.readme || 'Default'
})
});
logger.info(`Repository in Organisation ${GITEA_ORG} erstellt: ${repoData.full_name}`);
return {
success: true,
repository: {
id: repoData.id,
name: repoData.name,
fullName: repoData.full_name,
owner: repoData.owner.login,
cloneUrl: repoData.clone_url,
htmlUrl: repoData.html_url,
defaultBranch: repoData.default_branch
}
};
} catch (error) {
logger.error('Fehler beim Erstellen des Repositories:', error);
return { success: false, error: error.message };
}
}
/**
* Repository löschen
*/
async function deleteRepository(owner, repo) {
try {
await giteaFetch(`/repos/${owner}/${repo}`, {
method: 'DELETE'
});
logger.info(`Repository gelöscht: ${owner}/${repo}`);
return { success: true };
} catch (error) {
logger.error(`Fehler beim Löschen des Repositories ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Authentifizierten Benutzer abrufen
*/
async function getCurrentUser() {
try {
const user = await giteaFetch('/user');
return {
success: true,
user: {
id: user.id,
login: user.login,
fullName: user.full_name,
email: user.email,
avatarUrl: user.avatar_url
}
};
} catch (error) {
logger.error('Fehler beim Abrufen des aktuellen Benutzers:', error);
return { success: false, error: error.message };
}
}
/**
* Prüft ob die Gitea-Verbindung funktioniert
*/
async function testConnection() {
try {
const result = await getCurrentUser();
return {
success: result.success,
connected: result.success,
user: result.user,
giteaUrl: GITEA_URL,
organization: GITEA_ORG
};
} catch (error) {
return {
success: false,
connected: false,
error: error.message,
giteaUrl: GITEA_URL,
organization: GITEA_ORG
};
}
}
/**
* Clone-URL mit Token für private Repos
*/
function getAuthenticatedCloneUrl(cloneUrl) {
if (!GITEA_TOKEN) {
return cloneUrl;
}
// Füge Token zur URL hinzu
return cloneUrl.replace('https://', `https://${GITEA_TOKEN}@`);
}
/**
* Gitea-URL ohne Token (für Anzeige)
*/
function getSafeCloneUrl(cloneUrl) {
// Entferne Token aus URL falls vorhanden
return cloneUrl.replace(/https:\/\/[^@]+@/, 'https://');
}
module.exports = {
listRepositories,
getRepository,
getRepositoryBranches,
getRepositoryCommits,
createRepository,
deleteRepository,
getCurrentUser,
testConnection,
getAuthenticatedCloneUrl,
getSafeCloneUrl,
GITEA_URL,
GITEA_ORG
};

Datei anzeigen

@ -0,0 +1,290 @@
/**
* TASKMATE - Notification Service
* ================================
* Zentrale Logik für das Benachrichtigungssystem
*/
const { getDb } = require('../database');
const logger = require('../utils/logger');
/**
* Benachrichtigungstypen mit Titeln und Icons
*/
const NOTIFICATION_TYPES = {
'task:assigned': {
title: (data) => 'Neue Aufgabe zugewiesen',
message: (data) => `Du wurdest der Aufgabe "${data.taskTitle}" zugewiesen`
},
'task:unassigned': {
title: (data) => 'Zuweisung entfernt',
message: (data) => `Du wurdest von der Aufgabe "${data.taskTitle}" entfernt`
},
'task:due_soon': {
title: (data) => 'Aufgabe bald fällig',
message: (data) => `Die Aufgabe "${data.taskTitle}" ist morgen fällig`
},
'task:completed': {
title: (data) => 'Aufgabe erledigt',
message: (data) => `Die Aufgabe "${data.taskTitle}" wurde erledigt`
},
'task:due_changed': {
title: (data) => 'Fälligkeitsdatum geändert',
message: (data) => `Das Fälligkeitsdatum von "${data.taskTitle}" wurde geändert`
},
'task:priority_up': {
title: (data) => 'Priorität erhöht',
message: (data) => `Die Priorität von "${data.taskTitle}" wurde auf "Hoch" gesetzt`
},
'comment:created': {
title: (data) => 'Neuer Kommentar',
message: (data) => `${data.actorName} hat "${data.taskTitle}" kommentiert`
},
'comment:mention': {
title: (data) => 'Du wurdest erwähnt',
message: (data) => `${data.actorName} hat dich in "${data.taskTitle}" erwähnt`
},
'approval:pending': {
title: (data) => 'Genehmigung erforderlich',
message: (data) => `Neue Genehmigung: "${data.proposalTitle}"`
},
'approval:granted': {
title: (data) => 'Genehmigung erteilt',
message: (data) => `"${data.proposalTitle}" wurde genehmigt`
},
'approval:rejected': {
title: (data) => 'Genehmigung abgelehnt',
message: (data) => `"${data.proposalTitle}" wurde abgelehnt`
}
};
const notificationService = {
/**
* Benachrichtigung erstellen und per WebSocket senden
* @param {number} userId - Empfänger
* @param {string} type - Benachrichtigungstyp
* @param {object} data - Zusätzliche Daten
* @param {object} io - Socket.io Instanz
* @param {boolean} persistent - Ob die Benachrichtigung persistent ist
*/
create(userId, type, data, io, persistent = false) {
try {
const db = getDb();
const typeConfig = NOTIFICATION_TYPES[type];
if (!typeConfig) {
logger.warn(`Unbekannter Benachrichtigungstyp: ${type}`);
return null;
}
const title = typeConfig.title(data);
const message = typeConfig.message(data);
const result = db.prepare(`
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
type,
title,
message,
data.taskId || null,
data.projectId || null,
data.proposalId || null,
data.actorId || null,
persistent ? 1 : 0
);
const notification = db.prepare(`
SELECT n.*, u.display_name as actor_name, u.color as actor_color
FROM notifications n
LEFT JOIN users u ON n.actor_id = u.id
WHERE n.id = ?
`).get(result.lastInsertRowid);
// WebSocket-Event senden
if (io) {
io.to(`user:${userId}`).emit('notification:new', {
notification: this.formatNotification(notification)
});
// Auch aktualisierte Zählung senden
const count = this.getUnreadCount(userId);
io.to(`user:${userId}`).emit('notification:count', { count });
}
logger.info(`Benachrichtigung erstellt: ${type} für User ${userId}`);
return notification;
} catch (error) {
logger.error('Fehler beim Erstellen der Benachrichtigung:', error);
return null;
}
},
/**
* Alle Benachrichtigungen für einen User abrufen
*/
getForUser(userId, limit = 50) {
const db = getDb();
const notifications = db.prepare(`
SELECT n.*, u.display_name as actor_name, u.color as actor_color
FROM notifications n
LEFT JOIN users u ON n.actor_id = u.id
WHERE n.user_id = ?
ORDER BY n.is_persistent DESC, n.created_at DESC
LIMIT ?
`).all(userId, limit);
return notifications.map(n => this.formatNotification(n));
},
/**
* Ungelesene Anzahl ermitteln
*/
getUnreadCount(userId) {
const db = getDb();
const result = db.prepare(`
SELECT COUNT(*) as count
FROM notifications
WHERE user_id = ? AND is_read = 0
`).get(userId);
return result.count;
},
/**
* Als gelesen markieren
*/
markAsRead(notificationId, userId) {
const db = getDb();
const result = db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE id = ? AND user_id = ?
`).run(notificationId, userId);
return result.changes > 0;
},
/**
* Alle als gelesen markieren
*/
markAllAsRead(userId) {
const db = getDb();
const result = db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE user_id = ? AND is_read = 0
`).run(userId);
return result.changes;
},
/**
* Benachrichtigung löschen (nur nicht-persistente)
*/
delete(notificationId, userId) {
const db = getDb();
const result = db.prepare(`
DELETE FROM notifications
WHERE id = ? AND user_id = ? AND is_persistent = 0
`).run(notificationId, userId);
return result.changes > 0;
},
/**
* Persistente Benachrichtigungen auflösen (z.B. bei Genehmigung)
*/
resolvePersistent(proposalId) {
const db = getDb();
const result = db.prepare(`
DELETE FROM notifications
WHERE proposal_id = ? AND is_persistent = 1
`).run(proposalId);
logger.info(`${result.changes} persistente Benachrichtigungen für Proposal ${proposalId} aufgelöst`);
return result.changes;
},
/**
* Fälligkeits-Check für Aufgaben (1 Tag vorher)
*/
checkDueTasks(io) {
try {
const db = getDb();
// Morgen berechnen
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split('T')[0];
// Aufgaben die morgen fällig sind
const tasks = db.prepare(`
SELECT t.id, t.title, t.project_id, ta.user_id as assignee_id
FROM tasks t
JOIN task_assignees ta ON t.id = ta.task_id
LEFT JOIN columns c ON t.column_id = c.id
WHERE t.due_date = ?
AND t.archived = 0
AND c.filter_category != 'completed'
AND NOT EXISTS (
SELECT 1 FROM notifications n
WHERE n.task_id = t.id
AND n.user_id = ta.user_id
AND n.type = 'task:due_soon'
AND DATE(n.created_at) = DATE('now')
)
`).all(tomorrowStr);
let count = 0;
tasks.forEach(task => {
this.create(task.assignee_id, 'task:due_soon', {
taskId: task.id,
taskTitle: task.title,
projectId: task.project_id
}, io);
count++;
});
if (count > 0) {
logger.info(`${count} Fälligkeits-Benachrichtigungen erstellt`);
}
return count;
} catch (error) {
logger.error('Fehler beim Fälligkeits-Check:', error);
return 0;
}
},
/**
* Benachrichtigung formatieren für Frontend
*/
formatNotification(notification) {
return {
id: notification.id,
userId: notification.user_id,
type: notification.type,
title: notification.title,
message: notification.message,
taskId: notification.task_id,
projectId: notification.project_id,
proposalId: notification.proposal_id,
actorId: notification.actor_id,
actorName: notification.actor_name,
actorColor: notification.actor_color,
isRead: notification.is_read === 1,
isPersistent: notification.is_persistent === 1,
createdAt: notification.created_at
};
},
/**
* Benachrichtigung an mehrere User senden
*/
createForMultiple(userIds, type, data, io, persistent = false) {
const results = [];
userIds.forEach(userId => {
const result = this.create(userId, type, data, io, persistent);
if (result) results.push(result);
});
return results;
}
};
module.exports = notificationService;