mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01: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 }}
|
||||
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
|
||||
|
|
|
|||
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_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: ManaCore <noreply@mana.how>
|
||||
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how
|
||||
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how
|
||||
# DuckDB Analytics (Business Metrics)
|
||||
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
||||
volumes:
|
||||
|
|
@ -589,6 +589,60 @@ services:
|
|||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# SkillTree App (Gamified Skill Tracking)
|
||||
# ============================================
|
||||
|
||||
skilltree-backend:
|
||||
image: ghcr.io/memo-2023/skilltree-backend:latest
|
||||
container_name: skilltree-backend
|
||||
restart: always
|
||||
depends_on:
|
||||
mana-core-auth:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3024
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-manacore123}@postgres:5432/skilltree
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||
CORS_ORIGINS: https://skilltree.mana.how,https://mana.how
|
||||
ports:
|
||||
- "3024:3024"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3024/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
skilltree-web:
|
||||
image: ghcr.io/memo-2023/skilltree-web:latest
|
||||
container_name: skilltree-web
|
||||
restart: always
|
||||
depends_on:
|
||||
skilltree-backend:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 5195
|
||||
PUBLIC_BACKEND_URL: http://skilltree-backend:3024
|
||||
PUBLIC_MANA_CORE_AUTH_URL: http://mana-core-auth:3001
|
||||
PUBLIC_BACKEND_URL_CLIENT: https://skilltree-api.mana.how
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT: https://auth.mana.how
|
||||
ports:
|
||||
- "5195:5195"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:5195/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Monitoring Stack
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue