diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac1f2745..db795278d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,6 @@ 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 }} mana-matrix-bot: ${{ steps.changes.outputs.mana-matrix-bot }} any-changes: ${{ steps.changes.outputs.any-changes }} @@ -101,7 +100,6 @@ 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 "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT @@ -139,7 +137,6 @@ 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 "mana-matrix-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT @@ -322,13 +319,7 @@ 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-backend: REMOVED — migrated to local-first # skilltree-web SKILLTREE_WEB_CHANGED=$(check_pattern "apps/skilltree/apps/web/|apps/skilltree/packages/") @@ -381,7 +372,7 @@ 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-backend | removed |" >> $GITHUB_STEP_SUMMARY echo "| skilltree-web | ${{ steps.changes.outputs.skilltree-web }} |" >> $GITHUB_STEP_SUMMARY echo "| zitare-backend | removed |" >> $GITHUB_STEP_SUMMARY @@ -976,34 +967,7 @@ jobs: 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-backend: REMOVED — migrated to local-first build-skilltree-web: name: Build skilltree-web diff --git a/apps/citycorners/apps/backend/Dockerfile b/apps/citycorners/apps/backend/Dockerfile deleted file mode 100644 index 5e27e699b..000000000 --- a/apps/citycorners/apps/backend/Dockerfile +++ /dev/null @@ -1,87 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-alpine AS builder - -# Install pnpm -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -# Copy root workspace files -COPY pnpm-workspace.yaml ./ -COPY package.json ./ -COPY pnpm-lock.yaml ./ -COPY patches ./patches - -# Copy shared packages (required dependencies) -COPY packages/shared-drizzle-config ./packages/shared-drizzle-config -COPY packages/shared-errors ./packages/shared-errors -COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth -COPY packages/shared-nestjs-health ./packages/shared-nestjs-health -COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics -COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup -COPY packages/shared-tsconfig ./packages/shared-tsconfig -COPY packages/shared-error-tracking ./packages/shared-error-tracking - -# Copy citycorners backend -COPY apps/citycorners/apps/backend ./apps/citycorners/apps/backend - -# Install dependencies -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -WORKDIR /app/packages/shared-errors -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-auth -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-health -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-metrics -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-setup -RUN pnpm build - -WORKDIR /app/packages/shared-error-tracking -RUN pnpm build - -# Build the backend -WORKDIR /app/apps/citycorners/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/citycorners ./apps/citycorners - -# Copy entrypoint script -COPY apps/citycorners/apps/backend/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/apps/citycorners/apps/backend - -# Expose port -EXPOSE 3025 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3025/health || exit 1 - -# Run entrypoint script -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] diff --git a/apps/citycorners/apps/backend/docker-entrypoint.sh b/apps/citycorners/apps/backend/docker-entrypoint.sh deleted file mode 100755 index c93279b36..000000000 --- a/apps/citycorners/apps/backend/docker-entrypoint.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/sh -set -e - -echo "=== CityCorners Backend Entrypoint ===" - -# Wait for PostgreSQL to be ready -echo "Waiting for PostgreSQL..." -until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-manacore} 2>/dev/null; do - echo "PostgreSQL is unavailable - sleeping" - sleep 2 -done -echo "PostgreSQL is up!" - -cd /app/apps/citycorners/apps/backend - -# Run schema push (for development) or migrations (for production) -if [ "$NODE_ENV" = "production" ] && [ -d "src/db/migrations/meta" ]; then - echo "Running database migrations..." - npx tsx src/db/migrate.ts - echo "Migrations completed!" -else - echo "Pushing database schema (development mode)..." - npx drizzle-kit push --force - echo "Schema push completed!" -fi - -# Run seed if seed file exists and SEED_ON_START is set -if [ "$SEED_ON_START" = "true" ] && [ -f "src/db/seed.ts" ]; then - echo "Running database seed..." - npx tsx src/db/seed.ts - echo "Seed completed!" -fi - -# Execute the main command -echo "Starting application..." -exec "$@" diff --git a/apps/citycorners/apps/backend/drizzle.config.ts b/apps/citycorners/apps/backend/drizzle.config.ts deleted file mode 100644 index f626d69fa..000000000 --- a/apps/citycorners/apps/backend/drizzle.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ - dbName: 'citycorners', - additionalEnvVars: ['CITYCORNERS_DATABASE_URL'], -}); diff --git a/apps/citycorners/apps/backend/jest.config.js b/apps/citycorners/apps/backend/jest.config.js deleted file mode 100644 index 45440de67..000000000 --- a/apps/citycorners/apps/backend/jest.config.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: [ - '**/*.(t|j)s', - '!**/*.spec.ts', - '!**/index.ts', - '!main.ts', - '!instrument.ts', - ], - coverageDirectory: '../coverage', - testEnvironment: 'node', -}; diff --git a/apps/citycorners/apps/backend/package.json b/apps/citycorners/apps/backend/package.json deleted file mode 100644 index 3da4c21ba..000000000 --- a/apps/citycorners/apps/backend/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@citycorners/backend", - "version": "0.0.1", - "private": true, - "scripts": { - "build": "nest build", - "start": "nest start", - "dev": "nest start --watch", - "start:dev": "nest start --watch", - "start:debug": "nest start --debug --watch", - "start:prod": "node dist/main", - "type-check": "tsc --noEmit", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "migration:generate": "drizzle-kit generate", - "migration:run": "tsx src/db/migrate.ts", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:seed": "tsx src/db/seed.ts" - }, - "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", - "@manacore/shared-nestjs-auth": "workspace:*", - "@manacore/shared-nestjs-health": "workspace:*", - "@manacore/shared-nestjs-metrics": "workspace:*", - "@manacore/shared-nestjs-setup": "workspace:*", - "@nestjs/common": "^10.4.15", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.15", - "@nestjs/platform-express": "^10.4.15", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dotenv": "^16.4.7", - "drizzle-kit": "^0.30.2", - "drizzle-orm": "^0.38.3", - "postgres": "^3.4.5", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^10.4.15", - "@types/express": "^5.0.0", - "@types/jest": "^30.0.0", - "@types/node": "^22.10.2", - "jest": "^30.0.0", - "source-map-support": "^0.5.21", - "ts-jest": "^29.2.5", - "ts-loader": "^9.5.1", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.2", - "typescript": "^5.7.2" - } -} diff --git a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts b/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts deleted file mode 100644 index 003d21817..000000000 --- a/apps/citycorners/apps/backend/src/__tests__/mock-factories.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Location } from '../db/schema/locations.schema'; -import type { Favorite } from '../db/schema/favorites.schema'; -import type { Collection } from '../db/schema/collections.schema'; - -export const TEST_USER_ID = 'test-user-123'; -export const TEST_USER_EMAIL = 'test@example.com'; - -export function createMockLocation(overrides: Partial = {}): Location { - return { - id: 'loc-1', - name: 'Konstanzer Münster', - slug: 'konstanzer-muenster', - category: 'sight', - description: 'Historic cathedral in Konstanz.', - address: 'Münsterplatz 1, 78462 Konstanz', - latitude: 47.6603, - longitude: 9.1757, - imageUrl: '/images/muenster.svg', - images: [], - timeline: [{ year: '615', event: 'Founded' }], - website: null, - phone: null, - openingHours: null, - createdBy: null, - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - deletedAt: null, - ...overrides, - }; -} - -export function createMockFavorite(overrides: Partial = {}): Favorite { - return { - id: 'fav-1', - userId: TEST_USER_ID, - locationId: 'loc-1', - createdAt: new Date('2026-01-01'), - ...overrides, - }; -} - -export function createMockCollection(overrides: Partial = {}): Collection { - return { - id: 'col-1', - userId: TEST_USER_ID, - name: 'My Favorites', - description: null, - locationIds: [], - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), - ...overrides, - }; -} - -export function createMockDb() { - return { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - returning: jest.fn(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - delete: jest.fn().mockReturnThis(), - }; -} diff --git a/apps/citycorners/apps/backend/src/app.module.ts b/apps/citycorners/apps/backend/src/app.module.ts deleted file mode 100644 index bdb6d169d..000000000 --- a/apps/citycorners/apps/backend/src/app.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { DatabaseModule } from './db/database.module'; -import { LocationModule } from './location/location.module'; -import { FavoriteModule } from './favorite/favorite.module'; -import { CollectionModule } from './collection/collection.module'; -import { ReviewModule } from './review/review.module'; -import { HealthModule } from '@manacore/shared-nestjs-health'; -import { MetricsModule } from '@manacore/shared-nestjs-metrics'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - DatabaseModule, - LocationModule, - FavoriteModule, - CollectionModule, - ReviewModule, - HealthModule.forRoot({ serviceName: 'citycorners-backend' }), - MetricsModule.register({ - prefix: 'citycorners_', - excludePaths: ['/health'], - }), - ], -}) -export class AppModule {} diff --git a/apps/citycorners/apps/backend/src/collection/collection.controller.ts b/apps/citycorners/apps/backend/src/collection/collection.controller.ts deleted file mode 100644 index 47d80d7f6..000000000 --- a/apps/citycorners/apps/backend/src/collection/collection.controller.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { CollectionService } from './collection.service'; -import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; - -class CreateCollectionDto { - @IsString() - @IsNotEmpty() - name!: string; - - @IsString() - @IsOptional() - description?: string; -} - -class UpdateCollectionDto { - @IsString() - @IsOptional() - name?: string; - - @IsString() - @IsOptional() - description?: string; -} - -@Controller('collections') -@UseGuards(JwtAuthGuard) -export class CollectionController { - constructor(private readonly collectionService: CollectionService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const collections = await this.collectionService.findByUserId(user.userId); - return { collections }; - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCollectionDto) { - const collection = await this.collectionService.create({ - name: dto.name, - description: dto.description, - userId: user.userId, - }); - return { collection }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateCollectionDto - ) { - const collection = await this.collectionService.update(id, dto, user.userId); - return { collection }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.collectionService.delete(id, user.userId); - return { success: true }; - } - - @Post(':id/locations/:locationId') - async addLocation( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Param('locationId') locationId: string - ) { - const collection = await this.collectionService.addLocation(id, locationId, user.userId); - return { collection }; - } - - @Delete(':id/locations/:locationId') - async removeLocation( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Param('locationId') locationId: string - ) { - const collection = await this.collectionService.removeLocation(id, locationId, user.userId); - return { collection }; - } -} diff --git a/apps/citycorners/apps/backend/src/collection/collection.module.ts b/apps/citycorners/apps/backend/src/collection/collection.module.ts deleted file mode 100644 index e12282c73..000000000 --- a/apps/citycorners/apps/backend/src/collection/collection.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CollectionController } from './collection.controller'; -import { CollectionService } from './collection.service'; - -@Module({ - controllers: [CollectionController], - providers: [CollectionService], - exports: [CollectionService], -}) -export class CollectionModule {} diff --git a/apps/citycorners/apps/backend/src/collection/collection.service.ts b/apps/citycorners/apps/backend/src/collection/collection.service.ts deleted file mode 100644 index 517932ce0..000000000 --- a/apps/citycorners/apps/backend/src/collection/collection.service.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { collections } from '../db/schema'; -import type { Collection, NewCollection } from '../db/schema'; - -@Injectable() -export class CollectionService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findByUserId(userId: string): Promise { - return this.db.select().from(collections).where(eq(collections.userId, userId)); - } - - async findById(id: string, userId: string): Promise { - const [collection] = await this.db - .select() - .from(collections) - .where(and(eq(collections.id, id), eq(collections.userId, userId))); - if (!collection) { - throw new NotFoundException(`Collection with id ${id} not found`); - } - return collection; - } - - async create(data: { name: string; description?: string; userId: string }): Promise { - const [collection] = await this.db - .insert(collections) - .values({ - name: data.name, - description: data.description, - userId: data.userId, - locationIds: [], - }) - .returning(); - return collection; - } - - async update( - id: string, - data: { name?: string; description?: string }, - userId: string - ): Promise { - const existing = await this.findById(id, userId); - - const [updated] = await this.db - .update(collections) - .set(data) - .where(eq(collections.id, id)) - .returning(); - return updated; - } - - async delete(id: string, userId: string): Promise { - await this.findById(id, userId); - await this.db.delete(collections).where(eq(collections.id, id)); - } - - async addLocation(id: string, locationId: string, userId: string): Promise { - const collection = await this.findById(id, userId); - const currentIds: string[] = (collection.locationIds as string[]) || []; - - if (currentIds.includes(locationId)) { - return collection; - } - - const [updated] = await this.db - .update(collections) - .set({ locationIds: [...currentIds, locationId] }) - .where(eq(collections.id, id)) - .returning(); - return updated; - } - - async removeLocation(id: string, locationId: string, userId: string): Promise { - const collection = await this.findById(id, userId); - const currentIds: string[] = (collection.locationIds as string[]) || []; - - const [updated] = await this.db - .update(collections) - .set({ locationIds: currentIds.filter((lid) => lid !== locationId) }) - .where(eq(collections.id, id)) - .returning(); - return updated; - } -} diff --git a/apps/citycorners/apps/backend/src/db/connection.ts b/apps/citycorners/apps/backend/src/db/connection.ts deleted file mode 100644 index fccc63f4a..000000000 --- a/apps/citycorners/apps/backend/src/db/connection.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { drizzle } from 'drizzle-orm/postgres-js'; -import * as schema from './schema'; - -// Use require for postgres to avoid ESM/CommonJS interop issues -// eslint-disable-next-line @typescript-eslint/no-var-requires -const postgres = require('postgres'); - -let connection: ReturnType | null = null; -let db: ReturnType | null = null; - -export function getConnection(databaseUrl: string) { - if (!connection) { - connection = postgres(databaseUrl, { - max: 10, - idle_timeout: 20, - connect_timeout: 10, - }); - } - return connection; -} - -export function getDb(databaseUrl: string) { - if (!db) { - const conn = getConnection(databaseUrl); - db = drizzle(conn, { schema }); - } - return db; -} - -export async function closeConnection() { - if (connection) { - await connection.end(); - connection = null; - db = null; - } -} - -export type Database = ReturnType; diff --git a/apps/citycorners/apps/backend/src/db/database.module.ts b/apps/citycorners/apps/backend/src/db/database.module.ts deleted file mode 100644 index 5a0a033b3..000000000 --- a/apps/citycorners/apps/backend/src/db/database.module.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Module, Global, OnModuleDestroy } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { getDb, closeConnection } from './connection'; -import type { Database } from './connection'; - -export const DATABASE_CONNECTION = 'DATABASE_CONNECTION'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_CONNECTION, - useFactory: (configService: ConfigService): Database => { - const databaseUrl = configService.get('DATABASE_URL'); - if (!databaseUrl) { - throw new Error('DATABASE_URL environment variable is not set'); - } - return getDb(databaseUrl); - }, - inject: [ConfigService], - }, - ], - exports: [DATABASE_CONNECTION], -}) -export class DatabaseModule implements OnModuleDestroy { - async onModuleDestroy() { - await closeConnection(); - } -} diff --git a/apps/citycorners/apps/backend/src/db/migrate.ts b/apps/citycorners/apps/backend/src/db/migrate.ts deleted file mode 100644 index e0ee2c5b1..000000000 --- a/apps/citycorners/apps/backend/src/db/migrate.ts +++ /dev/null @@ -1,25 +0,0 @@ -import 'dotenv/config'; -import { drizzle } from 'drizzle-orm/postgres-js'; -import { migrate } from 'drizzle-orm/postgres-js/migrator'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const postgres = require('postgres'); - -const databaseUrl = - process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/citycorners'; - -async function runMigrations() { - const connection = postgres(databaseUrl, { max: 1 }); - const db = drizzle(connection); - - console.log('Running migrations...'); - await migrate(db, { migrationsFolder: './src/db/migrations' }); - console.log('Migrations completed.'); - - await connection.end(); -} - -runMigrations().catch((err) => { - console.error('Migration failed:', err); - process.exit(1); -}); diff --git a/apps/citycorners/apps/backend/src/db/schema/collections.schema.ts b/apps/citycorners/apps/backend/src/db/schema/collections.schema.ts deleted file mode 100644 index 325621dc3..000000000 --- a/apps/citycorners/apps/backend/src/db/schema/collections.schema.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { pgTable, uuid, text, timestamp, jsonb } from 'drizzle-orm/pg-core'; - -export const collections = pgTable('collections', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - name: text('name').notNull(), - description: text('description'), - locationIds: jsonb('location_ids').$type().default([]), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), -}); - -export type Collection = typeof collections.$inferSelect; -export type NewCollection = typeof collections.$inferInsert; diff --git a/apps/citycorners/apps/backend/src/db/schema/favorites.schema.ts b/apps/citycorners/apps/backend/src/db/schema/favorites.schema.ts deleted file mode 100644 index 727922158..000000000 --- a/apps/citycorners/apps/backend/src/db/schema/favorites.schema.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; -import { locations } from './locations.schema'; - -export const favorites = pgTable( - 'favorites', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - locationId: uuid('location_id') - .notNull() - .references(() => locations.id, { onDelete: 'cascade' }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - uniqueUserLocation: unique().on(table.userId, table.locationId), - }) -); - -export type Favorite = typeof favorites.$inferSelect; -export type NewFavorite = typeof favorites.$inferInsert; diff --git a/apps/citycorners/apps/backend/src/db/schema/index.ts b/apps/citycorners/apps/backend/src/db/schema/index.ts deleted file mode 100644 index ef5930e90..000000000 --- a/apps/citycorners/apps/backend/src/db/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './locations.schema'; -export * from './favorites.schema'; -export * from './collections.schema'; -export * from './reviews.schema'; diff --git a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts b/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts deleted file mode 100644 index 91015c7f8..000000000 --- a/apps/citycorners/apps/backend/src/db/schema/locations.schema.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { - pgTable, - uuid, - text, - timestamp, - doublePrecision, - jsonb, - pgEnum, -} from 'drizzle-orm/pg-core'; - -export const categoryEnum = pgEnum('location_category', [ - 'sight', - 'restaurant', - 'shop', - 'museum', - 'cafe', - 'bar', - 'park', - 'beach', - 'hotel', - 'event_venue', - 'viewpoint', -]); - -export type OpeningHours = Record; - -export const locations = pgTable('locations', { - id: uuid('id').primaryKey().defaultRandom(), - name: text('name').notNull(), - slug: text('slug').unique(), - category: categoryEnum('category').notNull(), - description: text('description').notNull(), - address: text('address'), - latitude: doublePrecision('latitude'), - longitude: doublePrecision('longitude'), - imageUrl: text('image_url'), - images: jsonb('images').$type().default([]), - timeline: jsonb('timeline').$type().default([]), - website: text('website'), - phone: text('phone'), - openingHours: jsonb('opening_hours').$type(), - createdBy: text('created_by'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }) - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), - deletedAt: timestamp('deleted_at', { withTimezone: true }), -}); - -export interface LocationImage { - url: string; - addedBy?: string; - addedAt?: string; -} - -export interface TimelineEntry { - year: string; - event: string; -} - -export type Location = typeof locations.$inferSelect; -export type NewLocation = typeof locations.$inferInsert; diff --git a/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts b/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts deleted file mode 100644 index 0c064cf61..000000000 --- a/apps/citycorners/apps/backend/src/db/schema/reviews.schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { pgTable, uuid, text, integer, timestamp, unique } from 'drizzle-orm/pg-core'; -import { locations } from './locations.schema'; - -export const reviews = pgTable( - 'reviews', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - locationId: uuid('location_id') - .notNull() - .references(() => locations.id, { onDelete: 'cascade' }), - rating: integer('rating').notNull(), - comment: text('comment'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - uniqueUserLocation: unique().on(table.userId, table.locationId), - }) -); - -export type Review = typeof reviews.$inferSelect; -export type NewReview = typeof reviews.$inferInsert; diff --git a/apps/citycorners/apps/backend/src/db/seed.ts b/apps/citycorners/apps/backend/src/db/seed.ts deleted file mode 100644 index 2f128bc25..000000000 --- a/apps/citycorners/apps/backend/src/db/seed.ts +++ /dev/null @@ -1,602 +0,0 @@ -import 'dotenv/config'; -import { drizzle } from 'drizzle-orm/postgres-js'; -import { locations } from './schema'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const postgres = require('postgres'); - -const databaseUrl = - process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/citycorners'; - -async function seed() { - const connection = postgres(databaseUrl); - const db = drizzle(connection); - - console.log('Seeding citycorners database...'); - - await db.insert(locations).values([ - // === SIGHTS === - { - name: 'Konstanzer Münster', - slug: 'konstanzer-muenster', - category: 'sight', - description: - 'Das Konstanzer Münster ist eine römisch-katholische Basilika in der Altstadt von Konstanz. Der Bau begann im Jahr 615 und wurde im Laufe der Jahrhunderte mehrmals erweitert.', - address: 'Münsterplatz 1, 78462 Konstanz', - latitude: 47.6603, - longitude: 9.1757, - imageUrl: '/images/muenster.jpg', - timeline: [ - { year: '615', event: 'Grundsteinlegung' }, - { year: '1089', event: 'Romanischer Neubau' }, - { year: '1414-1418', event: 'Konzil von Konstanz' }, - ], - }, - { - name: 'Imperia', - slug: 'imperia', - category: 'sight', - description: - 'Die Imperia ist eine satirische Skulptur des Bildhauers Peter Lenk im Hafen von Konstanz. Sie dreht sich langsam um die eigene Achse.', - address: 'Hafenstraße, 78462 Konstanz', - latitude: 47.6596, - longitude: 9.1784, - imageUrl: '/images/imperia.jpg', - timeline: [{ year: '1993', event: 'Aufstellung im Hafen' }], - }, - - // === RESTAURANTS === - { - name: 'Restaurant Ophelia', - slug: 'restaurant-ophelia', - category: 'restaurant', - description: - 'Fine-Dining-Restaurant im Riva-Gebäude am Konstanzer Hafen mit Blick auf den Bodensee.', - address: 'Seestraße 25, 78464 Konstanz', - latitude: 47.6589, - longitude: 9.1795, - imageUrl: '/images/ophelia.jpg', - openingHours: { - mo: 'closed', - tu: 'closed', - we: '18:30 - 22:00', - th: '18:30 - 22:00', - fr: '18:30 - 22:00', - sa: '18:30 - 22:00', - su: 'closed', - }, - }, - - // === SHOPS === - { - name: 'LAGO Shopping Center', - slug: 'lago-shopping-center', - category: 'shop', - description: 'Großes Einkaufszentrum in der Konstanzer Innenstadt mit über 80 Geschäften.', - address: 'Bodanstraße 1, 78462 Konstanz', - latitude: 47.6615, - longitude: 9.1742, - imageUrl: '/images/lago.jpg', - openingHours: { - mo: '09:30 - 20:00', - tu: '09:30 - 20:00', - we: '09:30 - 20:00', - th: '09:30 - 20:00', - fr: '09:30 - 20:00', - sa: '09:30 - 20:00', - su: 'closed', - }, - }, - - // === MUSEUMS === - { - name: 'Rosgartenmuseum', - slug: 'rosgartenmuseum', - category: 'museum', - description: - 'Das Rosgartenmuseum zeigt die Geschichte der Stadt Konstanz und der Bodenseeregion.', - address: 'Rosgartenstraße 3-5, 78462 Konstanz', - latitude: 47.6612, - longitude: 9.1753, - openingHours: { - mo: 'closed', - tu: '10:00 - 18:00', - we: '10:00 - 18:00', - th: '10:00 - 18:00', - fr: '10:00 - 18:00', - sa: '10:00 - 17:00', - su: '10:00 - 17:00', - }, - }, - { - name: 'Archäologisches Landesmuseum', - slug: 'archaeologisches-landesmuseum', - category: 'museum', - description: 'Landesmuseum für Archäologie in Baden-Württemberg mit Funden aus der Region.', - address: 'Benediktinerplatz 5, 78467 Konstanz', - latitude: 47.6637, - longitude: 9.1801, - openingHours: { - mo: 'closed', - tu: '10:00 - 18:00', - we: '10:00 - 18:00', - th: '10:00 - 18:00', - fr: '10:00 - 18:00', - sa: '10:00 - 18:00', - su: '10:00 - 18:00', - }, - }, - - // === CAFÉS === - { - name: 'Café Zeitlos', - slug: 'cafe-zeitlos', - category: 'cafe', - description: - 'Gemütliches Café in der Konstanzer Altstadt mit hausgemachten Kuchen, Frühstück und einer großen Auswahl an Kaffeespezialitäten.', - address: 'Hussenstraße 13, 78462 Konstanz', - latitude: 47.6609, - longitude: 9.1749, - openingHours: { - mo: '08:00 - 18:00', - tu: '08:00 - 18:00', - we: '08:00 - 18:00', - th: '08:00 - 18:00', - fr: '08:00 - 18:00', - sa: '09:00 - 18:00', - su: '10:00 - 17:00', - }, - }, - { - name: 'Café Wessenberg', - slug: 'cafe-wessenberg', - category: 'cafe', - description: - 'Traditionsreiches Café im Herzen von Konstanz mit Terrasse und Blick auf die Altstadt. Bekannt für Torten und Frühstücksbuffet.', - address: 'Wessenbergstraße 41, 78462 Konstanz', - latitude: 47.6614, - longitude: 9.1739, - openingHours: { - mo: '07:30 - 18:30', - tu: '07:30 - 18:30', - we: '07:30 - 18:30', - th: '07:30 - 18:30', - fr: '07:30 - 18:30', - sa: '08:00 - 18:00', - su: '09:00 - 17:00', - }, - }, - { - name: 'Café Gessler 1159', - slug: 'cafe-gessler-1159', - category: 'cafe', - description: - 'Modernes Café und Bäckerei mit langer Tradition. Frisches Gebäck, Snacks und Kaffeespezialitäten in zentraler Lage.', - address: 'Bodanstraße 9, 78462 Konstanz', - latitude: 47.6608, - longitude: 9.173, - openingHours: { - mo: '06:30 - 19:00', - tu: '06:30 - 19:00', - we: '06:30 - 19:00', - th: '06:30 - 19:00', - fr: '06:30 - 19:00', - sa: '07:00 - 18:00', - su: '08:00 - 17:00', - }, - }, - { - name: 'Voglhaus Café', - slug: 'voglhaus-cafe', - category: 'cafe', - description: - 'Beliebtes Bio-Café mit vegetarischer und veganer Küche. Kreative Frühstücksgerichte und selbstgemachte Limonaden.', - address: 'Wessenbergstraße 8, 78462 Konstanz', - latitude: 47.6619, - longitude: 9.1744, - openingHours: { - mo: '09:00 - 18:00', - tu: '09:00 - 18:00', - we: '09:00 - 18:00', - th: '09:00 - 18:00', - fr: '09:00 - 18:00', - sa: '09:00 - 18:00', - su: '10:00 - 17:00', - }, - }, - { - name: 'Café Herr Hase', - slug: 'cafe-herr-hase', - category: 'cafe', - description: - 'Kleines Specialty-Coffee-Café in der Niederburg. Third-Wave-Kaffee, Matcha und hausgemachte Leckereien.', - address: 'Niederburggasse 2, 78462 Konstanz', - latitude: 47.6623, - longitude: 9.1762, - openingHours: { - mo: '08:30 - 17:00', - tu: '08:30 - 17:00', - we: '08:30 - 17:00', - th: '08:30 - 17:00', - fr: '08:30 - 17:00', - sa: '09:00 - 17:00', - su: 'closed', - }, - }, - - // === BARS === - { - name: 'Klimperkasten', - slug: 'klimperkasten', - category: 'bar', - description: - 'Kultige Kneipe und Bar in der Altstadt mit Live-Musik, Cocktails und lockerer Atmosphäre. Treffpunkt für Studierende und Nachtschwärmer.', - address: 'Bodanstraße 18, 78462 Konstanz', - latitude: 47.6611, - longitude: 9.1736, - openingHours: { - mo: '18:00 - 01:00', - tu: '18:00 - 01:00', - we: '18:00 - 01:00', - th: '18:00 - 02:00', - fr: '18:00 - 03:00', - sa: '18:00 - 03:00', - su: 'closed', - }, - }, - { - name: 'Shamrock Irish Pub', - slug: 'shamrock-irish-pub', - category: 'bar', - description: - 'Irischer Pub mit großer Bierauswahl, Live-Sportübertragungen und regelmäßigen Quiz-Abenden. Seit Jahren eine Institution.', - address: 'Bodanstraße 28, 78462 Konstanz', - latitude: 47.6607, - longitude: 9.1728, - openingHours: { - mo: '17:00 - 01:00', - tu: '17:00 - 01:00', - we: '17:00 - 01:00', - th: '17:00 - 01:00', - fr: '17:00 - 02:00', - sa: '15:00 - 02:00', - su: '15:00 - 00:00', - }, - }, - { - name: 'Seekuh', - slug: 'seekuh', - category: 'bar', - description: - 'Legendäre Konstanzer Bar und Kulturkneipe am Seerhein. Craft Beer, Cocktails und regelmäßig Konzerte auf kleiner Bühne.', - address: 'Konradigasse 1, 78462 Konstanz', - latitude: 47.6632, - longitude: 9.1773, - openingHours: { - mo: '17:00 - 01:00', - tu: '17:00 - 01:00', - we: '17:00 - 01:00', - th: '17:00 - 02:00', - fr: '17:00 - 03:00', - sa: '15:00 - 03:00', - su: '15:00 - 00:00', - }, - }, - { - name: 'Brauhaus Johann Albrecht', - slug: 'brauhaus-johann-albrecht', - category: 'bar', - description: - 'Brauhaus-Restaurant mit hauseigenem Bier direkt am Seerhein. Deftige Küche und frisch gebrautes Bier in historischem Ambiente.', - address: 'Konradigasse 2, 78462 Konstanz', - latitude: 47.663, - longitude: 9.177, - openingHours: { - mo: '11:00 - 23:00', - tu: '11:00 - 23:00', - we: '11:00 - 23:00', - th: '11:00 - 23:00', - fr: '11:00 - 00:00', - sa: '11:00 - 00:00', - su: '11:00 - 22:00', - }, - }, - { - name: 'Schwarze Katz', - slug: 'schwarze-katz', - category: 'bar', - description: - 'Kleine, gemütliche Bar in der Katzgasse mit kreativen Cocktails und einer großen Gin-Auswahl. Perfekt für einen entspannten Abend.', - address: 'Katzgasse 7, 78462 Konstanz', - latitude: 47.6617, - longitude: 9.1752, - openingHours: { - mo: 'closed', - tu: '19:00 - 01:00', - we: '19:00 - 01:00', - th: '19:00 - 02:00', - fr: '19:00 - 03:00', - sa: '19:00 - 03:00', - su: 'closed', - }, - }, - - // === PARKS === - { - name: 'Stadtgarten Konstanz', - slug: 'stadtgarten-konstanz', - category: 'park', - description: - 'Großer Park direkt am Bodenseeufer mit altem Baumbestand, Spielplätzen, Minigolf und Biergarten. Der beliebteste Erholungsort der Stadt.', - address: 'Seestraße, 78464 Konstanz', - latitude: 47.6582, - longitude: 9.1812, - }, - { - name: 'Herosé-Park', - slug: 'herose-park', - category: 'park', - description: - 'Ruhiger Park am Seerhein mit Liegewiesen, Grillplätzen und Rheinuferweg. Ideal zum Joggen, Grillen oder Entspannen.', - address: 'Herosé-Park, 78467 Konstanz', - latitude: 47.6676, - longitude: 9.1699, - }, - { - name: 'Lorettowald', - slug: 'lorettowald', - category: 'park', - description: - 'Bewaldeter Hügel im Süden von Konstanz mit Wanderwegen und Aussichtspunkten über den Bodensee. Beliebt bei Joggern und Spaziergängern.', - address: 'Lorettostraße, 78464 Konstanz', - latitude: 47.6524, - longitude: 9.1768, - }, - { - name: 'Bücklepark', - slug: 'buecklepark', - category: 'park', - description: - 'Kleiner, gepflegter Park nahe der Universität mit Spielplatz und schattigem Baumbestand. Ein ruhiges Plätzchen abseits des Trubels.', - address: 'Bücklestraße, 78467 Konstanz', - latitude: 47.6672, - longitude: 9.1726, - }, - { - name: 'Rheinsteig-Promenade', - slug: 'rheinsteig-promenade', - category: 'park', - description: - 'Landschaftlich reizvoller Uferweg entlang des Seerheins von der Altstadt bis Petershausen. Perfekt für Spaziergänge und Radtouren.', - address: 'Rheinsteig, 78462 Konstanz', - latitude: 47.6641, - longitude: 9.1753, - }, - - // === BEACHES === - { - name: 'Strandbad Horn', - slug: 'strandbad-horn', - category: 'beach', - description: - 'Eines der größten Freibäder am Bodensee mit großer Liegewiese, Sandstrand, Sprungturm und Beachvolleyball. Traumhafter Seeblick.', - address: 'Eichhornstraße 100, 78464 Konstanz', - latitude: 47.6527, - longitude: 9.201, - }, - { - name: 'Freibad Hörnle', - slug: 'freibad-hoernle', - category: 'beach', - description: - 'Beliebtes Strandbad an der Spitze der Halbinsel Horn mit flachem Einstieg, ideal für Familien. Kiosk und Liegewiesen vorhanden.', - address: 'Hörnleweg, 78464 Konstanz', - latitude: 47.6487, - longitude: 9.207, - }, - { - name: 'Rheinstrandbad', - slug: 'rheinstrandbad', - category: 'beach', - description: - 'Freibad am Seerhein mit beheiztem Becken und Flusszugang. Seit den 1930er-Jahren ein Konstanzer Klassiker.', - address: 'Schlosserstraße 18, 78467 Konstanz', - latitude: 47.671, - longitude: 9.1661, - }, - { - name: 'Freibad Jakob', - slug: 'freibad-jakob', - category: 'beach', - description: - 'Familiäres Freibad im Stadtteil Petershausen mit Bodenseezugang, Nichtschwimmerbecken und großer Liegewiese.', - address: 'Jakobstraße 153, 78467 Konstanz', - latitude: 47.6723, - longitude: 9.1592, - }, - { - name: 'Schmugglerbucht', - slug: 'schmugglerbucht', - category: 'beach', - description: - 'Kleine, versteckte Badestelle unterhalb der Seestraße. Bei Einheimischen beliebt als Geheimtipp zum Schwimmen im Bodensee.', - address: 'Seestraße, 78464 Konstanz', - latitude: 47.6561, - longitude: 9.186, - }, - - // === HOTELS === - { - name: 'Steigenberger Inselhotel', - slug: 'steigenberger-inselhotel', - category: 'hotel', - description: - 'Luxushotel in einem ehemaligen Dominikanerkloster auf einer Insel im Bodensee. Eines der historischsten Hotels Deutschlands.', - address: 'Auf der Insel 1, 78462 Konstanz', - latitude: 47.6598, - longitude: 9.181, - timeline: [ - { year: '1235', event: 'Gründung als Dominikanerkloster' }, - { year: '1875', event: 'Umbau zum Hotel' }, - ], - }, - { - name: 'Hotel Riva', - slug: 'hotel-riva', - category: 'hotel', - description: - 'Modernes Designhotel direkt am Bodenseeufer. Beherbergt das Sternerestaurant Ophelia und bietet einen eigenen Spa-Bereich.', - address: 'Seestraße 25, 78464 Konstanz', - latitude: 47.6589, - longitude: 9.1795, - }, - { - name: 'Hotel Halm', - slug: 'hotel-halm', - category: 'hotel', - description: - 'Traditionsreiches Vier-Sterne-Hotel am Bahnhof mit eleganten Zimmern, Restaurant und zentraler Lage für Stadterkundungen.', - address: 'Bahnhofplatz 6, 78462 Konstanz', - latitude: 47.6586, - longitude: 9.1717, - }, - { - name: 'Hotel Barbarossa', - slug: 'hotel-barbarossa', - category: 'hotel', - description: - 'Historisches Boutique-Hotel am Obermarkt mitten in der Altstadt. Individuell gestaltete Zimmer in einem Gebäude aus dem 15. Jahrhundert.', - address: 'Obermarkt 8-12, 78462 Konstanz', - latitude: 47.6621, - longitude: 9.1746, - timeline: [{ year: '1419', event: 'Erstmalige urkundliche Erwähnung' }], - }, - { - name: 'Hotel Viva Sky', - slug: 'hotel-viva-sky', - category: 'hotel', - description: - 'Modernes Hotel nahe der Altstadt mit Rooftop-Bar und Blick über die Dächer von Konstanz bis zum Bodensee.', - address: 'Sigismundstraße 19, 78462 Konstanz', - latitude: 47.6597, - longitude: 9.173, - }, - - // === EVENT VENUES === - { - name: 'Konzil Konstanz', - slug: 'konzil-konstanz', - category: 'event_venue', - description: - 'Historisches Konzilgebäude am Hafen, in dem 1417 das Konklave zur Papstwahl stattfand. Heute Veranstaltungshalle und Restaurant.', - address: 'Hafenstraße 2, 78462 Konstanz', - latitude: 47.6596, - longitude: 9.178, - timeline: [ - { year: '1388', event: 'Erbaut als Kaufhaus' }, - { year: '1414-1418', event: 'Tagungsort des Konzils' }, - ], - }, - { - name: 'Stadttheater Konstanz', - slug: 'stadttheater-konstanz', - category: 'event_venue', - description: - 'Das Theater Konstanz ist eines der ältesten aktiven Theater Deutschlands. Schauspiel, Musiktheater und Junges Theater auf mehreren Bühnen.', - address: 'Konzilstraße 11, 78462 Konstanz', - latitude: 47.6593, - longitude: 9.177, - }, - { - name: 'Bodenseeforum', - slug: 'bodenseeforum', - category: 'event_venue', - description: - 'Modernes Kongress- und Veranstaltungszentrum direkt am Seerhein. Konferenzen, Messen, Konzerte und kulturelle Events.', - address: 'Reichenaustraße 21, 78467 Konstanz', - latitude: 47.6652, - longitude: 9.172, - }, - { - name: 'Spiegelhalle', - slug: 'spiegelhalle', - category: 'event_venue', - description: - 'Spielstätte des Stadttheaters für experimentelles Theater, Lesungen und Kleinkunst. Intimere Atmosphäre als das Haupthaus.', - address: 'Sigismundstraße 11, 78462 Konstanz', - latitude: 47.66, - longitude: 9.1735, - }, - { - name: 'Kulturzentrum am Münster', - slug: 'kulturzentrum-am-muenster', - category: 'event_venue', - description: - 'Kulturelles Veranstaltungszentrum neben dem Münster mit wechselnden Ausstellungen, Vorträgen und Kulturprogramm.', - address: 'Wessenbergstraße 43, 78462 Konstanz', - latitude: 47.661, - longitude: 9.1755, - }, - - // === VIEWPOINTS === - { - name: 'Münsterturm-Aussichtsplattform', - slug: 'muensterturm-aussicht', - category: 'viewpoint', - description: - 'Nach 193 Stufen erreicht man die Aussichtsplattform des Münsterturms mit 360°-Panorama über Konstanz, den Bodensee und bei klarer Sicht bis zu den Alpen.', - address: 'Münsterplatz 1, 78462 Konstanz', - latitude: 47.6603, - longitude: 9.1757, - }, - { - name: 'Bismarckturm', - slug: 'bismarckturm', - category: 'viewpoint', - description: - 'Historischer Aussichtsturm auf einer Anhöhe im Konstanzer Stadtteil Litzelstetten. Weiter Blick über den Überlinger See und die Insel Mainau.', - address: 'Bismarckturm, 78465 Konstanz-Litzelstetten', - latitude: 47.693, - longitude: 9.2052, - timeline: [{ year: '1914', event: 'Einweihung des Turms' }], - }, - { - name: 'Seerheinsteg', - slug: 'seerheinsteg', - category: 'viewpoint', - description: - 'Fußgängerbrücke über den Seerhein mit freiem Blick auf die Altstadt, das Münster und den Rheinabfluss aus dem Bodensee.', - address: 'Seerheinsteg, 78462 Konstanz', - latitude: 47.6638, - longitude: 9.1748, - }, - { - name: 'Lorettowald Aussichtspunkt', - slug: 'lorettowald-aussichtspunkt', - category: 'viewpoint', - description: - 'Aussichtspunkt im Lorettowald über den Baumkronen. Blick auf den westlichen Bodensee, die Schweizer Alpen und die Altstadt.', - address: 'Lorettostraße, 78464 Konstanz', - latitude: 47.6518, - longitude: 9.1755, - }, - { - name: 'Hörnle-Spitze', - slug: 'hoernle-spitze', - category: 'viewpoint', - description: - 'Äußerste Spitze der Halbinsel Horn mit unverbautem 180°-Panorama über den Bodensee. Besonders beeindruckend bei Sonnenuntergang.', - address: 'Hörnleweg, 78464 Konstanz', - latitude: 47.648, - longitude: 9.2085, - }, - ]); - - console.log('Seeded 41 locations.'); - await connection.end(); -} - -seed().catch((err) => { - console.error('Seed failed:', err); - process.exit(1); -}); diff --git a/apps/citycorners/apps/backend/src/favorite/favorite.controller.ts b/apps/citycorners/apps/backend/src/favorite/favorite.controller.ts deleted file mode 100644 index 44f92c26d..000000000 --- a/apps/citycorners/apps/backend/src/favorite/favorite.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Get, Post, Delete, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { FavoriteService } from './favorite.service'; - -@Controller('favorites') -@UseGuards(JwtAuthGuard) -export class FavoriteController { - constructor(private readonly favoriteService: FavoriteService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const favorites = await this.favoriteService.findByUserId(user.userId); - return { favorites }; - } - - @Post(':locationId') - async add(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) { - const favorite = await this.favoriteService.add(user.userId, locationId); - return { favorite }; - } - - @Delete(':locationId') - async remove(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) { - await this.favoriteService.remove(user.userId, locationId); - return { success: true }; - } -} diff --git a/apps/citycorners/apps/backend/src/favorite/favorite.module.ts b/apps/citycorners/apps/backend/src/favorite/favorite.module.ts deleted file mode 100644 index 8c07b7591..000000000 --- a/apps/citycorners/apps/backend/src/favorite/favorite.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { FavoriteController } from './favorite.controller'; -import { FavoriteService } from './favorite.service'; - -@Module({ - controllers: [FavoriteController], - providers: [FavoriteService], - exports: [FavoriteService], -}) -export class FavoriteModule {} diff --git a/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts b/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts deleted file mode 100644 index f513b6072..000000000 --- a/apps/citycorners/apps/backend/src/favorite/favorite.service.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConflictException } from '@nestjs/common'; -import { FavoriteService } from './favorite.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { createMockDb, createMockFavorite, TEST_USER_ID } from '../__tests__/mock-factories'; - -describe('FavoriteService', () => { - let service: FavoriteService; - let mockDb: ReturnType; - - beforeEach(async () => { - mockDb = createMockDb(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [FavoriteService, { provide: DATABASE_CONNECTION, useValue: mockDb }], - }).compile(); - - service = module.get(FavoriteService); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('findByUserId', () => { - it('should return user favorites', async () => { - const favorites = [ - createMockFavorite(), - createMockFavorite({ id: 'fav-2', locationId: 'loc-2' }), - ]; - mockDb.where.mockResolvedValue(favorites); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual(favorites); - expect(result).toHaveLength(2); - }); - - it('should return empty array if no favorites', async () => { - mockDb.where.mockResolvedValue([]); - - const result = await service.findByUserId(TEST_USER_ID); - - expect(result).toEqual([]); - }); - }); - - describe('add', () => { - it('should add a location to favorites', async () => { - const favorite = createMockFavorite(); - // First call: check existence -> empty - mockDb.where.mockResolvedValueOnce([]); - // Second call: insert + returning - mockDb.returning.mockResolvedValue([favorite]); - - const result = await service.add(TEST_USER_ID, 'loc-1'); - - expect(result).toEqual(favorite); - }); - - it('should throw ConflictException if already favorited', async () => { - mockDb.where.mockResolvedValue([createMockFavorite()]); - - await expect(service.add(TEST_USER_ID, 'loc-1')).rejects.toThrow(ConflictException); - }); - }); - - describe('remove', () => { - it('should remove a favorite', async () => { - mockDb.where.mockResolvedValue(undefined); - - await expect(service.remove(TEST_USER_ID, 'loc-1')).resolves.not.toThrow(); - expect(mockDb.delete).toHaveBeenCalled(); - }); - }); - - describe('isFavorite', () => { - it('should return true if favorited', async () => { - mockDb.where.mockResolvedValue([createMockFavorite()]); - - const result = await service.isFavorite(TEST_USER_ID, 'loc-1'); - - expect(result).toBe(true); - }); - - it('should return false if not favorited', async () => { - mockDb.where.mockResolvedValue([]); - - const result = await service.isFavorite(TEST_USER_ID, 'loc-2'); - - expect(result).toBe(false); - }); - }); -}); diff --git a/apps/citycorners/apps/backend/src/favorite/favorite.service.ts b/apps/citycorners/apps/backend/src/favorite/favorite.service.ts deleted file mode 100644 index e19fb031d..000000000 --- a/apps/citycorners/apps/backend/src/favorite/favorite.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable, Inject, ConflictException } from '@nestjs/common'; -import { eq, and } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { favorites } from '../db/schema'; -import type { Favorite } from '../db/schema'; - -@Injectable() -export class FavoriteService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findByUserId(userId: string): Promise { - return this.db.select().from(favorites).where(eq(favorites.userId, userId)); - } - - async add(userId: string, locationId: string): Promise { - const existing = await this.db - .select() - .from(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId))); - - if (existing.length > 0) { - throw new ConflictException('Location already in favorites'); - } - - const [favorite] = await this.db.insert(favorites).values({ userId, locationId }).returning(); - return favorite; - } - - async remove(userId: string, locationId: string): Promise { - await this.db - .delete(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId))); - } - - async isFavorite(userId: string, locationId: string): Promise { - const result = await this.db - .select() - .from(favorites) - .where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId))); - return result.length > 0; - } -} diff --git a/apps/citycorners/apps/backend/src/guards/rate-limit.guard.ts b/apps/citycorners/apps/backend/src/guards/rate-limit.guard.ts deleted file mode 100644 index 40f37a316..000000000 --- a/apps/citycorners/apps/backend/src/guards/rate-limit.guard.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - HttpException, - HttpStatus, -} from '@nestjs/common'; - -interface RequestRecord { - count: number; - resetAt: number; -} - -@Injectable() -export class RateLimitGuard implements CanActivate { - private readonly requests = new Map(); - private readonly maxRequests = 10; - private readonly windowMs = 60_000; // 1 minute - private cleanupInterval: ReturnType; - - constructor() { - // Clean up old entries every 5 minutes - this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60_000); - this.cleanupInterval.unref(); - } - - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const ip = - request.headers['x-forwarded-for']?.split(',')[0]?.trim() || - request.ip || - request.connection?.remoteAddress || - 'unknown'; - - const now = Date.now(); - const record = this.requests.get(ip); - - if (!record || now > record.resetAt) { - this.requests.set(ip, { count: 1, resetAt: now + this.windowMs }); - return true; - } - - record.count++; - - if (record.count > this.maxRequests) { - const retryAfter = Math.ceil((record.resetAt - now) / 1000); - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - message: 'Too many requests. Please try again later.', - retryAfter, - }, - HttpStatus.TOO_MANY_REQUESTS - ); - } - - return true; - } - - private cleanup() { - const now = Date.now(); - for (const [ip, record] of this.requests) { - if (now > record.resetAt) { - this.requests.delete(ip); - } - } - } -} diff --git a/apps/citycorners/apps/backend/src/instrument.ts b/apps/citycorners/apps/backend/src/instrument.ts deleted file mode 100644 index 90cb7a61a..000000000 --- a/apps/citycorners/apps/backend/src/instrument.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { initErrorTracking } from '@manacore/shared-error-tracking'; - -initErrorTracking({ - serviceName: 'citycorners-backend', - environment: process.env.NODE_ENV, - release: process.env.APP_VERSION, - debug: process.env.NODE_ENV === 'development', -}); diff --git a/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts b/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts deleted file mode 100644 index 74d640e75..000000000 --- a/apps/citycorners/apps/backend/src/location/location-lookup.service.spec.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { LocationLookupService } from './location-lookup.service'; - -// Mock global fetch -const mockFetch = jest.fn(); -global.fetch = mockFetch as any; - -describe('LocationLookupService', () => { - let service: LocationLookupService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - LocationLookupService, - { - provide: ConfigService, - useValue: { - get: jest.fn().mockReturnValue('http://localhost:3021'), - }, - }, - ], - }).compile(); - - service = module.get(LocationLookupService); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('lookup', () => { - it('should return location data from search results', async () => { - // Mock search response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [ - { - url: 'https://example.com/muenster', - title: 'Konstanzer Münster', - snippet: 'Das Münster ist eine historische Basilika in Konstanz am Bodensee.', - engine: 'google', - score: 1, - }, - { - url: 'https://example.com/muenster2', - title: 'Münster Konstanz - Wikipedia', - snippet: 'Die Basilika befindet sich in der Münsterplatz 1, 78462 Konstanz.', - engine: 'bing', - score: 0.9, - }, - ], - }), - }); - - // Mock bulk extract response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [ - { - success: true, - content: { - text: 'Das Konstanzer Münster ist eine imposante Basilika. Die Adresse ist Münsterplatz 1, 78462 Konstanz. Es wurde im Jahr 615 gegründet.', - }, - }, - ], - }), - }); - - const result = await service.lookup('Konstanzer Münster'); - - expect(result).not.toBeNull(); - expect(result!.name).toBe('Konstanzer Münster'); - expect(result!.description.length).toBeGreaterThan(0); - expect(result!.sources).toHaveLength(2); - expect(result!.category).toBe('sight'); - }); - - it('should detect restaurant category', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [ - { - url: 'https://example.com', - title: 'Restaurant Test', - snippet: 'Ein wunderbares Restaurant mit feiner Küche und exzellentem Essen.', - engine: 'google', - score: 1, - }, - ], - }), - }); - - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ results: [] }), - }); - - const result = await service.lookup('Restaurant Ophelia'); - - expect(result).not.toBeNull(); - expect(result!.category).toBe('restaurant'); - }); - - it('should return null on empty search results', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ results: [] }), - }); - - const result = await service.lookup('xyznonexistent'); - - expect(result).toBeNull(); - }); - - it('should return null on search API failure', async () => { - mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); - - const result = await service.lookup('Test'); - - expect(result).toBeNull(); - }); - - it('should handle extract failure gracefully', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - results: [ - { - url: 'https://example.com', - title: 'Test Place', - snippet: 'A nice place in Konstanz with great atmosphere.', - engine: 'google', - score: 1, - }, - ], - }), - }); - - // Extract fails - mockFetch.mockRejectedValueOnce(new Error('Timeout')); - - const result = await service.lookup('Test Place'); - - // Should still return result using search snippets - expect(result).not.toBeNull(); - expect(result!.description.length).toBeGreaterThan(0); - }); - }); -}); diff --git a/apps/citycorners/apps/backend/src/location/location-lookup.service.ts b/apps/citycorners/apps/backend/src/location/location-lookup.service.ts deleted file mode 100644 index be589a9a4..000000000 --- a/apps/citycorners/apps/backend/src/location/location-lookup.service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -export interface LookupResult { - name: string; - description: string; - address?: string; - category?: string; - imageUrl?: string; - sources: { url: string; title: string }[]; -} - -@Injectable() -export class LocationLookupService { - private readonly logger = new Logger(LocationLookupService.name); - private readonly searchUrl: string; - - constructor(private readonly configService: ConfigService) { - this.searchUrl = this.configService.get('MANA_SEARCH_URL') || 'http://localhost:3021'; - } - - async lookup(query: string): Promise { - const searchQuery = `${query} Konstanz`; - - try { - // Search for the location - const searchRes = await fetch(`${this.searchUrl}/api/v1/search`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: searchQuery, - options: { categories: ['general'], language: 'de-DE', limit: 5 }, - }), - signal: AbortSignal.timeout(15000), - }); - - if (!searchRes.ok) { - this.logger.warn(`Search failed: ${searchRes.status}`); - return null; - } - - const searchData = await searchRes.json(); - const results = searchData.results || []; - - if (results.length === 0) return null; - - // Extract content from top 3 results - const topUrls = results.slice(0, 3).map((r: any) => r.url); - let extractedTexts: string[] = []; - - try { - const extractRes = await fetch(`${this.searchUrl}/api/v1/extract/bulk`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - urls: topUrls, - options: { includeMarkdown: false, maxLength: 5000 }, - concurrency: 3, - }), - signal: AbortSignal.timeout(20000), - }); - - if (extractRes.ok) { - const extractData = await extractRes.json(); - extractedTexts = (extractData.results || []) - .filter((r: any) => r.success && r.content?.text) - .map((r: any) => r.content.text.substring(0, 2000)); - } - } catch (err) { - this.logger.warn('Bulk extract failed, using search snippets', err); - } - - // Combine search snippets + extracted text for the description - const snippets = results.map((r: any) => r.snippet).filter(Boolean); - const allText = [...extractedTexts, ...snippets].join('\n\n'); - - // Try to detect address from text - const address = this.extractAddress(allText); - - // Try to guess category - const category = this.guessCategory(query, allText); - - // Build a description from the best snippet or extracted text - const description = this.buildDescription(snippets, extractedTexts); - - // Try to find an image URL from search results - const imageUrl = this.extractImageUrl(results); - - return { - name: query, - description, - address, - category, - imageUrl, - sources: results.slice(0, 5).map((r: any) => ({ - url: r.url, - title: r.title, - })), - }; - } catch (err) { - this.logger.error('Lookup failed', err); - return null; - } - } - - private extractAddress(text: string): string | undefined { - // Look for German address patterns (street + number + PLZ + city) - const addressPattern = - /(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w]*,?\s*\d{5}\s+\w+)/i; - const match = text.match(addressPattern); - if (match) return match[1]; - - // Simpler: just street + number in Konstanz - const simplePattern = - /(\b[A-ZÄÖÜ][a-zäöüß]+(?:straße|gasse|weg|platz|allee|ring)\s+\d+[\w-]*)/i; - const simpleMatch = text.match(simplePattern); - if (simpleMatch) return `${simpleMatch[1]}, 78462 Konstanz`; - - return undefined; - } - - private guessCategory(query: string, text: string): string { - const lowerQuery = query.toLowerCase(); - const lowerText = text.toLowerCase(); - const combined = lowerQuery + ' ' + lowerText.substring(0, 500); - - if (/café|cafe|kaffee|coffee|konditorei|bäckerei|bakery/i.test(combined)) { - return 'cafe'; - } - if (/\bbar\b|kneipe|pub|cocktail|lounge|nachtleben|nightlife/i.test(combined)) { - return 'bar'; - } - if (/restaurant|essen|küche|dining|speise|bistro|gasth/i.test(combined)) { - return 'restaurant'; - } - if (/\bhotel\b|pension|gasthof|unterkunft|übernacht|hostel/i.test(combined)) { - return 'hotel'; - } - if (/strandbad|strand|freibad|beach|badestelle|schwimmbad/i.test(combined)) { - return 'beach'; - } - if (/\bpark\b|grünanlage|garten|wald|naturschutz/i.test(combined)) { - return 'park'; - } - if (/aussichtspunkt|viewpoint|panorama|aussicht|turm.*blick/i.test(combined)) { - return 'viewpoint'; - } - if (/konzert|theater|bühne|veranstaltung|event|halle|forum|kulturzentrum/i.test(combined)) { - return 'event_venue'; - } - if (/museum|ausstellung|galerie|sammlung/i.test(combined)) { - return 'museum'; - } - if (/laden|shop|geschäft|boutique|markt|einkauf|shopping/i.test(combined)) { - return 'shop'; - } - return 'sight'; - } - - private extractImageUrl(results: any[]): string | undefined { - for (const result of results) { - // SearXNG results may include img_src or thumbnail - if (result.img_src && this.isValidImageUrl(result.img_src)) { - return result.img_src; - } - if (result.thumbnail && this.isValidImageUrl(result.thumbnail)) { - return result.thumbnail; - } - } - return undefined; - } - - private isValidImageUrl(url: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === 'https:' && /\.(jpg|jpeg|png|webp)/i.test(parsed.pathname); - } catch { - return false; - } - } - - private buildDescription(snippets: string[], extractedTexts: string[]): string { - // Prefer extracted text (more detailed) - if (extractedTexts.length > 0) { - const text = extractedTexts[0]; - // Take first meaningful paragraph (at least 50 chars) - const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 50); - if (paragraphs.length > 0) { - const desc = paragraphs[0].trim(); - return desc.length > 300 ? desc.substring(0, 297) + '...' : desc; - } - } - - // Fall back to search snippets - if (snippets.length > 0) { - const combined = snippets.slice(0, 2).join(' '); - return combined.length > 300 ? combined.substring(0, 297) + '...' : combined; - } - - return ''; - } -} diff --git a/apps/citycorners/apps/backend/src/location/location.controller.spec.ts b/apps/citycorners/apps/backend/src/location/location.controller.spec.ts deleted file mode 100644 index 3c67edbaf..000000000 --- a/apps/citycorners/apps/backend/src/location/location.controller.spec.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { LocationController } from './location.controller'; -import { createMockLocation, TEST_USER_ID, TEST_USER_EMAIL } from '../__tests__/mock-factories'; - -const mockUser = { userId: TEST_USER_ID, email: TEST_USER_EMAIL } as any; - -describe('LocationController', () => { - let controller: LocationController; - let locationService: any; - let lookupService: any; - let reviewService: any; - - beforeEach(() => { - locationService = { - findAll: jest.fn(), - findById: jest.fn(), - findBySlug: jest.fn(), - findByIdOrSlug: jest.fn(), - search: jest.fn(), - create: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - restore: jest.fn(), - }; - lookupService = { - lookup: jest.fn(), - }; - reviewService = { - getStats: jest.fn().mockResolvedValue({ averageRating: 0, totalReviews: 0 }), - getStatsForLocations: jest.fn().mockResolvedValue({}), - }; - controller = new LocationController(locationService, lookupService, reviewService); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('findAll', () => { - it('should return paginated locations', async () => { - const locations = [createMockLocation(), createMockLocation({ id: 'loc-2' })]; - locationService.findAll.mockResolvedValue({ - items: locations, - total: 2, - page: 1, - limit: 20, - totalPages: 1, - }); - - const result = await controller.findAll(); - - expect(result.locations).toEqual(locations); - expect(result.pagination).toEqual({ total: 2, page: 1, limit: 20, totalPages: 1 }); - }); - - it('should pass category and pagination params', async () => { - locationService.findAll.mockResolvedValue({ - items: [], - total: 0, - page: 2, - limit: 10, - totalPages: 0, - }); - - await controller.findAll('museum', '2', '10'); - - expect(locationService.findAll).toHaveBeenCalledWith('museum', 2, 10); - }); - }); - - describe('findById', () => { - it('should use findByIdOrSlug', async () => { - const location = createMockLocation(); - locationService.findByIdOrSlug.mockResolvedValue(location); - - const result = await controller.findById('konstanzer-muenster'); - - expect(locationService.findByIdOrSlug).toHaveBeenCalledWith('konstanzer-muenster'); - expect(result).toEqual({ location }); - }); - }); - - describe('search', () => { - it('should search locations', async () => { - const locations = [createMockLocation()]; - locationService.search.mockResolvedValue(locations); - - const result = await controller.search('Münster'); - - expect(result).toEqual({ locations }); - }); - - it('should return empty for empty query', async () => { - const result = await controller.search(''); - - expect(result).toEqual({ locations: [] }); - }); - }); - - describe('lookup', () => { - it('should return lookup result', async () => { - const lookupResult = { - name: 'Konzil', - description: 'Historic building', - category: 'sight', - sources: [{ url: 'https://example.com', title: 'Konzil' }], - }; - lookupService.lookup.mockResolvedValue(lookupResult); - - const result = await controller.lookup('Konzil'); - - expect(result).toEqual({ result: lookupResult }); - }); - - it('should return null for empty query', async () => { - const result = await controller.lookup(''); - - expect(result).toEqual({ result: null }); - }); - }); - - describe('create', () => { - it('should create a location with createdBy', async () => { - const location = createMockLocation({ id: 'new-loc', createdBy: TEST_USER_ID }); - locationService.create.mockResolvedValue(location); - - const result = await controller.create(mockUser, { - name: 'Test', - category: 'sight' as const, - description: 'A test location', - }); - - expect(result).toEqual({ location }); - expect(locationService.create).toHaveBeenCalledWith({ - name: 'Test', - category: 'sight', - description: 'A test location', - createdBy: TEST_USER_ID, - }); - }); - }); - - describe('update', () => { - it('should pass userId to service', async () => { - const location = createMockLocation({ name: 'Updated' }); - locationService.update.mockResolvedValue(location); - - await controller.update(mockUser, 'loc-1', { name: 'Updated' }); - - expect(locationService.update).toHaveBeenCalledWith( - 'loc-1', - { name: 'Updated' }, - TEST_USER_ID - ); - }); - }); - - describe('delete', () => { - it('should pass userId to service', async () => { - locationService.delete.mockResolvedValue(undefined); - - const result = await controller.delete(mockUser, 'loc-1'); - - expect(result).toEqual({ success: true }); - expect(locationService.delete).toHaveBeenCalledWith('loc-1', TEST_USER_ID); - }); - }); - - describe('restore', () => { - it('should restore a soft-deleted location', async () => { - const location = createMockLocation(); - locationService.restore.mockResolvedValue(location); - - const result = await controller.restore(mockUser, 'loc-1'); - - expect(result).toEqual({ location }); - expect(locationService.restore).toHaveBeenCalledWith('loc-1', TEST_USER_ID); - }); - }); -}); diff --git a/apps/citycorners/apps/backend/src/location/location.controller.ts b/apps/citycorners/apps/backend/src/location/location.controller.ts deleted file mode 100644 index 086b62e2c..000000000 --- a/apps/citycorners/apps/backend/src/location/location.controller.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { LocationService } from './location.service'; -import { LocationLookupService } from './location-lookup.service'; -import { ReviewService } from '../review/review.service'; -import { RateLimitGuard } from '../guards/rate-limit.guard'; -import { IsString, IsNotEmpty, IsOptional, IsNumber, IsObject } from 'class-validator'; -import { Type } from 'class-transformer'; -import type { OpeningHours } from '../db/schema/locations.schema'; - -class CreateLocationDto { - @IsString() - @IsNotEmpty() - name!: string; - - @IsString() - @IsNotEmpty() - category!: - | 'sight' - | 'restaurant' - | 'shop' - | 'museum' - | 'cafe' - | 'bar' - | 'park' - | 'beach' - | 'hotel' - | 'event_venue' - | 'viewpoint'; - - @IsString() - @IsNotEmpty() - description!: string; - - @IsString() - @IsOptional() - address?: string; - - @IsNumber() - @IsOptional() - @Type(() => Number) - latitude?: number; - - @IsNumber() - @IsOptional() - @Type(() => Number) - longitude?: number; - - @IsString() - @IsOptional() - imageUrl?: string; - - @IsString() - @IsOptional() - website?: string; - - @IsString() - @IsOptional() - phone?: string; - - @IsObject() - @IsOptional() - openingHours?: OpeningHours; -} - -class UpdateLocationDto { - @IsString() - @IsOptional() - name?: string; - - @IsString() - @IsOptional() - category?: - | 'sight' - | 'restaurant' - | 'shop' - | 'museum' - | 'cafe' - | 'bar' - | 'park' - | 'beach' - | 'hotel' - | 'event_venue' - | 'viewpoint'; - - @IsString() - @IsOptional() - description?: string; - - @IsString() - @IsOptional() - address?: string; - - @IsNumber() - @IsOptional() - @Type(() => Number) - latitude?: number; - - @IsNumber() - @IsOptional() - @Type(() => Number) - longitude?: number; - - @IsString() - @IsOptional() - imageUrl?: string; - - @IsString() - @IsOptional() - website?: string; - - @IsString() - @IsOptional() - phone?: string; - - @IsObject() - @IsOptional() - openingHours?: OpeningHours; -} - -@Controller('locations') -export class LocationController { - constructor( - private readonly locationService: LocationService, - private readonly lookupService: LocationLookupService, - private readonly reviewService: ReviewService - ) {} - - @Get() - async findAll( - @Query('category') category?: string, - @Query('page') page?: string, - @Query('limit') limit?: string - ) { - const pageNum = page ? Math.max(1, parseInt(page, 10)) : 1; - const limitNum = limit ? Math.min(100, Math.max(1, parseInt(limit, 10))) : 20; - - const result = await this.locationService.findAll(category, pageNum, limitNum); - const locationIds = result.items.map((l) => l.id); - const reviewStats = await this.reviewService.getStatsForLocations(locationIds); - - return { - locations: result.items.map((l) => ({ - ...l, - reviewStats: reviewStats[l.id] || { averageRating: 0, totalReviews: 0 }, - })), - pagination: { - total: result.total, - page: result.page, - limit: result.limit, - totalPages: result.totalPages, - }, - }; - } - - @Get('lookup') - async lookup(@Query('q') query: string) { - if (!query || query.trim().length === 0) { - return { result: null }; - } - const result = await this.lookupService.lookup(query.trim()); - return { result }; - } - - @Get('search') - async search(@Query('q') query: string) { - if (!query || query.trim().length === 0) { - return { locations: [] }; - } - const locations = await this.locationService.search(query.trim()); - return { locations }; - } - - @Get('suggestions') - async suggestions(@Query('q') query: string) { - if (!query || query.trim().length === 0) { - return { suggestions: [] }; - } - const suggestions = await this.locationService.searchSuggestions(query.trim()); - return { suggestions }; - } - - @Get(':id') - async findById(@Param('id') id: string) { - const location = await this.locationService.findByIdOrSlug(id); - const reviewStats = await this.reviewService.getStats(location.id); - return { location: { ...location, reviewStats } }; - } - - @Get(':id/nearby') - async findNearby(@Param('id') id: string, @Query('radius') radius?: string) { - const radiusKm = radius ? Math.min(10, Math.max(0.5, parseFloat(radius))) : 2; - const nearby = await this.locationService.findNearby(id, radiusKm); - return { locations: nearby }; - } - - @Post(':id/images') - @UseGuards(JwtAuthGuard) - async addImage( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() body: { imageUrl: string } - ) { - const location = await this.locationService.addImage(id, body.imageUrl, user.userId); - return { location }; - } - - @Post() - @UseGuards(JwtAuthGuard, RateLimitGuard) - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateLocationDto) { - const location = await this.locationService.create({ - ...dto, - createdBy: user.userId, - }); - return { location }; - } - - @Put(':id') - @UseGuards(JwtAuthGuard, RateLimitGuard) - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateLocationDto - ) { - const location = await this.locationService.update(id, dto, user.userId); - return { location }; - } - - @Delete(':id') - @UseGuards(JwtAuthGuard, RateLimitGuard) - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.locationService.delete(id, user.userId); - return { success: true }; - } - - @Post(':id/restore') - @UseGuards(JwtAuthGuard) - async restore(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const location = await this.locationService.restore(id, user.userId); - return { location }; - } -} diff --git a/apps/citycorners/apps/backend/src/location/location.module.ts b/apps/citycorners/apps/backend/src/location/location.module.ts deleted file mode 100644 index 3b68e7bc6..000000000 --- a/apps/citycorners/apps/backend/src/location/location.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LocationController } from './location.controller'; -import { LocationService } from './location.service'; -import { LocationLookupService } from './location-lookup.service'; -import { ReviewModule } from '../review/review.module'; - -@Module({ - imports: [ReviewModule], - controllers: [LocationController], - providers: [LocationService, LocationLookupService], - exports: [LocationService], -}) -export class LocationModule {} diff --git a/apps/citycorners/apps/backend/src/location/location.service.spec.ts b/apps/citycorners/apps/backend/src/location/location.service.spec.ts deleted file mode 100644 index f5a2437d1..000000000 --- a/apps/citycorners/apps/backend/src/location/location.service.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException, ForbiddenException } from '@nestjs/common'; -import { LocationService, generateSlug } from './location.service'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { createMockDb, createMockLocation } from '../__tests__/mock-factories'; - -describe('LocationService', () => { - let service: LocationService; - let mockDb: ReturnType; - - beforeEach(async () => { - mockDb = createMockDb(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [LocationService, { provide: DATABASE_CONNECTION, useValue: mockDb }], - }).compile(); - - service = module.get(LocationService); - }); - - afterEach(() => jest.clearAllMocks()); - - describe('generateSlug', () => { - it('should convert name to slug', () => { - expect(generateSlug('Konstanzer Münster')).toBe('konstanzer-muenster'); - }); - - it('should replace umlauts', () => { - expect(generateSlug('Über den Flüssen')).toBe('ueber-den-fluessen'); - }); - - it('should replace ß with ss', () => { - expect(generateSlug('Große Straße')).toBe('grosse-strasse'); - }); - - it('should deduplicate hyphens', () => { - expect(generateSlug('Name -- with --- hyphens')).toBe('name-with-hyphens'); - }); - - it('should trim leading/trailing hyphens', () => { - expect(generateSlug(' Hello World ')).toBe('hello-world'); - }); - }); - - describe('findAll', () => { - it('should return paginated locations', async () => { - const locations = [ - createMockLocation(), - createMockLocation({ id: 'loc-2', name: 'Imperia' }), - ]; - // Without category: count query calls where() (for notDeleted filter), data calls offset() - mockDb.where.mockResolvedValueOnce([{ count: 2 }]); // count query - mockDb.offset.mockResolvedValue(locations); - - const result = await service.findAll(); - - expect(result.items).toEqual(locations); - expect(result.total).toBe(2); - expect(result.page).toBe(1); - expect(result.limit).toBe(20); - expect(mockDb.select).toHaveBeenCalled(); - }); - - it('should filter by category', async () => { - const museums = [ - createMockLocation({ id: 'loc-3', category: 'museum', name: 'Rosgartenmuseum' }), - ]; - // With category: count calls where(), data calls offset() - mockDb.where.mockResolvedValueOnce([{ count: 1 }]); // count query - mockDb.offset.mockResolvedValue(museums); - - const result = await service.findAll('museum'); - - expect(result.items).toEqual(museums); - expect(result.total).toBe(1); - }); - - it('should respect page and limit', async () => { - mockDb.where.mockResolvedValueOnce([{ count: 50 }]); - mockDb.offset.mockResolvedValue([]); - - const result = await service.findAll(undefined, 3, 10); - - expect(result.page).toBe(3); - expect(result.limit).toBe(10); - expect(result.totalPages).toBe(5); - }); - }); - - describe('findById', () => { - it('should return a location by id', async () => { - const location = createMockLocation(); - mockDb.where.mockResolvedValue([location]); - - const result = await service.findById('loc-1'); - - expect(result).toEqual(location); - }); - - it('should throw NotFoundException if not found', async () => { - mockDb.where.mockResolvedValue([]); - - await expect(service.findById('nonexistent')).rejects.toThrow(NotFoundException); - }); - }); - - describe('findBySlug', () => { - it('should return a location by slug', async () => { - const location = createMockLocation(); - mockDb.where.mockResolvedValue([location]); - - const result = await service.findBySlug('konstanzer-muenster'); - - expect(result).toEqual(location); - }); - - it('should throw NotFoundException if slug not found', async () => { - mockDb.where.mockResolvedValue([]); - - await expect(service.findBySlug('nonexistent-slug')).rejects.toThrow(NotFoundException); - }); - }); - - describe('findByIdOrSlug', () => { - it('should call findById for UUID', async () => { - const location = createMockLocation(); - mockDb.where.mockResolvedValue([location]); - - const result = await service.findByIdOrSlug('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); - - expect(result).toEqual(location); - }); - - it('should call findBySlug for non-UUID', async () => { - const location = createMockLocation(); - mockDb.where.mockResolvedValue([location]); - - const result = await service.findByIdOrSlug('konstanzer-muenster'); - - expect(result).toEqual(location); - }); - }); - - describe('search', () => { - it('should search locations by query', async () => { - const locations = [createMockLocation()]; - mockDb.where.mockResolvedValue(locations); - - const result = await service.search('Münster'); - - expect(result).toEqual(locations); - }); - - it('should return empty array for no matches', async () => { - mockDb.where.mockResolvedValue([]); - - const result = await service.search('nonexistent'); - - expect(result).toEqual([]); - }); - }); - - describe('create', () => { - it('should create a new location with auto-generated slug', async () => { - const newLocation = createMockLocation({ id: 'loc-new' }); - // generateUniqueSlug: check existing slug - mockDb.where.mockResolvedValueOnce([]); // no existing slug - mockDb.returning.mockResolvedValue([newLocation]); - - const result = await service.create({ - name: 'Test Location', - category: 'sight', - description: 'A test location', - }); - - expect(result).toEqual(newLocation); - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); - - describe('update', () => { - it('should update a location owned by user', async () => { - const existing = createMockLocation({ createdBy: 'user-1' }); - mockDb.where.mockResolvedValueOnce([existing]); // findById - const updated = createMockLocation({ name: 'Updated Name', createdBy: 'user-1' }); - mockDb.returning.mockResolvedValue([updated]); - - const result = await service.update('loc-1', { name: 'Updated Name' }, 'user-1'); - - expect(result.name).toBe('Updated Name'); - }); - - it('should throw ForbiddenException if not owner', async () => { - const existing = createMockLocation({ createdBy: 'other-user' }); - mockDb.where.mockResolvedValueOnce([existing]); // findById - - await expect(service.update('loc-1', { name: 'Hacked' }, 'attacker-user')).rejects.toThrow( - ForbiddenException - ); - }); - - it('should allow update of unowned locations', async () => { - const existing = createMockLocation({ createdBy: null as any }); - mockDb.where.mockResolvedValueOnce([existing]); // findById - const updated = createMockLocation({ name: 'Updated' }); - mockDb.returning.mockResolvedValue([updated]); - - const result = await service.update('loc-1', { name: 'Updated' }, 'any-user'); - - expect(result.name).toBe('Updated'); - }); - - it('should throw NotFoundException if not found', async () => { - mockDb.where.mockResolvedValue([]); - - await expect(service.update('nonexistent', { name: 'Test' })).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('delete', () => { - it('should soft delete a location owned by user', async () => { - const existing = createMockLocation({ createdBy: 'user-1' }); - mockDb.where - .mockResolvedValueOnce([existing]) // findById - .mockReturnThis(); // update where - - await expect(service.delete('loc-1', 'user-1')).resolves.not.toThrow(); - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalled(); - }); - - it('should throw ForbiddenException if not owner', async () => { - const existing = createMockLocation({ createdBy: 'other-user' }); - mockDb.where.mockResolvedValueOnce([existing]); // findById - - await expect(service.delete('loc-1', 'attacker-user')).rejects.toThrow(ForbiddenException); - }); - - it('should throw NotFoundException if not found', async () => { - mockDb.where.mockResolvedValue([]); - - await expect(service.delete('nonexistent')).rejects.toThrow(NotFoundException); - }); - }); - - describe('restore', () => { - it('should restore a soft-deleted location', async () => { - const deleted = createMockLocation({ - createdBy: 'user-1', - deletedAt: new Date(), - }); - mockDb.where.mockResolvedValueOnce([deleted]); // find - const restored = createMockLocation({ createdBy: 'user-1', deletedAt: null }); - mockDb.returning.mockResolvedValue([restored]); - - const result = await service.restore('loc-1', 'user-1'); - - expect(result.deletedAt).toBeNull(); - }); - - it('should throw ForbiddenException if not owner', async () => { - const deleted = createMockLocation({ - createdBy: 'other-user', - deletedAt: new Date(), - }); - mockDb.where.mockResolvedValueOnce([deleted]); - - await expect(service.restore('loc-1', 'attacker-user')).rejects.toThrow(ForbiddenException); - }); - - it('should throw NotFoundException if not found', async () => { - mockDb.where.mockResolvedValue([]); - - await expect(service.restore('nonexistent')).rejects.toThrow(NotFoundException); - }); - }); -}); diff --git a/apps/citycorners/apps/backend/src/location/location.service.ts b/apps/citycorners/apps/backend/src/location/location.service.ts deleted file mode 100644 index 570eb3a06..000000000 --- a/apps/citycorners/apps/backend/src/location/location.service.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common'; -import { eq, or, ilike, sql, desc, ne, and, isNotNull, isNull } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { locations } from '../db/schema'; -import type { Location, NewLocation, LocationImage } from '../db/schema'; - -export interface PaginatedResult { - items: T[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -export function generateSlug(name: string): string { - return name - .toLowerCase() - .replace(/ä/g, 'ae') - .replace(/ö/g, 'oe') - .replace(/ü/g, 'ue') - .replace(/ß/g, 'ss') - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); -} - -const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -@Injectable() -export class LocationService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - private notDeleted = isNull(locations.deletedAt); - - async findAll(category?: string, page = 1, limit = 20): Promise> { - const offset = (page - 1) * limit; - - let items: Location[]; - let total: number; - - if (category) { - const countResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(locations) - .where(and(eq(locations.category, category as Location['category']), this.notDeleted)); - total = countResult[0]?.count ?? 0; - - items = await this.db - .select() - .from(locations) - .where(and(eq(locations.category, category as Location['category']), this.notDeleted)) - .orderBy(desc(locations.createdAt)) - .limit(limit) - .offset(offset); - } else { - const countResult = await this.db - .select({ count: sql`count(*)::int` }) - .from(locations) - .where(this.notDeleted); - total = countResult[0]?.count ?? 0; - - items = await this.db - .select() - .from(locations) - .where(this.notDeleted) - .orderBy(desc(locations.createdAt)) - .limit(limit) - .offset(offset); - } - - return { - items, - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }; - } - - async search(query: string): Promise { - const pattern = `%${query}%`; - return this.db - .select() - .from(locations) - .where( - and( - or( - ilike(locations.name, pattern), - ilike(locations.description, pattern), - ilike(locations.address, pattern) - ), - this.notDeleted - ) - ); - } - - async findById(id: string): Promise { - const [location] = await this.db - .select() - .from(locations) - .where(and(eq(locations.id, id), this.notDeleted)); - if (!location) { - throw new NotFoundException(`Location with id ${id} not found`); - } - return location; - } - - async findBySlug(slug: string): Promise { - const [location] = await this.db - .select() - .from(locations) - .where(and(eq(locations.slug, slug), this.notDeleted)); - if (!location) { - throw new NotFoundException(`Location with slug ${slug} not found`); - } - return location; - } - - async findByIdOrSlug(idOrSlug: string): Promise { - if (UUID_PATTERN.test(idOrSlug)) { - return this.findById(idOrSlug); - } - return this.findBySlug(idOrSlug); - } - - async create(data: NewLocation): Promise { - const slug = await this.generateUniqueSlug(data.name); - const [location] = await this.db - .insert(locations) - .values({ ...data, slug }) - .returning(); - return location; - } - - private async generateUniqueSlug(name: string): Promise { - const baseSlug = generateSlug(name); - let slug = baseSlug; - let counter = 1; - - while (true) { - const [existing] = await this.db - .select({ id: locations.id }) - .from(locations) - .where(eq(locations.slug, slug)); - if (!existing) break; - counter++; - slug = `${baseSlug}-${counter}`; - } - - return slug; - } - - async update(id: string, data: Partial, userId?: string): Promise { - const existing = await this.findById(id); - - // If location has an owner, only the owner can edit - if (existing.createdBy && userId && existing.createdBy !== userId) { - throw new ForbiddenException('You can only edit your own locations'); - } - - const [location] = await this.db - .update(locations) - .set(data) - .where(eq(locations.id, id)) - .returning(); - return location; - } - - async findNearby( - id: string, - radiusKm = 2, - limit = 5 - ): Promise<(Location & { distance: number })[]> { - const location = await this.findById(id); - if (!location.latitude || !location.longitude) return []; - - const haversine = sql` - 6371 * acos( - LEAST(1.0, cos(radians(${location.latitude})) * cos(radians(${locations.latitude})) - * cos(radians(${locations.longitude}) - radians(${location.longitude})) - + sin(radians(${location.latitude})) * sin(radians(${locations.latitude}))) - ) - `; - - const results = await this.db - .select({ - location: locations, - distance: haversine, - }) - .from(locations) - .where( - and( - ne(locations.id, id), - isNotNull(locations.latitude), - isNotNull(locations.longitude), - this.notDeleted - ) - ) - .orderBy(haversine) - .limit(limit); - - return results - .filter((r) => r.distance <= radiusKm) - .map((r) => ({ - ...r.location, - distance: Math.round(r.distance * 1000), // meters - })); - } - - async addImage(id: string, imageUrl: string, userId: string): Promise { - const location = await this.findById(id); - const currentImages: LocationImage[] = (location.images as LocationImage[]) || []; - - const newImage: LocationImage = { - url: imageUrl, - addedBy: userId, - addedAt: new Date().toISOString(), - }; - - const [updated] = await this.db - .update(locations) - .set({ images: [...currentImages, newImage] }) - .where(eq(locations.id, id)) - .returning(); - return updated; - } - - async searchSuggestions( - query: string, - limit = 5 - ): Promise<{ id: string; name: string; category: string }[]> { - if (!query.trim()) return []; - const pattern = `${query}%`; - const results = await this.db - .select({ id: locations.id, name: locations.name, category: locations.category }) - .from(locations) - .where(and(ilike(locations.name, pattern), this.notDeleted)) - .limit(limit); - return results; - } - - async delete(id: string, userId?: string): Promise { - const existing = await this.findById(id); - - // If location has an owner, only the owner can delete - if (existing.createdBy && userId && existing.createdBy !== userId) { - throw new ForbiddenException('You can only delete your own locations'); - } - - // Soft delete - await this.db.update(locations).set({ deletedAt: new Date() }).where(eq(locations.id, id)); - } - - async restore(id: string, userId?: string): Promise { - // Find including soft-deleted - const [existing] = await this.db.select().from(locations).where(eq(locations.id, id)); - if (!existing) { - throw new NotFoundException(`Location with id ${id} not found`); - } - - if (existing.createdBy && userId && existing.createdBy !== userId) { - throw new ForbiddenException('You can only restore your own locations'); - } - - const [restored] = await this.db - .update(locations) - .set({ deletedAt: null }) - .where(eq(locations.id, id)) - .returning(); - return restored; - } -} diff --git a/apps/citycorners/apps/backend/src/main.ts b/apps/citycorners/apps/backend/src/main.ts deleted file mode 100644 index c9db03782..000000000 --- a/apps/citycorners/apps/backend/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import './instrument'; -import { bootstrapApp } from '@manacore/shared-nestjs-setup'; -import { AppModule } from './app.module'; - -bootstrapApp(AppModule, { - defaultPort: 3025, - serviceName: 'CityCorners', - additionalCorsOrigins: ['http://localhost:5196'], -}); diff --git a/apps/citycorners/apps/backend/src/review/review.controller.ts b/apps/citycorners/apps/backend/src/review/review.controller.ts deleted file mode 100644 index 242540bed..000000000 --- a/apps/citycorners/apps/backend/src/review/review.controller.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Controller, Get, Post, Delete, Param, Body, UseGuards, Query } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { ReviewService } from './review.service'; -import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; -import { Type } from 'class-transformer'; - -class CreateReviewDto { - @IsInt() - @Min(1) - @Max(5) - @Type(() => Number) - rating!: number; - - @IsString() - @IsOptional() - comment?: string; -} - -@Controller('reviews') -export class ReviewController { - constructor(private readonly reviewService: ReviewService) {} - - @Get(':locationId') - async findByLocation(@Param('locationId') locationId: string) { - const [reviewsList, stats] = await Promise.all([ - this.reviewService.findByLocationId(locationId), - this.reviewService.getStats(locationId), - ]); - return { reviews: reviewsList, stats }; - } - - @Get(':locationId/stats') - async getStats(@Param('locationId') locationId: string) { - const stats = await this.reviewService.getStats(locationId); - return { stats }; - } - - @Post(':locationId') - @UseGuards(JwtAuthGuard) - async create( - @CurrentUser() user: CurrentUserData, - @Param('locationId') locationId: string, - @Body() dto: CreateReviewDto - ) { - const review = await this.reviewService.create( - user.userId, - locationId, - dto.rating, - dto.comment - ); - return { review }; - } - - @Delete(':locationId') - @UseGuards(JwtAuthGuard) - async remove(@CurrentUser() user: CurrentUserData, @Param('locationId') locationId: string) { - await this.reviewService.delete(user.userId, locationId); - return { success: true }; - } -} diff --git a/apps/citycorners/apps/backend/src/review/review.module.ts b/apps/citycorners/apps/backend/src/review/review.module.ts deleted file mode 100644 index 2f8f6fa15..000000000 --- a/apps/citycorners/apps/backend/src/review/review.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ReviewController } from './review.controller'; -import { ReviewService } from './review.service'; - -@Module({ - controllers: [ReviewController], - providers: [ReviewService], - exports: [ReviewService], -}) -export class ReviewModule {} diff --git a/apps/citycorners/apps/backend/src/review/review.service.ts b/apps/citycorners/apps/backend/src/review/review.service.ts deleted file mode 100644 index bf865e009..000000000 --- a/apps/citycorners/apps/backend/src/review/review.service.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Injectable, Inject, ConflictException } from '@nestjs/common'; -import { eq, and, sql, desc } from 'drizzle-orm'; -import { DATABASE_CONNECTION } from '../db/database.module'; -import { Database } from '../db/connection'; -import { reviews } from '../db/schema'; -import type { Review } from '../db/schema'; - -export interface ReviewStats { - averageRating: number; - totalReviews: number; -} - -@Injectable() -export class ReviewService { - constructor(@Inject(DATABASE_CONNECTION) private db: Database) {} - - async findByLocationId(locationId: string): Promise { - return this.db - .select() - .from(reviews) - .where(eq(reviews.locationId, locationId)) - .orderBy(desc(reviews.createdAt)); - } - - async getStats(locationId: string): Promise { - const [result] = await this.db - .select({ - averageRating: sql`coalesce(round(avg(${reviews.rating})::numeric, 1), 0)::float`, - totalReviews: sql`count(*)::int`, - }) - .from(reviews) - .where(eq(reviews.locationId, locationId)); - - return result || { averageRating: 0, totalReviews: 0 }; - } - - async getStatsForLocations(locationIds: string[]): Promise> { - if (locationIds.length === 0) return {}; - - const results = await this.db - .select({ - locationId: reviews.locationId, - averageRating: sql`round(avg(${reviews.rating})::numeric, 1)::float`, - totalReviews: sql`count(*)::int`, - }) - .from(reviews) - .where( - sql`${reviews.locationId} = ANY(${sql.raw(`ARRAY[${locationIds.map((id) => `'${id}'::uuid`).join(',')}]`)})` - ) - .groupBy(reviews.locationId); - - const map: Record = {}; - for (const r of results) { - map[r.locationId] = { averageRating: r.averageRating, totalReviews: r.totalReviews }; - } - return map; - } - - async create( - userId: string, - locationId: string, - rating: number, - comment?: string - ): Promise { - const existing = await this.db - .select() - .from(reviews) - .where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId))); - - if (existing.length > 0) { - throw new ConflictException('You have already reviewed this location'); - } - - const [review] = await this.db - .insert(reviews) - .values({ - userId, - locationId, - rating: Math.min(5, Math.max(1, rating)), - comment: comment || null, - }) - .returning(); - return review; - } - - async delete(userId: string, locationId: string): Promise { - await this.db - .delete(reviews) - .where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId))); - } -} diff --git a/apps/citycorners/apps/backend/tsconfig.json b/apps/citycorners/apps/backend/tsconfig.json deleted file mode 100644 index 27971033a..000000000 --- a/apps/citycorners/apps/backend/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "commonjs", - "moduleResolution": "node", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "baseUrl": "./", - "rootDir": "./src", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/apps/citycorners/apps/web/package.json b/apps/citycorners/apps/web/package.json index 2ee445fed..263d6aa1d 100644 --- a/apps/citycorners/apps/web/package.json +++ b/apps/citycorners/apps/web/package.json @@ -49,6 +49,7 @@ "@manacore/shared-theme": "workspace:*", "@manacore/shared-theme-ui": "workspace:*", "@manacore/shared-ui": "workspace:*", + "@manacore/shared-utils": "workspace:*", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", "svelte-i18n": "^4.0.1" diff --git a/apps/skilltree/apps/backend/Dockerfile b/apps/skilltree/apps/backend/Dockerfile deleted file mode 100644 index 655fce5e0..000000000 --- a/apps/skilltree/apps/backend/Dockerfile +++ /dev/null @@ -1,93 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build stage -FROM node:20-alpine AS builder - -# Install pnpm -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -# Copy root workspace files -COPY pnpm-workspace.yaml ./ -COPY package.json ./ -COPY pnpm-lock.yaml ./ -COPY patches ./patches - -# Copy shared packages (all required dependencies) -COPY packages/shared-drizzle-config ./packages/shared-drizzle-config -COPY packages/shared-errors ./packages/shared-errors -COPY packages/shared-nestjs-auth ./packages/shared-nestjs-auth -COPY packages/shared-nestjs-health ./packages/shared-nestjs-health -COPY packages/shared-nestjs-metrics ./packages/shared-nestjs-metrics -COPY packages/shared-nestjs-setup ./packages/shared-nestjs-setup -COPY packages/shared-error-tracking ./packages/shared-error-tracking -COPY packages/shared-tsconfig ./packages/shared-tsconfig - -# Copy skilltree backend -COPY apps/skilltree/apps/backend ./apps/skilltree/apps/backend - -# Install dependencies (ignore scripts since generate-env.mjs isn't in Docker context) -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --ignore-scripts - -# Build shared packages first (in dependency order) -WORKDIR /app/packages/shared-errors -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-auth -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-health -RUN pnpm build - -WORKDIR /app/packages/shared-nestjs-metrics -RUN pnpm build - -# Build the backend -WORKDIR /app/packages/shared-nestjs-setup -RUN pnpm build - -WORKDIR /app/packages/shared-error-tracking -RUN pnpm build - -WORKDIR /app/apps/skilltree/apps/backend -RUN pnpm build - -# Production stage -FROM node:20-alpine AS production - -# Install pnpm and postgresql-client for health checks -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate \ - && apk add --no-cache postgresql-client - -WORKDIR /app - -# Copy everything from builder (including node_modules) -COPY --from=builder /app/pnpm-workspace.yaml ./ -COPY --from=builder /app/package.json ./ -COPY --from=builder /app/pnpm-lock.yaml ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages ./packages -COPY --from=builder /app/apps/skilltree/apps/backend ./apps/skilltree/apps/backend - -# Copy entrypoint script -COPY apps/skilltree/apps/backend/docker-entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/docker-entrypoint.sh - -WORKDIR /app/packages/shared-nestjs-setup -RUN pnpm build - -WORKDIR /app/packages/shared-error-tracking -RUN pnpm build - -WORKDIR /app/apps/skilltree/apps/backend - -# Expose port -EXPOSE 3024 - -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:3024/health || exit 1 - -# Run entrypoint script -ENTRYPOINT ["docker-entrypoint.sh"] -CMD ["node", "dist/main.js"] diff --git a/apps/skilltree/apps/backend/docker-entrypoint.sh b/apps/skilltree/apps/backend/docker-entrypoint.sh deleted file mode 100644 index 1bee031c8..000000000 --- a/apps/skilltree/apps/backend/docker-entrypoint.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh -set -e - -echo "Starting SkillTree Backend..." - -# Wait for PostgreSQL to be ready -if [ -n "$DATABASE_URL" ]; then - echo "Waiting for PostgreSQL..." - - # Extract host and port from DATABASE_URL - DB_HOST=$(echo $DATABASE_URL | sed -n 's/.*@\([^:]*\):.*/\1/p') - DB_PORT=$(echo $DATABASE_URL | sed -n 's/.*:\([0-9]*\)\/.*/\1/p') - - # Default to postgres:5432 if extraction fails - DB_HOST=${DB_HOST:-postgres} - DB_PORT=${DB_PORT:-5432} - - until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U postgres 2>/dev/null; do - echo "PostgreSQL is unavailable - sleeping" - sleep 2 - done - - echo "PostgreSQL is ready!" - - # Run database migrations/push - echo "Pushing database schema..." - pnpm db:push || echo "Schema push completed (may have no changes)" -fi - -echo "Starting server..." -exec "$@" diff --git a/apps/skilltree/apps/backend/drizzle.config.ts b/apps/skilltree/apps/backend/drizzle.config.ts deleted file mode 100644 index 4ac1688e9..000000000 --- a/apps/skilltree/apps/backend/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import 'dotenv/config'; -import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; - -export default createDrizzleConfig({ - dbName: 'skilltree', - outDir: './drizzle', -}); diff --git a/apps/skilltree/apps/backend/jest.config.js b/apps/skilltree/apps/backend/jest.config.js deleted file mode 100644 index 702581f02..000000000 --- a/apps/skilltree/apps/backend/jest.config.js +++ /dev/null @@ -1,15 +0,0 @@ -/** @type {import('jest').Config} */ -module.exports = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': 'ts-jest', - }, - collectCoverageFrom: ['**/*.(t|j)s', '!**/*.module.ts', '!**/main.ts', '!**/*.dto.ts'], - coverageDirectory: '../coverage', - testEnvironment: 'node', - moduleNameMapper: { - '^src/(.*)$': '/$1', - }, -}; diff --git a/apps/skilltree/apps/backend/nest-cli.json b/apps/skilltree/apps/backend/nest-cli.json deleted file mode 100644 index 95538fb90..000000000 --- a/apps/skilltree/apps/backend/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$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 deleted file mode 100644 index 32f096c26..000000000 --- a/apps/skilltree/apps/backend/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "@skilltree/backend", - "version": "0.2.0", - "private": true, - "description": "SkillTree Backend API", - "scripts": { - "dev": "nest start --watch", - "build": "nest build", - "start": "nest start", - "start:prod": "node dist/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "db:push": "drizzle-kit push", - "db:studio": "drizzle-kit studio", - "db:generate": "drizzle-kit generate" - }, - "dependencies": { - "@manacore/shared-error-tracking": "workspace:*", - "@manacore/shared-nestjs-auth": "workspace:*", - "@manacore/shared-nestjs-health": "workspace:*", - "@manacore/shared-nestjs-metrics": "workspace:*", - "@nestjs/common": "^10.4.9", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.4.9", - "@nestjs/platform-express": "^10.4.9", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.1", - "dotenv": "^16.4.7", - "drizzle-orm": "^0.38.3", - "postgres": "^3.4.5", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1", - "prom-client": "^15.1.0" - }, - "devDependencies": { - "@nestjs/cli": "^10.4.9", - "@nestjs/schematics": "^10.2.3", - "@nestjs/testing": "^11.1.9", - "@types/express": "^5.0.1", - "@types/jest": "^30.0.0", - "@types/node": "^22.15.21", - "drizzle-kit": "^0.30.2", - "jest": "^30.2.0", - "ts-jest": "^29.2.5", - "tsx": "^4.19.4", - "typescript": "^5.9.3" - } -} diff --git a/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts b/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts deleted file mode 100644 index 90d97a268..000000000 --- a/apps/skilltree/apps/backend/src/achievement/achievement-definitions.ts +++ /dev/null @@ -1,307 +0,0 @@ -import type { NewAchievement } from '../db/schema'; - -/** - * All achievement definitions. These are seeded into the DB on startup. - * Conditions are evaluated by the AchievementService after relevant events. - */ -export const ACHIEVEMENT_DEFINITIONS: NewAchievement[] = [ - // === XP Achievements === - { - id: 'xp_100', - name: 'Erste Schritte', - description: 'Sammle 100 XP insgesamt', - icon: 'star', - category: 'xp', - rarity: 'common', - xpReward: 10, - sortOrder: 1, - condition: { type: 'total_xp', threshold: 100 }, - }, - { - id: 'xp_1000', - name: 'Tausender-Club', - description: 'Sammle 1.000 XP insgesamt', - icon: 'star', - category: 'xp', - rarity: 'uncommon', - xpReward: 25, - sortOrder: 2, - condition: { type: 'total_xp', threshold: 1000 }, - }, - { - id: 'xp_5000', - name: 'XP-Sammler', - description: 'Sammle 5.000 XP insgesamt', - icon: 'star', - category: 'xp', - rarity: 'rare', - xpReward: 50, - sortOrder: 3, - condition: { type: 'total_xp', threshold: 5000 }, - }, - { - id: 'xp_10000', - name: 'XP-Legende', - description: 'Sammle 10.000 XP insgesamt', - icon: 'crown', - category: 'xp', - rarity: 'epic', - xpReward: 100, - sortOrder: 4, - condition: { type: 'total_xp', threshold: 10000 }, - }, - { - id: 'xp_50000', - name: 'Grenzenlos', - description: 'Sammle 50.000 XP insgesamt', - icon: 'crown', - category: 'xp', - rarity: 'legendary', - xpReward: 250, - sortOrder: 5, - condition: { type: 'total_xp', threshold: 50000 }, - }, - - // === Skill Achievements === - { - id: 'skills_1', - name: 'Der Anfang', - description: 'Erstelle deinen ersten Skill', - icon: 'plus', - category: 'skills', - rarity: 'common', - xpReward: 10, - sortOrder: 10, - condition: { type: 'total_skills', threshold: 1 }, - }, - { - id: 'skills_5', - name: 'Vielseitig', - description: 'Erstelle 5 Skills', - icon: 'grid', - category: 'skills', - rarity: 'uncommon', - xpReward: 25, - sortOrder: 11, - condition: { type: 'total_skills', threshold: 5 }, - }, - { - id: 'skills_10', - name: 'Skill-Sammler', - description: 'Erstelle 10 Skills', - icon: 'grid', - category: 'skills', - rarity: 'rare', - xpReward: 50, - sortOrder: 12, - condition: { type: 'total_skills', threshold: 10 }, - }, - { - id: 'skills_20', - name: 'Meister aller Klassen', - description: 'Erstelle 20 Skills', - icon: 'grid', - category: 'skills', - rarity: 'epic', - xpReward: 100, - sortOrder: 13, - condition: { type: 'total_skills', threshold: 20 }, - }, - - // === Level Achievements === - { - id: 'level_1', - name: 'Anfänger', - description: 'Erreiche Level 1 mit einem Skill', - icon: 'arrow-up', - category: 'levels', - rarity: 'common', - xpReward: 15, - sortOrder: 20, - condition: { type: 'highest_level', threshold: 1 }, - }, - { - id: 'level_3', - name: 'Kompetent', - description: 'Erreiche Level 3 mit einem Skill', - icon: 'arrow-up', - category: 'levels', - rarity: 'rare', - xpReward: 50, - sortOrder: 21, - condition: { type: 'highest_level', threshold: 3 }, - }, - { - id: 'level_5', - name: 'Meister', - description: 'Erreiche Level 5 mit einem Skill', - icon: 'crown', - category: 'levels', - rarity: 'legendary', - xpReward: 200, - sortOrder: 22, - condition: { type: 'highest_level', threshold: 5 }, - }, - - // === Activity Achievements === - { - id: 'activities_1', - name: 'Erste Aktion', - description: 'Logge deine erste Aktivität', - icon: 'lightning', - category: 'activities', - rarity: 'common', - xpReward: 5, - sortOrder: 30, - condition: { type: 'total_activities', threshold: 1 }, - }, - { - id: 'activities_10', - name: 'Dranbleiber', - description: 'Logge 10 Aktivitäten', - icon: 'lightning', - category: 'activities', - rarity: 'uncommon', - xpReward: 20, - sortOrder: 31, - condition: { type: 'total_activities', threshold: 10 }, - }, - { - id: 'activities_50', - name: 'Fleißig', - description: 'Logge 50 Aktivitäten', - icon: 'lightning', - category: 'activities', - rarity: 'rare', - xpReward: 50, - sortOrder: 32, - condition: { type: 'total_activities', threshold: 50 }, - }, - { - id: 'activities_100', - name: 'Unaufhaltsam', - description: 'Logge 100 Aktivitäten', - icon: 'fire', - category: 'activities', - rarity: 'epic', - xpReward: 100, - sortOrder: 33, - condition: { type: 'total_activities', threshold: 100 }, - }, - { - id: 'activities_500', - name: 'Maschine', - description: 'Logge 500 Aktivitäten', - icon: 'fire', - category: 'activities', - rarity: 'legendary', - xpReward: 250, - sortOrder: 34, - condition: { type: 'total_activities', threshold: 500 }, - }, - - // === Streak Achievements === - { - id: 'streak_3', - name: '3-Tage-Streak', - description: 'Halte einen 3-Tage-Streak', - icon: 'flame', - category: 'streak', - rarity: 'common', - xpReward: 15, - sortOrder: 40, - condition: { type: 'streak_days', threshold: 3 }, - }, - { - id: 'streak_7', - name: 'Wochenkrieger', - description: 'Halte einen 7-Tage-Streak', - icon: 'flame', - category: 'streak', - rarity: 'uncommon', - xpReward: 30, - sortOrder: 41, - condition: { type: 'streak_days', threshold: 7 }, - }, - { - id: 'streak_14', - name: 'Zwei-Wochen-Held', - description: 'Halte einen 14-Tage-Streak', - icon: 'flame', - category: 'streak', - rarity: 'rare', - xpReward: 75, - sortOrder: 42, - condition: { type: 'streak_days', threshold: 14 }, - }, - { - id: 'streak_30', - name: 'Monatsmeister', - description: 'Halte einen 30-Tage-Streak', - icon: 'flame', - category: 'streak', - rarity: 'epic', - xpReward: 150, - sortOrder: 43, - condition: { type: 'streak_days', threshold: 30 }, - }, - { - id: 'streak_100', - name: 'Hundert Tage', - description: 'Halte einen 100-Tage-Streak', - icon: 'flame', - category: 'streak', - rarity: 'legendary', - xpReward: 500, - sortOrder: 44, - condition: { type: 'streak_days', threshold: 100 }, - }, - - // === Branch Achievements === - { - id: 'branches_3', - name: 'Entdecker', - description: 'Habe Skills in 3 verschiedenen Branches', - icon: 'compass', - category: 'branches', - rarity: 'uncommon', - xpReward: 25, - sortOrder: 50, - condition: { type: 'unique_branches', threshold: 3 }, - }, - { - id: 'branches_all', - name: 'Universalgelehrter', - description: 'Habe Skills in allen 6 Branches', - icon: 'compass', - category: 'branches', - rarity: 'epic', - xpReward: 100, - sortOrder: 51, - condition: { type: 'unique_branches', threshold: 6 }, - }, - - // === Special Achievements === - { - id: 'single_xp_100', - name: 'Mammut-Session', - description: 'Verdiene 100+ XP in einer einzelnen Aktivität', - icon: 'zap', - category: 'special', - rarity: 'rare', - xpReward: 25, - sortOrder: 60, - condition: { type: 'single_activity_xp', threshold: 100 }, - }, - { - id: 'all_branches_level_1', - name: 'Allrounder', - description: 'Erreiche Level 1 in allen 6 Branches', - icon: 'shield', - category: 'special', - rarity: 'epic', - xpReward: 150, - sortOrder: 61, - condition: { type: 'all_branches_min_level', threshold: 1 }, - }, -]; diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts b/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts deleted file mode 100644 index 83ef37fa8..000000000 --- a/apps/skilltree/apps/backend/src/achievement/achievement.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { AchievementService } from './achievement.service'; - -@Controller('achievements') -@UseGuards(JwtAuthGuard) -export class AchievementController { - constructor(private readonly achievementService: AchievementService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData) { - const achievements = await this.achievementService.getAllForUser(user.userId); - return { achievements }; - } - - @Get('unlocked') - async findUnlocked(@CurrentUser() user: CurrentUserData) { - const achievements = await this.achievementService.getUnlockedForUser(user.userId); - return { achievements }; - } - - @Get('stats') - async getStats(@CurrentUser() user: CurrentUserData) { - const stats = await this.achievementService.getStats(user.userId); - return { stats }; - } -} diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.module.ts b/apps/skilltree/apps/backend/src/achievement/achievement.module.ts deleted file mode 100644 index 41fe1c1bd..000000000 --- a/apps/skilltree/apps/backend/src/achievement/achievement.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AchievementController } from './achievement.controller'; -import { AchievementService } from './achievement.service'; - -@Module({ - controllers: [AchievementController], - providers: [AchievementService], - exports: [AchievementService], -}) -export class AchievementModule {} diff --git a/apps/skilltree/apps/backend/src/achievement/achievement.service.ts b/apps/skilltree/apps/backend/src/achievement/achievement.service.ts deleted file mode 100644 index 09e5b5e7c..000000000 --- a/apps/skilltree/apps/backend/src/achievement/achievement.service.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Injectable, Inject, OnModuleInit, Logger } from '@nestjs/common'; -import { eq, and, sql } from 'drizzle-orm'; -import { DATABASE_TOKEN } from '../db/database.module'; -import { Database } from '../db/connection'; -import { - achievements, - userAchievements, - skills, - activities, - userStats, - Achievement, - UserAchievement, -} from '../db/schema'; -import { ACHIEVEMENT_DEFINITIONS } from './achievement-definitions'; - -export interface AchievementWithStatus extends Achievement { - unlocked: boolean; - unlockedAt: Date | null; - progress: number; -} - -export interface AchievementUnlockResult { - achievement: Achievement; - xpReward: number; -} - -@Injectable() -export class AchievementService implements OnModuleInit { - private readonly logger = new Logger(AchievementService.name); - - constructor(@Inject(DATABASE_TOKEN) private db: Database) {} - - async onModuleInit() { - await this.seedAchievements(); - } - - private async seedAchievements(): Promise { - for (const def of ACHIEVEMENT_DEFINITIONS) { - await this.db - .insert(achievements) - .values(def) - .onConflictDoUpdate({ - target: achievements.id, - set: { - name: def.name, - description: def.description, - icon: def.icon, - category: def.category, - rarity: def.rarity, - xpReward: def.xpReward, - sortOrder: def.sortOrder, - condition: def.condition, - }, - }); - } - this.logger.log(`Seeded ${ACHIEVEMENT_DEFINITIONS.length} achievements`); - } - - async getAllForUser(userId: string): Promise { - const allAchievements = await this.db - .select() - .from(achievements) - .orderBy(achievements.sortOrder); - - const unlocked = await this.db - .select() - .from(userAchievements) - .where(eq(userAchievements.userId, userId)); - - const unlockedMap = new Map(unlocked.map((u) => [u.achievementId, u])); - - // Calculate current progress for each achievement - const progressMap = await this.calculateProgress(userId); - - return allAchievements.map((a) => { - const userAch = unlockedMap.get(a.id); - return { - ...a, - unlocked: !!userAch, - unlockedAt: userAch?.unlockedAt ?? null, - progress: userAch ? (a.condition as any).threshold : (progressMap.get(a.id) ?? 0), - }; - }); - } - - async getUnlockedForUser(userId: string): Promise { - const rows = await this.db - .select({ achievement: achievements }) - .from(userAchievements) - .innerJoin(achievements, eq(userAchievements.achievementId, achievements.id)) - .where(eq(userAchievements.userId, userId)); - - return rows.map((r) => r.achievement); - } - - async getStats(userId: string): Promise<{ total: number; unlocked: number }> { - const [totalResult] = await this.db.select({ count: sql`count(*)` }).from(achievements); - - const [unlockedResult] = await this.db - .select({ count: sql`count(*)` }) - .from(userAchievements) - .where(eq(userAchievements.userId, userId)); - - return { - total: Number(totalResult.count), - unlocked: Number(unlockedResult.count), - }; - } - - /** - * Check all achievements for a user and unlock any newly earned ones. - * Called after XP gain, skill creation, activity logging, etc. - */ - async checkAndUnlock( - userId: string, - context?: { activityXp?: number } - ): Promise { - const allAchievements = await this.db.select().from(achievements); - const unlocked = await this.db - .select() - .from(userAchievements) - .where(eq(userAchievements.userId, userId)); - const unlockedIds = new Set(unlocked.map((u) => u.achievementId)); - - // Get user data for condition evaluation - const userData = await this.getUserData(userId); - if (context?.activityXp) { - userData.lastActivityXp = context.activityXp; - } - - const newlyUnlocked: AchievementUnlockResult[] = []; - - for (const achievement of allAchievements) { - if (unlockedIds.has(achievement.id)) continue; - - const condition = achievement.condition as { type: string; threshold: number }; - if (this.evaluateCondition(condition, userData)) { - await this.db.insert(userAchievements).values({ - userId, - achievementId: achievement.id, - progress: condition.threshold, - }); - newlyUnlocked.push({ - achievement, - xpReward: achievement.xpReward, - }); - } - } - - return newlyUnlocked; - } - - private async getUserData(userId: string): Promise { - const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId)); - const [activityCount] = await this.db - .select({ count: sql`count(*)` }) - .from(activities) - .where(eq(activities.userId, userId)); - const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId)); - - const uniqueBranches = new Set(userSkills.map((s) => s.branch).filter((b) => b !== 'custom')); - - // Check min level per branch (for all_branches_min_level) - const branchMinLevels = new Map(); - const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset']; - for (const branch of mainBranches) { - const branchSkills = userSkills.filter((s) => s.branch === branch); - if (branchSkills.length > 0) { - branchMinLevels.set(branch, Math.max(...branchSkills.map((s) => s.level))); - } - } - const allBranchesMinLevel = - branchMinLevels.size === 6 ? Math.min(...branchMinLevels.values()) : 0; - - return { - totalXp: stats?.totalXp ?? 0, - totalSkills: userSkills.length, - highestLevel: stats?.highestLevel ?? 0, - totalActivities: Number(activityCount.count), - streakDays: stats?.streakDays ?? 0, - uniqueBranches: uniqueBranches.size, - allBranchesMinLevel, - lastActivityXp: 0, - }; - } - - private evaluateCondition( - condition: { type: string; threshold: number }, - data: UserData - ): boolean { - switch (condition.type) { - case 'total_xp': - return data.totalXp >= condition.threshold; - case 'total_skills': - return data.totalSkills >= condition.threshold; - case 'highest_level': - return data.highestLevel >= condition.threshold; - case 'total_activities': - return data.totalActivities >= condition.threshold; - case 'streak_days': - return data.streakDays >= condition.threshold; - case 'unique_branches': - return data.uniqueBranches >= condition.threshold; - case 'single_activity_xp': - return data.lastActivityXp >= condition.threshold; - case 'all_branches_min_level': - return data.allBranchesMinLevel >= condition.threshold; - default: - return false; - } - } - - private async calculateProgress(userId: string): Promise> { - const userData = await this.getUserData(userId); - const allAchievements = await this.db.select().from(achievements); - const progressMap = new Map(); - - for (const achievement of allAchievements) { - const condition = achievement.condition as { type: string; threshold: number }; - let current = 0; - - switch (condition.type) { - case 'total_xp': - current = userData.totalXp; - break; - case 'total_skills': - current = userData.totalSkills; - break; - case 'highest_level': - current = userData.highestLevel; - break; - case 'total_activities': - current = userData.totalActivities; - break; - case 'streak_days': - current = userData.streakDays; - break; - case 'unique_branches': - current = userData.uniqueBranches; - break; - case 'single_activity_xp': - current = 0; // Can't track historical max - break; - case 'all_branches_min_level': - current = userData.allBranchesMinLevel; - break; - } - - progressMap.set(achievement.id, Math.min(current, condition.threshold)); - } - - return progressMap; - } -} - -interface UserData { - totalXp: number; - totalSkills: number; - highestLevel: number; - totalActivities: number; - streakDays: number; - uniqueBranches: number; - allBranchesMinLevel: number; - lastActivityXp: number; -} diff --git a/apps/skilltree/apps/backend/src/activity/activity.controller.ts b/apps/skilltree/apps/backend/src/activity/activity.controller.ts deleted file mode 100644 index 7e52f471e..000000000 --- a/apps/skilltree/apps/backend/src/activity/activity.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { ActivityService } from './activity.service'; - -@Controller('activities') -@UseGuards(JwtAuthGuard) -export class ActivityController { - constructor(private readonly activityService: ActivityService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - const activities = await this.activityService.findAll(user.userId, limit ?? 50); - return { activities }; - } - - @Get('recent') - async getRecent(@CurrentUser() user: CurrentUserData, @Query('limit') limit?: number) { - const activities = await this.activityService.getRecent(user.userId, limit ?? 10); - return { activities }; - } - - @Get('skill/:skillId') - async findBySkill(@CurrentUser() user: CurrentUserData, @Param('skillId') skillId: string) { - const activities = await this.activityService.findBySkill(user.userId, skillId); - return { activities }; - } -} diff --git a/apps/skilltree/apps/backend/src/activity/activity.module.ts b/apps/skilltree/apps/backend/src/activity/activity.module.ts deleted file mode 100644 index b747ea624..000000000 --- a/apps/skilltree/apps/backend/src/activity/activity.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ActivityController } from './activity.controller'; -import { ActivityService } from './activity.service'; - -@Module({ - controllers: [ActivityController], - providers: [ActivityService], - exports: [ActivityService], -}) -export class ActivityModule {} diff --git a/apps/skilltree/apps/backend/src/activity/activity.service.ts b/apps/skilltree/apps/backend/src/activity/activity.service.ts deleted file mode 100644 index adafd9b38..000000000 --- a/apps/skilltree/apps/backend/src/activity/activity.service.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Injectable, Inject } from '@nestjs/common'; -import { eq, desc } from 'drizzle-orm'; -import { DATABASE_TOKEN } from '../db/database.module'; -import { Database } from '../db/connection'; -import { activities, Activity } from '../db/schema'; - -@Injectable() -export class ActivityService { - constructor(@Inject(DATABASE_TOKEN) private db: Database) {} - - async findAll(userId: string, limit = 50): Promise { - 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 deleted file mode 100644 index 461dd1b22..000000000 --- a/apps/skilltree/apps/backend/src/app.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { MetricsModule } from '@manacore/shared-nestjs-metrics'; -import { DatabaseModule } from './db/database.module'; -import { HealthModule } from '@manacore/shared-nestjs-health'; -import { SkillModule } from './skill/skill.module'; -import { ActivityModule } from './activity/activity.module'; -import { AchievementModule } from './achievement/achievement.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: '.env', - }), - MetricsModule.register({ - prefix: 'skilltree_', - excludePaths: ['/health'], - }), - DatabaseModule, - HealthModule.forRoot({ serviceName: 'skilltree-backend' }), - SkillModule, - ActivityModule, - AchievementModule, - ], -}) -export class AppModule {} diff --git a/apps/skilltree/apps/backend/src/db/connection.ts b/apps/skilltree/apps/backend/src/db/connection.ts deleted file mode 100644 index 34773decd..000000000 --- a/apps/skilltree/apps/backend/src/db/connection.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 955eefe5f..000000000 --- a/apps/skilltree/apps/backend/src/db/database.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Module, Global, OnModuleDestroy } from '@nestjs/common'; -import { getDb, closeConnection, Database } from './connection'; - -export const DATABASE_TOKEN = 'DATABASE'; - -@Global() -@Module({ - providers: [ - { - provide: DATABASE_TOKEN, - useFactory: () => getDb(), - }, - ], - exports: [DATABASE_TOKEN], -}) -export class DatabaseModule implements OnModuleDestroy { - async onModuleDestroy() { - await closeConnection(); - } -} diff --git a/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts b/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts deleted file mode 100644 index def324ca8..000000000 --- a/apps/skilltree/apps/backend/src/db/schema/achievements.schema.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - pgTable, - uuid, - timestamp, - varchar, - text, - integer, - boolean, - index, - jsonb, -} from 'drizzle-orm/pg-core'; - -export type AchievementCategory = - | 'xp' - | 'skills' - | 'levels' - | 'activities' - | 'streak' - | 'branches' - | 'special'; - -export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; - -export const achievements = pgTable('achievements', { - id: varchar('id', { length: 50 }).primaryKey(), // e.g. 'first_activity', 'streak_7' - name: varchar('name', { length: 200 }).notNull(), - description: text('description').notNull(), - icon: varchar('icon', { length: 50 }).notNull().default('trophy'), - category: varchar('category', { length: 20 }).notNull().$type(), - rarity: varchar('rarity', { length: 20 }).notNull().$type(), - xpReward: integer('xp_reward').default(0).notNull(), - sortOrder: integer('sort_order').default(0).notNull(), - condition: jsonb('condition').notNull(), // { type: 'total_xp', threshold: 1000 } - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), -}); - -export type Achievement = typeof achievements.$inferSelect; -export type NewAchievement = typeof achievements.$inferInsert; - -export const userAchievements = pgTable( - 'user_achievements', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - achievementId: varchar('achievement_id', { length: 50 }) - .references(() => achievements.id, { onDelete: 'cascade' }) - .notNull(), - unlockedAt: timestamp('unlocked_at', { withTimezone: true }).defaultNow().notNull(), - progress: integer('progress').default(0).notNull(), // current progress toward threshold - }, - (table) => ({ - userIdx: index('user_achievements_user_idx').on(table.userId), - achievementIdx: index('user_achievements_achievement_idx').on(table.achievementId), - uniqueUserAchievement: index('user_achievements_unique_idx').on( - table.userId, - table.achievementId - ), - }) -); - -export type UserAchievement = typeof userAchievements.$inferSelect; -export type NewUserAchievement = typeof userAchievements.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts b/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts deleted file mode 100644 index e832ad1f2..000000000 --- a/apps/skilltree/apps/backend/src/db/schema/activities.schema.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - pgTable, - uuid, - timestamp, - varchar, - text, - integer, - index, -} from 'drizzle-orm/pg-core'; -import { skills } from './skills.schema'; - -export const activities = pgTable( - 'activities', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - skillId: uuid('skill_id') - .references(() => skills.id, { onDelete: 'cascade' }) - .notNull(), - - // Activity details - xpEarned: integer('xp_earned').notNull(), - description: varchar('description', { length: 500 }).notNull(), - duration: integer('duration'), // in minutes - - // Timestamp - timestamp: timestamp('timestamp', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('activities_user_idx').on(table.userId), - skillIdx: index('activities_skill_idx').on(table.skillId), - timestampIdx: index('activities_timestamp_idx').on(table.userId, table.timestamp), - }) -); - -export type Activity = typeof activities.$inferSelect; -export type NewActivity = typeof activities.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/db/schema/index.ts b/apps/skilltree/apps/backend/src/db/schema/index.ts deleted file mode 100644 index e84ef7a56..000000000 --- a/apps/skilltree/apps/backend/src/db/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './skills.schema'; -export * from './activities.schema'; -export * from './user-stats.schema'; -export * from './achievements.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 deleted file mode 100644 index f4d2f672b..000000000 --- a/apps/skilltree/apps/backend/src/db/schema/skills.schema.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { - pgTable, - uuid, - timestamp, - varchar, - text, - integer, - index, -} from 'drizzle-orm/pg-core'; - -export type SkillBranch = - | 'intellect' - | 'body' - | 'creativity' - | 'social' - | 'practical' - | 'mindset' - | 'custom'; - -export const skills = pgTable( - 'skills', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - - // Content - name: varchar('name', { length: 200 }).notNull(), - description: text('description'), - branch: varchar('branch', { length: 20 }).notNull().$type(), - 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 deleted file mode 100644 index a5ba16422..000000000 --- a/apps/skilltree/apps/backend/src/db/schema/user-stats.schema.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - pgTable, - uuid, - timestamp, - text, - integer, - date, - index, -} from 'drizzle-orm/pg-core'; - -export const userStats = pgTable( - 'user_stats', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull().unique(), - - // Aggregated stats - totalXp: integer('total_xp').default(0).notNull(), - totalSkills: integer('total_skills').default(0).notNull(), - highestLevel: integer('highest_level').default(0).notNull(), - streakDays: integer('streak_days').default(0).notNull(), - lastActivityDate: date('last_activity_date'), - - // Timestamps - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdx: index('user_stats_user_idx').on(table.userId), - }) -); - -export type UserStat = typeof userStats.$inferSelect; -export type NewUserStat = typeof userStats.$inferInsert; diff --git a/apps/skilltree/apps/backend/src/instrument.ts b/apps/skilltree/apps/backend/src/instrument.ts deleted file mode 100644 index d286808e7..000000000 --- a/apps/skilltree/apps/backend/src/instrument.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { initErrorTracking } from '@manacore/shared-error-tracking'; - -initErrorTracking({ - serviceName: 'skilltree-backend', - environment: process.env.NODE_ENV, - release: process.env.APP_VERSION, - debug: process.env.NODE_ENV === 'development', -}); diff --git a/apps/skilltree/apps/backend/src/main.ts b/apps/skilltree/apps/backend/src/main.ts deleted file mode 100644 index 4dfd74d48..000000000 --- a/apps/skilltree/apps/backend/src/main.ts +++ /dev/null @@ -1,65 +0,0 @@ -import './instrument'; -import { NestFactory } from '@nestjs/core'; -import { ValidationPipe, Logger } from '@nestjs/common'; -import { AppModule } from './app.module'; - -async function bootstrap() { - const logger = new Logger('Bootstrap'); - const app = await NestFactory.create(AppModule); - - // Enable CORS - app.enableCors({ - origin: (origin, callback) => { - if (!origin) { - callback(null, true); - return; - } - - const allowedOrigins = process.env.CORS_ORIGINS?.split(',') || [ - 'http://localhost:5173', - 'http://localhost:5195', - 'http://localhost:8081', - ]; - - if (process.env.NODE_ENV === 'development' && origin.includes('localhost')) { - callback(null, true); - return; - } - - if (allowedOrigins.includes(origin)) { - callback(null, true); - } else { - logger.warn(`Blocked request from origin: ${origin}`); - callback(new Error('Not allowed by CORS'), false); - } - }, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], - }); - - // Global validation pipe - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - transformOptions: { - enableImplicitConversion: true, - }, - }) - ); - - // API prefix - app.setGlobalPrefix('api/v1', { - exclude: ['metrics', 'health'], - }); - - const port = process.env.PORT || 3024; - await app.listen(port); - - logger.log(`SkillTree API is running on: http://localhost:${port}`); - logger.log(`Health check: http://localhost:${port}/health`); -} - -bootstrap(); 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 deleted file mode 100644 index e3dd9167a..000000000 --- a/apps/skilltree/apps/backend/src/skill/dto/add-xp.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsString, IsNumber, IsOptional, Min, Max, MaxLength } from 'class-validator'; - -export class AddXpDto { - @IsNumber() - @Min(1) - @Max(10000) - xp: number; - - @IsString() - @MaxLength(500) - description: string; - - @IsOptional() - @IsNumber() - @Min(1) - duration?: number; // in minutes -} 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 deleted file mode 100644 index baf677575..000000000 --- a/apps/skilltree/apps/backend/src/skill/dto/create-skill.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator'; -import type { SkillBranch } from '../../db/schema'; - -const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const; - -export class CreateSkillDto { - @IsString() - @MaxLength(200) - name: string; - - @IsOptional() - @IsString() - @MaxLength(1000) - description?: string; - - @IsIn(BRANCHES) - branch: SkillBranch; - - @IsOptional() - @IsUUID() - parentId?: string; - - @IsOptional() - @IsString() - @MaxLength(50) - icon?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - color?: string; -} diff --git a/apps/skilltree/apps/backend/src/skill/dto/index.ts b/apps/skilltree/apps/backend/src/skill/dto/index.ts deleted file mode 100644 index c173f123c..000000000 --- a/apps/skilltree/apps/backend/src/skill/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index d7e858dad..000000000 --- a/apps/skilltree/apps/backend/src/skill/dto/update-skill.dto.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { IsString, IsOptional, IsIn, MaxLength, IsUUID } from 'class-validator'; -import type { SkillBranch } from '../../db/schema'; - -const BRANCHES = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset', 'custom'] as const; - -export class UpdateSkillDto { - @IsOptional() - @IsString() - @MaxLength(200) - name?: string; - - @IsOptional() - @IsString() - @MaxLength(1000) - description?: string; - - @IsOptional() - @IsIn(BRANCHES) - branch?: SkillBranch; - - @IsOptional() - @IsUUID() - parentId?: string | null; - - @IsOptional() - @IsString() - @MaxLength(50) - icon?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - color?: string; -} diff --git a/apps/skilltree/apps/backend/src/skill/skill.controller.ts b/apps/skilltree/apps/backend/src/skill/skill.controller.ts deleted file mode 100644 index d36808592..000000000 --- a/apps/skilltree/apps/backend/src/skill/skill.controller.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; -import { SkillService } from './skill.service'; -import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto'; - -@Controller('skills') -@UseGuards(JwtAuthGuard) -export class SkillController { - constructor(private readonly skillService: SkillService) {} - - @Get() - async findAll(@CurrentUser() user: CurrentUserData, @Query('branch') branch?: string) { - if (branch) { - const skills = await this.skillService.findByBranch(user.userId, branch); - return { skills }; - } - const skills = await this.skillService.findAll(user.userId); - return { skills }; - } - - @Get('stats') - async getStats(@CurrentUser() user: CurrentUserData) { - const stats = await this.skillService.getUserStats(user.userId); - return { stats }; - } - - @Get(':id') - async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - const skill = await this.skillService.findByIdOrThrow(id, user.userId); - return { skill }; - } - - @Post() - async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateSkillDto) { - const result = await this.skillService.create(user.userId, dto); - return { skill: result.skill, newAchievements: result.newAchievements }; - } - - @Put(':id') - async update( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: UpdateSkillDto - ) { - const skill = await this.skillService.update(id, user.userId, dto); - return { skill }; - } - - @Delete(':id') - async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) { - await this.skillService.delete(id, user.userId); - return { success: true }; - } - - @Post(':id/xp') - async addXp( - @CurrentUser() user: CurrentUserData, - @Param('id') id: string, - @Body() dto: AddXpDto - ) { - const result = await this.skillService.addXp(id, user.userId, dto); - return result; - } -} diff --git a/apps/skilltree/apps/backend/src/skill/skill.module.ts b/apps/skilltree/apps/backend/src/skill/skill.module.ts deleted file mode 100644 index e4f359ea5..000000000 --- a/apps/skilltree/apps/backend/src/skill/skill.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SkillController } from './skill.controller'; -import { SkillService } from './skill.service'; -import { AchievementModule } from '../achievement/achievement.module'; - -@Module({ - imports: [AchievementModule], - controllers: [SkillController], - providers: [SkillService], - exports: [SkillService], -}) -export class SkillModule {} diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts b/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts deleted file mode 100644 index 69b70a3ad..000000000 --- a/apps/skilltree/apps/backend/src/skill/skill.service.spec.ts +++ /dev/null @@ -1,508 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { SkillService } from './skill.service'; -import { DATABASE_TOKEN } from '../db/database.module'; -import { AchievementService } from '../achievement/achievement.service'; - -// Mock database operations -// Uses a query builder pattern where each query chain is thenable -const createMockDb = () => { - // Queue for resolved values - each await will pop from this queue - const resolveQueue: any[] = []; - - // Create a thenable query result (only used for final await) - const createQueryResult = (): any => { - return { - then: (resolve: (value: any) => void, reject?: (reason: any) => void) => { - const value = resolveQueue.shift() ?? []; - return Promise.resolve(value).then(resolve, reject); - }, - }; - }; - - // The mock database object - NOT thenable itself - const mockDb: any = { - // Helper methods - _queueResult: (value: any) => { - resolveQueue.push(value); - }, - _queueResults: (...values: any[]) => { - values.forEach((v) => resolveQueue.push(v)); - }, - _clearQueue: () => { - resolveQueue.length = 0; - }, - }; - - // Create a query builder that returns thenable results - const createChainableMethod = () => { - const chainable: any = createQueryResult(); - chainable.select = jest.fn(() => chainable); - chainable.from = jest.fn(() => chainable); - chainable.where = jest.fn(() => chainable); - chainable.orderBy = jest.fn(() => chainable); - chainable.limit = jest.fn(() => chainable); - chainable.returning = jest.fn(() => chainable); - chainable.insert = jest.fn(() => chainable); - chainable.values = jest.fn(() => chainable); - chainable.update = jest.fn(() => chainable); - chainable.set = jest.fn(() => chainable); - chainable.delete = jest.fn(() => chainable); - chainable.onConflictDoUpdate = jest.fn(() => chainable); - return chainable; - }; - - // Database entry points return new chainable builders - mockDb.select = jest.fn(() => createChainableMethod()); - mockDb.insert = jest.fn(() => createChainableMethod()); - mockDb.update = jest.fn(() => createChainableMethod()); - mockDb.delete = jest.fn(() => createChainableMethod()); - - return mockDb; -}; - -const mockAchievementService = { - checkAndUnlock: jest.fn().mockResolvedValue([]), -}; - -describe('SkillService', () => { - let service: SkillService; - let mockDb: ReturnType; - - const testUserId = 'test-user-123'; - const testSkillId = 'skill-uuid-123'; - - const mockSkill = { - id: testSkillId, - userId: testUserId, - name: 'TypeScript', - description: 'Learn TypeScript programming', - branch: 'intellect', - parentId: null, - icon: 'code', - color: '#3178C6', - currentXp: 150, - totalXp: 150, - level: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; - - beforeEach(async () => { - mockDb = createMockDb(); - mockAchievementService.checkAndUnlock.mockClear(); - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SkillService, - { - provide: DATABASE_TOKEN, - useValue: mockDb, - }, - { - provide: AchievementService, - useValue: mockAchievementService, - }, - ], - }).compile(); - - service = module.get(SkillService); - }); - - afterEach(() => { - jest.clearAllMocks(); - mockDb._clearQueue(); - }); - - describe('findAll', () => { - it('should return all skills for a user', async () => { - const skills = [mockSkill, { ...mockSkill, id: 'skill-2', name: 'JavaScript' }]; - mockDb._queueResult(skills); - - const result = await service.findAll(testUserId); - - expect(result).toEqual(skills); - expect(mockDb.select).toHaveBeenCalled(); - }); - - it('should return empty array when user has no skills', async () => { - mockDb._queueResult([]); - - const result = await service.findAll(testUserId); - - expect(result).toEqual([]); - }); - }); - - describe('findByBranch', () => { - it('should return skills filtered by branch', async () => { - const intellectSkills = [mockSkill]; - mockDb._queueResult(intellectSkills); - - const result = await service.findByBranch(testUserId, 'intellect'); - - expect(result).toEqual(intellectSkills); - }); - - it('should return empty array for branch with no skills', async () => { - mockDb._queueResult([]); - - const result = await service.findByBranch(testUserId, 'body'); - - expect(result).toEqual([]); - }); - }); - - describe('findById', () => { - it('should return skill when found', async () => { - mockDb._queueResult([mockSkill]); - - const result = await service.findById(testSkillId, testUserId); - - expect(result).toEqual(mockSkill); - }); - - it('should return null when skill not found', async () => { - mockDb._queueResult([]); - - const result = await service.findById('non-existent', testUserId); - - expect(result).toBeNull(); - }); - }); - - describe('findByIdOrThrow', () => { - it('should return skill when found', async () => { - mockDb._queueResult([mockSkill]); - - const result = await service.findByIdOrThrow(testSkillId, testUserId); - - expect(result).toEqual(mockSkill); - }); - - it('should throw NotFoundException when skill not found', async () => { - mockDb._queueResult([]); - - await expect(service.findByIdOrThrow('non-existent', testUserId)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('create', () => { - const createDto = { - name: 'React', - description: 'Learn React framework', - branch: 'intellect' as const, - parentId: undefined, - icon: 'component', - color: '#61DAFB', - }; - - it('should create a new skill with default XP and level', async () => { - const createdSkill = { - ...createDto, - id: 'new-skill-id', - userId: testUserId, - currentXp: 0, - totalXp: 0, - level: 0, - }; - - // Queue results in order of awaits: - // 1. insert().values().returning() -> [createdSkill] - // 2. updateUserStats: select().from(skills).where() -> [createdSkill] - // 3. updateUserStats: select().from(activities).where().orderBy().limit() -> [] - // 4. calculateStreak: select().from(activities).where().orderBy() -> [] - // 5. insert().values().onConflictDoUpdate() -> undefined - mockDb._queueResults( - [createdSkill], // 1. insert skill returning - [createdSkill], // 2. select skills - [], // 3. select activities (limit) - [], // 4. calculateStreak activities - undefined // 5. upsert stats - ); - - const result = await service.create(testUserId, createDto); - - expect(result.skill.name).toBe('React'); - expect(result.skill.currentXp).toBe(0); - expect(result.skill.level).toBe(0); - expect(result.newAchievements).toEqual([]); - }); - - it('should use default icon when not provided', async () => { - const dtoWithoutIcon = { - name: 'New Skill', - description: 'A skill', - branch: 'body' as const, - parentId: undefined, - color: undefined, - }; - - const createdSkill = { - ...dtoWithoutIcon, - id: 'new-id', - userId: testUserId, - icon: 'star', - currentXp: 0, - totalXp: 0, - level: 0, - }; - - mockDb._queueResults([createdSkill], [createdSkill], [], [], undefined); - - const result = await service.create(testUserId, dtoWithoutIcon); - - expect(result.skill.icon).toBe('star'); - }); - }); - - describe('update', () => { - const updateDto = { - name: 'Updated TypeScript', - description: 'Master TypeScript', - }; - - it('should update skill and return updated version', async () => { - const updatedSkill = { ...mockSkill, ...updateDto }; - - // Queue results: - // 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill] - // 2. update().set().where().returning() -> [updatedSkill] - mockDb._queueResults([mockSkill], [updatedSkill]); - - const result = await service.update(testSkillId, testUserId, updateDto); - - expect(result.name).toBe('Updated TypeScript'); - expect(result.description).toBe('Master TypeScript'); - }); - - it('should throw NotFoundException when updating non-existent skill', async () => { - mockDb._queueResult([]); - - await expect(service.update('non-existent', testUserId, updateDto)).rejects.toThrow( - NotFoundException - ); - }); - }); - - describe('delete', () => { - it('should delete skill successfully', async () => { - // Queue results: - // 1. findByIdOrThrow: select().from(skills).where() -> [mockSkill] - // 2. delete(skills).where() -> undefined - // 3. updateUserStats: select().from(skills).where() -> [] (empty after delete) - // 4. updateUserStats: select().from(activities).where().orderBy().limit() -> [] - // 5. calculateStreak: select().from(activities).where().orderBy() -> [] - // 6. insert().values().onConflictDoUpdate() -> undefined - mockDb._queueResults( - [mockSkill], // 1. findByIdOrThrow - undefined, // 2. delete - [], // 3. select skills - [], // 4. select activities (limit) - [], // 5. calculateStreak - undefined // 6. upsert stats - ); - - await expect(service.delete(testSkillId, testUserId)).resolves.not.toThrow(); - }); - - it('should throw NotFoundException when deleting non-existent skill', async () => { - mockDb._queueResult([]); - - await expect(service.delete('non-existent', testUserId)).rejects.toThrow(NotFoundException); - }); - }); - - describe('addXp', () => { - const addXpDto = { - xp: 50, - description: 'Completed tutorial', - duration: 30, - }; - - it('should add XP and update skill level when threshold crossed', async () => { - // Skill at level 0 with 80 XP, adding 50 should reach level 1 - const skillAt80Xp = { ...mockSkill, currentXp: 80, totalXp: 80, level: 0 }; - const updatedSkill = { - ...skillAt80Xp, - currentXp: 130, - totalXp: 130, - level: 1, - }; - const recentActivity = { timestamp: new Date() }; - - // Queue results: - // 1. findByIdOrThrow: select().from(skills).where() -> [skillAt80Xp] - // 2. update(skills).set().where().returning() -> [updatedSkill] - // 3. insert(activities).values() -> undefined - // 4. updateUserStats: select().from(skills).where() -> [updatedSkill] - // 5. updateUserStats: select().from(activities).where().orderBy().limit() -> [activity] - // 6. calculateStreak: select().from(activities).where().orderBy() -> [activity] - // 7. insert().values().onConflictDoUpdate() -> undefined - mockDb._queueResults( - [skillAt80Xp], // 1 - [updatedSkill], // 2 - undefined, // 3 - [updatedSkill], // 4 - [recentActivity], // 5 - [recentActivity], // 6 - undefined // 7 - ); - - const result = await service.addXp(testSkillId, testUserId, addXpDto); - - expect(result.skill.totalXp).toBe(130); - expect(result.skill.level).toBe(1); - expect(result.leveledUp).toBe(true); - expect(result.newLevel).toBe(1); - }); - - it('should not level up when threshold not crossed', async () => { - // Skill at level 1 with 150 XP, adding 50 stays at level 1 - const updatedSkill = { - ...mockSkill, - currentXp: 200, - totalXp: 200, - level: 1, - }; - const recentActivity = { timestamp: new Date() }; - - mockDb._queueResults( - [mockSkill], // findByIdOrThrow - [updatedSkill], // update skill - undefined, // insert activity - [updatedSkill], // select skills - [recentActivity], // select activities (limit) - [recentActivity], // calculateStreak - undefined // upsert stats - ); - - const result = await service.addXp(testSkillId, testUserId, addXpDto); - - expect(result.leveledUp).toBe(false); - expect(result.newLevel).toBe(1); - }); - - it('should throw NotFoundException when adding XP to non-existent skill', async () => { - mockDb._queueResult([]); - - await expect(service.addXp('non-existent', testUserId, addXpDto)).rejects.toThrow( - NotFoundException - ); - }); - - it('should create activity record when adding XP', async () => { - const updatedSkill = { ...mockSkill, currentXp: 200, totalXp: 200 }; - - mockDb._queueResults( - [mockSkill], // findByIdOrThrow - [updatedSkill], // update skill - undefined, // insert activity - [updatedSkill], // select skills - [], // select activities (limit) - [], // calculateStreak - undefined // upsert stats - ); - - await service.addXp(testSkillId, testUserId, addXpDto); - - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); - - describe('getUserStats', () => { - it('should return user stats when they exist', async () => { - const stats = { - userId: testUserId, - totalXp: 500, - totalSkills: 5, - highestLevel: 2, - streakDays: 7, - lastActivityDate: '2026-01-28', - }; - mockDb._queueResult([stats]); - - const result = await service.getUserStats(testUserId); - - expect(result).toEqual(stats); - }); - - it('should return default stats when none exist', async () => { - mockDb._queueResult([]); - - const result = await service.getUserStats(testUserId); - - expect(result).toEqual({ - totalXp: 0, - totalSkills: 0, - highestLevel: 0, - streakDays: 0, - lastActivityDate: null, - }); - }); - }); -}); - -describe('Level Calculation (Unit Tests)', () => { - // Test the calculateLevel function directly - const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000]; - - function calculateLevel(xp: number): number { - for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { - if (xp >= LEVEL_THRESHOLDS[i]) { - return i; - } - } - return 0; - } - - describe('calculateLevel', () => { - it.each([ - [0, 0], - [50, 0], - [99, 0], - [100, 1], - [250, 1], - [499, 1], - [500, 2], - [1000, 2], - [1499, 2], - [1500, 3], - [3999, 3], - [4000, 4], - [9999, 4], - [10000, 5], - [50000, 5], - ])('calculateLevel(%i) should return %i', (xp, expectedLevel) => { - expect(calculateLevel(xp)).toBe(expectedLevel); - }); - }); - - describe('Level up detection', () => { - it('should detect level up from 0 to 1', () => { - const oldLevel = calculateLevel(90); - const newLevel = calculateLevel(110); - expect(oldLevel).toBe(0); - expect(newLevel).toBe(1); - expect(newLevel > oldLevel).toBe(true); - }); - - it('should not detect level up within same level', () => { - const oldLevel = calculateLevel(100); - const newLevel = calculateLevel(200); - expect(oldLevel).toBe(1); - expect(newLevel).toBe(1); - expect(newLevel > oldLevel).toBe(false); - }); - - it('should detect multiple level ups', () => { - const oldLevel = calculateLevel(0); - const newLevel = calculateLevel(600); - expect(oldLevel).toBe(0); - expect(newLevel).toBe(2); - expect(newLevel - oldLevel).toBe(2); - }); - }); -}); diff --git a/apps/skilltree/apps/backend/src/skill/skill.service.ts b/apps/skilltree/apps/backend/src/skill/skill.service.ts deleted file mode 100644 index 17df2ee85..000000000 --- a/apps/skilltree/apps/backend/src/skill/skill.service.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { Injectable, Inject, NotFoundException } from '@nestjs/common'; -import { eq, and, desc } from 'drizzle-orm'; -import { DATABASE_TOKEN } from '../db/database.module'; -import { Database } from '../db/connection'; -import { skills, activities, userStats, Skill, NewSkill } from '../db/schema'; -import { CreateSkillDto, UpdateSkillDto, AddXpDto } from './dto'; -import { AchievementService, AchievementUnlockResult } from '../achievement/achievement.service'; - -// Level thresholds -const LEVEL_THRESHOLDS = [0, 100, 500, 1500, 4000, 10000]; - -function calculateLevel(xp: number): number { - for (let i = LEVEL_THRESHOLDS.length - 1; i >= 0; i--) { - if (xp >= LEVEL_THRESHOLDS[i]) { - return i; - } - } - return 0; -} - -@Injectable() -export class SkillService { - constructor( - @Inject(DATABASE_TOKEN) private db: Database, - private readonly achievementService: AchievementService - ) {} - - async findAll(userId: string): Promise { - 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<{ skill: Skill; newAchievements: AchievementUnlockResult[] }> { - const newSkill: NewSkill = { - userId, - name: dto.name, - description: dto.description, - branch: dto.branch, - parentId: dto.parentId, - icon: dto.icon ?? 'star', - color: dto.color, - currentXp: 0, - totalXp: 0, - level: 0, - }; - - const [skill] = await this.db.insert(skills).values(newSkill).returning(); - - // Update user stats - await this.updateUserStats(userId); - - // Check achievements - const newAchievements = await this.achievementService.checkAndUnlock(userId); - - return { skill, newAchievements }; - } - - async update(id: string, userId: string, dto: UpdateSkillDto): Promise { - 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; - newAchievements: AchievementUnlockResult[]; - }> { - const skill = await this.findByIdOrThrow(id, userId); - - const newTotalXp = skill.totalXp + dto.xp; - const newCurrentXp = skill.currentXp + dto.xp; - const newLevel = calculateLevel(newTotalXp); - const leveledUp = newLevel > skill.level; - - // Update skill - const [updated] = await this.db - .update(skills) - .set({ - totalXp: newTotalXp, - currentXp: newCurrentXp, - level: newLevel, - updatedAt: new Date(), - }) - .where(and(eq(skills.id, id), eq(skills.userId, userId))) - .returning(); - - // Create activity - await this.db.insert(activities).values({ - userId, - skillId: id, - xpEarned: dto.xp, - description: dto.description, - duration: dto.duration, - }); - - // Update user stats - await this.updateUserStats(userId); - - // Check achievements - const newAchievements = await this.achievementService.checkAndUnlock(userId, { - activityXp: dto.xp, - }); - - return { skill: updated, leveledUp, newLevel, newAchievements }; - } - - private async updateUserStats(userId: string): Promise { - // 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 deleted file mode 100644 index 27971033a..000000000 --- a/apps/skilltree/apps/backend/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2021", - "module": "commonjs", - "moduleResolution": "node", - "declaration": true, - "removeComments": true, - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "allowSyntheticDefaultImports": true, - "outDir": "./dist", - "baseUrl": "./", - "rootDir": "./src", - "incremental": true, - "skipLibCheck": true, - "strictNullChecks": true, - "noImplicitAny": true, - "strictBindCallApply": true, - "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "sourceMap": true - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/package.json b/package.json index 814e07008..799c6feb5 100644 --- a/package.json +++ b/package.json @@ -169,12 +169,8 @@ "citycorners:dev": "turbo run dev --filter=citycorners...", "dev:citycorners:landing": "pnpm --filter @citycorners/landing dev", "dev:citycorners:web": "pnpm --filter @citycorners/web dev", - "dev:citycorners:backend": "pnpm --filter @citycorners/backend dev", - "dev:citycorners:app": "turbo run dev --filter=@citycorners/web --filter=@citycorners/backend", - "dev:citycorners:full": "./scripts/setup-databases.sh citycorners && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:citycorners:backend\" \"pnpm dev:citycorners:web\"", - "citycorners:db:push": "pnpm --filter @citycorners/backend db:push", - "citycorners:db:studio": "pnpm --filter @citycorners/backend db:studio", - "citycorners:db:seed": "pnpm --filter @citycorners/backend db:seed", + "dev:citycorners:app": "pnpm dev:citycorners:web", + "dev:citycorners:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:citycorners:web\"", "deploy:landing:citycorners": "pnpm --filter @citycorners/landing build && npx wrangler pages deploy apps/citycorners/apps/landing/dist --project-name=citycorners-landing", "planta:dev": "turbo run dev --filter=planta...", "dev:planta:web": "pnpm --filter @planta/web dev", @@ -264,12 +260,9 @@ "dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend,web -c blue,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\" \"pnpm dev:questions:web\"", "questions:db:push": "pnpm --filter @questions/backend db:push", "questions:db:studio": "pnpm --filter @questions/backend db:studio", - "dev:skilltree:backend": "pnpm --filter @skilltree/backend dev", "dev:skilltree:web": "pnpm --filter @skilltree/web dev", - "dev:skilltree:app": "turbo run dev --filter=@skilltree/web --filter=@skilltree/backend", - "dev:skilltree:full": "./scripts/setup-databases.sh skilltree && ./scripts/setup-databases.sh auth && concurrently -n auth,backend,web -c blue,green,cyan \"pnpm dev:auth\" \"pnpm dev:skilltree:backend\" \"pnpm dev:skilltree:web\"", - "skilltree:db:push": "pnpm --filter @skilltree/backend db:push", - "skilltree:db:studio": "pnpm --filter @skilltree/backend db:studio", + "dev:skilltree:app": "pnpm dev:skilltree:web", + "dev:skilltree:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:skilltree:web\"", "dev:matrix": "cd services/mana-matrix-bot && go run ./cmd/server", "build:matrix": "cd services/mana-matrix-bot && go build -ldflags=\"-s -w\" -o dist/mana-matrix-bot ./cmd/server", "test:matrix": "cd services/mana-matrix-bot && go test ./...", diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 91d8af1cc..645569100 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -283,20 +283,7 @@ const APP_CONFIGS = [ }, }, - // SkillTree Backend (NestJS) - { - path: 'apps/skilltree/apps/backend/.env', - vars: { - NODE_ENV: () => 'development', - PORT: (env) => env.SKILLTREE_BACKEND_PORT || '3024', - DATABASE_URL: (env) => env.SKILLTREE_DATABASE_URL, - MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, - DEV_BYPASS_AUTH: () => 'true', - DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000', - JWT_PUBLIC_KEY: (env) => env.JWT_PUBLIC_KEY, - CORS_ORIGINS: (env) => env.CORS_ORIGINS, - }, - }, + // SkillTree Backend: REMOVED — migrated to local-first // SkillTree Web (SvelteKit) { @@ -591,19 +578,7 @@ const APP_CONFIGS = [ }, }, - // CityCorners Backend (NestJS) - { - path: 'apps/citycorners/apps/backend/.env', - vars: { - NODE_ENV: () => 'development', - PORT: (env) => env.CITYCORNERS_BACKEND_PORT || '3025', - DATABASE_URL: (env) => env.CITYCORNERS_DATABASE_URL, - MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, - DEV_BYPASS_AUTH: () => 'true', - DEV_USER_ID: (env) => env.DEV_USER_ID || '00000000-0000-0000-0000-000000000000', - CORS_ORIGINS: (env) => env.CORS_ORIGINS, - }, - }, + // CityCorners Backend: REMOVED — migrated to local-first // CityCorners Web (SvelteKit) { diff --git a/scripts/mac-mini/ensure-containers-running.sh b/scripts/mac-mini/ensure-containers-running.sh index 81b46ce51..f3d46b8da 100755 --- a/scripts/mac-mini/ensure-containers-running.sh +++ b/scripts/mac-mini/ensure-containers-running.sh @@ -162,7 +162,7 @@ for container in $ALL_PROBLEM_CONTAINERS; do mana-app-nutriphi-web) SERVICE_NAME="nutriphi-web" ;; mana-app-nutriphi-backend) SERVICE_NAME="nutriphi-backend" ;; mana-app-skilltree-web) SERVICE_NAME="skilltree-web" ;; - mana-app-skilltree-backend) SERVICE_NAME="skilltree-backend" ;; + # mana-app-skilltree-backend: REMOVED mana-app-photos-web) SERVICE_NAME="photos-web" ;; # mana-app-photos-backend: REMOVED mana-app-web) SERVICE_NAME="mana-web" ;; diff --git a/scripts/setup-databases.sh b/scripts/setup-databases.sh index e5227c64c..8bcb96320 100755 --- a/scripts/setup-databases.sh +++ b/scripts/setup-databases.sh @@ -192,7 +192,7 @@ setup_service() { ;; skilltree) create_db_if_not_exists "skilltree" - push_schema "@skilltree/backend" "skilltree" + # Schema managed by mana-sync (backend removed) ;; mukke) create_db_if_not_exists "mukke" @@ -208,7 +208,7 @@ setup_service() { ;; citycorners) create_db_if_not_exists "citycorners" - push_schema "@citycorners/backend" "citycorners" + # Schema managed by mana-sync (backend removed) ;; *) echo -e "${RED}Unknown service: $service${NC}"