From 7a0b26eb3d3b343f6851bef5322ed0f83b66e728 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 23:59:50 +0000 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 82 ++++++ apps/skilltree/apps/backend/Dockerfile | 67 +++++ .../apps/backend/docker-entrypoint.sh | 31 +++ apps/skilltree/apps/backend/drizzle.config.ts | 11 + apps/skilltree/apps/backend/nest-cli.json | 8 + apps/skilltree/apps/backend/package.json | 47 ++++ .../src/activity/activity.controller.ts | 27 ++ .../backend/src/activity/activity.module.ts | 10 + .../backend/src/activity/activity.service.ts | 36 +++ apps/skilltree/apps/backend/src/app.module.ts | 22 ++ .../apps/backend/src/db/connection.ts | 38 +++ .../apps/backend/src/db/database.module.ts | 20 ++ .../src/db/schema/activities.schema.ts | 37 +++ .../apps/backend/src/db/schema/index.ts | 3 + .../backend/src/db/schema/skills.schema.ts | 52 ++++ .../src/db/schema/user-stats.schema.ts | 34 +++ .../backend/src/health/health.controller.ts | 13 + .../apps/backend/src/health/health.module.ts | 7 + apps/skilltree/apps/backend/src/main.ts | 102 ++++++++ .../apps/backend/src/metrics/index.ts | 3 + .../backend/src/metrics/metrics.controller.ts | 13 + .../backend/src/metrics/metrics.module.ts | 11 + .../backend/src/metrics/metrics.service.ts | 37 +++ .../apps/backend/src/skill/dto/add-xp.dto.ts | 17 ++ .../backend/src/skill/dto/create-skill.dto.ts | 32 +++ .../apps/backend/src/skill/dto/index.ts | 3 + .../backend/src/skill/dto/update-skill.dto.ts | 34 +++ .../backend/src/skill/skill.controller.ts | 64 +++++ .../apps/backend/src/skill/skill.module.ts | 10 + .../apps/backend/src/skill/skill.service.ts | 238 ++++++++++++++++++ apps/skilltree/apps/backend/tsconfig.json | 25 ++ apps/skilltree/apps/web/Dockerfile | 66 +++++ docker-compose.macmini.yml | 56 ++++- 33 files changed, 1255 insertions(+), 1 deletion(-) create mode 100644 apps/skilltree/apps/backend/Dockerfile create mode 100644 apps/skilltree/apps/backend/docker-entrypoint.sh create mode 100644 apps/skilltree/apps/backend/drizzle.config.ts create mode 100644 apps/skilltree/apps/backend/nest-cli.json create mode 100644 apps/skilltree/apps/backend/package.json create mode 100644 apps/skilltree/apps/backend/src/activity/activity.controller.ts create mode 100644 apps/skilltree/apps/backend/src/activity/activity.module.ts create mode 100644 apps/skilltree/apps/backend/src/activity/activity.service.ts create mode 100644 apps/skilltree/apps/backend/src/app.module.ts create mode 100644 apps/skilltree/apps/backend/src/db/connection.ts create mode 100644 apps/skilltree/apps/backend/src/db/database.module.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/activities.schema.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/index.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/skills.schema.ts create mode 100644 apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts create mode 100644 apps/skilltree/apps/backend/src/health/health.controller.ts create mode 100644 apps/skilltree/apps/backend/src/health/health.module.ts create mode 100644 apps/skilltree/apps/backend/src/main.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/index.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.controller.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.module.ts create mode 100644 apps/skilltree/apps/backend/src/metrics/metrics.service.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/index.ts create mode 100644 apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.controller.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.module.ts create mode 100644 apps/skilltree/apps/backend/src/skill/skill.service.ts create mode 100644 apps/skilltree/apps/backend/tsconfig.json create mode 100644 apps/skilltree/apps/web/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cc4fbd98..c1c4ff138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/skilltree/apps/backend/Dockerfile b/apps/skilltree/apps/backend/Dockerfile new file mode 100644 index 000000000..6c03c81bc --- /dev/null +++ b/apps/skilltree/apps/backend/Dockerfile @@ -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"] diff --git a/apps/skilltree/apps/backend/docker-entrypoint.sh b/apps/skilltree/apps/backend/docker-entrypoint.sh new file mode 100644 index 000000000..1bee031c8 --- /dev/null +++ b/apps/skilltree/apps/backend/docker-entrypoint.sh @@ -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 "$@" diff --git a/apps/skilltree/apps/backend/drizzle.config.ts b/apps/skilltree/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..140553e32 --- /dev/null +++ b/apps/skilltree/apps/backend/drizzle.config.ts @@ -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!, + }, +}); diff --git a/apps/skilltree/apps/backend/nest-cli.json b/apps/skilltree/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/apps/skilltree/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/skilltree/apps/backend/package.json b/apps/skilltree/apps/backend/package.json new file mode 100644 index 000000000..2f1dd3c59 --- /dev/null +++ b/apps/skilltree/apps/backend/package.json @@ -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" + } +} diff --git a/apps/skilltree/apps/backend/src/activity/activity.controller.ts b/apps/skilltree/apps/backend/src/activity/activity.controller.ts new file mode 100644 index 000000000..7e52f471e --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.controller.ts @@ -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 }; + } +} diff --git a/apps/skilltree/apps/backend/src/activity/activity.module.ts b/apps/skilltree/apps/backend/src/activity/activity.module.ts new file mode 100644 index 000000000..b747ea624 --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.module.ts @@ -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 {} diff --git a/apps/skilltree/apps/backend/src/activity/activity.service.ts b/apps/skilltree/apps/backend/src/activity/activity.service.ts new file mode 100644 index 000000000..adafd9b38 --- /dev/null +++ b/apps/skilltree/apps/backend/src/activity/activity.service.ts @@ -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 { + return this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)) + .limit(limit); + } + + async findBySkill(userId: string, skillId: string): Promise { + return this.db + .select() + .from(activities) + .where(eq(activities.skillId, skillId)) + .orderBy(desc(activities.timestamp)); + } + + async getRecent(userId: string, limit = 10): Promise { + return this.db + .select() + .from(activities) + .where(eq(activities.userId, userId)) + .orderBy(desc(activities.timestamp)) + .limit(limit); + } +} diff --git a/apps/skilltree/apps/backend/src/app.module.ts b/apps/skilltree/apps/backend/src/app.module.ts new file mode 100644 index 000000000..1cc59968e --- /dev/null +++ b/apps/skilltree/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/skilltree/apps/backend/src/db/connection.ts b/apps/skilltree/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..34773decd --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/connection.ts @@ -0,0 +1,38 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +let db: ReturnType> | null = null; +let connection: ReturnType | 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; diff --git a/apps/skilltree/apps/backend/src/db/database.module.ts b/apps/skilltree/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..955eefe5f --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/database.module.ts @@ -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(); + } +} diff --git a/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts b/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts new file mode 100644 index 000000000..e832ad1f2 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts @@ -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; diff --git a/apps/skilltree/apps/backend/src/db/schema/index.ts b/apps/skilltree/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..f37fa5cb3 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/index.ts @@ -0,0 +1,3 @@ +export * from './skills.schema'; +export * from './activities.schema'; +export * from './user-stats.schema'; diff --git a/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts b/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts new file mode 100644 index 000000000..f4d2f672b --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts @@ -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(), + 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; diff --git a/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts b/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts new file mode 100644 index 000000000..a5ba16422 --- /dev/null +++ b/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts @@ -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; diff --git a/apps/skilltree/apps/backend/src/health/health.controller.ts b/apps/skilltree/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..8fb4727a8 --- /dev/null +++ b/apps/skilltree/apps/backend/src/health/health.controller.ts @@ -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', + }; + } +} diff --git a/apps/skilltree/apps/backend/src/health/health.module.ts b/apps/skilltree/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/apps/skilltree/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/skilltree/apps/backend/src/main.ts b/apps/skilltree/apps/backend/src/main.ts new file mode 100644 index 000000000..3cd031213 --- /dev/null +++ b/apps/skilltree/apps/backend/src/main.ts @@ -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(); diff --git a/apps/skilltree/apps/backend/src/metrics/index.ts b/apps/skilltree/apps/backend/src/metrics/index.ts new file mode 100644 index 000000000..860cd0cdf --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/index.ts @@ -0,0 +1,3 @@ +export * from './metrics.module'; +export * from './metrics.service'; +export * from './metrics.controller'; diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts b/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts new file mode 100644 index 000000000..4ee665772 --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.controller.ts @@ -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 { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.module.ts b/apps/skilltree/apps/backend/src/metrics/metrics.module.ts new file mode 100644 index 000000000..6c262956a --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.module.ts @@ -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 {} diff --git a/apps/skilltree/apps/backend/src/metrics/metrics.service.ts b/apps/skilltree/apps/backend/src/metrics/metrics.service.ts new file mode 100644 index 000000000..0b9d56083 --- /dev/null +++ b/apps/skilltree/apps/backend/src/metrics/metrics.service.ts @@ -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 { + return this.registry.metrics(); + } +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts new file mode 100644 index 000000000..e3dd9167a --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts @@ -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 +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts new file mode 100644 index 000000000..baf677575 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts @@ -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; +} diff --git a/apps/skilltree/apps/backend/src/skill/dto/index.ts b/apps/skilltree/apps/backend/src/skill/dto/index.ts new file mode 100644 index 000000000..c173f123c --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-skill.dto'; +export * from './update-skill.dto'; +export * from './add-xp.dto'; diff --git a/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts b/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts new file mode 100644 index 000000000..d7e858dad --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts @@ -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; +} diff --git a/apps/skilltree/apps/backend/src/skill/skill.controller.ts b/apps/skilltree/apps/backend/src/skill/skill.controller.ts new file mode 100644 index 000000000..32f3fd01b --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.controller.ts @@ -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; + } +} diff --git a/apps/skilltree/apps/backend/src/skill/skill.module.ts b/apps/skilltree/apps/backend/src/skill/skill.module.ts new file mode 100644 index 000000000..191b57fe7 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.module.ts @@ -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 {} diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.ts b/apps/skilltree/apps/backend/src/skill/skill.service.ts new file mode 100644 index 000000000..649ebc146 --- /dev/null +++ b/apps/skilltree/apps/backend/src/skill/skill.service.ts @@ -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 { + return this.db.select().from(skills).where(eq(skills.userId, userId)).orderBy(desc(skills.totalXp)); + } + + async findByBranch(userId: string, branch: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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; + } +} diff --git a/apps/skilltree/apps/backend/tsconfig.json b/apps/skilltree/apps/backend/tsconfig.json new file mode 100644 index 000000000..b1459ec35 --- /dev/null +++ b/apps/skilltree/apps/backend/tsconfig.json @@ -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"] +} diff --git a/apps/skilltree/apps/web/Dockerfile b/apps/skilltree/apps/web/Dockerfile new file mode 100644 index 000000000..d906736d0 --- /dev/null +++ b/apps/skilltree/apps/web/Dockerfile @@ -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"] diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 20ec32283..0b56d3c74 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -89,7 +89,7 @@ services: SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: ManaCore - 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 # ============================================