mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
5b291c1a17
commit
7a0b26eb3d
33 changed files with 1255 additions and 1 deletions
82
.github/workflows/ci.yml
vendored
82
.github/workflows/ci.yml
vendored
|
|
@ -68,6 +68,8 @@ jobs:
|
||||||
telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }}
|
telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }}
|
||||||
nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }}
|
nutriphi-backend: ${{ steps.changes.outputs.nutriphi-backend }}
|
||||||
nutriphi-web: ${{ steps.changes.outputs.nutriphi-web }}
|
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 }}
|
any-changes: ${{ steps.changes.outputs.any-changes }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|
@ -100,6 +102,8 @@ jobs:
|
||||||
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
||||||
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "nutriphi-web=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
|
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -136,6 +140,8 @@ jobs:
|
||||||
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT
|
||||||
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
echo "nutriphi-backend=true" >> $GITHUB_OUTPUT
|
||||||
echo "nutriphi-web=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
|
echo "any-changes=true" >> $GITHUB_OUTPUT
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
@ -319,6 +325,22 @@ jobs:
|
||||||
echo "nutriphi-web=false" >> $GITHUB_OUTPUT
|
echo "nutriphi-web=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
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
|
# Check if any service needs building
|
||||||
if grep -q "=true" $GITHUB_OUTPUT; then
|
if grep -q "=true" $GITHUB_OUTPUT; then
|
||||||
echo "any-changes=true" >> $GITHUB_OUTPUT
|
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 "| 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-backend | ${{ steps.changes.outputs.nutriphi-backend }} |" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "| nutriphi-web | ${{ steps.changes.outputs.nutriphi-web }} |" >> $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
|
# Validation job - runs on PRs
|
||||||
|
|
@ -940,3 +964,61 @@ jobs:
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
||||||
|
|
|
||||||
67
apps/skilltree/apps/backend/Dockerfile
Normal file
67
apps/skilltree/apps/backend/Dockerfile
Normal 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"]
|
||||||
31
apps/skilltree/apps/backend/docker-entrypoint.sh
Normal file
31
apps/skilltree/apps/backend/docker-entrypoint.sh
Normal 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 "$@"
|
||||||
11
apps/skilltree/apps/backend/drizzle.config.ts
Normal file
11
apps/skilltree/apps/backend/drizzle.config.ts
Normal 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!,
|
||||||
|
},
|
||||||
|
});
|
||||||
8
apps/skilltree/apps/backend/nest-cli.json
Normal file
8
apps/skilltree/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
47
apps/skilltree/apps/backend/package.json
Normal file
47
apps/skilltree/apps/backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/skilltree/apps/backend/src/activity/activity.module.ts
Normal file
10
apps/skilltree/apps/backend/src/activity/activity.module.ts
Normal 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 {}
|
||||||
36
apps/skilltree/apps/backend/src/activity/activity.service.ts
Normal file
36
apps/skilltree/apps/backend/src/activity/activity.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/skilltree/apps/backend/src/app.module.ts
Normal file
22
apps/skilltree/apps/backend/src/app.module.ts
Normal 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 {}
|
||||||
38
apps/skilltree/apps/backend/src/db/connection.ts
Normal file
38
apps/skilltree/apps/backend/src/db/connection.ts
Normal 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>;
|
||||||
20
apps/skilltree/apps/backend/src/db/database.module.ts
Normal file
20
apps/skilltree/apps/backend/src/db/database.module.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
3
apps/skilltree/apps/backend/src/db/schema/index.ts
Normal file
3
apps/skilltree/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './skills.schema';
|
||||||
|
export * from './activities.schema';
|
||||||
|
export * from './user-stats.schema';
|
||||||
52
apps/skilltree/apps/backend/src/db/schema/skills.schema.ts
Normal file
52
apps/skilltree/apps/backend/src/db/schema/skills.schema.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
13
apps/skilltree/apps/backend/src/health/health.controller.ts
Normal file
13
apps/skilltree/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/skilltree/apps/backend/src/health/health.module.ts
Normal file
7
apps/skilltree/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
102
apps/skilltree/apps/backend/src/main.ts
Normal file
102
apps/skilltree/apps/backend/src/main.ts
Normal 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();
|
||||||
3
apps/skilltree/apps/backend/src/metrics/index.ts
Normal file
3
apps/skilltree/apps/backend/src/metrics/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './metrics.module';
|
||||||
|
export * from './metrics.service';
|
||||||
|
export * from './metrics.controller';
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/skilltree/apps/backend/src/metrics/metrics.module.ts
Normal file
11
apps/skilltree/apps/backend/src/metrics/metrics.module.ts
Normal 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 {}
|
||||||
37
apps/skilltree/apps/backend/src/metrics/metrics.service.ts
Normal file
37
apps/skilltree/apps/backend/src/metrics/metrics.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts
Normal file
17
apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
3
apps/skilltree/apps/backend/src/skill/dto/index.ts
Normal file
3
apps/skilltree/apps/backend/src/skill/dto/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './create-skill.dto';
|
||||||
|
export * from './update-skill.dto';
|
||||||
|
export * from './add-xp.dto';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
64
apps/skilltree/apps/backend/src/skill/skill.controller.ts
Normal file
64
apps/skilltree/apps/backend/src/skill/skill.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/skilltree/apps/backend/src/skill/skill.module.ts
Normal file
10
apps/skilltree/apps/backend/src/skill/skill.module.ts
Normal 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 {}
|
||||||
238
apps/skilltree/apps/backend/src/skill/skill.service.ts
Normal file
238
apps/skilltree/apps/backend/src/skill/skill.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/skilltree/apps/backend/tsconfig.json
Normal file
25
apps/skilltree/apps/backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
66
apps/skilltree/apps/web/Dockerfile
Normal file
66
apps/skilltree/apps/web/Dockerfile
Normal 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"]
|
||||||
|
|
@ -89,7 +89,7 @@ services:
|
||||||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
SMTP_FROM: ManaCore <noreply@mana.how>
|
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 Analytics (Business Metrics)
|
||||||
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -589,6 +589,60 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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
|
# Monitoring Stack
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue