refactor(infra): remove citycorners + skilltree NestJS backends, clean up CI/CD

Both apps migrated to local-first (mana-sync handles CRUD).

- Delete apps/citycorners/apps/backend/ (37 files)
- Delete apps/skilltree/apps/backend/ (32 files)
- Remove from CI build jobs, change detection, summary
- Remove from package.json scripts (replaced with sync-based dev commands)
- Remove from setup-databases.sh push_schema calls
- Remove from generate-env.mjs backend env generation
- Remove from ensure-containers-running.sh

Total: 6 NestJS backends removed across all sessions (Zitare, Clock,
Presi, Photos, CityCorners, SkillTree). ~12,000 lines of boilerplate
eliminated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 10:24:23 +01:00
parent b60877e367
commit 5d02b0419d
75 changed files with 13 additions and 5355 deletions

View file

@ -1,93 +0,0 @@
# syntax=docker/dockerfile:1
# Build stage
FROM node:20-alpine AS builder
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
WORKDIR /app
# Copy root workspace files
COPY pnpm-workspace.yaml ./
COPY package.json ./
COPY pnpm-lock.yaml ./
COPY patches ./patches
# Copy shared packages (all required dependencies)
COPY packages/shared-drizzle-config ./packages/shared-drizzle-config
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
COPY packages/shared-nestjs-health ./packages/shared-nestjs-health
COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics
COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup
COPY packages/shared-error-tracking ./packages/shared-error-tracking
COPY packages/shared-tsconfig ./packages/shared-tsconfig
# Copy skilltree backend
COPY apps/skilltree/apps/backend ./apps/skilltree/apps/backend
# Install dependencies (ignore scripts since generate-env.mjs isn't in Docker context)
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts
# Build shared packages first (in dependency order)
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-health
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-metrics
RUN pnpm build
# Build the backend
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
WORKDIR /app/apps/skilltree/apps/backend
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
# Install pnpm and postgresql-client for health checks
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \
&& apk add --no-cache postgresql-client
WORKDIR /app
# Copy everything from builder (including node_modules)
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/packages ./packages
COPY --from=builder /app/apps/skilltree/apps/backend ./apps/skilltree/apps/backend
# Copy entrypoint script
COPY apps/skilltree/apps/backend/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /app/packages/shared-nestjs-setup
RUN pnpm build
WORKDIR /app/packages/shared-error-tracking
RUN pnpm build
WORKDIR /app/apps/skilltree/apps/backend
# Expose port
EXPOSE 3024
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3024/health || exit 1
# Run entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "dist/main.js"]

View file

@ -1,31 +0,0 @@
#!/bin/sh
set -e
echo "Starting SkillTree Backend..."
# Wait for PostgreSQL to be ready
if [ -n "$DATABASE_URL" ]; then
echo "Waiting for PostgreSQL..."
# Extract host and port from DATABASE_URL
DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p')
DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p')
# Default to postgres:5432 if extraction fails
DB_HOST=${DB_HOST:-postgres}
DB_PORT=${DB_PORT:-5432}
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U postgres 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
echo "PostgreSQL is ready!"
# Run database migrations/push
echo "Pushing database schema..."
pnpm db:push || echo "Schema push completed (may have no changes)"
fi
echo "Starting server..."
exec "$@"

View file

@ -1,7 +0,0 @@
import 'dotenv/config';
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'skilltree',
outDir: './drizzle',
});

View file

@ -1,15 +0,0 @@
/** @type {import('jest').Config} */
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: ['**/*.(t|j)s', '!**/*.module.ts', '!**/main.ts', '!**/*.dto.ts'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^src/(.*)$': '<rootDir>/$1',
},
};

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -1,50 +0,0 @@
{
"name": "@skilltree/backend",
"version": "0.2.0",
"private": true,
"description": "SkillTree Backend API",
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "nest start",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:generate": "drizzle-kit generate"
},
"dependencies": {
"@manacore/shared-error-tracking": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@manacore/shared-nestjs-health": "workspace:*",
"@manacore/shared-nestjs-metrics": "workspace:*",
"@nestjs/common": "^10.4.9",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.9",
"@nestjs/platform-express": "^10.4.9",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"prom-client": "^15.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^11.1.9",
"@types/express": "^5.0.1",
"@types/jest": "^30.0.0",
"@types/node": "^22.15.21",
"drizzle-kit": "^0.30.2",
"jest": "^30.2.0",
"ts-jest": "^29.2.5",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}
}

View file

