feat(matrix-skilltree-bot): add Matrix bot for skill tree and XP management

- Skill management: create, list, view details, delete
- XP tracking with level-up notifications
- Branch filtering (intellect, body, creativity, social, practical, mindset, custom)
- Activity history per skill or global
- User statistics (total XP, skill count, highest level, streak)
- German/English command aliases
- Number-based reference system for ease of use
- JWT auth via mana-core-auth
- Health check endpoint on port 3326

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-30 17:00:01 +01:00
parent edbe7502d3
commit 3ed1453ff4
17 changed files with 1272 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HealthController } from './health.controller';
import { BotModule } from './bot/bot.module';
import { SkilltreeModule } from './skilltree/skilltree.module';
import { SessionModule } from './session/session.module';
import configuration from './config/configuration';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
BotModule,
SkilltreeModule,
SessionModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { MatrixService } from './matrix.service';
import { SkilltreeModule } from '../skilltree/skilltree.module';
import { SessionModule } from '../session/session.module';
@Module({
imports: [SkilltreeModule, SessionModule],
providers: [MatrixService],
exports: [MatrixService],
})
export class BotModule {}

View file

@ -0,0 +1,561 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MatrixClient,
SimpleFsStorageProvider,
AutojoinRoomsMixin,
} from 'matrix-bot-sdk';
import { SkilltreeService, Skill, SkillBranch } from '../skilltree/skilltree.service';
import { SessionService } from '../session/session.service';
import { HELP_MESSAGE } from '../config/configuration';
@Injectable()
export class MatrixService implements OnModuleInit {
private readonly logger = new Logger(MatrixService.name);
private client: MatrixClient;
private allowedRooms: string[];
// Store last shown skills per user for reference by number
private lastSkillsList: Map<string, Skill[]> = new Map();
// Branch name mappings (German/English)
private readonly branchMappings: Record<string, SkillBranch> = {
intellect: 'intellect',
wissen: 'intellect',
gehirn: 'intellect',
body: 'body',
koerper: 'body',
fitness: 'body',
sport: 'body',
creativity: 'creativity',
kreativ: 'creativity',
kreativitaet: 'creativity',
kunst: 'creativity',
social: 'social',
sozial: 'social',
practical: 'practical',
praktisch: 'practical',
handwerk: 'practical',
mindset: 'mindset',
achtsamkeit: 'mindset',
mental: 'mindset',
custom: 'custom',
eigene: 'custom',
};
constructor(
private configService: ConfigService,
private skilltreeService: SkilltreeService,
private sessionService: SessionService
) {}
async onModuleInit() {
const homeserverUrl = this.configService.get<string>('matrix.homeserverUrl');
const accessToken = this.configService.get<string>('matrix.accessToken');
const storagePath = this.configService.get<string>('matrix.storagePath');
this.allowedRooms = this.configService.get<string[]>('matrix.allowedRooms') || [];
if (!accessToken) {
this.logger.warn('No Matrix access token configured, bot disabled');
return;
}
const storage = new SimpleFsStorageProvider(storagePath);
this.client = new MatrixClient(homeserverUrl, accessToken, storage);
AutojoinRoomsMixin.setupOnClient(this.client);
this.client.on('room.message', this.handleMessage.bind(this));
await this.client.start();
this.logger.log('Matrix Skilltree Bot started');
}
private async handleMessage(roomId: string, event: any) {
if (event.sender === (await this.client.getUserId())) return;
if (event.content?.msgtype !== 'm.text') return;
const body = event.content.body?.trim();
if (!body?.startsWith('!')) return;
if (this.allowedRooms.length > 0 && !this.allowedRooms.includes(roomId)) {
return;
}
const sender = event.sender;
const parts = body.slice(1).split(/\s+/);
const command = parts[0].toLowerCase();
const args = parts.slice(1);
const argString = args.join(' ');
try {
switch (command) {
case 'help':
case 'hilfe':
await this.sendHtml(roomId, HELP_MESSAGE);
break;
case 'login':
await this.handleLogin(roomId, sender, args);
break;
case 'logout':
this.sessionService.logout(sender);
await this.sendHtml(roomId, '<p>Erfolgreich abgemeldet.</p>');
break;
case 'status':
await this.handleStatus(roomId, sender);
break;
// Skill commands
case 'skills':
case 'liste':
case 'faehigkeiten':
await this.handleListSkills(roomId, sender, args[0]);
break;
case 'skill':
case 'details':
await this.handleSkillDetails(roomId, sender, args[0]);
break;
case 'neu':
case 'new':
case 'create':
await this.handleCreateSkill(roomId, sender, argString);
break;
case 'loeschen':
case 'delete':
await this.handleDeleteSkill(roomId, sender, args[0]);
break;
// XP commands
case 'xp':
case 'punkte':
await this.handleAddXp(roomId, sender, argString);
break;
// Stats commands
case 'stats':
case 'statistik':
await this.handleStats(roomId, sender);
break;
// Activity commands
case 'aktivitaeten':
case 'activities':
case 'verlauf':
await this.handleActivities(roomId, sender, args[0]);
break;
default:
await this.sendHtml(
roomId,
`<p>Unbekannter Befehl: <code>${command}</code>. Nutze <code>!help</code> fuer Hilfe.</p>`
);
}
} catch (error) {
this.logger.error(`Error handling command ${command}:`, error);
await this.sendHtml(roomId, `<p>Fehler: ${error.message}</p>`);
}
}
private async sendHtml(roomId: string, html: string) {
await this.client.sendMessage(roomId, {
msgtype: 'm.text',
body: html.replace(/<[^>]*>/g, ''),
format: 'org.matrix.custom.html',
formatted_body: html,
});
}
private requireAuth(sender: string): string {
const token = this.sessionService.getToken(sender);
if (!token) {
throw new Error('Nicht angemeldet. Nutze <code>!login email passwort</code>');
}
return token;
}
// Auth handlers
private async handleLogin(roomId: string, sender: string, args: string[]) {
if (args.length < 2) {
await this.sendHtml(roomId, '<p>Verwendung: <code>!login email passwort</code></p>');
return;
}
const [email, password] = args;
const result = await this.sessionService.login(sender, email, password);
if (result.success) {
await this.sendHtml(roomId, `<p>Erfolgreich angemeldet als <strong>${email}</strong></p>`);
} else {
await this.sendHtml(roomId, `<p>Login fehlgeschlagen: ${result.error}</p>`);
}
}
private async handleStatus(roomId: string, sender: string) {
const backendOk = await this.skilltreeService.checkHealth();
const loggedIn = this.sessionService.isLoggedIn(sender);
const sessions = this.sessionService.getSessionCount();
await this.sendHtml(
roomId,
`<h3>Skilltree Bot Status</h3>
<ul>
<li>Backend: ${backendOk ? 'Online' : 'Offline'}</li>
<li>Angemeldet: ${loggedIn ? 'Ja' : 'Nein'}</li>
<li>Aktive Sessions: ${sessions}</li>
</ul>`
);
}
// Skill handlers
private async handleListSkills(roomId: string, sender: string, branchFilter?: string) {
const token = this.requireAuth(sender);
let branch: string | undefined;
if (branchFilter) {
branch = this.branchMappings[branchFilter.toLowerCase()];
if (!branch) {
await this.sendHtml(
roomId,
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
);
return;
}
}
const result = await this.skilltreeService.getSkills(token, branch);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const skills = result.data?.skills || [];
this.lastSkillsList.set(sender, skills);
if (skills.length === 0) {
await this.sendHtml(
roomId,
'<p>Keine Skills vorhanden. Erstelle einen mit <code>!neu Name | Branch</code></p>'
);
return;
}
let html = '<h3>Deine Skills</h3><ol>';
for (const skill of skills) {
const levelName = this.getLevelName(skill.level);
const branchIcon = this.getBranchIcon(skill.branch);
const progress = this.getProgressBar(skill.totalXp, skill.level);
html += `<li>${branchIcon} <strong>${skill.name}</strong> - Lvl ${skill.level} (${levelName}) ${progress}</li>`;
}
html += '</ol>';
html += '<p><em>Nutze <code>!skill [nr]</code> fuer Details oder <code>!xp [nr] 50 Aktivitaet</code></em></p>';
await this.sendHtml(roomId, html);
}
private async handleSkillDetails(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, numberStr);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const result = await this.skilltreeService.getSkill(token, skill.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const s = result.data!.skill;
const levelName = this.getLevelName(s.level);
const nextLevelXp = this.getNextLevelXp(s.level);
const branchIcon = this.getBranchIcon(s.branch);
let html = `<h3>${branchIcon} ${s.name}</h3>`;
if (s.description) html += `<p><em>${s.description}</em></p>`;
html += '<ul>';
html += `<li>Branch: ${this.translateBranch(s.branch)}</li>`;
html += `<li>Level: ${s.level} (${levelName})</li>`;
html += `<li>XP: ${s.totalXp.toLocaleString('de-DE')}`;
if (nextLevelXp) html += ` / ${nextLevelXp.toLocaleString('de-DE')} (naechstes Level)`;
html += '</li>';
html += `<li>Erstellt: ${new Date(s.createdAt).toLocaleDateString('de-DE')}</li>`;
html += '</ul>';
html += `<p><em>Nutze <code>!xp ${numberStr} [xp] [aktivitaet]</code> um XP hinzuzufuegen</em></p>`;
await this.sendHtml(roomId, html);
}
private async handleCreateSkill(roomId: string, sender: string, input: string) {
if (!input) {
await this.sendHtml(
roomId,
'<p>Verwendung: <code>!neu Name | Branch</code></p><p>Branches: intellect, body, creativity, social, practical, mindset, custom</p>'
);
return;
}
const token = this.requireAuth(sender);
const parts = input.split('|').map((s) => s.trim());
const name = parts[0];
const branchInput = parts[1]?.toLowerCase() || 'custom';
const branch = this.branchMappings[branchInput];
if (!branch) {
await this.sendHtml(
roomId,
'<p>Unbekannter Branch. Verfuegbar: intellect, body, creativity, social, practical, mindset, custom</p>'
);
return;
}
const description = parts[2];
const result = await this.skilltreeService.createSkill(token, name, branch, description);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastSkillsList.delete(sender);
const branchIcon = this.getBranchIcon(branch);
await this.sendHtml(
roomId,
`<p>${branchIcon} Skill <strong>${result.data!.skill.name}</strong> erstellt!</p>
<p><em>Nutze <code>!skills</code> und dann <code>!xp [nr] [xp] [aktivitaet]</code></em></p>`
);
}
private async handleDeleteSkill(roomId: string, sender: string, numberStr: string) {
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, numberStr);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const result = await this.skilltreeService.deleteSkill(token, skill.id);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
this.lastSkillsList.delete(sender);
await this.sendHtml(roomId, `<p>Skill <strong>${skill.name}</strong> geloescht.</p>`);
}
// XP handler
private async handleAddXp(roomId: string, sender: string, argString: string) {
const args = argString.split(/\s+/);
if (args.length < 3) {
await this.sendHtml(
roomId,
'<p>Verwendung: <code>!xp [nr] [xp] [aktivitaet]</code></p><p>Optional: <code>--min 60</code> fuer Dauer</p>'
);
return;
}
const token = this.requireAuth(sender);
const skill = this.getSkillByNumber(sender, args[0]);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
const xp = parseInt(args[1], 10);
if (isNaN(xp) || xp < 1 || xp > 10000) {
await this.sendHtml(roomId, '<p>XP muss zwischen 1 und 10000 liegen.</p>');
return;
}
// Parse duration (--min N)
let duration: number | undefined;
const minMatch = argString.match(/--min\s+(\d+)/i);
if (minMatch) {
duration = parseInt(minMatch[1], 10);
}
// Get description (everything after xp number, minus --min part)
let description = args.slice(2).join(' ');
description = description.replace(/--min\s+\d+/i, '').trim();
if (!description) {
description = 'Aktivitaet';
}
const result = await this.skilltreeService.addXp(token, skill.id, xp, description, duration);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const { leveledUp, newLevel } = result.data!;
let html = `<p><strong>+${xp} XP</strong> fuer <strong>${skill.name}</strong>!</p>`;
html += `<p><em>${description}</em></p>`;
if (leveledUp) {
const levelName = this.getLevelName(newLevel);
html += `<p>&#127881; <strong>LEVEL UP!</strong> Du bist jetzt Level ${newLevel} (${levelName})!</p>`;
}
await this.sendHtml(roomId, html);
}
// Stats handler
private async handleStats(roomId: string, sender: string) {
const token = this.requireAuth(sender);
const result = await this.skilltreeService.getStats(token);
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const stats = result.data!.stats;
let html = '<h3>Deine Statistiken</h3><ul>';
html += `<li>Gesamt-XP: ${stats.totalXp.toLocaleString('de-DE')}</li>`;
html += `<li>Skills: ${stats.totalSkills}</li>`;
html += `<li>Hoechstes Level: ${stats.highestLevel}</li>`;
html += `<li>Streak: ${stats.streakDays} Tage &#128293;</li>`;
if (stats.lastActivityDate) {
html += `<li>Letzte Aktivitaet: ${stats.lastActivityDate}</li>`;
}
html += '</ul>';
await this.sendHtml(roomId, html);
}
// Activities handler
private async handleActivities(roomId: string, sender: string, numberStr?: string) {
const token = this.requireAuth(sender);
let result;
let skillName = '';
if (numberStr) {
const skill = this.getSkillByNumber(sender, numberStr);
if (!skill) {
await this.sendHtml(roomId, '<p>Ungueltige Nummer. Nutze zuerst <code>!skills</code></p>');
return;
}
result = await this.skilltreeService.getSkillActivities(token, skill.id);
skillName = skill.name;
} else {
result = await this.skilltreeService.getRecentActivities(token, 10);
}
if (result.error) {
await this.sendHtml(roomId, `<p>Fehler: ${result.error}</p>`);
return;
}
const activities = result.data?.activities || [];
if (activities.length === 0) {
await this.sendHtml(roomId, '<p>Keine Aktivitaeten vorhanden.</p>');
return;
}
const title = skillName ? `Aktivitaeten: ${skillName}` : 'Letzte Aktivitaeten';
let html = `<h3>${title}</h3><ol>`;
for (const activity of activities) {
const date = new Date(activity.timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const duration = activity.duration ? ` (${activity.duration} min)` : '';
html += `<li><strong>+${activity.xpEarned} XP</strong> - ${activity.description}${duration}<br/><em>${date}</em></li>`;
}
html += '</ol>';
await this.sendHtml(roomId, html);
}
// Helper methods
private getSkillByNumber(sender: string, numberStr: string): Skill | null {
const skills = this.lastSkillsList.get(sender);
if (!skills) return null;
const index = parseInt(numberStr, 10) - 1;
if (isNaN(index) || index < 0 || index >= skills.length) return null;
return skills[index];
}
private getLevelName(level: number): string {
const names: Record<number, string> = {
0: 'Unbekannt',
1: 'Anfaenger',
2: 'Fortgeschritten',
3: 'Kompetent',
4: 'Experte',
5: 'Meister',
};
return names[level] || `Level ${level}`;
}
private getNextLevelXp(level: number): number | null {
const thresholds: Record<number, number> = {
0: 100,
1: 500,
2: 1500,
3: 4000,
4: 10000,
};
return thresholds[level] || null;
}
private getBranchIcon(branch: string): string {
const icons: Record<string, string> = {
intellect: '&#129504;', // Brain
body: '&#128170;', // Flexed biceps
creativity: '&#127912;', // Artist palette
social: '&#128101;', // Busts in silhouette
practical: '&#128295;', // Wrench
mindset: '&#128150;', // Heart
custom: '&#11088;', // Star
};
return icons[branch] || '&#11088;';
}
private translateBranch(branch: string): string {
const translations: Record<string, string> = {
intellect: 'Wissen',
body: 'Koerper',
creativity: 'Kreativitaet',
social: 'Sozial',
practical: 'Praktisch',
mindset: 'Achtsamkeit',
custom: 'Eigene',
};
return translations[branch] || branch;
}
private getProgressBar(totalXp: number, level: number): string {
const nextXp = this.getNextLevelXp(level);
if (!nextXp) return '';
const prevXp = level > 0 ? this.getNextLevelXp(level - 1) || 0 : 0;
const progress = Math.min(100, Math.round(((totalXp - prevXp) / (nextXp - prevXp)) * 100));
return `[${progress}%]`;
}
}

View file

@ -0,0 +1,61 @@
export default () => ({
port: parseInt(process.env.PORT, 10) || 3326,
matrix: {
homeserverUrl: process.env.MATRIX_HOMESERVER_URL || 'http://localhost:8008',
accessToken: process.env.MATRIX_ACCESS_TOKEN,
allowedRooms: process.env.MATRIX_ALLOWED_ROOMS?.split(',') || [],
storagePath: process.env.MATRIX_STORAGE_PATH || './data/bot-storage.json',
},
skilltree: {
backendUrl: process.env.SKILLTREE_BACKEND_URL || 'http://localhost:3024',
apiPrefix: process.env.SKILLTREE_API_PREFIX || '/api/v1',
},
auth: {
url: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
},
});
export const HELP_MESSAGE = `<h2>Skilltree Bot - Befehle</h2>
<h3>Authentifizierung</h3>
<ul>
<li><code>!login email passwort</code> - Anmelden</li>
<li><code>!logout</code> - Abmelden</li>
<li><code>!status</code> - Bot-Status anzeigen</li>
</ul>
<h3>Skills</h3>
<ul>
<li><code>!skills</code> - Alle Skills auflisten</li>
<li><code>!skills koerper</code> - Nach Branch filtern</li>
<li><code>!skill [nr]</code> - Skill-Details anzeigen</li>
<li><code>!neu Name | Branch</code> - Neuen Skill erstellen</li>
<li><code>!loeschen [nr]</code> - Skill loeschen</li>
</ul>
<h3>XP sammeln</h3>
<ul>
<li><code>!xp [nr] 50 Aktivitaet</code> - XP hinzufuegen</li>
<li><code>!xp [nr] 100 Training --min 60</code> - Mit Dauer</li>
</ul>
<h3>Statistiken</h3>
<ul>
<li><code>!stats</code> - Gesamtstatistik anzeigen</li>
<li><code>!aktivitaeten</code> - Letzte Aktivitaeten</li>
<li><code>!aktivitaeten [nr]</code> - Aktivitaeten fuer Skill</li>
</ul>
<h3>Branches</h3>
<p><code>intellect</code> (Wissen), <code>body</code>/<code>koerper</code> (Fitness), <code>creativity</code>/<code>kreativ</code> (Kunst), <code>social</code>/<code>sozial</code> (Kommunikation), <code>practical</code>/<code>praktisch</code> (Handwerk), <code>mindset</code> (Achtsamkeit), <code>custom</code> (Eigene)</p>
<h3>Level-System</h3>
<ul>
<li>Level 1: 100 XP (Anfaenger)</li>
<li>Level 2: 500 XP (Fortgeschritten)</li>
<li>Level 3: 1500 XP (Kompetent)</li>
<li>Level 4: 4000 XP (Experte)</li>
<li>Level 5: 10000 XP (Meister)</li>
</ul>
<p><em>Tipp: Nutze Nummern aus der zuletzt angezeigten Liste.</em></p>`;

View file

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', service: 'matrix-skilltree-bot' };
}
}

View file

@ -0,0 +1,10 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3326;
await app.listen(port);
console.log(`Matrix Skilltree Bot running on port ${port}`);
}
bootstrap();

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SessionService } from './session.service';
@Module({
providers: [SessionService],
exports: [SessionService],
})
export class SessionModule {}

View file

@ -0,0 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface UserSession {
token: string;
email: string;
expiresAt: Date;
}
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private sessions: Map<string, UserSession> = new Map();
private authUrl: string;
constructor(private configService: ConfigService) {
this.authUrl = this.configService.get<string>('auth.url') || 'http://localhost:3001';
}
async login(
matrixUserId: string,
email: string,
password: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${this.authUrl}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
success: false,
error: errorData.message || 'Authentifizierung fehlgeschlagen',
};
}
const data = await response.json();
const token = data.accessToken || data.token;
if (!token) {
return { success: false, error: 'Kein Token erhalten' };
}
this.sessions.set(matrixUserId, {
token,
email,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
this.logger.log(`User ${matrixUserId} logged in as ${email}`);
return { success: true };
} catch (error) {
this.logger.error(`Login failed for ${matrixUserId}:`, error);
return {
success: false,
error: 'Verbindung zum Auth-Server fehlgeschlagen',
};
}
}
logout(matrixUserId: string): void {
this.sessions.delete(matrixUserId);
this.logger.log(`User ${matrixUserId} logged out`);
}
getToken(matrixUserId: string): string | null {
const session = this.sessions.get(matrixUserId);
if (!session) return null;
if (session.expiresAt < new Date()) {
this.sessions.delete(matrixUserId);
return null;
}
return session.token;
}
isLoggedIn(matrixUserId: string): boolean {
return this.getToken(matrixUserId) !== null;
}
getSessionCount(): number {
return this.sessions.size;
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { SkilltreeService } from './skilltree.service';
@Module({
providers: [SkilltreeService],
exports: [SkilltreeService],
})
export class SkilltreeModule {}

View file

@ -0,0 +1,151 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type SkillBranch = 'intellect' | 'body' | 'creativity' | 'social' | 'practical' | 'mindset' | 'custom';
export interface Skill {
id: string;
name: string;
description?: string;
branch: SkillBranch;
parentId?: string;
icon: string;
color?: string;
currentXp: number;
totalXp: number;
level: number;
createdAt: string;
updatedAt: string;
}
export interface Activity {
id: string;
skillId: string;
xpEarned: number;
description: string;
duration?: number;
timestamp: string;
}
export interface UserStats {
totalXp: number;
totalSkills: number;
highestLevel: number;
streakDays: number;
lastActivityDate?: string;
}
export interface AddXpResult {
skill: Skill;
leveledUp: boolean;
newLevel: number;
}
@Injectable()
export class SkilltreeService {
private readonly logger = new Logger(SkilltreeService.name);
private backendUrl: string;
private apiPrefix: string;
constructor(private configService: ConfigService) {
this.backendUrl = this.configService.get<string>('skilltree.backendUrl') || 'http://localhost:3024';
this.apiPrefix = this.configService.get<string>('skilltree.apiPrefix') || '/api/v1';
}
private async request<T>(
token: string,
endpoint: string,
options: RequestInit = {}
): Promise<{ data?: T; error?: string }> {
try {
const url = `${this.backendUrl}${this.apiPrefix}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return { error: errorData.message || `Fehler: ${response.status}` };
}
const data = await response.json();
return { data };
} catch (error) {
this.logger.error(`Request failed: ${endpoint}`, error);
return { error: 'Verbindung zum Backend fehlgeschlagen' };
}
}
// Skill operations
async getSkills(token: string, branch?: string): Promise<{ data?: { skills: Skill[] }; error?: string }> {
const query = branch ? `?branch=${branch}` : '';
return this.request<{ skills: Skill[] }>(token, `/skills${query}`);
}
async getSkill(token: string, skillId: string): Promise<{ data?: { skill: Skill }; error?: string }> {
return this.request<{ skill: Skill }>(token, `/skills/${skillId}`);
}
async createSkill(
token: string,
name: string,
branch: SkillBranch,
description?: string
): Promise<{ data?: { skill: Skill }; error?: string }> {
return this.request<{ skill: Skill }>(token, '/skills', {
method: 'POST',
body: JSON.stringify({ name, branch, description }),
});
}
async deleteSkill(token: string, skillId: string): Promise<{ error?: string }> {
return this.request(token, `/skills/${skillId}`, { method: 'DELETE' });
}
async addXp(
token: string,
skillId: string,
xp: number,
description: string,
duration?: number
): Promise<{ data?: AddXpResult; error?: string }> {
return this.request<AddXpResult>(token, `/skills/${skillId}/xp`, {
method: 'POST',
body: JSON.stringify({ xp, description, duration }),
});
}
// Stats
async getStats(token: string): Promise<{ data?: { stats: UserStats }; error?: string }> {
return this.request<{ stats: UserStats }>(token, '/skills/stats');
}
// Activities
async getActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> {
const query = limit ? `?limit=${limit}` : '';
return this.request<{ activities: Activity[] }>(token, `/activities${query}`);
}
async getRecentActivities(token: string, limit?: number): Promise<{ data?: { activities: Activity[] }; error?: string }> {
const query = limit ? `?limit=${limit}` : '';
return this.request<{ activities: Activity[] }>(token, `/activities/recent${query}`);
}
async getSkillActivities(token: string, skillId: string): Promise<{ data?: { activities: Activity[] }; error?: string }> {
return this.request<{ activities: Activity[] }>(token, `/activities/skill/${skillId}`);
}
async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${this.backendUrl}/health`);
return response.ok;
} catch {
return false;
}
}
}