diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..02beacb3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-2026 StellarHunts contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/package.json b/backend/package.json index 1ef86f0b..5d0a16f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "MIT", "scripts": { "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts index 8f7b31cb..e940a3c3 100644 --- a/backend/src/analytics/analytics.controller.ts +++ b/backend/src/analytics/analytics.controller.ts @@ -7,8 +7,10 @@ import { HttpCode, HttpStatus, Logger, + Query, } from '@nestjs/common'; import { AnalyticsService } from './analytics.service'; +import type { PaginatedUserPuzzleHistory } from './analytics.service'; class RecordSolveDto { userId: string; @@ -52,14 +54,20 @@ export class AnalyticsController { } @Get('users/:userId/history') - getUserPuzzleHistory(@Param('userId') userId: string): Record { - this.logger.log(`Handling request for user ${userId} puzzle history.`); - const userHistoryMap = this.analyticsService.getUserPuzzleStats(userId); - - const userHistoryObject = {}; - userHistoryMap.forEach((value, key) => { - userHistoryObject[key] = value; - }); - return userHistoryObject; + getUserPuzzleHistory( + @Param('userId') userId: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ): PaginatedUserPuzzleHistory { + const parsedPage = page ? parseInt(page, 10) : 1; + const parsedLimit = limit ? parseInt(limit, 10) : 20; + this.logger.log( + `Handling paginated request for user ${userId} puzzle history (page=${parsedPage}, limit=${parsedLimit}).`, + ); + return this.analyticsService.getUserPuzzleStatsPage( + userId, + parsedPage > 0 ? parsedPage : 1, + parsedLimit > 0 ? parsedLimit : 20, + ); } } diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts index d39dc084..008823ad 100644 --- a/backend/src/analytics/analytics.service.ts +++ b/backend/src/analytics/analytics.service.ts @@ -13,6 +13,30 @@ interface UserPuzzleEngagement { lastSolved?: Date; } +export interface UserPuzzleHistoryEntry { + puzzleId: string; + solveCount: number; + totalSolveTime: number; + attempts: number; + lastSolved?: Date; +} + +export interface PaginationMetadata { + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PaginatedUserPuzzleHistory { + data: UserPuzzleHistoryEntry[]; + meta: PaginationMetadata; +} + +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + @Injectable() export class AnalyticsService { private readonly logger = new Logger(AnalyticsService.name); @@ -93,6 +117,56 @@ export class AnalyticsService { ); } + getUserPuzzleStatsPage( + userId: string, + page: number = DEFAULT_PAGE, + limit: number = DEFAULT_LIMIT, + ): PaginatedUserPuzzleHistory { + this.logger.log( + `Fetching paginated puzzle history for user ${userId} (page=${page}, limit=${limit})...`, + ); + + const normalizedLimit = Math.min(Math.max(limit, 1), MAX_LIMIT); + const normalizedPage = Math.max(page, 1); + + const userHistory = + this.userPuzzleHistory.get(userId) || + new Map(); + + const allEntries: UserPuzzleHistoryEntry[] = Array.from( + userHistory.entries(), + ).map(([puzzleId, stats]) => ({ + puzzleId, + solveCount: stats.solveCount, + totalSolveTime: stats.totalSolveTime, + attempts: stats.attempts, + lastSolved: stats.lastSolved, + })); + + // Newest activity first; fall back to puzzleId for deterministic ordering. + allEntries.sort((a, b) => { + const aTime = a.lastSolved ? a.lastSolved.getTime() : 0; + const bTime = b.lastSolved ? b.lastSolved.getTime() : 0; + if (aTime !== bTime) return bTime - aTime; + return a.puzzleId.localeCompare(b.puzzleId); + }); + + const total = allEntries.length; + const totalPages = total === 0 ? 0 : Math.ceil(total / normalizedLimit); + const startIdx = (normalizedPage - 1) * normalizedLimit; + const data = allEntries.slice(startIdx, startIdx + normalizedLimit); + + return { + data, + meta: { + total, + page: normalizedPage, + limit: normalizedLimit, + totalPages, + }, + }; + } + seedData(): void { this.logger.log('Seeding initial analytics data...'); this.recordPuzzleSolve('user1', 'puzzleA', 120); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6b860209..8c16fd61 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,44 +1,38 @@ -/* eslint-disable prettier/prettier */ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; + import appConfig from 'config/app.config'; import databaseConfig from 'config/database.config'; -import { PuzzleModule } from './puzzle/puzzle.module'; -import { PuzzleSubmissionModule } from './puzzle-submission/puzzle-submission.module'; -import { TimeTrialModule } from './time-trial/time-trial.module'; + +import { User } from './auth/entities/user.entity'; +import { TimeTrial } from './time-trial/time-trial.entity'; +import { Puzzle } from './puzzle/puzzle.entity'; +import { Category } from './puzzle-category/entities/category.entity'; + +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + import { ActivityModule } from './activity/activity.module'; -import { Module } from "@nestjs/common" -import { AppController } from "./app.controller" -import { AppService } from "./app.service" -import { TypeOrmModule } from "@nestjs/typeorm" -import { ConfigModule, ConfigService } from "@nestjs/config" -import { AuthModule } from "./auth/auth.module" -import { UserInventoryModule } from "./user-inventory/user-inventory.module" -import appConfig from "config/app.config" -import databaseConfig from "config/database.config" -import { PuzzleCategoryModule } from "./puzzle-category/puzzle-category.module" -import { RewardsModule } from "./rewards/rewards.module" -import { PuzzleModule } from "./puzzle/puzzle.module" -import { PuzzleSubmissionModule } from "./puzzle-submission/puzzle-submission.module" -import { ContentModule } from "./content/content.module" -import { UserReportCardModule } from "./user-report-card/user-report-card.module" -import { PuzzleDependencyModule } from "./puzzle-dependency/puzzle-dependency.module" -import { TimeTrialModule } from "./time-trial/time-trial.module" -import { InAppNotificationsModule } from "./in-app-notifications/in-app-notifications.module" -import { User } from "./auth/entities/user.entity" -import { TimeTrial } from "./time-trial/time-trial.entity" -import { Puzzle } from "./puzzle/puzzle.entity" -import { Category } from "./puzzle-category/entities/category.entity" import { AnalyticsModule } from './analytics/analytics.module'; -import { RewardShopModule } from './reward-shop/reward-shop.module'; import { ApiKeyModule } from './api-key/api-key.module'; -import { UserRankingModule } from './user-ranking/user-ranking.module'; -import { ProgressModule } from './progress/progress.module'; +import { ContentModule } from './content/content.module'; import { ContentRatingModule } from './content-rating/content-rating.module'; -import { UserActivityLogModule } from "./user-activity-log/user-activity-log.module" +import { InAppNotificationsModule } from './in-app-notifications/in-app-notifications.module'; +import { MultiplayerQueueModule } from './multiplayer-queue/multiplayer-queue.module'; +import { NFTClaimModule } from './nft-claim/nft-claim.module'; +import { ProgressModule } from './progress/progress.module'; +import { PuzzleDependencyModule } from './puzzle-dependency/puzzle-dependency.module'; +import { PuzzleModule } from './puzzle/puzzle.module'; +import { PuzzleSubmissionModule } from './puzzle-submission/puzzle-submission.module'; +import { PuzzleTranslationModule } from './puzzle-translation/puzzle-translation.module'; +import { ReportsModule } from './reports/reports.module'; +import { RewardShopModule } from './reward-shop/reward-shop.module'; +import { TimeTrialModule } from './time-trial/time-trial.module'; +import { UserActivityLogModule } from './user-activity-log/user-activity-log.module'; +import { UserRankingModule } from './user-ranking/user-ranking.module'; +import { UserReactionModule } from './user-reaction/user-reaction.module'; +import { UserReportCardModule } from './user-report-card/user-report-card.module'; @Module({ imports: [ @@ -63,28 +57,28 @@ import { UserActivityLogModule } from "./user-activity-log/user-activity-log.mod autoLoadEntities: configService.get('database.autoload'), }), }), - PuzzleModule, - PuzzleSubmissionModule, - ContentModule, - UserReportCardModule, - PuzzleDependencyModule, - TimeTrialModule ActivityModule, - InAppNotificationsModule, - ReportsModule, - PuzzleTranslationModule, - NFTClaimModule, AnalyticsModule, - RewardShopModule, ApiKeyModule, - UserReactionModule, - MultiplayerQueueModule, - UserRankingModule, - ProgressModule, // <--- ProgressModule is kept + ContentModule, ContentRatingModule, + InAppNotificationsModule, + MultiplayerQueueModule, + NFTClaimModule, + ProgressModule, + PuzzleDependencyModule, + PuzzleModule, + PuzzleSubmissionModule, + PuzzleTranslationModule, + ReportsModule, + RewardShopModule, + TimeTrialModule, UserActivityLogModule, + UserRankingModule, + UserReactionModule, + UserReportCardModule, ], controllers: [AppController], providers: [AppService], }) -export class AppModule {} \ No newline at end of file +export class AppModule {} diff --git a/backend/src/main.ts b/backend/src/main.ts index d8b2d016..faa29cfa 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,68 +1,78 @@ -import { Module } from "@nestjs/common" -import { AppController } from "./app.controller" -import { AppService } from "./app.service" -import { TypeOrmModule } from "@nestjs/typeorm" -import { ConfigModule, ConfigService } from "@nestjs/config" -import { AuthModule } from "./auth/auth.module" -import { UserInventoryModule } from "./user-inventory/user-inventory.module" -import appConfig from "config/app.config" -import databaseConfig from "config/database.config" -import { PuzzleCategoryModule } from "./puzzle-category/puzzle-category.module" -import { RewardsModule } from "./rewards/rewards.module" -import { PuzzleModule } from "./puzzle/puzzle.module" -import { PuzzleSubmissionModule } from "./puzzle-submission/puzzle-submission.module" -import { ContentModule } from "./content/content.module" -import { UserReportCardModule } from "./user-report-card/user-report-card.module" -import { PuzzleDependencyModule } from "./puzzle-dependency/puzzle-dependency.module" -import { TimeTrialModule } from "./time-trial/time-trial.module" -import { InAppNotificationsModule } from "./in-app-notifications/in-app-notifications.module" -import { User } from "./auth/entities/user.entity" -import { TimeTrial } from "./time-trial/time-trial.entity" -import { Puzzle } from "./puzzle/puzzle.entity" -import { Category } from "./puzzle-category/entities/category.entity" -import { AnalyticsModule } from './analytics/analytics.module'; -import { RewardShopModule } from './reward-shop/reward-shop.module'; -import { ApiKeyModule } from './api-key/api-key.module'; +import { NestFactory } from '@nestjs/core'; +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: ['.env'], - load: [appConfig, databaseConfig], - cache: true, - }), - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - type: 'postgres', - host: configService.get('database.host'), - port: configService.get('database.port'), - username: configService.get('database.user'), - password: configService.get('database.password'), - database: configService.get('database.name'), - entities: [User, TimeTrial, Puzzle, Category], - synchronize: configService.get('database.synchronize'), - autoLoadEntities: configService.get('database.autoload'), - }), +import { AppModule } from './app.module'; + +async function bootstrap(): Promise { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + + // The frontend calls endpoints under the `/api` prefix (see + // frontend/store calls to /api/login, /api/register, etc.), so the global + // prefix is set on the whole Nest app. This also resolves issue #105 which + // expects the /api/users/:userId/history URL shape. + // + // Swagger UI is excluded so /docs, its JSON sibling /docs-json, and its + // nested asset routes (e.g. /docs/swagger-ui-init.js) stay at canonical + // paths instead of being double-prefixed to /api. Nest treats string + // entries as exact paths, so we also pass a RegExp to cover /docs/.... + // A single anchored regex covers `docs`, `docs-json`, and any nested + // /docs/ route (e.g. /docs/swagger-ui-init.js). Nest evaluates the + // exclude list against the registered handler path before the global + // prefix is applied. + app.setGlobalPrefix('api', { exclude: [/^docs/] }); + + app.enableCors({ + origin: configService.get('appConfig.cors.origin') ?? '*', + methods: configService.get('appConfig.cors.methods') ?? [ + 'GET', + 'POST', + 'PUT', + 'DELETE', + 'OPTIONS', + ], + allowedHeaders: configService.get( + 'appConfig.cors.allowedHeaders', + ) ?? [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Authorization', + ], + credentials: + configService.get('appConfig.cors.credentials') ?? true, + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: false, }), - PuzzleModule, - PuzzleSubmissionModule, - ContentModule, - UserReportCardModule, - PuzzleDependencyModule, - TimeTrialModule, - InAppNotificationsModule, - PuzzleTranslationModule, - NFTClaimModule, - AnalyticsModule, - RewardShopModule, - ApiKeyModule, - UserReactionModule, - MultiplayerQueueModule, - ], - controllers: [AppController], - providers: [AppService], -}) -export class AppModule {} \ No newline at end of file + ); + + const apiVersion = configService.get('appConfig.apiVersion') ?? '1.0'; + const swaggerConfig = new DocumentBuilder() + .setTitle('StellarHunts API') + .setDescription('StellarHunts backend REST API documentation.') + .setVersion(apiVersion) + .addBearerAuth( + { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + 'bearer', + ) + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + // Excluded from the global prefix above, so this resolves to /docs. + SwaggerModule.setup('docs', app, document); + + const port = parseInt(process.env.PORT, 10) || 3001; + await app.listen(port); + logger.log(`StellarHunts API listening on http://localhost:${port}`); + logger.log(`Swagger UI available at http://localhost:${port}/docs`); +} + +bootstrap();