@ -1,307 +0,0 @@
import type { NewAchievement } from '../db/schema';
/**
* All achievement definitions. These are seeded into the DB on startup.
* Conditions are evaluated by the AchievementService after relevant events.
*/
export const ACHIEVEMENT_DEFINITIONS: NewAchievement[] = [
// === XP Achievements ===
{
id: 'xp_100',
name: 'Erste Schritte',
description: 'Sammle 100 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'common',
xpReward: 10,
sortOrder: 1,
condition: { type: 'total_xp', threshold: 100 },
},
{
id: 'xp_1000',
name: 'Tausender-Club',
description: 'Sammle 1.000 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 2,
condition: { type: 'total_xp', threshold: 1000 },
},
{
id: 'xp_5000',
name: 'XP-Sammler',
description: 'Sammle 5.000 XP insgesamt',
icon: 'star',
category: 'xp',
rarity: 'rare',
xpReward: 50,
sortOrder: 3,
condition: { type: 'total_xp', threshold: 5000 },
},
{
id: 'xp_10000',
name: 'XP-Legende',
description: 'Sammle 10.000 XP insgesamt',
icon: 'crown',
category: 'xp',
rarity: 'epic',
xpReward: 100,
sortOrder: 4,
condition: { type: 'total_xp', threshold: 10000 },
},
{
id: 'xp_50000',
name: 'Grenzenlos',
description: 'Sammle 50.000 XP insgesamt',
icon: 'crown',
category: 'xp',
rarity: 'legendary',
xpReward: 250,
sortOrder: 5,
condition: { type: 'total_xp', threshold: 50000 },
},
// === Skill Achievements ===
{
id: 'skills_1',
name: 'Der Anfang',
description: 'Erstelle deinen ersten Skill',
icon: 'plus',
category: 'skills',
rarity: 'common',
xpReward: 10,
sortOrder: 10,
condition: { type: 'total_skills', threshold: 1 },
},
{
id: 'skills_5',
name: 'Vielseitig',
description: 'Erstelle 5 Skills',
icon: 'grid',
category: 'skills',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 11,
condition: { type: 'total_skills', threshold: 5 },
},
{
id: 'skills_10',
name: 'Skill-Sammler',
description: 'Erstelle 10 Skills',
icon: 'grid',
category: 'skills',
rarity: 'rare',
xpReward: 50,
sortOrder: 12,
condition: { type: 'total_skills', threshold: 10 },
},
{
id: 'skills_20',
name: 'Meister aller Klassen',
description: 'Erstelle 20 Skills',
icon: 'grid',
category: 'skills',
rarity: 'epic',
xpReward: 100,
sortOrder: 13,
condition: { type: 'total_skills', threshold: 20 },
},
// === Level Achievements ===
{
id: 'level_1',
name: 'Anfänger',
description: 'Erreiche Level 1 mit einem Skill',
icon: 'arrow-up',
category: 'levels',
rarity: 'common',
xpReward: 15,
sortOrder: 20,
condition: { type: 'highest_level', threshold: 1 },
},
{
id: 'level_3',
name: 'Kompetent',
description: 'Erreiche Level 3 mit einem Skill',
icon: 'arrow-up',
category: 'levels',
rarity: 'rare',
xpReward: 50,
sortOrder: 21,
condition: { type: 'highest_level', threshold: 3 },
},
{
id: 'level_5',
name: 'Meister',
description: 'Erreiche Level 5 mit einem Skill',
icon: 'crown',
category: 'levels',
rarity: 'legendary',
xpReward: 200,
sortOrder: 22,
condition: { type: 'highest_level', threshold: 5 },
},
// === Activity Achievements ===
{
id: 'activities_1',
name: 'Erste Aktion',
description: 'Logge deine erste Aktivität',
icon: 'lightning',
category: 'activities',
rarity: 'common',
xpReward: 5,
sortOrder: 30,
condition: { type: 'total_activities', threshold: 1 },
},
{
id: 'activities_10',
name: 'Dranbleiber',
description: 'Logge 10 Aktivitäten',
icon: 'lightning',
category: 'activities',
rarity: 'uncommon',
xpReward: 20,
sortOrder: 31,
condition: { type: 'total_activities', threshold: 10 },
},
{
id: 'activities_50',
name: 'Fleißig',
description: 'Logge 50 Aktivitäten',
icon: 'lightning',
category: 'activities',
rarity: 'rare',
xpReward: 50,
sortOrder: 32,
condition: { type: 'total_activities', threshold: 50 },
},
{
id: 'activities_100',
name: 'Unaufhaltsam',
description: 'Logge 100 Aktivitäten',
icon: 'fire',
category: 'activities',
rarity: 'epic',
xpReward: 100,
sortOrder: 33,
condition: { type: 'total_activities', threshold: 100 },
},
{
id: 'activities_500',
name: 'Maschine',
description: 'Logge 500 Aktivitäten',
icon: 'fire',
category: 'activities',
rarity: 'legendary',
xpReward: 250,
sortOrder: 34,
condition: { type: 'total_activities', threshold: 500 },
},
// === Streak Achievements ===
{
id: 'streak_3',
name: '3-Tage-Streak',
description: 'Halte einen 3-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'common',
xpReward: 15,
sortOrder: 40,
condition: { type: 'streak_days', threshold: 3 },
},
{
id: 'streak_7',
name: 'Wochenkrieger',
description: 'Halte einen 7-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'uncommon',
xpReward: 30,
sortOrder: 41,
condition: { type: 'streak_days', threshold: 7 },
},
{
id: 'streak_14',
name: 'Zwei-Wochen-Held',
description: 'Halte einen 14-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'rare',
xpReward: 75,
sortOrder: 42,
condition: { type: 'streak_days', threshold: 14 },
},
{
id: 'streak_30',
name: 'Monatsmeister',
description: 'Halte einen 30-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'epic',
xpReward: 150,
sortOrder: 43,
condition: { type: 'streak_days', threshold: 30 },
},
{
id: 'streak_100',
name: 'Hundert Tage',
description: 'Halte einen 100-Tage-Streak',
icon: 'flame',
category: 'streak',
rarity: 'legendary',
xpReward: 500,
sortOrder: 44,
condition: { type: 'streak_days', threshold: 100 },
},
// === Branch Achievements ===
{
id: 'branches_3',
name: 'Entdecker',
description: 'Habe Skills in 3 verschiedenen Branches',
icon: 'compass',
category: 'branches',
rarity: 'uncommon',
xpReward: 25,
sortOrder: 50,
condition: { type: 'unique_branches', threshold: 3 },
},
{
id: 'branches_all',
name: 'Universalgelehrter',
description: 'Habe Skills in allen 6 Branches',
icon: 'compass',
category: 'branches',
rarity: 'epic',
xpReward: 100,
sortOrder: 51,
condition: { type: 'unique_branches', threshold: 6 },
},
// === Special Achievements ===
{
id: 'single_xp_100',
name: 'Mammut-Session',
description: 'Verdiene 100+ XP in einer einzelnen Aktivität',
icon: 'zap',
category: 'special',
rarity: 'rare',
xpReward: 25,
sortOrder: 60,
condition: { type: 'single_activity_xp', threshold: 100 },
},
{
id: 'all_branches_level_1',
name: 'Allrounder',
description: 'Erreiche Level 1 in allen 6 Branches',
icon: 'shield',
category: 'special',
rarity: 'epic',
xpReward: 150,
sortOrder: 61,
condition: { type: 'all_branches_min_level', threshold: 1 },
},
];

View file

