mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
refactor(infra): remove citycorners + skilltree NestJS backends, clean up CI/CD
Both apps migrated to local-first (mana-sync handles CRUD). - Delete apps/citycorners/apps/backend/ (37 files) - Delete apps/skilltree/apps/backend/ (32 files) - Remove from CI build jobs, change detection, summary - Remove from package.json scripts (replaced with sync-based dev commands) - Remove from setup-databases.sh push_schema calls - Remove from generate-env.mjs backend env generation - Remove from ensure-containers-running.sh Total: 6 NestJS backends removed across all sessions (Zitare, Clock, Presi, Photos, CityCorners, SkillTree). ~12,000 lines of boilerplate eliminated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b60877e367
commit
5d02b0419d
75 changed files with 13 additions and 5355 deletions
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'citycorners',
|
||||
additionalEnvVars: ['CITYCORNERS_DATABASE_URL'],
|
||||
});
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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> = {}): 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> = {}): 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> = {}): 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<Collection[]> {
|
||||
return this.db.select().from(collections).where(eq(collections.userId, userId));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Collection> {
|
||||
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<Collection> {
|
||||
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<Collection> {
|
||||
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<void> {
|
||||
await this.findById(id, userId);
|
||||
await this.db.delete(collections).where(eq(collections.id, id));
|
||||
}
|
||||
|
||||
async addLocation(id: string, locationId: string, userId: string): Promise<Collection> {
|
||||
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<Collection> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | 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<typeof getDb>;
|
||||
|
|
@ -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<string>('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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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<string[]>().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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './locations.schema';
|
||||
export * from './favorites.schema';
|
||||
export * from './collections.schema';
|
||||
export * from './reviews.schema';
|
||||
|
|
@ -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<string, string>;
|
||||
|
||||
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<LocationImage[]>().default([]),
|
||||
timeline: jsonb('timeline').$type<TimelineEntry[]>().default([]),
|
||||
website: text('website'),
|
||||
phone: text('phone'),
|
||||
openingHours: jsonb('opening_hours').$type<OpeningHours>(),
|
||||
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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [FavoriteService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<FavoriteService>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Favorite[]> {
|
||||
return this.db.select().from(favorites).where(eq(favorites.userId, userId));
|
||||
}
|
||||
|
||||
async add(userId: string, locationId: string): Promise<Favorite> {
|
||||
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<void> {
|
||||
await this.db
|
||||
.delete(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId)));
|
||||
}
|
||||
|
||||
async isFavorite(userId: string, locationId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(and(eq(favorites.userId, userId), eq(favorites.locationId, locationId)));
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, RequestRecord>();
|
||||
private readonly maxRequests = 10;
|
||||
private readonly windowMs = 60_000; // 1 minute
|
||||
private cleanupInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string>('MANA_SEARCH_URL') || 'http://localhost:3021';
|
||||
}
|
||||
|
||||
async lookup(query: string): Promise<LookupResult | null> {
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDb = createMockDb();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [LocationService, { provide: DATABASE_CONNECTION, useValue: mockDb }],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LocationService>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T> {
|
||||
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<PaginatedResult<Location>> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let items: Location[];
|
||||
let total: number;
|
||||
|
||||
if (category) {
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`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<number>`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<Location[]> {
|
||||
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<Location> {
|
||||
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<Location> {
|
||||
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<Location> {
|
||||
if (UUID_PATTERN.test(idOrSlug)) {
|
||||
return this.findById(idOrSlug);
|
||||
}
|
||||
return this.findBySlug(idOrSlug);
|
||||
}
|
||||
|
||||
async create(data: NewLocation): Promise<Location> {
|
||||
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<string> {
|
||||
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<NewLocation>, userId?: string): Promise<Location> {
|
||||
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<number>`
|
||||
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<Location> {
|
||||
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<void> {
|
||||
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<Location> {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<Review[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(reviews)
|
||||
.where(eq(reviews.locationId, locationId))
|
||||
.orderBy(desc(reviews.createdAt));
|
||||
}
|
||||
|
||||
async getStats(locationId: string): Promise<ReviewStats> {
|
||||
const [result] = await this.db
|
||||
.select({
|
||||
averageRating: sql<number>`coalesce(round(avg(${reviews.rating})::numeric, 1), 0)::float`,
|
||||
totalReviews: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(reviews)
|
||||
.where(eq(reviews.locationId, locationId));
|
||||
|
||||
return result || { averageRating: 0, totalReviews: 0 };
|
||||
}
|
||||
|
||||
async getStatsForLocations(locationIds: string[]): Promise<Record<string, ReviewStats>> {
|
||||
if (locationIds.length === 0) return {};
|
||||
|
||||
const results = await this.db
|
||||
.select({
|
||||
locationId: reviews.locationId,
|
||||
averageRating: sql<number>`round(avg(${reviews.rating})::numeric, 1)::float`,
|
||||
totalReviews: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(reviews)
|
||||
.where(
|
||||
sql`${reviews.locationId} = ANY(${sql.raw(`ARRAY[${locationIds.map((id) => `'${id}'::uuid`).join(',')}]`)})`
|
||||
)
|
||||
.groupBy(reviews.locationId);
|
||||
|
||||
const map: Record<string, ReviewStats> = {};
|
||||
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<Review> {
|
||||
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<void> {
|
||||
await this.db
|
||||
.delete(reviews)
|
||||
.where(and(eq(reviews.userId, userId), eq(reviews.locationId, locationId)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import 'dotenv/config';
|
||||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({
|
||||
dbName: 'skilltree',
|
||||
outDir: './drizzle',
|
||||
});
|
||||
|
|
@ -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/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<void> {
|
||||
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<AchievementWithStatus[]> {
|
||||
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<Achievement[]> {
|
||||
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<number>`count(*)` }).from(achievements);
|
||||
|
||||
const [unlockedResult] = await this.db
|
||||
.select({ count: sql<number>`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<AchievementUnlockResult[]> {
|
||||
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<UserData> {
|
||||
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
|
||||
const [activityCount] = await this.db
|
||||
.select({ count: sql<number>`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<string, number>();
|
||||
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<Map<string, number>> {
|
||||
const userData = await this.getUserData(userId);
|
||||
const allAchievements = await this.db.select().from(achievements);
|
||||
const progressMap = new Map<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async findBySkill(userId: string, skillId: string): Promise<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.skillId, skillId))
|
||||
.orderBy(desc(activities.timestamp));
|
||||
}
|
||||
|
||||
async getRecent(userId: string, limit = 10): Promise<Activity[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(limit);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
|
||||
export function getDb(connectionString?: string) {
|
||||
if (db) return db;
|
||||
|
||||
const url = connectionString || process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL is not defined');
|
||||
}
|
||||
|
||||
connection = postgres(url, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
db = drizzle(connection, { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
export function getConnection() {
|
||||
return connection;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AchievementCategory>(),
|
||||
rarity: varchar('rarity', { length: 20 }).notNull().$type<AchievementRarity>(),
|
||||
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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './skills.schema';
|
||||
export * from './activities.schema';
|
||||
export * from './user-stats.schema';
|
||||
export * from './achievements.schema';
|
||||
|
|
@ -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<SkillBranch>(),
|
||||
parentId: uuid('parent_id'),
|
||||
icon: varchar('icon', { length: 50 }).default('star'),
|
||||
color: varchar('color', { length: 20 }),
|
||||
|
||||
// Progress
|
||||
currentXp: integer('current_xp').default(0).notNull(),
|
||||
totalXp: integer('total_xp').default(0).notNull(),
|
||||
level: integer('level').default(0).notNull(),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdx: index('skills_user_idx').on(table.userId),
|
||||
branchIdx: index('skills_branch_idx').on(table.userId, table.branch),
|
||||
parentIdx: index('skills_parent_idx').on(table.parentId),
|
||||
levelIdx: index('skills_level_idx').on(table.userId, table.level),
|
||||
})
|
||||
);
|
||||
|
||||
export type Skill = typeof skills.$inferSelect;
|
||||
export type NewSkill = typeof skills.$inferInsert;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export * from './create-skill.dto';
|
||||
export * from './update-skill.dto';
|
||||
export * from './add-xp.dto';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<typeof createMockDb>;
|
||||
|
||||
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>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Skill[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(eq(skills.userId, userId))
|
||||
.orderBy(desc(skills.totalXp));
|
||||
}
|
||||
|
||||
async findByBranch(userId: string, branch: string): Promise<Skill[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(and(eq(skills.userId, userId), eq(skills.branch, branch as any)))
|
||||
.orderBy(desc(skills.totalXp));
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<Skill | null> {
|
||||
const [skill] = await this.db
|
||||
.select()
|
||||
.from(skills)
|
||||
.where(and(eq(skills.id, id), eq(skills.userId, userId)));
|
||||
return skill ?? null;
|
||||
}
|
||||
|
||||
async findByIdOrThrow(id: string, userId: string): Promise<Skill> {
|
||||
const skill = await this.findById(id, userId);
|
||||
if (!skill) {
|
||||
throw new NotFoundException(`Skill with id ${id} not found`);
|
||||
}
|
||||
return skill;
|
||||
}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
dto: CreateSkillDto
|
||||
): Promise<{ skill: 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<Skill> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(skills)
|
||||
.set({
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(skills.id, id), eq(skills.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
await this.findByIdOrThrow(id, userId);
|
||||
|
||||
await this.db.delete(skills).where(and(eq(skills.id, id), eq(skills.userId, userId)));
|
||||
|
||||
// Update user stats
|
||||
await this.updateUserStats(userId);
|
||||
}
|
||||
|
||||
async addXp(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: AddXpDto
|
||||
): Promise<{
|
||||
skill: Skill;
|
||||
leveledUp: boolean;
|
||||
newLevel: number;
|
||||
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<void> {
|
||||
// Get aggregated stats
|
||||
const userSkills = await this.db.select().from(skills).where(eq(skills.userId, userId));
|
||||
|
||||
const totalXp = userSkills.reduce((sum, s) => sum + s.totalXp, 0);
|
||||
const totalSkills = userSkills.length;
|
||||
const highestLevel = userSkills.reduce((max, s) => Math.max(max, s.level), 0);
|
||||
|
||||
// Get last activity date
|
||||
const [lastActivity] = await this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp))
|
||||
.limit(1);
|
||||
|
||||
const lastActivityDate = lastActivity?.timestamp
|
||||
? lastActivity.timestamp.toISOString().split('T')[0]
|
||||
: null;
|
||||
|
||||
// Calculate streak
|
||||
const streakDays = await this.calculateStreak(userId);
|
||||
|
||||
// Upsert user stats
|
||||
await this.db
|
||||
.insert(userStats)
|
||||
.values({
|
||||
userId,
|
||||
totalXp,
|
||||
totalSkills,
|
||||
highestLevel,
|
||||
streakDays,
|
||||
lastActivityDate,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userStats.userId,
|
||||
set: {
|
||||
totalXp,
|
||||
totalSkills,
|
||||
highestLevel,
|
||||
streakDays,
|
||||
lastActivityDate,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async calculateStreak(userId: string): Promise<number> {
|
||||
const allActivities = await this.db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.userId, userId))
|
||||
.orderBy(desc(activities.timestamp));
|
||||
|
||||
if (allActivities.length === 0) return 0;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Get unique dates
|
||||
const uniqueDates = [
|
||||
...new Set(
|
||||
allActivities.map((a) => {
|
||||
const d = new Date(a.timestamp);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
})
|
||||
),
|
||||
].sort((a, b) => b - a); // Newest first
|
||||
|
||||
let streak = 0;
|
||||
let expectedDate = today.getTime();
|
||||
|
||||
for (const date of uniqueDates) {
|
||||
if (date === expectedDate || date === expectedDate - 86400000) {
|
||||
streak++;
|
||||
expectedDate = date - 86400000;
|
||||
} else if (date < expectedDate - 86400000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
async getUserStats(userId: string) {
|
||||
const [stats] = await this.db.select().from(userStats).where(eq(userStats.userId, userId));
|
||||
|
||||
if (!stats) {
|
||||
// Return default stats
|
||||
return {
|
||||
totalXp: 0,
|
||||
totalSkills: 0,
|
||||
highestLevel: 0,
|
||||
streakDays: 0,
|
||||
lastActivityDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
15
package.json
15
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 ./...",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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" ;;
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue