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:
Till JS 2026-03-28 10:24:23 +01:00
parent b60877e367
commit 5d02b0419d
75 changed files with 13 additions and 5355 deletions

View file

@ -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

View file

@ -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"]

View file

@ -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 "$@"

View file

@ -1,6 +0,0 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'citycorners',
additionalEnvVars: ['CITYCORNERS_DATABASE_URL'],
});

View file

@ -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',
};

View file

@ -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"
}
}

View file

@ -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(),
};
}

View file

@ -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 {}

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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;
}
}

View file

@ -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>;

View file

@ -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();
}
}

View file

@ -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);
});

View file

@ -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;

View file

@ -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;

View file

@ -1,4 +0,0 @@
export * from './locations.schema';
export * from './favorites.schema';
export * from './collections.schema';
export * from './reviews.schema';

View file

@ -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;

View file

@ -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;

View file

@ -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);
});

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -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);
}
}
}
}

View file

@ -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',
});

View file

@ -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);
});
});
});

View file

@ -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 '';
}
}

View file

@ -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);
});
});
});

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -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'],
});

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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)));
}
}

View file

@ -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"]
}

View file

@ -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"

View file

@ -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"]

View file

@ -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 "$@"

View file

@ -1,7 +0,0 @@
import 'dotenv/config';
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({
dbName: 'skilltree',
outDir: './drizzle',
});

View file

@ -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',
},
};

View file

@ -1,8 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -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"
}
}

View file

@ -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 },
},
];

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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;
}

View file

@ -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 };
}
}

View file

@ -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 {}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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>;

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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;

View file

@ -1,4 +0,0 @@
export * from './skills.schema';
export * from './activities.schema';
export * from './user-stats.schema';
export * from './achievements.schema';

View file

@ -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;

View file

@ -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;

View file

@ -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',
});

View file

@ -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();

View file

@ -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
}

View file

@ -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;
}

View file

@ -1,3 +0,0 @@
export * from './create-skill.dto';
export * from './update-skill.dto';
export * from './add-xp.dto';

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -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"]
}

View file

@ -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 ./...",

View file

@ -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)
{

View file

@ -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" ;;

View file

@ -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}"