@ -1,27 +0,0 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { AchievementService } from './achievement.service';
@Controller('achievements')
@UseGuards(JwtAuthGuard)
export class AchievementController {
constructor(private readonly achievementService: AchievementService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData) {
const achievements = await this.achievementService.getAllForUser(user.userId);
return { achievements };
}
@Get('unlocked')
async findUnlocked(@CurrentUser() user: CurrentUserData) {
const achievements = await this.achievementService.getUnlockedForUser(user.userId);
return { achievements };
}
@Get('stats')
async getStats(@CurrentUser() user: CurrentUserData) {
const stats = await this.achievementService.getStats(user.userId);
return { stats };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { AchievementController } from './achievement.controller';
import { AchievementService } from './achievement.service';
@Module({
controllers: [AchievementController],
providers: [AchievementService],
exports: [AchievementService],
})
export class AchievementModule {}

View file

@ -1,265 +0,0 @@
import { Injectable, Inject, OnModuleInit, Logger } from '@nestjs/common';
import { eq, and, sql } from 'drizzle-orm';
import { DATABASE_TOKEN } from '../db/database.module';
import { Database } from '../db/connection';
import {
achievements,
userAchievements,
skills,
activities,
userStats,
Achievement,
UserAchievement,
} from '../db/schema';
import { ACHIEVEMENT_DEFINITIONS } from './achievement-definitions';
export interface AchievementWithStatus extends Achievement {
unlocked: boolean;
unlockedAt: Date | null;
progress: number;
}
export interface AchievementUnlockResult {
achievement: Achievement;
xpReward: number;
}
@Injectable()
export class AchievementService implements OnModuleInit {
private readonly logger = new Logger(AchievementService.name);
constructor(@Inject(DATABASE_TOKEN) private db: Database) {}
async onModuleInit() {
await this.seedAchievements();
}
private async seedAchievements(): Promise<void> {
for (const def of ACHIEVEMENT_DEFINITIONS) {
await this.db
.insert(achievements)
.values(def)
.onConflictDoUpdate({
target: achievements.id,
set: {
name: def.name,
description: def.description,
icon: def.icon,
category: def.category,
rarity: def.rarity,
xpReward: def.xpReward,
sortOrder: def.sortOrder,
condition: def.condition,
},
});
}
this.logger.log(`Seeded ${ACHIEVEMENT_DEFINITIONS.length} achievements`);
}
async getAllForUser(userId: string): Promise<AchievementWithStatus[]> {
const allAchievements = await this.db
.select()
.from(achievements)
.orderBy(achievements.sortOrder);
const unlocked = await this.db
.select()
.from(userAchievements)
.where(eq(userAchievements.userId, userId));
const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u]));
// Calculate current progress for each achievement
const progressMap = await this.calculateProgress(userId);
return allAchievements.map((a) => {
const userAch = unlockedMap.get(a.id);
return {
...a,
unlocked: !!userAch,
unlockedAt: userAch?.unlockedAt ?? null,
progress: userAch ? (a.condition as any).threshold : (progressMap.get(a.id) ?? 0),
};
});
}
async getUnlockedForUser(userId: string): Promise<Achievement[]> {
const rows = await this.db
.select({ achievement: achievements })
.from(userAchievements)
.innerJoin(achievements, eq(userAchievements.achievementId, achievements.id))
.where(eq(userAchievements.userId, userId));
return rows.map((r) => r.achievement);
}
async getStats(userId: string): Promise<{ total: number; unlocked: number }> {
const [totalResult] = await this.db.select({ count: sql<number>`count(*)` }).from(achievements);
const [unlockedResult] = await this.db
.select({ count: sql<number>`count(*)` })
.from(userAchievements)
.where(eq(userAchievements.userId, userId));
return {
total: Number(totalResult.count),
unlocked: Number(unlockedResult.count),
};
}
/**
* Check all achievements for a user and unlock any newly earned ones.
* Called after XP gain, skill creation, activity logging, etc.
*/
async checkAndUnlock(
userId: string,
context?: { activityXp?: number }
): Promise<AchievementUnlockResult[]> {
const allAchievements = await this.db.select().from(achievements);
const unlocked = await this.db
.select()
.from(userAchievements)
.where(eq(userAchievements.userId, userId));
const unlockedIds = new Set(unlocked.map((u) => u.achievementId));
// Get user data for condition evaluation
const userData = await this.getUserData(userId);
if (context?.activityXp) {
userData.lastActivityXp = context.activityXp;
}
const newlyUnlocked: AchievementUnlockResult[] = [];
for (const achievement of allAchievements) {
if (unlockedIds.has(achievement.id)) continue;
const condition = achievement.condition as { type: string; threshold: number };
if (this.evaluateCondition(condition, userData)) {
await this.db.insert(userAchievements).values({
userId,
achievementId: achievement.id,
progress: condition.threshold,
});
newlyUnlocked.push({
achievement,
xpReward: achievement.xpReward,
});
}
}
return newlyUnlocked;
}
private async getUserData(userId: string): Promise<UserData> {
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
const [activityCount] = await this.db
.select({ count: sql<number>`count(*)` })
.from(activities)
.where(eq(activities.userId, userId));
const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId));
const uniqueBranches = new Set(userSkills.map((s) => s.branch).filter((b) => b !== 'custom'));
// Check min level per branch (for all_branches_min_level)
const branchMinLevels = new Map<string, number>();
const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset'];
for (const branch of mainBranches) {
const branchSkills = userSkills.filter((s) => s.branch === branch);
if (branchSkills.length > 0) {
branchMinLevels.set(branch, Math.max(...branchSkills.map((s) => s.level)));
}
}
const allBranchesMinLevel =
branchMinLevels.size === 6 ? Math.min(...branchMinLevels.values()) : 0;
return {
totalXp: stats?.totalXp ?? 0,
totalSkills: userSkills.length,
highestLevel: stats?.highestLevel ?? 0,
totalActivities: Number(activityCount.count),
streakDays: stats?.streakDays ?? 0,
uniqueBranches: uniqueBranches.size,
allBranchesMinLevel,
lastActivityXp: 0,
};
}
private evaluateCondition(
condition: { type: string; threshold: number },
data: UserData
): boolean {
switch (condition.type) {
case 'total_xp':
return data.totalXp >= condition.threshold;
case 'total_skills':
return data.totalSkills >= condition.threshold;
case 'highest_level':
return data.highestLevel >= condition.threshold;
case 'total_activities':
return data.totalActivities >= condition.threshold;
case 'streak_days':
return data.streakDays >= condition.threshold;
case 'unique_branches':
return data.uniqueBranches >= condition.threshold;
case 'single_activity_xp':
return data.lastActivityXp >= condition.threshold;
case 'all_branches_min_level':
return data.allBranchesMinLevel >= condition.threshold;
default:
return false;
}
}
private async calculateProgress(userId: string): Promise<Map<string, number>> {
const userData = await this.getUserData(userId);
const allAchievements = await this.db.select().from(achievements);
const progressMap = new Map<string, number>();
for (const achievement of allAchievements) {
const condition = achievement.condition as { type: string; threshold: number };
let current = 0;
switch (condition.type) {
case 'total_xp':
current = userData.totalXp;
break;
case 'total_skills':
current = userData.totalSkills;
break;
case 'highest_level':
current = userData.highestLevel;
break;
case 'total_activities':
current = userData.totalActivities;
break;
case 'streak_days':
current = userData.streakDays;
break;
case 'unique_branches':
current = userData.uniqueBranches;
break;
case 'single_activity_xp':
current = 0; // Can't track historical max
break;
case 'all_branches_min_level':
current = userData.allBranchesMinLevel;
break;
}
progressMap.set(achievement.id, Math.min(current, condition.threshold));
}
return progressMap;
}
}
interface UserData {
totalXp: number;
totalSkills: number;
highestLevel: number;
totalActivities: number;
streakDays: number;
uniqueBranches: number;
allBranchesMinLevel: number;
lastActivityXp: number;
}

