mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
b60877e367
commit
5d02b0419d
75 changed files with 13 additions and 5355 deletions
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import 'dotenv/config';
|
||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'skilltree',
|
||||
outDir: './drizzle',
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './skills.schema';
|
||||
export * from './activities.schema';
|
||||
export * from './user-stats.schema';
|
||||
export * from './achievements.schema';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './create-skill.dto';
|
||||
export * from './update-skill.dto';
|
||||
export * from './add-xp.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue