feat(skilltree): add NestJS backend with Docker deployment

- Add NestJS backend with Drizzle ORM and PostgreSQL
- Implement skills CRUD API with XP/level progression system
- Add activities tracking endpoint
- Configure Docker containers for backend (port 3024) and web (port 5195)
- Add skilltree services to docker-compose.macmini.yml
- Add CI build jobs for skilltree-backend and skilltree-web

https://claude.ai/code/session_015XCsTDS9aLZ64Zin4HU6ex
This commit is contained in:
Claude 2026-01-28 23:59:50 +00:00
parent 5b291c1a17
commit 7a0b26eb3d
No known key found for this signature in database
33 changed files with 1255 additions and 1 deletions

View file

@ -68,6 +68,8 @@ jobs:
telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }}
nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }}
nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }}
skilltree-backend: ${{ steps.changes.outputs.skilltree-backend }}
skilltree-web: ${{ steps.changes.outputs.skilltree-web }}
any-changes: ${{ steps.changes.outputs.any-changes }}
steps:
- name: Checkout code
@ -100,6 +102,8 @@ jobs:
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
echo "nutriphi-web=true" >> $GITHUB_OUTPUT
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
echo "skilltree-web=true" >> $GITHUB_OUTPUT
echo "any-changes=true" >> $GITHUB_OUTPUT
exit 0
fi
@ -136,6 +140,8 @@ jobs:
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
echo "nutriphi-web=true" >> $GITHUB_OUTPUT
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
echo "skilltree-web=true" >> $GITHUB_OUTPUT
echo "any-changes=true" >> $GITHUB_OUTPUT
exit 0
fi
@ -319,6 +325,22 @@ jobs:
echo "nutriphi-web=false" >> $GITHUB_OUTPUT
fi
# skilltree-backend
SKILLTREE_BACKEND_CHANGED=$(check_pattern "apps/skilltree/apps/backend/|apps/skilltree/packages/")
if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SKILLTREE_BACKEND_CHANGED" == "true" ]; then
echo "skilltree-backend=true" >> $GITHUB_OUTPUT
else
echo "skilltree-backend=false" >> $GITHUB_OUTPUT
fi
# skilltree-web
SKILLTREE_WEB_CHANGED=$(check_pattern "apps/skilltree/apps/web/|apps/skilltree/packages/")
if [ "$COMMON_CHANGED" == "true" ] || [ "$SHARED_AUTH_CHANGED" == "true" ] || [ "$SHARED_UI_CHANGED" == "true" ] || [ "$SHARED_WEB_CHANGED" == "true" ] || [ "$SKILLTREE_WEB_CHANGED" == "true" ]; then
echo "skilltree-web=true" >> $GITHUB_OUTPUT
else
echo "skilltree-web=false" >> $GITHUB_OUTPUT
fi
# Check if any service needs building
if grep -q "=true" $GITHUB_OUTPUT; then
echo "any-changes=true" >> $GITHUB_OUTPUT
@ -351,6 +373,8 @@ jobs:
echo "| telegram-stats-bot | ${{ steps.changes.outputs.telegram-stats-bot }} |" >> $GITHUB_STEP_SUMMARY
echo "| nutriphi-backend | ${{ steps.changes.outputs.nutriphi-backend }} |" >> $GITHUB_STEP_SUMMARY
echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $GITHUB_STEP_SUMMARY
echo "| skilltree-backend | ${{ steps.changes.outputs.skilltree-backend }} |" >> $GITHUB_STEP_SUMMARY
echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY
# ===========================================
# Validation job - runs on PRs
@ -940,3 +964,61 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-skilltree-backend:
name: Build skilltree-backend
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.skilltree-backend == 'true'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository_owner }}/skilltree-backend
tags: type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
file: apps/skilltree/apps/backend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-skilltree-web:
name: Build skilltree-web
runs-on: ubuntu-latest
needs: detect-changes
if: needs.detect-changes.outputs.skilltree-web == 'true'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository_owner }}/skilltree-web
tags: type=raw,value=latest
- uses: docker/build-push-action@v5
with:
context: .
file: apps/skilltree/apps/web/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -0,0 +1,67 @@
# 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 shared packages
COPY packages/shared-errors ./packages/shared-errors
COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth
# Copy skilltree backend
COPY apps/skilltree/apps/backend ./apps/skilltree/apps/backend
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build shared packages first
WORKDIR /app/packages/shared-errors
RUN pnpm build
WORKDIR /app/packages/shared-nestjs-auth
RUN pnpm build
# Build the backend
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/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

@ -0,0 +1,31 @@
#!/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

@ -0,0 +1,11 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

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

View file

@ -0,0 +1,47 @@
{
"name": "@skilltree/backend",
"version": "1.0.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-nestjs-auth": "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

@ -0,0 +1,27 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,36 @@
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

@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { MetricsModule } from './metrics';
import { SkillModule } from './skill/skill.module';
import { ActivityModule } from './activity/activity.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
MetricsModule,
DatabaseModule,
HealthModule,
SkillModule,
ActivityModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,38 @@
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

@ -0,0 +1,20 @@
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

@ -0,0 +1,37 @@
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

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

View file

@ -0,0 +1,52 @@
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

@ -0,0 +1,34 @@
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

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'skilltree-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,102 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { AppModule } from './app.module';
import { MetricsService } from './metrics/metrics.service';
// Normalize route paths to prevent high cardinality
function normalizeRoute(path: string): string {
return path
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id')
.replace(/\/\d+/g, '/:id');
}
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
// Get MetricsService for request tracking
const metricsService = app.get(MetricsService);
// Global Express middleware to track ALL HTTP requests
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path === '/metrics') {
return next();
}
const startTime = Date.now();
const method = req.method;
const route = normalizeRoute(req.path);
res.once('finish', () => {
const duration = (Date.now() - startTime) / 1000;
metricsService.httpRequestsTotal.inc({
method,
route,
status: res.statusCode.toString(),
});
metricsService.httpRequestDuration.observe(
{ method, route, status: res.statusCode.toString() },
duration
);
});
next();
});
// 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

@ -0,0 +1,3 @@
export * from './metrics.module';
export * from './metrics.service';
export * from './metrics.controller';

View file

@ -0,0 +1,13 @@
import { Controller, Get, Header } from '@nestjs/common';
import { MetricsService } from './metrics.service';
@Controller('metrics')
export class MetricsController {
constructor(private metricsService: MetricsService) {}
@Get()
@Header('Content-Type', 'text/plain')
async getMetrics(): Promise<string> {
return this.metricsService.getMetrics();
}
}

View file

@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { MetricsController } from './metrics.controller';
import { MetricsService } from './metrics.service';
@Global()
@Module({
controllers: [MetricsController],
providers: [MetricsService],
exports: [MetricsService],
})
export class MetricsModule {}

View file

@ -0,0 +1,37 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { collectDefaultMetrics, Counter, Histogram, Registry } from 'prom-client';
@Injectable()
export class MetricsService implements OnModuleInit {
private registry: Registry;
public httpRequestsTotal: Counter;
public httpRequestDuration: Histogram;
constructor() {
this.registry = new Registry();
this.httpRequestsTotal = new Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status'],
registers: [this.registry],
});
this.httpRequestDuration = new Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [this.registry],
});
}
onModuleInit() {
collectDefaultMetrics({ register: this.registry });
}
async getMetrics(): Promise<string> {
return this.registry.metrics();
}
}

View file

@ -0,0 +1,17 @@
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

@ -0,0 +1,32 @@
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

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

View file

@ -0,0 +1,34 @@
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

@ -0,0 +1,64 @@
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 skill = await this.skillService.create(user.userId, dto);
return { skill };
}
@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

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

View file

@ -0,0 +1,238 @@
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';
// 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) {}
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> {
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);
return skill;
}
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 }> {
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);
return { skill: updated, leveledUp, newLevel };
}
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

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

View file

@ -0,0 +1,66 @@
# Build stage
FROM node:20-alpine AS builder
# Build arguments for SvelteKit static env vars
ARG PUBLIC_BACKEND_URL=http://skilltree-backend:3024
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-core-auth:3001
# Set as environment variables for build
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
# 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 shared packages needed by skilltree web
COPY packages/shared-tailwind ./packages/shared-tailwind
COPY packages/shared-theme ./packages/shared-theme
COPY packages/shared-utils ./packages/shared-utils
# Copy skilltree web
COPY apps/skilltree/apps/web ./apps/skilltree/apps/web
# Install dependencies
RUN pnpm install --frozen-lockfile
# Build the web app
WORKDIR /app/apps/skilltree/apps/web
RUN pnpm exec svelte-kit sync
RUN pnpm build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app/apps/skilltree/apps/web
# Copy the pnpm store that symlinks point to
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
# Copy the app's node_modules
COPY --from=builder /app/apps/skilltree/apps/web/node_modules ./node_modules
# Copy built application
COPY --from=builder /app/apps/skilltree/apps/web/build ./build
COPY --from=builder /app/apps/skilltree/apps/web/package.json ./
# Expose port
EXPOSE 5195
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5195
ENV HOST=0.0.0.0
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:5195/health || exit 1
# Run the app
CMD ["node", "build"]

View file

@ -89,7 +89,7 @@ services:
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM: ManaCore <noreply@mana.how>
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how
# DuckDB Analytics (Business Metrics)
DUCKDB_PATH: /data/analytics/metrics.duckdb
volumes:
@ -589,6 +589,60 @@ services:
retries: 3
start_period: 40s
# ============================================
# SkillTree App (Gamified Skill Tracking)
# ============================================
skilltree-backend:
image: ghcr.io/memo-2023/skilltree-backend:latest
container_name: skilltree-backend
restart: always
depends_on:
mana-core-auth:
condition: service_healthy
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3024
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/skilltree
DB_HOST: postgres
DB_PORT: 5432
DB_USER: postgres
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
CORS_ORIGINS: https://skilltree.mana.how,https://mana.how
ports:
- "3024:3024"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3024/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
skilltree-web:
image: ghcr.io/memo-2023/skilltree-web:latest
container_name: skilltree-web
restart: always
depends_on:
skilltree-backend:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 5195
PUBLIC_BACKEND_URL: http://skilltree-backend:3024
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
PUBLIC_BACKEND_URL_CLIENT: https://skilltree-api.mana.how
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
ports:
- "5195:5195"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5195/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# ============================================
# Monitoring Stack
# ============================================