View file

@ -1,27 +0,0 @@
import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ActivityService } from './activity.service';
@Controller('activities')
@UseGuards(JwtAuthGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
const activities = await this.activityService.findAll(user.userId, limit ?? 50);
return { activities };
}
@Get('recent')
async getRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) {
const activities = await this.activityService.getRecent(user.userId, limit ?? 10);
return { activities };
}
@Get('skill/:skillId')
async findBySkill(@CurrentUser() user: CurrentUserData, @Param('skillId') skillId: string) {
const activities = await this.activityService.findBySkill(user.userId, skillId);
return { activities };
}
}

View file

@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { ActivityController } from './activity.controller';
import { ActivityService } from './activity.service';
@Module({
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}

View file

@ -1,36 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { eq, desc } from 'drizzle-orm';
import { DATABASE_TOKEN } from '../db/database.module';
import { Database } from '../db/connection';
import { activities, Activity } from '../db/schema';
@Injectable()
export class ActivityService {
constructor(@Inject(DATABASE_TOKEN) private db: Database) {}
async findAll(userId: string, limit = 50): Promise<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp))
.limit(limit);
}
async findBySkill(userId: string, skillId: string): Promise<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.skillId, skillId))
.orderBy(desc(activities.timestamp));
}
async getRecent(userId: string, limit = 10): Promise<Activity[]> {
return this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp))
.limit(limit);
}
}

View file

@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from '@manacore/shared-nestjs-health';
import { SkillModule } from './skill/skill.module';
import { ActivityModule } from './activity/activity.module';
import { AchievementModule } from './achievement/achievement.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MetricsModule.register({
prefix: 'skilltree_',
excludePaths: ['/health'],
}),
DatabaseModule,
HealthModule.forRoot({ serviceName: 'skilltree-backend' }),
SkillModule,
ActivityModule,
AchievementModule,
],
})
export class AppModule {}

View file

@ -1,38 +0,0 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
let connection: ReturnType<typeof postgres> | null = null;
export function getDb(connectionString?: string) {
if (db) return db;
const url = connectionString || process.env.DATABASE_URL;
if (!url) {
throw new Error('DATABASE_URL is not defined');
}
connection = postgres(url, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
db = drizzle(connection, { schema });
return db;
}
export function getConnection() {
return connection;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -1,20 +0,0 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { getDb, closeConnection, Database } from './connection';
export const DATABASE_TOKEN = 'DATABASE';
@Global()
@Module({
providers: [
{
provide: DATABASE_TOKEN,
useFactory: () => getDb(),
},
],
exports: [DATABASE_TOKEN],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -1,62 +0,0 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
integer,
boolean,
index,
jsonb,
} from 'drizzle-orm/pg-core';
export type AchievementCategory =
| 'xp'
| 'skills'
| 'levels'
| 'activities'
| 'streak'
| 'branches'
| 'special';
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
export const achievements = pgTable('achievements', {
id: varchar('id', { length: 50 }).primaryKey(), // e.g. 'first_activity', 'streak_7'
name: varchar('name', { length: 200 }).notNull(),
description: text('description').notNull(),
icon: varchar('icon', { length: 50 }).notNull().default('trophy'),
category: varchar('category', { length: 20 }).notNull().$type<AchievementCategory>(),
rarity: varchar('rarity', { length: 20 }).notNull().$type<AchievementRarity>(),
xpReward: integer('xp_reward').default(0).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
condition: jsonb('condition').notNull(), // { type: 'total_xp', threshold: 1000 }
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export type Achievement = typeof achievements.$inferSelect;
export type NewAchievement = typeof achievements.$inferInsert;
export const userAchievements = pgTable(
'user_achievements',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
achievementId: varchar('achievement_id', { length: 50 })
.references(() => achievements.id, { onDelete: 'cascade' })
.notNull(),
unlockedAt: timestamp('unlocked_at', { withTimezone: true }).defaultNow().notNull(),
progress: integer('progress').default(0).notNull(), // current progress toward threshold
},
(table) => ({
userIdx: index('user_achievements_user_idx').on(table.userId),
achievementIdx: index('user_achievements_achievement_idx').on(table.achievementId),
uniqueUserAchievement: index('user_achievements_unique_idx').on(
table.userId,
table.achievementId
),
})
);
export type UserAchievement = typeof userAchievements.$inferSelect;
export type NewUserAchievement = typeof userAchievements.$inferInsert;

View file

@ -1,37 +0,0 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
integer,
index,
} from 'drizzle-orm/pg-core';
import { skills } from './skills.schema';
export const activities = pgTable(
'activities',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
skillId: uuid('skill_id')
.references(() => skills.id, { onDelete: 'cascade' })
.notNull(),
// Activity details
xpEarned: integer('xp_earned').notNull(),
description: varchar('description', { length: 500 }).notNull(),
duration: integer('duration'), // in minutes
// Timestamp
timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('activities_user_idx').on(table.userId),
skillIdx: index('activities_skill_idx').on(table.skillId),
timestampIdx: index('activities_timestamp_idx').on(table.userId, table.timestamp),
})
);
export type Activity = typeof activities.$inferSelect;
export type NewActivity = typeof activities.$inferInsert;

View file

@ -1,4 +0,0 @@
export * from './skills.schema';
export * from './activities.schema';
export * from './user-stats.schema';
export * from './achievements.schema';

View file

@ -1,52 +0,0 @@
import {
pgTable,
uuid,
timestamp,
varchar,
text,
integer,
index,
} from 'drizzle-orm/pg-core';
export type SkillBranch =
| 'intellect'
| 'body'
| 'creativity'
| 'social'
| 'practical'
| 'mindset'
| 'custom';
export const skills = pgTable(
'skills',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
// Content
name: varchar('name', { length: 200 }).notNull(),
description: text('description'),
branch: varchar('branch', { length: 20 }).notNull().$type<SkillBranch>(),
parentId: uuid('parent_id'),
icon: varchar('icon', { length: 50 }).default('star'),
color: varchar('color', { length: 20 }),
// Progress
currentXp: integer('current_xp').default(0).notNull(),
totalXp: integer('total_xp').default(0).notNull(),
level: integer('level').default(0).notNull(),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('skills_user_idx').on(table.userId),
branchIdx: index('skills_branch_idx').on(table.userId, table.branch),
parentIdx: index('skills_parent_idx').on(table.parentId),
levelIdx: index('skills_level_idx').on(table.userId, table.level),
})
);
export type Skill = typeof skills.$inferSelect;
export type NewSkill = typeof skills.$inferInsert;

View file

@ -1,34 +0,0 @@
import {
pgTable,
uuid,
timestamp,
text,
integer,
date,
index,
} from 'drizzle-orm/pg-core';
export const userStats = pgTable(
'user_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull().unique(),
// Aggregated stats
totalXp: integer('total_xp').default(0).notNull(),
totalSkills: integer('total_skills').default(0).notNull(),
highestLevel: integer('highest_level').default(0).notNull(),
streakDays: integer('streak_days').default(0).notNull(),
lastActivityDate: date('last_activity_date'),
// Timestamps
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
userIdx: index('user_stats_user_idx').on(table.userId),
})
);
export type UserStat = typeof userStats.$inferSelect;
export type NewUserStat = typeof userStats.$inferInsert;

View file

@ -1,8 +0,0 @@
import { initErrorTracking } from '@manacore/shared-error-tracking';
initErrorTracking({
serviceName: 'skilltree-backend',
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
debug: process.env.NODE_ENV === 'development',
});

View file

@ -1,65 +0,0 @@
import './instrument';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
// Enable CORS
app.enableCors({
origin: (origin, callback) => {
if (!origin) {
callback(null, true);
return;
}
const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [
'http://localhost:5173',
'http://localhost:5195',
'http://localhost:8081',
];
if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) {
callback(null, true);
return;
}
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
logger.warn(`Blocked request from origin: ${origin}`);
callback(new Error('Not allowed by CORS'), false);
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
// Global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
})
);
// API prefix
app.setGlobalPrefix('api/v1', {
exclude: ['metrics', 'health'],
});
const port = process.env.PORT || 3024;
await app.listen(port);
logger.log(`SkillTree API is running on: http://localhost:${port}`);
logger.log(`Health check: http://localhost:${port}/health`);
}
bootstrap();

View file

@ -1,17 +0,0 @@
import { IsString, IsNumber, IsOptional, Min, Max, MaxLength } from 'class-validator';
export class AddXpDto {
@IsNumber()
@Min(1)
@Max(10000)
xp: number;
@IsString()
@MaxLength(500)
description: string;
@IsOptional()
@IsNumber()
@Min(1)
duration?: number; // in minutes
}

View file

@ -1,32 +0,0 @@
import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator';
import type { SkillBranch } from '../../db/schema';
const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const;
export class CreateSkillDto {
@IsString()
@MaxLength(200)
name: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsIn(BRANCHES)
branch: SkillBranch;
@IsOptional()
@IsUUID()
parentId?: string;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
}

View file

@ -1,3 +0,0 @@
export * from './create-skill.dto';
export * from './update-skill.dto';
export * from './add-xp.dto';

View file

@ -1,34 +0,0 @@
import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator';
import type { SkillBranch } from '../../db/schema';
const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const;
export class UpdateSkillDto {
@IsOptional()
@IsString()
@MaxLength(200)
name?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsOptional()
@IsIn(BRANCHES)
branch?: SkillBranch;
@IsOptional()
@IsUUID()
parentId?: string | null;
@IsOptional()
@IsString()
@MaxLength(50)
icon?: string;
@IsOptional()
@IsString()
@MaxLength(20)
color?: string;
}

View file

@ -1,64 +0,0 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { SkillService } from './skill.service';
import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto';
@Controller('skills')
@UseGuards(JwtAuthGuard)
export class SkillController {
constructor(private readonly skillService: SkillService) {}
@Get()
async findAll(@CurrentUser() user: CurrentUserData, @Query('branch') branch?: string) {
if (branch) {
const skills = await this.skillService.findByBranch(user.userId, branch);
return { skills };
}
const skills = await this.skillService.findAll(user.userId);
return { skills };
}
@Get('stats')
async getStats(@CurrentUser() user: CurrentUserData) {
const stats = await this.skillService.getUserStats(user.userId);
return { stats };
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const skill = await this.skillService.findByIdOrThrow(id, user.userId);
return { skill };
}
@Post()
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateSkillDto) {
const result = await this.skillService.create(user.userId, dto);
return { skill: result.skill, newAchievements: result.newAchievements };
}
@Put(':id')
async update(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateSkillDto
) {
const skill = await this.skillService.update(id, user.userId, dto);
return { skill };
}
@Delete(':id')
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.skillService.delete(id, user.userId);
return { success: true };
}
@Post(':id/xp')
async addXp(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: AddXpDto
) {
const result = await this.skillService.addXp(id, user.userId, dto);
return result;
}
}

View file

@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { SkillController } from './skill.controller';
import { SkillService } from './skill.service';
import { AchievementModule } from '../achievement/achievement.module';
@Module({
imports: [AchievementModule],
controllers: [SkillController],
providers: [SkillService],
exports: [SkillService],
})
export class SkillModule {}

View file

@ -1,508 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { SkillService } from './skill.service';
import { DATABASE_TOKEN } from '../db/database.module';
import { AchievementService } from '../achievement/achievement.service';
// Mock database operations
// Uses a query builder pattern where each query chain is thenable
const createMockDb = () => {
// Queue for resolved values - each await will pop from this queue
const resolveQueue: any[] = [];
// Create a thenable query result (only used for final await)
const createQueryResult = (): any => {
return {
then: (resolve: (value: any) => void, reject?: (reason: any) => void) => {
const value = resolveQueue.shift() ?? [];
return Promise.resolve(value).then(resolve, reject);
},
};
};
// The mock database object - NOT thenable itself
const mockDb: any = {
// Helper methods
_queueResult: (value: any) => {
resolveQueue.push(value);
},
_queueResults: (...values: any[]) => {
values.forEach((v) => resolveQueue.push(v));
},
_clearQueue: () => {
resolveQueue.length = 0;
},
};
// Create a query builder that returns thenable results
const createChainableMethod = () => {
const chainable: any = createQueryResult();
chainable.select = jest.fn(() => chainable);
chainable.from = jest.fn(() => chainable);
chainable.where = jest.fn(() => chainable);
chainable.orderBy = jest.fn(() => chainable);
chainable.limit = jest.fn(() => chainable);
chainable.returning = jest.fn(() => chainable);
chainable.insert = jest.fn(() => chainable);
chainable.values = jest.fn(() => chainable);
chainable.update = jest.fn(() => chainable);
chainable.set = jest.fn(() => chainable);
chainable.delete = jest.fn(() => chainable);
chainable.onConflictDoUpdate = jest.fn(() => chainable);
return chainable;
};
// Database entry points return new chainable builders
mockDb.select = jest.fn(() => createChainableMethod());
mockDb.insert = jest.fn(() => createChainableMethod());
mockDb.update = jest.fn(() => createChainableMethod());
mockDb.delete = jest.fn(() => createChainableMethod());
return mockDb;
};
const mockAchievementService = {
checkAndUnlock: jest.fn().mockResolvedValue([]),
};
describe('SkillService', () => {
let service: SkillService;
let mockDb: ReturnType<typeof createMockDb>;
const testUserId = 'test-user-123';
const testSkillId = 'skill-uuid-123';
const mockSkill = {
id: testSkillId,
userId: testUserId,
name: 'TypeScript',
description: 'Learn TypeScript programming',
branch: 'intellect',
parentId: null,
icon: 'code',
color: '#3178C6',
currentXp: 150,
totalXp: 150,
level: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
mockDb = createMockDb();
mockAchievementService.checkAndUnlock.mockClear();
const module: TestingModule = await Test.createTestingModule({
providers: [
SkillService,
{
provide: DATABASE_TOKEN,
useValue: mockDb,
},
{
provide: AchievementService,
useValue: mockAchievementService,
},
],
}).compile();
service = module.get<SkillService>(SkillService);
});
afterEach(() => {
jest.clearAllMocks();
mockDb._clearQueue();
});
describe('findAll', () => {
it('should return all skills for a user', async () => {
const skills = [mockSkill, { ...mockSkill, id: 'skill-2', name: 'JavaScript' }];
mockDb._queueResult(skills);
const result = await service.findAll(testUserId);
expect(result).toEqual(skills);
expect(mockDb.select).toHaveBeenCalled();
});
it('should return empty array when user has no skills', async () => {
mockDb._queueResult([]);
const result = await service.findAll(testUserId);
expect(result).toEqual([]);
});
});
describe('findByBranch', () => {
it('should return skills filtered by branch', async () => {
const intellectSkills = [mockSkill];
mockDb._queueResult(intellectSkills);
const result = await service.findByBranch(testUserId, 'intellect');
expect(result).toEqual(intellectSkills);
});
it('should return empty array for branch with no skills', async () => {
mockDb._queueResult([]);
const result = await service.findByBranch(testUserId, 'body');
expect(result).toEqual([]);
});
});
describe('findById', () => {
it('should return skill when found', async () => {
mockDb._queueResult([mockSkill]);
const result = await service.findById(testSkillId, testUserId);
expect(result).toEqual(mockSkill);
});
it('should return null when skill not found', async () => {
mockDb._queueResult([]);
const result = await service.findById('non-existent', testUserId);
expect(result).toBeNull();
});
});
describe('findByIdOrThrow', () => {
it('should return skill when found', async () => {
mockDb._queueResult([mockSkill]);
const result = await service.findByIdOrThrow(testSkillId, testUserId);
expect(result).toEqual(mockSkill);
});
it('should throw NotFoundException when skill not found', async () => {
mockDb._queueResult([]);
await expect(service.findByIdOrThrow('non-existent', testUserId)).rejects.toThrow(
NotFoundException
);
});
});
describe('create', () => {
const createDto = {
name: 'React',
description: 'Learn React framework',
branch: 'intellect' as const,
parentId: undefined,
icon: 'component',
color: '#61DAFB',
};
it('should create a new skill with default XP and level', async () => {
const createdSkill = {
...createDto,
id: 'new-skill-id',
userId: testUserId,
currentXp: 0,
totalXp: 0,
level: 0,
};
// Queue results in order of awaits:
// 1. insert().values().returning() -> [createdSkill]
// 2. updateUserStats: select().from(skills).where() -> [createdSkill]
// 3. updateUserStats: select().from(activities).where().orderBy().limit() -> []
// 4. calculateStreak: select().from(activities).where().orderBy() -> []
// 5. insert().values().onConflictDoUpdate() -> undefined
mockDb._queueResults(
[createdSkill], // 1. insert skill returning
[createdSkill], // 2. select skills
[], // 3. select activities (limit)
[], // 4. calculateStreak activities
undefined // 5. upsert stats
);
const result = await service.create(testUserId, createDto);
expect(result.skill.name).toBe('React');
expect(result.skill.currentXp).toBe(0);
expect(result.skill.level).toBe(0);
expect(result.newAchievements).toEqual([]);
});
it('should use default icon when not provided', async () => {
const dtoWithoutIcon = {
name: 'New Skill',
description: 'A skill',
branch: 'body' as const,
parentId: undefined,
color: undefined,
};
const createdSkill = {
...dtoWithoutIcon,
id: 'new-id',
userId: testUserId,
icon: 'star',
currentXp: 0,
totalXp: 0,
level: 0,
};
mockDb._queueResults([createdSkill], [createdSkill], [], [], undefined);
const result = await service.create(testUserId, dtoWithoutIcon);
expect(result.skill.icon).toBe('star');
});
});
describe('update', () => {
const updateDto = {
name: 'Updated TypeScript',
description: 'Master TypeScript',
};
it('should update skill and return updated version', async () => {
const updatedSkill = { ...mockSkill, ...updateDto };
// Queue results:
// 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill]
// 2. update().set().where().returning() -> [updatedSkill]
mockDb._queueResults([mockSkill], [updatedSkill]);
const result = await service.update(testSkillId, testUserId, updateDto);
expect(result.name).toBe('Updated TypeScript');
expect(result.description).toBe('Master TypeScript');
});
it('should throw NotFoundException when updating non-existent skill', async () => {
mockDb._queueResult([]);
await expect(service.update('non-existent', testUserId, updateDto)).rejects.toThrow(
NotFoundException
);
});
});
describe('delete', () => {
it('should delete skill successfully', async () => {
// Queue results:
// 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill]
// 2. delete(skills).where() -> undefined
// 3. updateUserStats: select().from(skills).where() -> [] (empty after delete)
// 4. updateUserStats: select().from(activities).where().orderBy().limit() -> []
// 5. calculateStreak: select().from(activities).where().orderBy() -> []
// 6. insert().values().onConflictDoUpdate() -> undefined
mockDb._queueResults(
[mockSkill], // 1. findByIdOrThrow
undefined, // 2. delete
[], // 3. select skills
[], // 4. select activities (limit)
[], // 5. calculateStreak
undefined // 6. upsert stats
);
await expect(service.delete(testSkillId, testUserId)).resolves.not.toThrow();
});
it('should throw NotFoundException when deleting non-existent skill', async () => {
mockDb._queueResult([]);
await expect(service.delete('non-existent', testUserId)).rejects.toThrow(NotFoundException);
});
});
describe('addXp', () => {
const addXpDto = {
xp: 50,
description: 'Completed tutorial',
duration: 30,
};
it('should add XP and update skill level when threshold crossed', async () => {
// Skill at level 0 with 80 XP, adding 50 should reach level 1
const skillAt80Xp = { ...mockSkill, currentXp: 80, totalXp: 80, level: 0 };
const updatedSkill = {
...skillAt80Xp,
currentXp: 130,
totalXp: 130,
level: 1,
};
const recentActivity = { timestamp: new Date() };
// Queue results:
// 1. findByIdOrThrow: select().from(skills).where() -> [skillAt80Xp]
// 2. update(skills).set().where().returning() -> [updatedSkill]
// 3. insert(activities).values() -> undefined
// 4. updateUserStats: select().from(skills).where() -> [updatedSkill]
// 5. updateUserStats: select().from(activities).where().orderBy().limit() -> [activity]
// 6. calculateStreak: select().from(activities).where().orderBy() -> [activity]
// 7. insert().values().onConflictDoUpdate() -> undefined
mockDb._queueResults(
[skillAt80Xp], // 1
[updatedSkill], // 2
undefined, // 3
[updatedSkill], // 4
[recentActivity], // 5
[recentActivity], // 6
undefined // 7
);
const result = await service.addXp(testSkillId, testUserId, addXpDto);
expect(result.skill.totalXp).toBe(130);
expect(result.skill.level).toBe(1);
expect(result.leveledUp).toBe(true);
expect(result.newLevel).toBe(1);
});
it('should not level up when threshold not crossed', async () => {
// Skill at level 1 with 150 XP, adding 50 stays at level 1
const updatedSkill = {
...mockSkill,
currentXp: 200,
totalXp: 200,
level: 1,
};
const recentActivity = { timestamp: new Date() };
mockDb._queueResults(
[mockSkill], // findByIdOrThrow
[updatedSkill], // update skill
undefined, // insert activity
[updatedSkill], // select skills
[recentActivity], // select activities (limit)
[recentActivity], // calculateStreak
undefined // upsert stats
);
const result = await service.addXp(testSkillId, testUserId, addXpDto);
expect(result.leveledUp).toBe(false);
expect(result.newLevel).toBe(1);
});
it('should throw NotFoundException when adding XP to non-existent skill', async () => {
mockDb._queueResult([]);
await expect(service.addXp('non-existent', testUserId, addXpDto)).rejects.toThrow(
NotFoundException
);
});
it('should create activity record when adding XP', async () => {
const updatedSkill = { ...mockSkill, currentXp: 200, totalXp: 200 };
mockDb._queueResults(
[mockSkill], // findByIdOrThrow
[updatedSkill], // update skill
undefined, // insert activity
[updatedSkill], // select skills
[], // select activities (limit)
[], // calculateStreak
undefined // upsert stats
);
await service.addXp(testSkillId, testUserId, addXpDto);
expect(mockDb.insert).toHaveBeenCalled();
});
});
describe('getUserStats', () => {
it('should return user stats when they exist', async () => {
const stats = {
userId: testUserId,
totalXp: 500,
totalSkills: 5,
highestLevel: 2,
streakDays: 7,
lastActivityDate: '2026-01-28',
};
mockDb._queueResult([stats]);
const result = await service.getUserStats(testUserId);
expect(result).toEqual(stats);
});
it('should return default stats when none exist', async () => {
mockDb._queueResult([]);
const result = await service.getUserStats(testUserId);
expect(result).toEqual({
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
});
});
});
});
describe('Level Calculation (Unit Tests)', () => {
// Test the calculateLevel function directly
const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000];
function calculateLevel(xp: number): number {
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
if (xp >= LEVEL_THRESHOLDS[i]) {
return i;
}
}
return 0;
}
describe('calculateLevel', () => {
it.each([
[0, 0],
[50, 0],
[99, 0],
[100, 1],
[250, 1],
[499, 1],
[500, 2],
[1000, 2],
[1499, 2],
[1500, 3],
[3999, 3],
[4000, 4],
[9999, 4],
[10000, 5],
[50000, 5],
])('calculateLevel(%i) should return %i', (xp, expectedLevel) => {
expect(calculateLevel(xp)).toBe(expectedLevel);
});
});
describe('Level up detection', () => {
it('should detect level up from 0 to 1', () => {
const oldLevel = calculateLevel(90);
const newLevel = calculateLevel(110);
expect(oldLevel).toBe(0);
expect(newLevel).toBe(1);
expect(newLevel > oldLevel).toBe(true);
});
it('should not detect level up within same level', () => {
const oldLevel = calculateLevel(100);
const newLevel = calculateLevel(200);
expect(oldLevel).toBe(1);
expect(newLevel).toBe(1);
expect(newLevel > oldLevel).toBe(false);
});
it('should detect multiple level ups', () => {
const oldLevel = calculateLevel(0);
const newLevel = calculateLevel(600);
expect(oldLevel).toBe(0);
expect(newLevel).toBe(2);
expect(newLevel - oldLevel).toBe(2);
});
});
});

View file

@ -1,262 +0,0 @@
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
import { eq, and, desc } from 'drizzle-orm';
import { DATABASE_TOKEN } from '../db/database.module';
import { Database } from '../db/connection';
import { skills, activities, userStats, Skill, NewSkill } from '../db/schema';
import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto';
import { AchievementService, AchievementUnlockResult } from '../achievement/achievement.service';
// Level thresholds
const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000];
function calculateLevel(xp: number): number {
for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) {
if (xp >= LEVEL_THRESHOLDS[i]) {
return i;
}
}
return 0;
}
@Injectable()
export class SkillService {
constructor(
@Inject(DATABASE_TOKEN) private db: Database,
private readonly achievementService: AchievementService
) {}
async findAll(userId: string): Promise<Skill[]> {
return this.db
.select()
.from(skills)
.where(eq(skills.userId, userId))
.orderBy(desc(skills.totalXp));
}
async findByBranch(userId: string, branch: string): Promise<Skill[]> {
return this.db
.select()
.from(skills)
.where(and(eq(skills.userId, userId), eq(skills.branch, branch as any)))
.orderBy(desc(skills.totalXp));
}
async findById(id: string, userId: string): Promise<Skill | null> {
const [skill] = await this.db
.select()
.from(skills)
.where(and(eq(skills.id, id), eq(skills.userId, userId)));
return skill ?? null;
}
async findByIdOrThrow(id: string, userId: string): Promise<Skill> {
const skill = await this.findById(id, userId);
if (!skill) {
throw new NotFoundException(`Skill with id ${id} not found`);
}
return skill;
}
async create(
userId: string,
dto: CreateSkillDto
): Promise<{ skill: Skill; newAchievements: AchievementUnlockResult[] }> {
const newSkill: NewSkill = {
userId,
name: dto.name,
description: dto.description,
branch: dto.branch,
parentId: dto.parentId,
icon: dto.icon ?? 'star',
color: dto.color,
currentXp: 0,
totalXp: 0,
level: 0,
};
const [skill] = await this.db.insert(skills).values(newSkill).returning();
// Update user stats
await this.updateUserStats(userId);
// Check achievements
const newAchievements = await this.achievementService.checkAndUnlock(userId);
return { skill, newAchievements };
}
async update(id: string, userId: string, dto: UpdateSkillDto): Promise<Skill> {
await this.findByIdOrThrow(id, userId);
const [updated] = await this.db
.update(skills)
.set({
...dto,
updatedAt: new Date(),
})
.where(and(eq(skills.id, id), eq(skills.userId, userId)))
.returning();
return updated;
}
async delete(id: string, userId: string): Promise<void> {
await this.findByIdOrThrow(id, userId);
await this.db.delete(skills).where(and(eq(skills.id, id), eq(skills.userId, userId)));
// Update user stats
await this.updateUserStats(userId);
}
async addXp(
id: string,
userId: string,
dto: AddXpDto
): Promise<{
skill: Skill;
leveledUp: boolean;
newLevel: number;
newAchievements: AchievementUnlockResult[];
}> {
const skill = await this.findByIdOrThrow(id, userId);
const newTotalXp = skill.totalXp + dto.xp;
const newCurrentXp = skill.currentXp + dto.xp;
const newLevel = calculateLevel(newTotalXp);
const leveledUp = newLevel > skill.level;
// Update skill
const [updated] = await this.db
.update(skills)
.set({
totalXp: newTotalXp,
currentXp: newCurrentXp,
level: newLevel,
updatedAt: new Date(),
})
.where(and(eq(skills.id, id), eq(skills.userId, userId)))
.returning();
// Create activity
await this.db.insert(activities).values({
userId,
skillId: id,
xpEarned: dto.xp,
description: dto.description,
duration: dto.duration,
});
// Update user stats
await this.updateUserStats(userId);
// Check achievements
const newAchievements = await this.achievementService.checkAndUnlock(userId, {
activityXp: dto.xp,
});
return { skill: updated, leveledUp, newLevel, newAchievements };
}
private async updateUserStats(userId: string): Promise<void> {
// Get aggregated stats
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
const totalXp = userSkills.reduce((sum, s) => sum + s.totalXp, 0);
const totalSkills = userSkills.length;
const highestLevel = userSkills.reduce((max, s) => Math.max(max, s.level), 0);
// Get last activity date
const [lastActivity] = await this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp))
.limit(1);
const lastActivityDate = lastActivity?.timestamp
? lastActivity.timestamp.toISOString().split('T')[0]
: null;
// Calculate streak
const streakDays = await this.calculateStreak(userId);
// Upsert user stats
await this.db
.insert(userStats)
.values({
userId,
totalXp,
totalSkills,
highestLevel,
streakDays,
lastActivityDate,
})
.onConflictDoUpdate({
target: userStats.userId,
set: {
totalXp,
totalSkills,
highestLevel,
streakDays,
lastActivityDate,
updatedAt: new Date(),
},
});
}
private async calculateStreak(userId: string): Promise<number> {
const allActivities = await this.db
.select()
.from(activities)
.where(eq(activities.userId, userId))
.orderBy(desc(activities.timestamp));
if (allActivities.length === 0) return 0;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get unique dates
const uniqueDates = [
...new Set(
allActivities.map((a) => {
const d = new Date(a.timestamp);
d.setHours(0, 0, 0, 0);
return d.getTime();
})
),
].sort((a, b) => b - a); // Newest first
let streak = 0;
let expectedDate = today.getTime();
for (const date of uniqueDates) {
if (date === expectedDate || date === expectedDate - 86400000) {
streak++;
expectedDate = date - 86400000;
} else if (date < expectedDate - 86400000) {
break;
}
}
return streak;
}
async getUserStats(userId: string) {
const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId));
if (!stats) {
// Return default stats
return {
totalXp: 0,
totalSkills: 0,
highestLevel: 0,
streakDays: 0,
lastActivityDate: null,
};
}
return stats;
}
}

View file

@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "ES2021",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"outDir": "./dist",
"baseUrl": "./",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}