├── .server_pid ├── .gitignore ├── .prettierrc ├── main.sh ├── tsconfig.build.json ├── src ├── app.service.ts ├── friends │ ├── dto │ │ ├── create-friend-request.dto.ts │ │ └── handle-request.dto.ts │ ├── friends.module.ts │ ├── friends.controller.ts │ └── friends.service.ts ├── users │ ├── dto │ │ ├── create-user.dto.ts │ │ └── update-user.dto.ts │ ├── users.module.ts │ ├── users.controller.ts │ └── users.service.ts ├── common │ ├── decorators │ │ ├── public.decorator.ts │ │ ├── roles.decorator.ts │ │ ├── admin-only.decorator.ts │ │ └── current-user.decorator.ts │ ├── logs │ │ └── logs.service.ts │ ├── guards │ │ ├── owner-or-admin.guard.ts │ │ ├── jwt-auth.guard.ts │ │ ├── roles.guard.ts │ │ └── global-jwt-auth.guard.ts │ ├── interceptors │ │ ├── transform.interceptor.ts │ │ └── logging.interceptor.ts │ └── filters │ │ └── http-exception.filter.ts ├── books │ ├── dto │ │ ├── update-book.dto.ts │ │ ├── create-book.dto.ts │ │ └── find-all-books.dto.ts │ ├── books.module.ts │ ├── books.controller.ts │ └── books.service.ts ├── auth │ ├── auth.module.ts │ ├── auth.controller.ts │ ├── jwt.util.ts │ └── auth.service.ts ├── app.controller.ts ├── reading │ ├── dto │ │ ├── update-progress.dto.ts │ │ └── find-shelf.dto.ts │ ├── reading.module.ts │ ├── reading.controller.ts │ └── reading.service.ts ├── utils │ ├── uuid.ts │ └── auth.utils.ts ├── admin │ └── admin.controller.ts ├── app.module.ts ├── types │ └── shims.d.ts ├── database │ ├── database.service.ts │ └── mock-db.ts └── main.ts ├── nest-cli.json ├── tsconfig.json ├── package.json ├── Dockerfile ├── eslint.config.mjs ├── .github └── workflows │ └── docker-build-push.yml ├── docker-guide-windows ├── docker-guide-macos ├── example-outputs-course6.txt ├── LICENSE ├── test-curls-course5.txt ├── README.md ├── server.log ├── runtime.log ├── server.out ├── test-curls-course6.txt ├── course6-curl-results.txt └── public ├── index.html └── app.js /.server_pid: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /main.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd /usercode/FILESYSTEM 4 | 5 | bash .codesignal/run_solution.sh 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } -------------------------------------------------------------------------------- /src/friends/dto/create-friend-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsUUID } from 'class-validator'; 2 | 3 | export class CreateFriendRequestDto { 4 | @IsUUID() 5 | recipientId!: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class CreateUserDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name!: string; 7 | } -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | export const IS_PUBLIC_KEY = 'isPublic'; 3 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 4 | 5 | -------------------------------------------------------------------------------- /src/books/dto/update-book.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateBookDto } from './create-book.dto'; 3 | 4 | export class UpdateBookDto extends PartialType(CreateBookDto) {} -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/friends/dto/handle-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum } from 'class-validator'; 2 | 3 | export class HandleRequestDto { 4 | @IsEnum(['accepted', 'declined']) 5 | status!: 'accepted' | 'declined'; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/common/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | export const ROLES_KEY = 'roles'; 3 | export type Role = 'user' | 'admin'; 4 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 5 | 6 | -------------------------------------------------------------------------------- /src/users/dto/update-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | 3 | export class UpdateUserDto { 4 | @IsOptional() 5 | @IsString() 6 | name?: string; 7 | 8 | @IsOptional() 9 | @IsString() 10 | username?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | import { AuthController } from './auth.controller'; 4 | 5 | @Module({ 6 | controllers: [AuthController], 7 | providers: [AuthService], 8 | exports: [AuthService], 9 | }) 10 | export class AuthModule {} 11 | 12 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller('api') 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | 8 | @Get('hello') 9 | getHello() { 10 | const data = this.appService.getHello(); 11 | return { success: true, data }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/common/decorators/admin-only.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, UseGuards } from '@nestjs/common'; 2 | import { Roles } from './roles.decorator'; 3 | import { RolesGuard } from '../guards/roles.guard'; 4 | import { JwtAuthGuard } from '../guards/jwt-auth.guard'; 5 | 6 | export function AdminOnly() { 7 | return applyDecorators(UseGuards(JwtAuthGuard, RolesGuard), Roles('admin')); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { ReadingModule } from '../reading/reading.module'; 5 | 6 | @Module({ 7 | imports: [ReadingModule], 8 | controllers: [UsersController], 9 | providers: [UsersService], 10 | exports: [UsersService], 11 | }) 12 | export class UsersModule {} 13 | -------------------------------------------------------------------------------- /src/books/dto/create-book.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsInt, Min, IsOptional, IsISO8601 } from 'class-validator'; 2 | 3 | export class CreateBookDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | title!: string; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | author!: string; 11 | 12 | @IsInt() 13 | @Min(1) 14 | totalPages!: number; 15 | 16 | @IsOptional() 17 | @IsISO8601() 18 | publishDate?: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/common/decorators/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export interface TokenUser { 4 | userId: string; 5 | role: 'user' | 'admin'; 6 | } 7 | 8 | export const CurrentUser = createParamDecorator( 9 | (_data: unknown, ctx: ExecutionContext): TokenUser | undefined => { 10 | const req = ctx.switchToHttp().getRequest() as any; 11 | return req.user as TokenUser | undefined; 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /src/reading/dto/update-progress.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsInt, Min, IsOptional, IsEnum, IsUUID } from 'class-validator'; 2 | 3 | export class UpdateProgressDto { 4 | @IsUUID() 5 | userId!: string; 6 | 7 | @IsUUID() 8 | bookId!: string; 9 | 10 | @IsInt() 11 | @Min(0) 12 | currentPage!: number; 13 | 14 | @IsOptional() 15 | @IsEnum(['not-started', 'in-progress', 'completed', 'want-to-read']) 16 | status?: 'not-started' | 'in-progress' | 'completed' | 'want-to-read'; 17 | } 18 | -------------------------------------------------------------------------------- /src/books/books.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { BooksService } from './books.service'; 3 | import { BooksController } from './books.controller'; 4 | import { RolesGuard } from '../common/guards/roles.guard'; 5 | import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; 6 | 7 | @Module({ 8 | controllers: [BooksController], 9 | providers: [BooksService, RolesGuard, JwtAuthGuard], 10 | exports: [BooksService], 11 | }) 12 | export class BooksModule {} 13 | -------------------------------------------------------------------------------- /src/friends/friends.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FriendsService } from './friends.service'; 3 | import { FriendsController } from './friends.controller'; 4 | import { UsersModule } from '../users/users.module'; 5 | import { ReadingModule } from '../reading/reading.module'; 6 | 7 | @Module({ 8 | imports: [UsersModule, ReadingModule], 9 | controllers: [FriendsController], 10 | providers: [FriendsService], 11 | }) 12 | export class FriendsModule {} 13 | 14 | -------------------------------------------------------------------------------- /src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | // Local lightweight UUID v4 shim to avoid external dependency issues. 2 | // This is sufficient for demo/testing purposes and not cryptographically secure. 3 | export function v4(): string { 4 | // RFC4122-ish template 5 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 6 | const r = (Math.random() * 16) | 0; 7 | const v = c === 'x' ? r : (r & 0x3) | 0x8; 8 | return v.toString(16); 9 | }); 10 | } 11 | 12 | export { v4 as uuidv4 }; 13 | 14 | -------------------------------------------------------------------------------- /src/reading/dto/find-shelf.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsOptional } from 'class-validator'; 2 | 3 | export class FindShelfDto { 4 | @IsOptional() 5 | @IsEnum(['not-started', 'in-progress', 'completed', 'want-to-read']) 6 | status?: 'not-started' | 'in-progress' | 'completed' | 'want-to-read'; 7 | 8 | @IsOptional() 9 | @IsEnum(['title', 'author', 'updatedAt', 'progress']) 10 | sortBy?: 'title' | 'author' | 'updatedAt' | 'progress'; 11 | 12 | @IsOptional() 13 | @IsEnum(['asc', 'desc']) 14 | order?: 'asc' | 'desc'; 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/reading/reading.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ReadingService } from './reading.service'; 3 | import { ReadingController } from './reading.controller'; 4 | import { BooksModule } from '../books/books.module'; 5 | import { OwnerOrAdminGuard } from '../common/guards/owner-or-admin.guard'; 6 | 7 | @Module({ 8 | imports: [BooksModule], 9 | controllers: [ReadingController], 10 | providers: [ReadingService, OwnerOrAdminGuard], 11 | exports: [ReadingService], 12 | }) 13 | export class ReadingModule {} 14 | -------------------------------------------------------------------------------- /src/common/logs/logs.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | export interface RequestLog { 4 | method: string; 5 | url: string; 6 | status: number; 7 | ms: number; 8 | at: string; 9 | } 10 | 11 | @Injectable() 12 | export class LogsService { 13 | private buffer: RequestLog[] = []; 14 | private readonly max = 100; 15 | 16 | add(entry: RequestLog) { 17 | this.buffer.push(entry); 18 | if (this.buffer.length > this.max) this.buffer.shift(); 19 | } 20 | 21 | list(limit = 20) { 22 | return this.buffer.slice(-limit).reverse(); 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/admin/admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, DefaultValuePipe, ParseIntPipe } from '@nestjs/common'; 2 | import { LogsService } from '../common/logs/logs.service'; 3 | import { AdminOnly } from '../common/decorators/admin-only.decorator'; 4 | 5 | @Controller('admin') 6 | export class AdminController { 7 | constructor(private readonly logs: LogsService) {} 8 | 9 | @Get('logs') 10 | @AdminOnly() 11 | getLogs(@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number) { 12 | const data = this.logs.list(limit); 13 | return { success: true, data }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "types": [], 14 | "baseUrl": ".", 15 | "paths": { 16 | "uuid": ["src/utils/uuid"], 17 | "uuid/*": ["src/utils/uuid"] 18 | } 19 | }, 20 | "include": ["src/**/*"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /src/books/dto/find-all-books.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsInt, IsOptional, IsString, Min, Max } from 'class-validator'; 2 | 3 | export class FindAllBooksDto { 4 | @IsOptional() 5 | @IsString() 6 | q?: string; // case-insensitive title/author search 7 | 8 | @IsOptional() 9 | @IsEnum(['title', 'author', 'publishDate', 'uploadedAt', 'avgProgress']) 10 | sortBy?: 'title' | 'author' | 'publishDate' | 'uploadedAt' | 'avgProgress'; 11 | 12 | @IsOptional() 13 | @IsEnum(['asc', 'desc']) 14 | order?: 'asc' | 'desc'; 15 | 16 | @IsOptional() 17 | @IsInt() 18 | @Min(1) 19 | page?: number; 20 | 21 | @IsOptional() 22 | @IsInt() 23 | @Min(1) 24 | @Max(100) 25 | pageSize?: number; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | import { AuthService } from './auth.service'; 3 | 4 | @Controller('auth') 5 | export class AuthController { 6 | constructor(private readonly authService: AuthService) {} 7 | 8 | @Post('register') 9 | async register(@Body() body: { name: string; username: string; password: string }) { 10 | const data = await this.authService.register(body.name, body.username, body.password); 11 | return { success: true, data }; 12 | } 13 | 14 | @Post('login') 15 | async login(@Body() body: { username: string; password: string }) { 16 | const data = await this.authService.login(body.username, body.password); 17 | return { success: true, data }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/guards/owner-or-admin.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class OwnerOrAdminGuard implements CanActivate { 5 | canActivate(context: ExecutionContext): boolean { 6 | const req = context.switchToHttp().getRequest() as any; 7 | const user = req.user as { userId: string; role: 'user' | 'admin' } | undefined; 8 | if (!user) return false; // Global JWT guard should set this 9 | 10 | if (user.role === 'admin') return true; 11 | 12 | const bodyUserId = req.body?.userId as string; 13 | if (user.userId !== bodyUserId) { 14 | throw new ForbiddenException('You can only modify your own progress'); 15 | } 16 | return true; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/common/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | interface Envelope { 6 | data: T; 7 | meta?: { count?: number; timestamp?: string }; 8 | } 9 | 10 | @Injectable() 11 | export class TransformInterceptor implements NestInterceptor> { 12 | intercept(_context: ExecutionContext, next: CallHandler): Observable> { 13 | return next.handle().pipe( 14 | map((payload: any) => { 15 | const meta: Envelope['meta'] = { timestamp: new Date().toISOString() }; 16 | if (Array.isArray(payload)) meta.count = payload.length; 17 | return { data: payload, meta }; 18 | }), 19 | ); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-app", 3 | "version": "1.0.0", 4 | "main": "dist/main.js", 5 | "scripts": { 6 | "build": "tsc -p tsconfig.json", 7 | "start": "node dist/main.js", 8 | "start:dev": "nest start --watch" 9 | }, 10 | "dependencies": { 11 | "@nestjs/common": "^10.0.0", 12 | "@nestjs/core": "^10.0.0", 13 | "@nestjs/mapped-types": "^2.1.0", 14 | "@nestjs/platform-express": "^10.0.0", 15 | "bcryptjs": "^3.0.2", 16 | "uuid": "^9.0.1", 17 | "class-transformer": "^0.5.1", 18 | "class-validator": "^0.14.2", 19 | "reflect-metadata": "^0.1.13", 20 | "rxjs": "^7.0.0" 21 | }, 22 | "devDependencies": { 23 | "@nestjs/testing": "^10.0.0", 24 | "@types/express": "^5.0.3", 25 | "@types/node": "^20.17.23", 26 | "ts-node": "^10.9.2", 27 | "ts-node-dev": "2.0.0", 28 | "typescript": "^5.8.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/common/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { verifyJwt } from '../../auth/jwt.util'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard implements CanActivate { 6 | canActivate(context: ExecutionContext): boolean | Promise { 7 | const req = context.switchToHttp().getRequest() as any; 8 | const auth = req.headers['authorization'] as string | undefined; 9 | if (!auth || !auth.startsWith('Bearer ')) { 10 | throw new UnauthorizedException('Missing token'); 11 | } 12 | const token = auth.substring('Bearer '.length); 13 | const payload = verifyJwt(token); 14 | if (!payload) { 15 | throw new UnauthorizedException('Invalid token'); 16 | } 17 | req.user = { userId: payload.sub, role: payload.role }; 18 | return true; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/common/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; 2 | import { Request, Response } from 'express'; 3 | 4 | @Catch(HttpException) 5 | export class HttpExceptionFilter implements ExceptionFilter { 6 | catch(exception: HttpException, host: ArgumentsHost) { 7 | const ctx = host.switchToHttp(); 8 | const res = ctx.getResponse(); 9 | const req = ctx.getRequest(); 10 | const status = exception.getStatus(); 11 | const raw = exception.getResponse(); 12 | const message = typeof raw === 'string' ? raw : (raw as any)?.message; 13 | 14 | res.status(status).json({ 15 | error: { 16 | statusCode: status, 17 | message: Array.isArray(message) ? message.join(', ') : message, 18 | path: req.url, 19 | timestamp: new Date().toISOString(), 20 | }, 21 | }); 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/common/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { ROLES_KEY, Role } from '../decorators/roles.decorator'; 4 | 5 | @Injectable() 6 | export class RolesGuard implements CanActivate { 7 | constructor(private readonly reflector: Reflector) {} 8 | 9 | canActivate(context: ExecutionContext): boolean { 10 | const required = this.reflector.getAllAndOverride(ROLES_KEY, [ 11 | context.getHandler(), 12 | context.getClass(), 13 | ]); 14 | if (!required || required.length === 0) return true; 15 | 16 | const req = context.switchToHttp().getRequest() as any; 17 | const role = req.user?.role as Role | undefined; 18 | 19 | if (!role || !required.includes(role)) { 20 | throw new ForbiddenException('Insufficient role'); 21 | } 22 | return true; 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Multi-stage Dockerfile for NestJS app 2 | 3 | FROM node:20-alpine AS base 4 | WORKDIR /app 5 | 6 | # Install dependencies separately for layer caching 7 | FROM base AS deps 8 | COPY package.json package-lock.json ./ 9 | RUN npm ci --no-audit --no-fund 10 | 11 | # Build the application (needs devDependencies like @nestjs/cli) 12 | FROM deps AS build 13 | COPY . . 14 | RUN npm run build 15 | 16 | # Prune dev dependencies to shrink runtime size 17 | RUN npm prune --omit=dev 18 | 19 | # Final runtime image 20 | FROM node:20-alpine AS runner 21 | ENV NODE_ENV=production 22 | ENV PORT=3001 23 | WORKDIR /app 24 | 25 | # Only copy what is needed at runtime 26 | COPY --from=build /app/node_modules ./node_modules 27 | COPY --from=build /app/package.json ./package.json 28 | COPY --from=build /app/package-lock.json ./package-lock.json 29 | COPY --from=build /app/dist ./dist 30 | COPY --from=build /app/public ./public 31 | 32 | EXPOSE 3001 33 | # Because tsconfig includes project root, compiled entry is dist/src/main.js 34 | CMD ["node", "dist/main.js"] 35 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { UsersModule } from './users/users.module'; 6 | import { DatabaseService } from './database/database.service'; 7 | import { BooksModule } from './books/books.module'; 8 | import { ReadingModule } from './reading/reading.module'; 9 | import { AuthModule } from './auth/auth.module'; 10 | import { AdminController } from './admin/admin.controller'; 11 | import { LogsService } from './common/logs/logs.service'; 12 | import { RolesGuard } from './common/guards/roles.guard'; 13 | import { FriendsModule } from './friends/friends.module'; 14 | 15 | @Global() 16 | @Module({ 17 | imports: [UsersModule, BooksModule, ReadingModule, AuthModule, FriendsModule], 18 | controllers: [AppController, AdminController], 19 | providers: [ 20 | AppService, 21 | DatabaseService, 22 | LogsService, 23 | RolesGuard, 24 | Reflector, 25 | ], 26 | exports: [DatabaseService], 27 | }) 28 | export class AppModule {} 29 | -------------------------------------------------------------------------------- /src/common/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { Observable } from 'rxjs'; 4 | import { tap } from 'rxjs/operators'; 5 | import { LogsService } from '../logs/logs.service'; 6 | 7 | @Injectable() 8 | export class LoggingInterceptor implements NestInterceptor { 9 | constructor(private readonly logs: LogsService) {} 10 | 11 | intercept(ctx: ExecutionContext, next: CallHandler): Observable { 12 | const http = ctx.switchToHttp(); 13 | const req = http.getRequest(); 14 | const res = http.getResponse(); 15 | const method = (req as any).method; 16 | const url = (req as any).originalUrl || (req as any).url; 17 | const start = Date.now(); 18 | 19 | return next.handle().pipe( 20 | tap(() => { 21 | this.logs.add({ 22 | method, 23 | url, 24 | status: (res as any).statusCode, 25 | ms: Date.now() - start, 26 | at: new Date().toISOString(), 27 | }); 28 | }), 29 | ); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/common/guards/global-jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; 4 | import { verifyJwt } from '../../auth/jwt.util'; 5 | 6 | @Injectable() 7 | export class GlobalJwtAuthGuard implements CanActivate { 8 | constructor(private readonly reflector: Reflector) {} 9 | 10 | canActivate(ctx: ExecutionContext): boolean { 11 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 12 | ctx.getHandler(), 13 | ctx.getClass(), 14 | ]); 15 | if (isPublic) return true; 16 | 17 | const req = ctx.switchToHttp().getRequest() as any; 18 | const auth = req.headers['authorization'] as string | undefined; 19 | if (!auth?.startsWith('Bearer ')) { 20 | throw new UnauthorizedException('Missing token'); 21 | } 22 | const token = auth.slice(7); 23 | const payload = verifyJwt(token); 24 | if (!payload) throw new UnauthorizedException('Invalid token'); 25 | 26 | req.user = { userId: payload.sub, role: payload.role }; 27 | return true; 28 | } 29 | } 30 | 31 | -------------------------------------------------------------------------------- /src/types/shims.d.ts: -------------------------------------------------------------------------------- 1 | // Minimal ambient module shims to avoid needing @types/* packages at build time. 2 | declare module 'express' { 3 | export type Request = any; 4 | export type Response = any; 5 | const e: any; 6 | export default e; 7 | } 8 | declare module '@nestjs/platform-express' { 9 | export type NestExpressApplication = any; 10 | } 11 | declare module 'body-parser' { const v: any; export = v; } 12 | declare module 'connect' { const v: any; export = v; } 13 | declare module 'express-serve-static-core' { const v: any; export = v; } 14 | declare module 'http-errors' { const v: any; export = v; } 15 | declare module 'mime' { const v: any; export = v; } 16 | declare module 'qs' { const v: any; export = v; } 17 | declare module 'range-parser' { const v: any; export = v; } 18 | declare module 'send' { const v: any; export = v; } 19 | declare module 'serve-static' { const v: any; export = v; } 20 | declare module 'strip-bom' { const v: any; export = v; } 21 | declare module 'strip-json-comments' { const v: any; export = v; } 22 | declare module 'path' { const v: any; export = v; } 23 | 24 | // globals used by Node code in this project 25 | declare var __dirname: string; 26 | declare var process: any; 27 | 28 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js'; 3 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 4 | import globals from 'globals'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { 9 | ignores: ['eslint.config.mjs'], 10 | }, 11 | eslint.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | eslintPluginPrettierRecommended, 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.node, 18 | ...globals.jest, 19 | }, 20 | sourceType: 'commonjs', 21 | parserOptions: { 22 | projectService: true, 23 | tsconfigRootDir: import.meta.dirname, 24 | }, 25 | }, 26 | }, 27 | // Frontend JS in public/ runs in the browser and should have browser globals 28 | { 29 | files: ['public/**/*.js'], 30 | languageOptions: { 31 | globals: { 32 | ...globals.browser, 33 | }, 34 | // Keep default JS parser for plain browser scripts 35 | parserOptions: { 36 | projectService: false, 37 | }, 38 | }, 39 | }, 40 | { 41 | rules: { 42 | '@typescript-eslint/no-explicit-any': 'off', 43 | '@typescript-eslint/no-floating-promises': 'warn', 44 | '@typescript-eslint/no-unsafe-argument': 'warn' 45 | }, 46 | }, 47 | ); 48 | -------------------------------------------------------------------------------- /src/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { users, books, readingSessions, friendRequests, User, Book, ReadingSession, FriendRequest } from './mock-db'; 3 | 4 | @Injectable() 5 | export class DatabaseService { 6 | private readonly users: User[] = users; 7 | private readonly books: Book[] = books; 8 | private readonly readingSessions: ReadingSession[] = readingSessions; 9 | private readonly friendRequests: FriendRequest[] = friendRequests; 10 | 11 | /** 12 | * Retrieve all users. 13 | */ 14 | getUsers(): User[] { 15 | return this.users; 16 | } 17 | 18 | /** 19 | * Find a user by its ID. 20 | */ 21 | findUserById(id: string): User | undefined { 22 | return this.users.find(user => user.id === id); 23 | } 24 | 25 | /** 26 | * Retrieve all books. 27 | */ 28 | getBooks(): Book[] { 29 | return this.books; 30 | } 31 | 32 | /** 33 | * Find a book by its ID. 34 | */ 35 | findBookById(id: string): Book | undefined { 36 | return this.books.find(book => book.id === id); 37 | } 38 | 39 | /** 40 | * Retrieve all reading sessions. 41 | */ 42 | getReadingSessions(): ReadingSession[] { 43 | return this.readingSessions; 44 | } 45 | 46 | /** 47 | * Retrieve all friend requests. 48 | */ 49 | getFriendRequests(): FriendRequest[] { 50 | return this.friendRequests; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/auth/jwt.util.ts: -------------------------------------------------------------------------------- 1 | const SECRET = 'jwt-secret'; 2 | 3 | function base64url(input: Buffer | string) { 4 | return Buffer.from(input) 5 | .toString('base64') 6 | .replace(/=/g, '') 7 | .replace(/\+/g, '-') 8 | .replace(/\//g, '_'); 9 | } 10 | 11 | export interface JwtPayload { 12 | sub: string; 13 | role: 'user' | 'admin'; 14 | iat?: number; 15 | exp?: number; 16 | } 17 | 18 | function simpleSig(input: string) { 19 | // Non-cryptographic placeholder signature: base64url(SECRET + '.' + input) 20 | return base64url(`${SECRET}.${input}`); 21 | } 22 | 23 | export function signJwt(payload: Omit, expiresInSeconds = 3600): string { 24 | const header = { alg: 'HS256', typ: 'JWT' }; 25 | const now = Math.floor(Date.now() / 1000); 26 | const fullPayload: JwtPayload = { ...payload, iat: now, exp: now + expiresInSeconds }; 27 | 28 | const headerB64 = base64url(JSON.stringify(header)); 29 | const payloadB64 = base64url(JSON.stringify(fullPayload)); 30 | const data = `${headerB64}.${payloadB64}`; 31 | const sigB64 = simpleSig(data); 32 | return `${data}.${sigB64}`; 33 | } 34 | 35 | export function verifyJwt(token: string): JwtPayload | null { 36 | try { 37 | const [headerB64, payloadB64, sigB64] = token.split('.'); 38 | if (!headerB64 || !payloadB64 || !sigB64) return null; 39 | const data = `${headerB64}.${payloadB64}`; 40 | const expected = simpleSig(data); 41 | if (expected !== sigB64) return null; 42 | const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString('utf8')) as JwtPayload; 43 | const now = Math.floor(Date.now() / 1000); 44 | if (payload.exp && payload.exp < now) return null; 45 | return payload; 46 | } catch { 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/friends/friends.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Post, Body, Get, Patch, Param, ParseUUIDPipe, UseGuards } from '@nestjs/common'; 2 | import { FriendsService } from './friends.service'; 3 | import { CreateFriendRequestDto } from './dto/create-friend-request.dto'; 4 | import { HandleRequestDto } from './dto/handle-request.dto'; 5 | import { CurrentUser, TokenUser } from '../common/decorators/current-user.decorator'; 6 | import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; 7 | 8 | @Controller('friends') 9 | export class FriendsController { 10 | constructor(private readonly friends: FriendsService) {} 11 | 12 | @Post('request') 13 | @UseGuards(JwtAuthGuard) 14 | requestFriend(@CurrentUser() user: TokenUser, @Body() dto: CreateFriendRequestDto) { 15 | const data = this.friends.requestFriend(user.userId, dto.recipientId); 16 | return { success: true, data }; 17 | } 18 | 19 | @Get('requests') 20 | @UseGuards(JwtAuthGuard) 21 | getIncoming(@CurrentUser() user: TokenUser) { 22 | const data = this.friends.getIncomingRequests(user.userId); 23 | return { success: true, data }; 24 | } 25 | 26 | @Patch('requests/:requestId') 27 | @UseGuards(JwtAuthGuard) 28 | handle( 29 | @CurrentUser() user: TokenUser, 30 | @Param('requestId', ParseUUIDPipe) requestId: string, 31 | @Body() dto: HandleRequestDto, 32 | ) { 33 | const data = this.friends.handleRequest(user.userId, requestId, dto.status); 34 | return { success: true, data }; 35 | } 36 | 37 | @Get(':friendId/progress') 38 | @UseGuards(JwtAuthGuard) 39 | getFriendProgress( 40 | @CurrentUser() user: TokenUser, 41 | @Param('friendId', ParseUUIDPipe) friendId: string, 42 | ) { 43 | const data = this.friends.getFriendProgress(user.userId, friendId); 44 | return { success: true, data }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { DatabaseService } from '../database/database.service'; 3 | import { hashPassword, verifyPassword } from '../utils/auth.utils'; 4 | import { signJwt } from './jwt.util'; 5 | import { persistUserSeed } from '../utils/auth.utils'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | @Injectable() 9 | export class AuthService { 10 | constructor(private readonly db: DatabaseService) {} 11 | 12 | async register(name: string, username: string, password: string) { 13 | if (!name || !username || !password) { 14 | throw new BadRequestException('Missing required fields'); 15 | } 16 | const users = this.db.getUsers(); 17 | const exists = users.some(u => u.username === username); 18 | if (exists) { 19 | throw new BadRequestException('Username already exists'); 20 | } 21 | const id = uuidv4(); 22 | const newUser = { 23 | id, 24 | name, 25 | username, 26 | passwordHash: hashPassword(password), 27 | role: 'user' as const, 28 | friendIds: [] as string[], 29 | }; 30 | this.db.getUsers().push(newUser); 31 | // Persist into src/database/mock-db.ts for course base task continuity 32 | persistUserSeed(this.db); 33 | return { message: 'User registered successfully' }; 34 | } 35 | 36 | async login(username: string, password: string) { 37 | if (!username || !password) { 38 | throw new UnauthorizedException('Invalid credentials'); 39 | } 40 | const user = this.db.getUsers().find(u => u.username === username); 41 | if (!user || !verifyPassword(password, user.passwordHash)) { 42 | throw new UnauthorizedException('Invalid credentials'); 43 | } 44 | const access_token = signJwt({ sub: user.id, role: user.role }, 60 * 60); 45 | return { access_token }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/reading/reading.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Patch, Body, Get, Param, ParseUUIDPipe, UseGuards, Query } from '@nestjs/common'; 2 | import { ReadingService } from './reading.service'; 3 | import { UpdateProgressDto } from './dto/update-progress.dto'; 4 | import { OwnerOrAdminGuard } from '../common/guards/owner-or-admin.guard'; 5 | import { CurrentUser, TokenUser } from '../common/decorators/current-user.decorator'; 6 | import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; 7 | import { FindShelfDto } from './dto/find-shelf.dto'; 8 | 9 | @Controller('reading') 10 | export class ReadingController { 11 | constructor(private readonly readingService: ReadingService) {} 12 | 13 | @Patch('progress') 14 | @UseGuards(JwtAuthGuard, OwnerOrAdminGuard) 15 | // Update reading progress for a user and book (auth required) 16 | updateProgress(@CurrentUser() user: TokenUser, @Body() updateProgressDto: UpdateProgressDto) { 17 | // For non-admins, force userId to the token's subject. 18 | // Admins may update any user's progress (userId comes from body). 19 | if (user.role !== 'admin') { 20 | updateProgressDto.userId = user.userId; 21 | } 22 | const data = this.readingService.updateProgress(updateProgressDto); 23 | return { success: true, data }; 24 | } 25 | @Get('progress/:bookId') 26 | // Get reading progress for a specific book across all users 27 | getProgress(@Param('bookId', ParseUUIDPipe) bookId: string) { 28 | const data = this.readingService.getProgressByBook(bookId); 29 | return { success: true, data }; 30 | } 31 | 32 | @Get('shelf') 33 | @UseGuards(JwtAuthGuard) 34 | // Get the authenticated user's shelf with optional filters/sorting 35 | getShelf(@CurrentUser() user: TokenUser, @Query() query: FindShelfDto) { 36 | const data = this.readingService.getShelf(user.userId, query); 37 | return { success: true, data }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | run-name: Build and push reading-tracker-app image for ${{ github.ref_name }} by @${{ github.actor }} 3 | 4 | on: 5 | push: 6 | branches: 7 | - '**' 8 | tags: 9 | - 'v*' 10 | pull_request: 11 | branches: 12 | - '**' 13 | 14 | env: 15 | REGISTRY: ghcr.io 16 | IMAGE_NAME: ${{ github.repository }} 17 | 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | jobs: 23 | build-and-push: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v3 31 | 32 | - name: Log in to Container Registry 33 | uses: docker/login-action@v3 34 | with: 35 | registry: ${{ env.REGISTRY }} 36 | username: ${{ github.actor }} 37 | password: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Extract metadata 40 | id: meta 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 44 | tags: | 45 | # set latest tag for default branch 46 | type=ref,event=branch 47 | type=ref,event=pr 48 | type=semver,pattern={{version}} 49 | type=semver,pattern={{major}}.{{minor}} 50 | type=semver,pattern={{major}} 51 | type=raw,value=latest,enable={{is_default_branch}} 52 | 53 | - name: Build and push Docker image 54 | uses: docker/build-push-action@v5 55 | with: 56 | context: . 57 | platforms: linux/amd64,linux/arm64 58 | push: true 59 | tags: ${{ steps.meta.outputs.tags }} 60 | labels: ${{ steps.meta.outputs.labels }} 61 | cache-from: type=gha 62 | cache-to: type=gha,mode=max 63 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { join } from 'path'; 5 | import { NestExpressApplication } from '@nestjs/platform-express'; 6 | import { Request, Response } from 'express'; 7 | import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; 8 | import { TransformInterceptor } from './common/interceptors/transform.interceptor'; 9 | import { HttpExceptionFilter } from './common/filters/http-exception.filter'; 10 | import { LogsService } from './common/logs/logs.service'; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule); 14 | // Enable CORS for all origins to allow frontend communication and enable validation 15 | app.enableCors({ origin: '*', credentials: true }); 16 | // Enable global validation pipe for DTO validation 17 | app.useGlobalPipes(new ValidationPipe({ transform: true, transformOptions: { enableImplicitConversion: true } })); 18 | // Global interceptors and filters 19 | const logs = app.get(LogsService); 20 | app.useGlobalInterceptors(new LoggingInterceptor(logs), new TransformInterceptor()); 21 | app.useGlobalFilters(new HttpExceptionFilter()); 22 | // Serve static UI from public folder 23 | const publicDir = join(__dirname, '..', 'public'); 24 | app.useStaticAssets(publicDir); 25 | const server = app.getHttpAdapter().getInstance(); 26 | // SPA fallback: exclude API routes and module endpoints 27 | // Exclude API routes from SPA fallback, including newly added 'friends' 28 | server.get(/^(?!\/(?:api|users|books|reading|auth|admin|friends)).*/, (req: Request, res: Response) => 29 | res.sendFile(join(publicDir, 'index.html')), 30 | ); 31 | 32 | const port = process.env.PORT || 3000; 33 | await app.listen(port); 34 | console.log(`Application is running on: http://localhost:${port}`); 35 | } 36 | 37 | bootstrap(); 38 | -------------------------------------------------------------------------------- /src/books/books.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, Delete, ParseUUIDPipe, UseGuards, Query } from '@nestjs/common'; 2 | import { BooksService } from './books.service'; 3 | import { CreateBookDto } from './dto/create-book.dto'; 4 | import { UpdateBookDto } from './dto/update-book.dto'; 5 | import { AdminOnly } from '../common/decorators/admin-only.decorator'; 6 | import { FindAllBooksDto } from './dto/find-all-books.dto'; 7 | 8 | @Controller('books') 9 | export class BooksController { 10 | constructor(private readonly booksService: BooksService) {} 11 | 12 | @Post() 13 | @AdminOnly() 14 | // Create a new book entry (admin only) 15 | create(@Body() createBookDto: CreateBookDto) { 16 | const data = this.booksService.create(createBookDto); 17 | return { success: true, data }; 18 | } 19 | 20 | @Get() 21 | // Retrieve books with search/sort/pagination 22 | findAll(@Query() query: FindAllBooksDto) { 23 | const data = this.booksService.findAll(query); 24 | return { success: true, data }; 25 | } 26 | 27 | @Get(':id') 28 | // Retrieve a specific book by ID 29 | findOne(@Param('id', ParseUUIDPipe) id: string) { 30 | const data = this.booksService.findOne(id); 31 | return { success: true, data }; 32 | } 33 | 34 | @Get(':id/stats') 35 | // Get aggregated stats for a book 36 | getStats(@Param('id', ParseUUIDPipe) id: string) { 37 | const data = this.booksService.getStats(id); 38 | return { success: true, data }; 39 | } 40 | 41 | @Patch(':id') 42 | @AdminOnly() 43 | // Update a book's data (admin only) 44 | update( 45 | @Param('id', ParseUUIDPipe) id: string, 46 | @Body() updateBookDto: UpdateBookDto, 47 | ) { 48 | const data = this.booksService.update(id, updateBookDto); 49 | return { success: true, data }; 50 | } 51 | 52 | @Delete(':id') 53 | @AdminOnly() 54 | // Remove a book by ID (admin only) 55 | remove(@Param('id', ParseUUIDPipe) id: string) { 56 | const data = this.booksService.remove(id); 57 | return { success: true, data }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/auth.utils.ts: -------------------------------------------------------------------------------- 1 | // src/auth/auth.util.ts 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import type { DatabaseService } from '../database/database.service'; 5 | import * as bcrypt from 'bcryptjs'; 6 | 7 | /** 8 | * Password utilities (moved from mock-db.ts) 9 | * Uses bcrypt for hashing and verification. 10 | */ 11 | const BCRYPT_ROUNDS = 10; // reasonable default for demos 12 | 13 | export function hashPassword(password: string): string { 14 | const salt = bcrypt.genSaltSync(BCRYPT_ROUNDS); 15 | return bcrypt.hashSync(password, salt); 16 | } 17 | 18 | export function verifyPassword(password: string, passwordHash: string): boolean { 19 | try { 20 | return bcrypt.compareSync(password, passwordHash); 21 | } catch { 22 | return false; 23 | } 24 | } 25 | 26 | /** Utilities for persisting user seeds (existing code) */ 27 | export function toTsLiteral(obj: any): string { 28 | const q = (s: string) => JSON.stringify(String(s)); 29 | const friendIds = Array.isArray(obj.friendIds) ? `[${obj.friendIds.map((id: any) => q(String(id))).join(', ')}]` : '[]'; 30 | const id = q(String(obj.id)); 31 | return `{ id: ${id}, name: ${q(obj.name)}, username: ${q( 32 | obj.username 33 | )}, passwordHash: ${q(obj.passwordHash)}, role: ${q(obj.role)}, friendIds: ${friendIds} }`; 34 | } 35 | 36 | export function persistUserSeed(db: DatabaseService) { 37 | try { 38 | const projectRoot = path.join(__dirname, '..', '..'); 39 | const filePath = path.join(projectRoot, 'src', 'database', 'mock-db.ts'); 40 | if (!fs.existsSync(filePath)) return; 41 | const content = fs.readFileSync(filePath, 'utf8'); 42 | const regex = /export const users: User\[\] = \[([\s\S]*?)\];/m; 43 | if (!regex.test(content)) return; 44 | const arr = db.getUsers().map((u: any) => toTsLiteral(u)).join(',\n '); 45 | const replacement = `export const users: User[] = [\n ${arr}\n];`; 46 | const updated = content.replace(regex, replacement); 47 | if (updated !== content) { 48 | fs.writeFileSync(filePath, updated, 'utf8'); 49 | } 50 | } catch { 51 | // ignore persistence errors 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /docker-guide-windows: -------------------------------------------------------------------------------- 1 | # Docker Guide (Windows + Docker Desktop) 2 | 3 | This guide walks you through building and running the NestJS app in Docker on Windows (PowerShell or Command Prompt). 4 | 5 | ## Prerequisites 6 | - Docker Desktop for Windows installed and running 7 | - Terminal opened at the project root (same folder as `Dockerfile`) 8 | 9 | ## 1) Build the Docker image 10 | Run in PowerShell or CMD: 11 | ``` 12 | docker build -t reading-tracker-app:latest . 13 | ``` 14 | 15 | ## 2) Run the container (map port 3000) 16 | Detached mode, mapping host port 3000 to container port 3000: 17 | ``` 18 | docker run -d --name reading-tracker -p 3000:3000 reading-tracker-app:latest 19 | ``` 20 | 21 | If port 3000 is in use on Windows, map a different host port (e.g., 8080): 22 | ``` 23 | docker run -d --name reading-tracker -p 8080:3000 reading-tracker-app:latest 24 | ``` 25 | 26 | ## 3) Verify the app is running 27 | - In a browser, open: 28 | ``` 29 | http://localhost:3000 30 | ``` 31 | - Or verify the public API endpoint. On Windows, prefer `curl.exe` to avoid PowerShell aliasing: 32 | ``` 33 | curl.exe http://localhost:3000/api/hello 34 | ``` 35 | Expected response: 36 | ``` 37 | {"success":true,"data":"Hello World!"} 38 | ``` 39 | 40 | ## 4) View logs (optional) 41 | Tail container logs: 42 | ``` 43 | docker logs -f reading-tracker 44 | ``` 45 | 46 | ## 5) Stop and remove the container 47 | Stop the container: 48 | ``` 49 | docker stop reading-tracker 50 | ``` 51 | Remove the stopped container: 52 | ``` 53 | docker rm reading-tracker 54 | ``` 55 | 56 | ## 6) Rebuild and clean up tips 57 | Rebuild without cache: 58 | ``` 59 | docker build --no-cache -t reading-tracker-app:latest . 60 | ``` 61 | Remove the image (forces clean rebuild next time): 62 | ``` 63 | docker rmi reading-tracker-app:latest 64 | ``` 65 | List running containers: 66 | ``` 67 | docker ps 68 | ``` 69 | List all containers (including exited): 70 | ``` 71 | docker ps -a 72 | ``` 73 | 74 | ## Notes 75 | - The container exposes port 3000 and starts via `node dist/src/main.js`. 76 | - Static assets are served from `/app/public` and API routes include `/api`, `/users`, `/books`, `/reading`, `/auth`, `/admin`, `/friends`. 77 | 78 | -------------------------------------------------------------------------------- /src/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Post, Body, Patch, Param, Delete, ParseUUIDPipe, UseGuards } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { UpdateUserDto } from './dto/update-user.dto'; 5 | import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; 6 | import { AdminOnly } from '../common/decorators/admin-only.decorator'; 7 | 8 | @Controller('users') 9 | export class UsersController { 10 | constructor(private readonly usersService: UsersService) {} 11 | 12 | @Get() 13 | // Return a list of all users 14 | findAll() { 15 | const data = this.usersService.findAll(); 16 | return { success: true, data }; 17 | } 18 | 19 | @Get(':id') 20 | // Retrieve a single user by ID 21 | findOne(@Param('id', ParseUUIDPipe) id: string) { 22 | const data = this.usersService.findOne(id); 23 | return { success: true, data }; 24 | } 25 | 26 | @Post() 27 | // Create a new user 28 | create(@Body() createUserDto: CreateUserDto) { 29 | const data = this.usersService.create(createUserDto); 30 | return { success: true, data }; 31 | } 32 | 33 | @Patch(':id') 34 | // Update an existing user 35 | @AdminOnly() 36 | update(@Param('id', ParseUUIDPipe) id: string, @Body() updateUserDto: UpdateUserDto) { 37 | const data = this.usersService.update(id, updateUserDto); 38 | return { success: true, data }; 39 | } 40 | 41 | @Delete(':id') 42 | // Remove a user 43 | @AdminOnly() 44 | remove(@Param('id', ParseUUIDPipe) id: string) { 45 | const data = this.usersService.remove(id); 46 | return { success: true, data }; 47 | } 48 | 49 | // NEW: list a user's friends 50 | @Get(':id/friends') 51 | @UseGuards(JwtAuthGuard) 52 | findFriends(@Param('id', ParseUUIDPipe) id: string) { 53 | const data = this.usersService.findFriends(id); 54 | return { success: true, data }; 55 | } 56 | 57 | // NEW: user stats (auth required by default global guard) 58 | @Get(':id/stats') 59 | @UseGuards(JwtAuthGuard) 60 | getStats(@Param('id', ParseUUIDPipe) id: string) { 61 | const data = this.usersService.getStats(id); 62 | return { success: true, data }; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /docker-guide-macos: -------------------------------------------------------------------------------- 1 | # Docker Guide (macOS + Docker Desktop) 2 | 3 | This guide walks you through building and running the NestJS app in Docker on macOS. 4 | 5 | ## Prerequisites 6 | - Docker Desktop for macOS installed and running 7 | - Terminal opened at the project root (same folder as `Dockerfile`) 8 | 9 | ## 1) Build the Docker image 10 | - Builds the production image using the multi‑stage `Dockerfile`. 11 | 12 | ``` 13 | docker build -t reading-tracker-app:latest . 14 | ``` 15 | 16 | ## 2) Run the container (map port 3000) 17 | - Runs the container in detached mode and maps host port 3000 to container port 3000. 18 | 19 | ``` 20 | docker run -d --name reading-tracker -p 3000:3000 reading-tracker-app:latest 21 | ``` 22 | 23 | If your Mac already uses port 3000, map a different host port (for example, 8080): 24 | ``` 25 | docker run -d --name reading-tracker -p 8080:3000 reading-tracker-app:latest 26 | ``` 27 | 28 | ## 3) Verify the app is running 29 | - Open the app in your browser: 30 | ``` 31 | open http://localhost:3000 32 | ``` 33 | - Or test the public API endpoint: 34 | ``` 35 | curl http://localhost:3000/api/hello 36 | ``` 37 | Expected response: 38 | ``` 39 | {"success":true,"data":"Hello World!"} 40 | ``` 41 | 42 | ## 4) View logs (optional) 43 | - Stream container logs to troubleshoot or confirm startup: 44 | ``` 45 | docker logs -f reading-tracker 46 | ``` 47 | 48 | ## 5) Stop and remove the container 49 | - Stop the running container: 50 | ``` 51 | docker stop reading-tracker 52 | ``` 53 | - Remove the stopped container: 54 | ``` 55 | docker rm reading-tracker 56 | ``` 57 | 58 | ## 6) Rebuild and clean up tips 59 | - Rebuild without using cache: 60 | ``` 61 | docker build --no-cache -t reading-tracker-app:latest . 62 | ``` 63 | - Remove the image (forces a clean rebuild next time): 64 | ``` 65 | docker rmi reading-tracker-app:latest 66 | ``` 67 | - List running containers: 68 | ``` 69 | docker ps 70 | ``` 71 | - List all containers (including exited): 72 | ``` 73 | docker ps -a 74 | ``` 75 | 76 | ## Notes 77 | - The container exposes port 3000 and starts via `node dist/src/main.js`. 78 | - Static assets are served from `/app/public` and API endpoints are under `/api`, `/users`, `/books`, `/reading`, `/auth`, `/admin`, `/friends`. 79 | 80 | -------------------------------------------------------------------------------- /example-outputs-course6.txt: -------------------------------------------------------------------------------- 1 | --- GET /api/hello --- 2 | {"success":false,"statusCode":401,"message":"Missing token"}\n--- POST /auth/login (admin/admin) --- 3 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsInJvbGUiOiJhZG1pbiIsImlhdCI6MTc1NjI4NTc5NywiZXhwIjoxNzU2Mjg5Mzk3fQ.Vom-u6A4Hv0dAxt1acyNI-CZTor6IuT9gVeINNprTd0 4 | \n--- POST /auth/login (alice/user123) --- 5 | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzU2Mjg1Nzk3LCJleHAiOjE3NTYyODkzOTd9.Extft662dpMImLvhFoC1b6kOQa8l8d6dwmn-GZ_WJVs 6 | \n--- GET /users (public) --- 7 | {"success":true,"data":[{"id":1,"name":"Admin","username":"admin","passwordHash":"4e4ea6b5cfcddddf574b4d2e23e2a79f7c2144b1d986e0bb79195e8cedfc589f","role":"admin","friendIds":[]},{"id":2,"name":"Alice","username":"alice","passwordHash":"9c9a95871e872929a8942e88513736d9bace87f0752425304b57cf87bea7fda7","role":"user","friendIds":[]}]}\n--- GET /books (public) --- 8 | {"success":true,"data":{"items":[{"id":1,"title":"The Hobbit","author":"J.R.R. Tolkien","totalPages":310,"publishDate":"1937-09-21","uploadedAt":"2025-08-27T09:09:56.273Z"},{"id":7,"title":"The Name of the Wind","author":"Patrick Rothfuss","totalPages":662,"publishDate":"2007-03-27","uploadedAt":"2025-08-27T09:09:56.273Z"},{"id":4,"title":"The Pragmatic Programmer","author":"Andrew Hunt","totalPages":352,"publishDate":"1999-10-30","uploadedAt":"2025-08-27T09:09:56.273Z"}],"page":1,"pageSize":5,"total":3}}\n--- GET /books/1/stats --- 9 | {"success":true,"data":{"bookId":1,"readers":1,"avgCurrentPage":50,"completionRate":0,"avgProgressPct":0.16129032258064516}}\n--- GET /reading/shelf (alice) --- 10 | {"success":true,"data":[{"bookId":2,"title":"Dune","author":"Frank Herbert","progress":0.24271844660194175,"currentPage":100,"status":"in-progress","updatedAt":"2025-08-27T09:09:56.273Z"},{"bookId":3,"title":"Clean Code","author":"Robert C. Martin","progress":0,"currentPage":0,"status":"want-to-read","updatedAt":"2025-08-27T09:09:56.273Z"}]}\n--- PATCH /reading/progress (alice) --- 11 | {"success":true,"data":{"userId":2,"bookId":2,"currentPage":120,"status":"in-progress","updatedAt":"2025-08-27T09:09:57.212Z"}}\n--- POST /friends/request (admin -> alice) --- 12 | {"success":true,"data":{"id":1,"senderId":1,"recipientId":2,"status":"pending"}}\n--- GET /friends/requests (alice incoming) --- 13 | 1 14 | \n--- PATCH /friends/requests/:id accept (alice) --- 15 | {"success":true,"data":{"id":1,"senderId":1,"recipientId":2,"status":"accepted"}}\n--- GET /friends/2/progress (admin) --- 16 | {"success":true,"data":[{"userId":2,"bookId":2,"currentPage":120,"status":"in-progress","updatedAt":"2025-08-27T09:09:57.212Z"},{"userId":2,"bookId":3,"currentPage":0,"status":"want-to-read","updatedAt":"2025-08-27T09:09:56.273Z"}]} -------------------------------------------------------------------------------- /src/friends/friends.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; 2 | import { DatabaseService } from '../database/database.service'; 3 | import { UsersService } from '../users/users.service'; 4 | import { ReadingService } from '../reading/reading.service'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | @Injectable() 8 | export class FriendsService { 9 | constructor( 10 | private readonly db: DatabaseService, 11 | private readonly users: UsersService, 12 | private readonly reading: ReadingService, 13 | ) {} 14 | 15 | requestFriend(senderId: string, recipientId: string) { 16 | if (senderId === recipientId) { 17 | throw new BadRequestException('Cannot send a friend request to yourself.'); 18 | } 19 | this.users.findOne(senderId); 20 | this.users.findOne(recipientId); 21 | 22 | const requests = this.db.getFriendRequests(); 23 | const alreadyPending = requests.some( 24 | (r) => r.senderId === senderId && r.recipientId === recipientId && r.status === 'pending', 25 | ); 26 | if (alreadyPending) { 27 | throw new BadRequestException('A pending request already exists.'); 28 | } 29 | 30 | const id = uuidv4(); 31 | const newReq = { id, senderId, recipientId, status: 'pending' as const }; 32 | requests.push(newReq); 33 | return newReq; 34 | } 35 | 36 | getIncomingRequests(userId: string) { 37 | return this.db 38 | .getFriendRequests() 39 | .filter((r) => r.recipientId === userId && r.status === 'pending'); 40 | } 41 | 42 | handleRequest(actingUserId: string, requestId: string, status: 'accepted' | 'declined') { 43 | const req = this.db.getFriendRequests().find((r) => r.id === requestId); 44 | if (!req) throw new NotFoundException('Friend request not found.'); 45 | if (req.status !== 'pending') throw new BadRequestException('Request already handled.'); 46 | if (req.recipientId !== actingUserId) 47 | throw new ForbiddenException('Only the recipient can act on this request.'); 48 | 49 | req.status = status; 50 | 51 | if (status === 'accepted') { 52 | const sender = this.users.findOne(req.senderId); 53 | const recipient = this.users.findOne(req.recipientId); 54 | if (!sender.friendIds.includes(recipient.id)) sender.friendIds.push(recipient.id); 55 | if (!recipient.friendIds.includes(sender.id)) recipient.friendIds.push(sender.id); 56 | } 57 | return req; 58 | } 59 | 60 | getFriendProgress(requesterId: string, friendId: string) { 61 | const requester = this.users.findOne(requesterId); 62 | if (!requester.friendIds.includes(friendId)) { 63 | throw new ForbiddenException('You can only view progress of your friends.'); 64 | } 65 | return this.reading.findAllForUser(friendId); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 2 | import { DatabaseService } from '../database/database.service'; 3 | import { CreateUserDto } from './dto/create-user.dto'; 4 | import { UpdateUserDto } from './dto/update-user.dto'; 5 | import { hashPassword } from '../utils/auth.utils'; 6 | import { ReadingService } from '../reading/reading.service'; 7 | import { v4 as uuidv4 } from 'uuid'; 8 | 9 | @Injectable() 10 | export class UsersService { 11 | constructor(private readonly db: DatabaseService, private readonly reading: ReadingService) {} 12 | 13 | /** 14 | * Retrieve all users. 15 | */ 16 | findAll() { 17 | return this.db.getUsers(); 18 | } 19 | 20 | /** 21 | * Find a user by ID. 22 | * @throws NotFoundException if user does not exist. 23 | */ 24 | findOne(id: string) { 25 | const user = this.db.findUserById(id); 26 | if (!user) { 27 | throw new NotFoundException(`User with ID ${id} not found.`); 28 | } 29 | return user; 30 | } 31 | 32 | /** 33 | * Create a new user with provided data. 34 | */ 35 | create(createUserDto: CreateUserDto) { 36 | const users = this.db.getUsers(); 37 | const id = uuidv4(); 38 | let base = (createUserDto.name || `user`).toLowerCase().replace(/\s+/g, ''); 39 | if (!base) base = `user${id}`; 40 | let username = base; 41 | let suffix = 1; 42 | const usernames = new Set(users.map(u => u.username)); 43 | while (usernames.has(username)) { 44 | username = `${base}${suffix++}`; 45 | } 46 | const newUser = { 47 | id, 48 | name: createUserDto.name, 49 | username, 50 | passwordHash: hashPassword('changeme'), 51 | role: 'user' as const, 52 | friendIds: [] as string[], 53 | }; 54 | users.push(newUser); 55 | return newUser; 56 | } 57 | 58 | /** 59 | * Update an existing user. 60 | */ 61 | update(id: string, updateUserDto: UpdateUserDto) { 62 | const user = this.findOne(id); 63 | if (updateUserDto.username && updateUserDto.username !== user.username) { 64 | const exists = this.db.getUsers().some(u => u.username === updateUserDto.username && u.id !== id); 65 | if (exists) { 66 | throw new BadRequestException('Username already exists'); 67 | } 68 | user.username = updateUserDto.username; 69 | } 70 | if (typeof updateUserDto.name === 'string' && updateUserDto.name.length) { 71 | user.name = updateUserDto.name; 72 | } 73 | return user; 74 | } 75 | 76 | /** 77 | * Remove a user by ID. 78 | */ 79 | remove(id: string) { 80 | const users = this.db.getUsers(); 81 | const index = users.findIndex((u) => u.id === id); 82 | if (index === -1) { 83 | throw new NotFoundException(`User with ID ${id} not found.`); 84 | } 85 | const [removedUser] = users.splice(index, 1); 86 | return removedUser; 87 | } 88 | 89 | /** 90 | * Get a list of a user's friends (as full user objects). 91 | */ 92 | findFriends(userId: string) { 93 | const user = this.findOne(userId); 94 | return user.friendIds.map(fid => this.findOne(fid)); 95 | } 96 | 97 | getStats(userId: string) { 98 | this.findOne(userId); 99 | const sessions = this.reading.findAllForUser(userId); 100 | const books = this.db.getBooks() as any[]; 101 | 102 | const totalPagesRead = sessions.reduce((sum: number, s: any) => sum + s.currentPage, 0); 103 | let booksCompleted = 0; 104 | let denom = 0; 105 | for (const s of sessions as any[]) { 106 | const b = books.find((bb: any) => bb.id === s.bookId); 107 | if (!b || !b.totalPages) continue; 108 | denom += b.totalPages; 109 | if (s.currentPage >= b.totalPages) booksCompleted += 1; 110 | } 111 | const avgProgressPct = denom > 0 ? totalPagesRead / denom : 0; 112 | 113 | return { 114 | userId, 115 | totalPagesRead, 116 | booksInShelf: sessions.length, 117 | booksCompleted, 118 | avgProgressPct, 119 | }; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | _As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim._ 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /src/database/mock-db.ts: -------------------------------------------------------------------------------- 1 | // src/database/mock-db.ts 2 | import { hashPassword } from '../utils/auth.utils'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | export interface User { 6 | id: string; 7 | name: string; 8 | username: string; 9 | passwordHash: string; // hashed password (bcrypt) 10 | role: 'user' | 'admin'; 11 | friendIds: string[]; // NEW: list of confirmed friend user IDs 12 | } 13 | 14 | export interface Book { 15 | id: string; 16 | title: string; 17 | author: string; 18 | totalPages: number; 19 | publishDate?: string; // ISO 8601 20 | uploadedAt: string; // ISO 8601 21 | } 22 | 23 | export interface ReadingSession { 24 | userId: string; 25 | bookId: string; 26 | currentPage: number; 27 | status?: 'not-started' | 'in-progress' | 'completed' | 'want-to-read'; 28 | updatedAt?: string; // ISO 8601 29 | } 30 | 31 | // NEW: Friend request structure 32 | export interface FriendRequest { 33 | id: string; 34 | senderId: string; 35 | recipientId: string; 36 | status: 'pending' | 'accepted' | 'declined'; 37 | } 38 | 39 | // Seed users with an admin account and a sample user 40 | const adminPassword = 'admin'; 41 | const userPassword = 'user123'; 42 | 43 | function ensureUniqueUsers(arr: User[]): User[] { 44 | const seen = new Set(); 45 | const out: User[] = []; 46 | for (const u of arr) { 47 | if (seen.has(u.username)) continue; 48 | seen.add(u.username); 49 | out.push(u); 50 | } 51 | return out; 52 | } 53 | 54 | const adminId = uuidv4(); 55 | const aliceId = uuidv4(); 56 | 57 | const seedUsers: User[] = [ 58 | { id: adminId, name: 'Admin', username: 'admin', passwordHash: hashPassword(adminPassword), role: 'admin', friendIds: [] }, 59 | { id: aliceId, name: 'Alice', username: 'alice', passwordHash: hashPassword(userPassword), role: 'user', friendIds: [] }, 60 | ]; 61 | 62 | export const users: User[] = ensureUniqueUsers(seedUsers); 63 | 64 | // Enriched book seed data with analytics-friendly fields. 65 | export const books: Book[] = [ 66 | { 67 | id: uuidv4(), 68 | title: 'The Hobbit', 69 | author: 'J.R.R. Tolkien', 70 | totalPages: 310, 71 | publishDate: '1937-09-21', 72 | uploadedAt: new Date().toISOString(), 73 | }, 74 | { 75 | id: uuidv4(), 76 | title: 'Dune', 77 | author: 'Frank Herbert', 78 | totalPages: 412, 79 | publishDate: '1965-08-01', 80 | uploadedAt: new Date().toISOString(), 81 | }, 82 | { 83 | id: uuidv4(), 84 | title: 'Clean Code', 85 | author: 'Robert C. Martin', 86 | totalPages: 464, 87 | publishDate: '2008-08-01', 88 | uploadedAt: new Date().toISOString(), 89 | }, 90 | { 91 | id: uuidv4(), 92 | title: 'The Pragmatic Programmer', 93 | author: 'Andrew Hunt', 94 | totalPages: 352, 95 | publishDate: '1999-10-30', 96 | uploadedAt: new Date().toISOString(), 97 | }, 98 | { 99 | id: uuidv4(), 100 | title: '1984', 101 | author: 'George Orwell', 102 | totalPages: 328, 103 | publishDate: '1949-06-08', 104 | uploadedAt: new Date().toISOString(), 105 | }, 106 | { 107 | id: uuidv4(), 108 | title: 'To Kill a Mockingbird', 109 | author: 'Harper Lee', 110 | totalPages: 281, 111 | publishDate: '1960-07-11', 112 | uploadedAt: new Date().toISOString(), 113 | }, 114 | { 115 | id: uuidv4(), 116 | title: 'The Name of the Wind', 117 | author: 'Patrick Rothfuss', 118 | totalPages: 662, 119 | publishDate: '2007-03-27', 120 | uploadedAt: new Date().toISOString(), 121 | }, 122 | { 123 | id: uuidv4(), 124 | title: 'Sapiens', 125 | author: 'Yuval Noah Harari', 126 | totalPages: 443, 127 | publishDate: '2011-01-01', 128 | uploadedAt: new Date().toISOString(), 129 | }, 130 | ]; 131 | 132 | // Seed reading sessions include status and timestamps 133 | export const readingSessions: ReadingSession[] = [ 134 | { userId: adminId, bookId: books[0].id, currentPage: 50, status: 'in-progress', updatedAt: new Date().toISOString() }, 135 | { userId: aliceId, bookId: books[1].id, currentPage: 100, status: 'in-progress', updatedAt: new Date().toISOString() }, 136 | { userId: aliceId, bookId: books[2].id, currentPage: 0, status: 'want-to-read', updatedAt: new Date().toISOString() }, 137 | { userId: adminId, bookId: books[3].id, currentPage: 352, status: 'completed', updatedAt: new Date().toISOString() }, 138 | ]; 139 | 140 | // NEW: in-memory friend requests store 141 | export const friendRequests: FriendRequest[] = []; 142 | -------------------------------------------------------------------------------- /src/reading/reading.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { DatabaseService } from '../database/database.service'; 3 | import { BooksService } from '../books/books.service'; 4 | import { UpdateProgressDto } from './dto/update-progress.dto'; 5 | import { FindShelfDto } from './dto/find-shelf.dto'; 6 | 7 | @Injectable() 8 | export class ReadingService { 9 | constructor( 10 | private readonly db: DatabaseService, 11 | private readonly booksService: BooksService, 12 | ) {} 13 | 14 | private deriveStatus(currentPage: number, totalPages: number): 'not-started' | 'in-progress' | 'completed' { 15 | if (currentPage <= 0) return 'not-started'; 16 | if (totalPages > 0 && currentPage >= totalPages) return 'completed'; 17 | return 'in-progress'; 18 | } 19 | 20 | /** 21 | * Update or create a reading session for a user and book. 22 | */ 23 | updateProgress(dto: UpdateProgressDto) { 24 | // Ensure user and book exist 25 | // Validate user exists 26 | if (!this.db.findUserById(dto.userId)) { 27 | throw new NotFoundException('User not found'); 28 | } 29 | const book: any = this.booksService.findOne(dto.bookId); 30 | 31 | let session = this.db 32 | .getReadingSessions() 33 | .find((s: any) => s.userId === dto.userId && s.bookId === dto.bookId); 34 | 35 | const normalized = 36 | dto.status === 'want-to-read' 37 | ? { currentPage: 0, status: 'want-to-read' as const } 38 | : { currentPage: dto.currentPage, status: dto.status ?? this.deriveStatus(dto.currentPage, book.totalPages) }; 39 | 40 | if (session) { 41 | session.currentPage = normalized.currentPage; 42 | (session as any).status = normalized.status; 43 | (session as any).updatedAt = new Date().toISOString(); 44 | } else { 45 | session = { userId: dto.userId, bookId: dto.bookId, currentPage: normalized.currentPage, status: normalized.status, updatedAt: new Date().toISOString() }; 46 | this.db.getReadingSessions().push(session); 47 | } 48 | return session; 49 | } 50 | 51 | /** 52 | * Get reading progress for a specific book across all users. 53 | */ 54 | getProgressByBook(bookId: string) { 55 | this.booksService.findOne(bookId); 56 | const sessions = this.db.getReadingSessions().filter((s: any) => String((s as any).bookId) === String(bookId)); 57 | return sessions.map(s => ({ 58 | user: this.db.findUserById(s.userId), 59 | currentPage: s.currentPage, 60 | })); 61 | } 62 | 63 | /** 64 | * Get all reading sessions for a specific user (friend viewing). 65 | */ 66 | findAllForUser(userId: string) { 67 | if (!this.db.findUserById(userId)) throw new NotFoundException('User not found'); 68 | return this.db.getReadingSessions().filter((s) => s.userId === userId); 69 | } 70 | 71 | getShelf(userId: string, query: FindShelfDto) { 72 | if (!this.db.findUserById(userId)) throw new NotFoundException('User not found'); 73 | const books = this.db.getBooks(); 74 | const byId = new Map(books.map((b: any) => [String((b as any).id), b])); 75 | 76 | let items = this.findAllForUser(userId).map((s: any) => { 77 | const book = byId.get(String((s as any).bookId))! as any; 78 | const total = book.totalPages || 0; 79 | const status = (s as any).status ?? this.deriveStatus(s.currentPage, total); 80 | const progress = total ? s.currentPage / total : 0; 81 | return { 82 | bookId: book.id, 83 | title: book.title, 84 | author: book.author, 85 | totalPages: total, 86 | currentPage: s.currentPage, 87 | status, 88 | progress, 89 | updatedAt: (s as any).updatedAt ?? null, 90 | }; 91 | }); 92 | 93 | if (query.status) items = items.filter((i) => i.status === query.status); 94 | 95 | const order = query.order ?? 'asc'; 96 | const cmpNum = (a: number, b: number) => (a < b ? (order === 'asc' ? -1 : 1) : a > b ? (order === 'asc' ? 1 : -1) : 0); 97 | const cmpStr = (a: string, b: string) => cmpNum(a.localeCompare(b), 0); 98 | 99 | if (query.sortBy === 'title') items.sort((a, b) => cmpStr(a.title, b.title)); 100 | if (query.sortBy === 'author') items.sort((a, b) => cmpStr(a.author, b.author)); 101 | if (query.sortBy === 'progress') items.sort((a, b) => cmpNum(a.progress, b.progress)); 102 | if (query.sortBy === 'updatedAt') items.sort((a, b) => cmpStr(a.updatedAt || '', b.updatedAt || '')); 103 | 104 | return items; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/books/books.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { DatabaseService } from '../database/database.service'; 3 | import { CreateBookDto } from './dto/create-book.dto'; 4 | import { UpdateBookDto } from './dto/update-book.dto'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | @Injectable() 8 | export class BooksService { 9 | constructor(private readonly db: DatabaseService) {} 10 | 11 | /** 12 | * Create and store a new book. 13 | */ 14 | create(createBookDto: CreateBookDto) { 15 | const books = this.db.getBooks(); 16 | const newBook = { 17 | id: uuidv4(), 18 | ...createBookDto, 19 | uploadedAt: new Date().toISOString(), 20 | }; 21 | books.push(newBook); 22 | return newBook; 23 | } 24 | 25 | /** 26 | * Get books with optional search/sort/pagination. 27 | */ 28 | findAll(query: any) { 29 | const { q, sortBy, order = 'asc', page = 1, pageSize = 20 } = query; 30 | 31 | let items = [...this.db.getBooks()]; 32 | 33 | if (q && String(q).trim().length) { 34 | const needle = String(q).trim().toLowerCase(); 35 | items = items.filter( 36 | (b: any) => b.title.toLowerCase().includes(needle) || b.author.toLowerCase().includes(needle), 37 | ); 38 | } 39 | 40 | // Pre-compute average progress per book for sorting if requested 41 | let avgByBookId = new Map(); 42 | if (sortBy === 'avgProgress') { 43 | const sessions = this.db.getReadingSessions(); 44 | const totals = new Map(); 45 | for (const b of items as any[]) totals.set((b as any).id, { readers: 0, current: 0, totalPages: (b as any).totalPages || 0 }); 46 | for (const s of sessions as any[]) { 47 | const agg = totals.get((s as any).bookId); 48 | if (!agg || !agg.totalPages) continue; 49 | agg.readers += 1; 50 | agg.current += (s as any).currentPage; 51 | } 52 | avgByBookId = new Map( 53 | [...totals.entries()].map(([bookId, t]) => [bookId, t.readers ? t.current / (t.totalPages * t.readers) : 0]), 54 | ); 55 | } 56 | 57 | if (sortBy) { 58 | const dir = order === 'asc' ? 1 : -1; 59 | items.sort((a: any, b: any) => { 60 | const A = sortBy === 'avgProgress' ? (avgByBookId.get((a as any).id) as any) : String((a as any)[sortBy] ?? '').toLowerCase(); 61 | const B = sortBy === 'avgProgress' ? (avgByBookId.get((b as any).id) as any) : String((b as any)[sortBy] ?? '').toLowerCase(); 62 | if (typeof A === 'number' && typeof B === 'number') return A < B ? -1 * dir : A > B ? 1 * dir : 0; 63 | return String(A).localeCompare(String(B)) * dir; 64 | }); 65 | } 66 | 67 | const total = items.length; 68 | const start = (page - 1) * pageSize; 69 | const paged = items.slice(start, start + pageSize); 70 | return { items: paged, page, pageSize, total }; 71 | } 72 | 73 | /** 74 | * Get a single book by ID. 75 | * @throws NotFoundException if book is not found. 76 | */ 77 | findOne(id: string) { 78 | const book = this.db.getBooks().find((b: any) => String((b as any).id) === String(id)); 79 | if (!book) { 80 | throw new NotFoundException(`Book with ID ${id} not found.`); 81 | } 82 | return book; 83 | } 84 | 85 | /** 86 | * Update an existing book's information. 87 | */ 88 | update(id: string, updateBookDto: UpdateBookDto) { 89 | const book = this.findOne(id); 90 | Object.assign(book, updateBookDto); 91 | return book; 92 | } 93 | 94 | /** 95 | * Remove a book by ID. 96 | */ 97 | remove(id: string) { 98 | const books = this.db.getBooks(); 99 | const index = books.findIndex((b: any) => String((b as any).id) === String(id)); 100 | if (index === -1) { 101 | throw new NotFoundException(`Book with ID ${id} not found.`); 102 | } 103 | const [removedBook] = books.splice(index, 1); 104 | return removedBook; 105 | } 106 | 107 | getStats(bookId: string) { 108 | const book: any = this.findOne(bookId); 109 | const sessions = this.db.getReadingSessions().filter((s: any) => String((s as any).bookId) === String(bookId)); 110 | const readers = sessions.length; 111 | const totalCurrent = sessions.reduce((acc: number, s: any) => acc + s.currentPage, 0); 112 | const avgCurrentPage = readers ? totalCurrent / readers : 0; 113 | const completed = sessions.filter((s: any) => book.totalPages && s.currentPage >= book.totalPages).length; 114 | const completionRate = readers ? completed / readers : 0; 115 | const avgProgressPct = readers && book.totalPages ? totalCurrent / (book.totalPages * readers) : 0; 116 | 117 | return { bookId, readers, avgCurrentPage, completionRate, avgProgressPct }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /test-curls-course5.txt: -------------------------------------------------------------------------------- 1 | # Course 5: Friends Workflow – Valid CURLs 2 | # This file contains two flows as requested plus acceptance: 3 | # 1) Admin logs in and sends a friend request to Alice 4 | # 2) Alice logs in and tries to send a friend request to herself (expected 400) 5 | # 3) Alice accepts Admin's friend request and verifies friendship lists 6 | # 7 | # Notes: 8 | # - IDs are UUIDs. We use a Node one-liner to parse JSON reliably instead of sed. 9 | # - "admin/admin" and "alice/user123" are seeded credentials. 10 | 11 | ############################### 12 | # 1) Admin -> Alice (request) # 13 | ############################### 14 | 15 | # Login as admin and capture token 16 | ADMIN_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 17 | -H 'Content-Type: application/json' \ 18 | -d '{"username":"admin","password":"admin"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);console.log(j.data.access_token)}catch(e){}})") 19 | echo "ADMIN_TOKEN: ${ADMIN_TOKEN}" 20 | 21 | # Get Alice's user id from /users 22 | ALICE_ID=$(curl -s http://localhost:3000/users | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let u=j.data.find(x=>x.username==='alice');if(u)console.log(u.id);})") 23 | echo "ALICE_ID: ${ALICE_ID}" 24 | 25 | # Admin sends a friend request to Alice (idempotent: may return an error if one is already pending) 26 | curl -s -X POST http://localhost:3000/friends/request \ 27 | -H "Authorization: Bearer ${ADMIN_TOKEN}" \ 28 | -H 'Content-Type: application/json' \ 29 | -d "{\"recipientId\":\"${ALICE_ID}\"}" 30 | 31 | 32 | ######################################## 33 | # 2) Alice -> Alice (self-request test) # 34 | ######################################## 35 | 36 | # Login as alice and capture token 37 | ALICE_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 38 | -H 'Content-Type: application/json' \ 39 | -d '{"username":"alice","password":"user123"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);console.log(j.data.access_token)}catch(e){}})") 40 | echo "ALICE_TOKEN: ${ALICE_TOKEN}" 41 | 42 | # Get Alice's own id from /users 43 | ALICE_SELF_ID=$(curl -s http://localhost:3000/users | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let u=j.data.find(x=>x.username==='alice');if(u)console.log(u.id);})") 44 | echo "ALICE_SELF_ID: ${ALICE_SELF_ID}" 45 | 46 | # Alice attempts to send a friend request to herself (should fail with 400) 47 | curl -s -o /dev/stderr -w "\nHTTP %{http_code}\n" -X POST http://localhost:3000/friends/request \ 48 | -H "Authorization: Bearer ${ALICE_TOKEN}" \ 49 | -H 'Content-Type: application/json' \ 50 | -d "{\"recipientId\":\"${ALICE_SELF_ID}\"}" 51 | 52 | 53 | ############################################################ 54 | # 3) Alice accepts Admin's request and verifies friendships # 55 | ############################################################ 56 | 57 | # Resolve IDs and tokens 58 | ADMIN_ID=$(curl -s http://localhost:3000/users | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let u=j.data.find(x=>x.username==='admin');if(u)console.log(u.id);})") 59 | ALICE_ID=$(curl -s http://localhost:3000/users | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let u=j.data.find(x=>x.username==='alice');if(u)console.log(u.id);})") 60 | ALICE_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 61 | -H 'Content-Type: application/json' \ 62 | -d '{"username":"alice","password":"user123"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);console.log(j.data.access_token)}catch(e){}})") 63 | 64 | # Find pending request from admin; if none, have admin create one now 65 | REQ_ID=$(curl -s -H "Authorization: Bearer ${ALICE_TOKEN}" http://localhost:3000/friends/requests \ 66 | | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let r=(j.data||[]).find(x=>x.senderId==='${ADMIN_ID}' && x.status==='pending');if(r)console.log(r.id);})") 67 | if [ -z "$REQ_ID" ]; then 68 | ADMIN_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 69 | -H 'Content-Type: application/json' \ 70 | -d '{"username":"admin","password":"admin"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);console.log(j.data.access_token)}catch(e){}})") 71 | curl -s -X POST http://localhost:3000/friends/request \ 72 | -H "Authorization: Bearer ${ADMIN_TOKEN}" \ 73 | -H 'Content-Type: application/json' \ 74 | -d "{\"recipientId\":\"${ALICE_ID}\"}" 75 | REQ_ID=$(curl -s -H "Authorization: Bearer ${ALICE_TOKEN}" http://localhost:3000/friends/requests \ 76 | | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);let r=(j.data||[]).find(x=>x.senderId==='${ADMIN_ID}' && x.status==='pending');if(r)console.log(r.id);})") 77 | fi 78 | 79 | echo "REQ_ID: ${REQ_ID}" 80 | 81 | # Accept the request 82 | curl -s -X PATCH http://localhost:3000/friends/requests/${REQ_ID} \ 83 | -H "Authorization: Bearer ${ALICE_TOKEN}" \ 84 | -H 'Content-Type: application/json' \ 85 | -d '{"status":"accepted"}' 86 | 87 | # Verify both users list each other as friends 88 | curl -s http://localhost:3000/users/${ADMIN_ID}/friends 89 | curl -s http://localhost:3000/users/${ALICE_ID}/friends 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Discord 15 | Backers on Open Collective 16 | Sponsors on Open Collective 17 | Donate us 18 | Support us 19 | Follow us on Twitter 20 |

21 | 23 | 24 | ## Description 25 | 26 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 27 | 28 | ## Project setup 29 | 30 | ```bash 31 | $ npm install 32 | ``` 33 | 34 | ## Compile and run the project 35 | 36 | ```bash 37 | # development 38 | $ npm run start 39 | 40 | # watch mode 41 | $ npm run start:dev 42 | 43 | # production mode 44 | $ npm run start:prod 45 | ``` 46 | 47 | ## Run tests 48 | 49 | ```bash 50 | # unit tests 51 | $ npm run test 52 | 53 | # e2e tests 54 | $ npm run test:e2e 55 | 56 | # test coverage 57 | $ npm run test:cov 58 | ``` 59 | 60 | ## Deployment 61 | 62 | When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. 63 | 64 | If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: 65 | 66 | ```bash 67 | $ npm install -g @nestjs/mau 68 | $ mau deploy 69 | ``` 70 | 71 | With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. 72 | 73 | ## Resources 74 | 75 | Check out a few resources that may come in handy when working with NestJS: 76 | 77 | - Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. 78 | - For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). 79 | - To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). 80 | - Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. 81 | - Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). 82 | - Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). 83 | - To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). 84 | - Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). 85 | 86 | ## Support 87 | 88 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 89 | 90 | ## Stay in touch 91 | 92 | - Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) 93 | - Website - [https://nestjs.com](https://nestjs.com/) 94 | - Twitter - [@nestframework](https://twitter.com/nestframework) 95 | 96 | ## License 97 | 98 | Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). 99 | -------------------------------------------------------------------------------- /server.log: -------------------------------------------------------------------------------- 1 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [NestFactory] Starting Nest application... 2 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [InstanceLoader] AuthModule dependencies initialized +11ms 3 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [InstanceLoader] AppModule dependencies initialized +1ms 4 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [InstanceLoader] UsersModule dependencies initialized +0ms 5 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [InstanceLoader] BooksModule dependencies initialized +0ms 6 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [InstanceLoader] ReadingModule dependencies initialized +0ms 7 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] AppController {/api}: +4ms 8 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/api/hello, GET} route +3ms 9 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] AdminController {/admin}: +0ms 10 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/admin/logs, GET} route +0ms 11 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] UsersController {/users}: +0ms 12 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/users, GET} route +1ms 13 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/users/:id, GET} route +0ms 14 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/users, POST} route +0ms 15 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/users/:id, PATCH} route +1ms 16 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/users/:id, DELETE} route +0ms 17 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] BooksController {/books}: +1ms 18 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/books, POST} route +1ms 19 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/books, GET} route +0ms 20 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/books/:id, GET} route +0ms 21 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/books/:id, PATCH} route +0ms 22 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/books/:id, DELETE} route +1ms 23 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] ReadingController {/reading}: +0ms 24 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/reading/progress, PATCH} route +0ms 25 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/reading/progress/:bookId, GET} route +0ms 26 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RoutesResolver] AuthController {/auth}: +0ms 27 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/auth/register, POST} route +0ms 28 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [RouterExplorer] Mapped {/auth/login, POST} route +0ms 29 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  LOG [NestApplication] Nest application successfully started +2ms 30 | [Nest] 2226 - 08/17/2025, 3:52:20 PM  ERROR [NestApplication] Error: listen EADDRINUSE: address already in use :::3000 +2ms 31 | node:net:1937 32 | const ex = new UVExceptionWithHostPort(err, 'listen', address, port); 33 | ^ 34 | 35 | Error: listen EADDRINUSE: address already in use :::3000 36 | at Server.setupListenHandle [as _listen2] (node:net:1937:16) 37 | at listenInCluster (node:net:1994:12) 38 | at Server.listen (node:net:2099:7) 39 | at ExpressAdapter.listen (/usercode/FILESYSTEM/node_modules/@nestjs/platform-express/adapters/express-adapter.js:95:32) 40 | at /usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:183:30 41 | at new Promise () 42 | at NestApplication.listen (/usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:173:16) 43 | at async bootstrap (/usercode/FILESYSTEM/dist/main.js:28:5) { 44 | code: 'EADDRINUSE', 45 | errno: -98, 46 | syscall: 'listen', 47 | address: '::', 48 | port: 3000 49 | } 50 | 51 | Node.js v22.13.1 52 | -------------------------------------------------------------------------------- /runtime.log: -------------------------------------------------------------------------------- 1 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [NestFactory] Starting Nest application... 2 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] AuthModule dependencies initialized +12ms 3 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] AppModule dependencies initialized +1ms 4 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] BooksModule dependencies initialized +0ms 5 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] ReadingModule dependencies initialized +1ms 6 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] UsersModule dependencies initialized +0ms 7 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [InstanceLoader] FriendsModule dependencies initialized +0ms 8 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] AppController {/api}: +4ms 9 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/api/hello, GET} route +2ms 10 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] AdminController {/admin}: +1ms 11 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/admin/logs, GET} route +0ms 12 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] UsersController {/users}: +0ms 13 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users, GET} route +1ms 14 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users/:id, GET} route +0ms 15 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users, POST} route +0ms 16 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users/:id, PATCH} route +0ms 17 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users/:id, DELETE} route +1ms 18 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users/:id/friends, GET} route +0ms 19 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/users/:id/stats, GET} route +0ms 20 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] ReadingController {/reading}: +0ms 21 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/reading/progress, PATCH} route +1ms 22 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/reading/progress/:bookId, GET} route +0ms 23 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/reading/shelf, GET} route +0ms 24 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] BooksController {/books}: +0ms 25 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books, POST} route +0ms 26 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books, GET} route +0ms 27 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books/:id, GET} route +1ms 28 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books/:id/stats, GET} route +0ms 29 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books/:id, PATCH} route +0ms 30 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/books/:id, DELETE} route +0ms 31 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] AuthController {/auth}: +0ms 32 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/auth/register, POST} route +0ms 33 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/auth/login, POST} route +1ms 34 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RoutesResolver] FriendsController {/friends}: +0ms 35 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/friends/request, POST} route +0ms 36 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/friends/requests, GET} route +0ms 37 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/friends/requests/:requestId, PATCH} route +0ms 38 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [RouterExplorer] Mapped {/friends/:friendId/progress, GET} route +0ms 39 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  LOG [NestApplication] Nest application successfully started +2ms 40 | [Nest] 3771 - 08/24/2025, 4:47:21 PM  ERROR [NestApplication] Error: listen EADDRINUSE: address already in use :::3000 +2ms 41 | node:net:1937 42 | const ex = new UVExceptionWithHostPort(err, 'listen', address, port); 43 | ^ 44 | 45 | Error: listen EADDRINUSE: address already in use :::3000 46 | at Server.setupListenHandle [as _listen2] (node:net:1937:16) 47 | at listenInCluster (node:net:1994:12) 48 | at Server.listen (node:net:2099:7) 49 | at ExpressAdapter.listen (/usercode/FILESYSTEM/node_modules/@nestjs/platform-express/adapters/express-adapter.js:95:32) 50 | at /usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:183:30 51 | at new Promise () 52 | at NestApplication.listen (/usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:173:16) 53 | at async bootstrap (/usercode/FILESYSTEM/dist/main.js:29:5) { 54 | code: 'EADDRINUSE', 55 | errno: -98, 56 | syscall: 'listen', 57 | address: '::', 58 | port: 3000 59 | } 60 | 61 | Node.js v22.13.1 62 | -------------------------------------------------------------------------------- /server.out: -------------------------------------------------------------------------------- 1 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [NestFactory] Starting Nest application... 2 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] AuthModule dependencies initialized +14ms 3 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] AppModule dependencies initialized +0ms 4 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] BooksModule dependencies initialized +0ms 5 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] ReadingModule dependencies initialized +1ms 6 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] UsersModule dependencies initialized +0ms 7 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [InstanceLoader] FriendsModule dependencies initialized +0ms 8 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] AppController {/api}: +4ms 9 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/api/hello, GET} route +2ms 10 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] AdminController {/admin}: +1ms 11 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/admin/logs, GET} route +0ms 12 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] UsersController {/users}: +0ms 13 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users, GET} route +1ms 14 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users/:id, GET} route +0ms 15 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users, POST} route +0ms 16 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users/:id, PATCH} route +1ms 17 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users/:id, DELETE} route +0ms 18 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users/:id/friends, GET} route +0ms 19 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/users/:id/stats, GET} route +0ms 20 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] ReadingController {/reading}: +1ms 21 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/reading/progress, PATCH} route +0ms 22 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/reading/progress/:bookId, GET} route +0ms 23 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/reading/shelf, GET} route +0ms 24 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] BooksController {/books}: +0ms 25 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books, POST} route +0ms 26 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books, GET} route +1ms 27 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books/:id, GET} route +0ms 28 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books/:id/stats, GET} route +0ms 29 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books/:id, PATCH} route +0ms 30 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/books/:id, DELETE} route +0ms 31 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] AuthController {/auth}: +1ms 32 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/auth/register, POST} route +0ms 33 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/auth/login, POST} route +0ms 34 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RoutesResolver] FriendsController {/friends}: +0ms 35 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/friends/request, POST} route +0ms 36 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/friends/requests, GET} route +0ms 37 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/friends/requests/:requestId, PATCH} route +0ms 38 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [RouterExplorer] Mapped {/friends/:friendId/progress, GET} route +0ms 39 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  LOG [NestApplication] Nest application successfully started +2ms 40 | [Nest] 4067 - 09/29/2025, 8:41:20 AM  ERROR [NestApplication] Error: listen EADDRINUSE: address already in use :::3000 +2ms 41 | node:net:1937 42 | const ex = new UVExceptionWithHostPort(err, 'listen', address, port); 43 | ^ 44 | 45 | Error: listen EADDRINUSE: address already in use :::3000 46 | at Server.setupListenHandle [as _listen2] (node:net:1937:16) 47 | at listenInCluster (node:net:1994:12) 48 | at Server.listen (node:net:2099:7) 49 | at ExpressAdapter.listen (/usercode/FILESYSTEM/node_modules/@nestjs/platform-express/adapters/express-adapter.js:95:32) 50 | at /usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:183:30 51 | at new Promise () 52 | at NestApplication.listen (/usercode/FILESYSTEM/node_modules/@nestjs/core/nest-application.js:173:16) 53 | at async bootstrap (/usercode/FILESYSTEM/dist/main.js:29:5) { 54 | code: 'EADDRINUSE', 55 | errno: -98, 56 | syscall: 'listen', 57 | address: '::', 58 | port: 3000 59 | } 60 | 61 | Node.js v22.13.1 62 | -------------------------------------------------------------------------------- /test-curls-course6.txt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | echo "== Course 6: Advanced Filtering, Aggregation, and Sorting — Test CURLs ==" 4 | 5 | # 0) Public: basic reads (wrapped responses) 6 | echo "-- Public: books --" 7 | curl -s http://localhost:3000/books | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 8 | echo "-- Public: users --" 9 | curl -s http://localhost:3000/users | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 10 | 11 | # 1) Admin login (seed: admin/admin) 12 | ADMIN_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 13 | -H 'Content-Type: application/json' \ 14 | -d '{"username":"admin","password":"admin"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log((p&&p.access_token)||'')}catch(e){}})") 15 | echo "ADMIN_TOKEN: ${ADMIN_TOKEN}" 16 | 17 | # 2) Admin creates enriched books with metadata (idempotent; may duplicate titles) 18 | echo "-- Create: Clean Code --" 19 | curl -s -X POST http://localhost:3000/books \ 20 | -H "Authorization: Bearer ${ADMIN_TOKEN}" \ 21 | -H 'Content-Type: application/json' \ 22 | -d '{"title":"Clean Code","author":"Robert C. Martin","totalPages":464,"publishDate":"2008-08-01"}' 23 | echo 24 | echo "-- Create: The Pragmatic Programmer --" 25 | curl -s -X POST http://localhost:3000/books \ 26 | -H "Authorization: Bearer ${ADMIN_TOKEN}" \ 27 | -H 'Content-Type: application/json' \ 28 | -d '{"title":"The Pragmatic Programmer","author":"Andrew Hunt","totalPages":352,"publishDate":"1999-10-30"}' 29 | echo 30 | 31 | # 3) Public catalog search + pagination + sorting 32 | echo "-- Search q='the' sorted by publishDate desc, page 1 size 2 --" 33 | curl -s "http://localhost:3000/books?q=the&sortBy=publishDate&order=desc&page=1&pageSize=2" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 34 | 35 | # 4) Login a normal user (Alice seeded) 36 | ALICE_TOKEN=$(curl -s -X POST http://localhost:3000/auth/login \ 37 | -H 'Content-Type: application/json' \ 38 | -d '{"username":"alice","password":"user123"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log((p&&p.access_token)||'')}catch(e){}})") 39 | echo "ALICE_TOKEN: ${ALICE_TOKEN}" 40 | 41 | # 5) Alice adds books to shelf: want-to-read and in-progress 42 | BOOKS_JSON=$(curl -s http://localhost:3000/books) 43 | BOOK1=$(echo "$BOOKS_JSON" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);let arr=(p&&p.items)||p;let b=arr.find(x=>x.title==='The Hobbit');if(b)console.log(b.id);})") 44 | BOOK2=$(echo "$BOOKS_JSON" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);let arr=(p&&p.items)||p;let b=arr.find(x=>x.title==='Clean Code');if(b)console.log(b.id);})") 45 | USERS_JSON=$(curl -s http://localhost:3000/users) 46 | ALICE_ID=$(echo "$USERS_JSON" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let arr=u(j);let u2=arr.find(x=>x.username==='alice');if(u2)console.log(u2.id);})") 47 | echo "BOOK1 Hobbit: ${BOOK1} BOOK2 CleanCode: ${BOOK2} ALICE_ID: ${ALICE_ID}" 48 | 49 | # want-to-read (status overrides currentPage to 0) 50 | curl -s -X PATCH http://localhost:3000/reading/progress \ 51 | -H "Authorization: Bearer ${ALICE_TOKEN}" \ 52 | -H 'Content-Type: application/json' \ 53 | -d "{\"userId\":\"${ALICE_ID}\",\"bookId\":\"${BOOK1}\",\"currentPage\":0,\"status\":\"want-to-read\"}" 54 | echo 55 | 56 | # in-progress (currentPage derives status if not provided) 57 | curl -s -X PATCH http://localhost:3000/reading/progress \ 58 | -H "Authorization: Bearer ${ALICE_TOKEN}" \ 59 | -H 'Content-Type: application/json' \ 60 | -d "{\"userId\":\"${ALICE_ID}\",\"bookId\":\"${BOOK2}\",\"currentPage\":120}" 61 | echo 62 | 63 | # 6) Alice views her shelf: filter by status and sort by progress desc 64 | curl -s -H "Authorization: Bearer ${ALICE_TOKEN}" \ 65 | "http://localhost:3000/reading/shelf?status=in-progress&sortBy=progress&order=desc" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 66 | 67 | # 7) Book analytics: avgProgress sort and per-book stats 68 | echo "-- Catalog sorted by avgProgress desc --" 69 | curl -s "http://localhost:3000/books?sortBy=avgProgress&order=desc&page=1&pageSize=10" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 70 | 71 | echo "-- Stats for The Hobbit --" 72 | curl -s "http://localhost:3000/books/${BOOK1}/stats" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 73 | 74 | # 8) User analytics 75 | USERS_JSON=$(curl -s http://localhost:3000/users) 76 | ALICE_ID=$(echo "$USERS_JSON" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let arr=u(j);let u2=arr.find(x=>x.username==='alice');if(u2)console.log(u2.id);})") 77 | curl -s -H "Authorization: Bearer ${ALICE_TOKEN}" http://localhost:3000/users/${ALICE_ID}/stats | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{let j=JSON.parse(s);function u(p){let x=p;for(let i=0;i<3;i++){if(x&&typeof x==='object'&&('data'in x)&&((x.success===true)||('meta'in x))){x=x.data;continue;}break;}return x;}let p=u(j);console.log(JSON.stringify(p,null,2))}catch(e){console.log(s)}})" 78 | 79 | echo "== Done ==" 80 | -------------------------------------------------------------------------------- /course6-curl-results.txt: -------------------------------------------------------------------------------- 1 | == Course 6: Advanced Filtering, Aggregation, and Sorting — Test CURLs == 2 | -- Public: books -- 3 | { 4 | "items": [ 5 | { 6 | "id": "b772cd76-d5b2-4c93-97bd-c423593171c6", 7 | "title": "The Hobbit", 8 | "author": "J.R.R. Tolkien", 9 | "totalPages": 310, 10 | "publishDate": "1937-09-21", 11 | "uploadedAt": "2025-09-29T08:10:09.120Z" 12 | }, 13 | { 14 | "id": "d0678180-2ddd-425c-a36b-babf2ad9d1f7", 15 | "title": "Dune", 16 | "author": "Frank Herbert", 17 | "totalPages": 412, 18 | "publishDate": "1965-08-01", 19 | "uploadedAt": "2025-09-29T08:10:09.120Z" 20 | }, 21 | { 22 | "id": "a6d78ef3-e52e-4ea3-b328-b71d4cf9adaf", 23 | "title": "Clean Code", 24 | "author": "Robert C. Martin", 25 | "totalPages": 464, 26 | "publishDate": "2008-08-01", 27 | "uploadedAt": "2025-09-29T08:10:09.120Z" 28 | }, 29 | { 30 | "id": "8a42ae3e-b20e-4535-bbc1-3e1e43ab26da", 31 | "title": "The Pragmatic Programmer", 32 | "author": "Andrew Hunt", 33 | "totalPages": 352, 34 | "publishDate": "1999-10-30", 35 | "uploadedAt": "2025-09-29T08:10:09.120Z" 36 | }, 37 | { 38 | "id": "b01d73bb-abaa-48f5-93ee-e357ab46f3ff", 39 | "title": "1984", 40 | "author": "George Orwell", 41 | "totalPages": 328, 42 | "publishDate": "1949-06-08", 43 | "uploadedAt": "2025-09-29T08:10:09.120Z" 44 | }, 45 | { 46 | "id": "7ad28510-0f49-40a7-a8d1-f74b3c530621", 47 | "title": "To Kill a Mockingbird", 48 | "author": "Harper Lee", 49 | "totalPages": 281, 50 | "publishDate": "1960-07-11", 51 | "uploadedAt": "2025-09-29T08:10:09.120Z" 52 | }, 53 | { 54 | "id": "94a497d7-1341-4974-9b79-b7fb6407b80e", 55 | "title": "The Name of the Wind", 56 | "author": "Patrick Rothfuss", 57 | "totalPages": 662, 58 | "publishDate": "2007-03-27", 59 | "uploadedAt": "2025-09-29T08:10:09.120Z" 60 | }, 61 | { 62 | "id": "40162d55-9413-4357-85ae-a55d98c44cbe", 63 | "title": "Sapiens", 64 | "author": "Yuval Noah Harari", 65 | "totalPages": 443, 66 | "publishDate": "2011-01-01", 67 | "uploadedAt": "2025-09-29T08:10:09.120Z" 68 | } 69 | ], 70 | "page": 1, 71 | "pageSize": 20, 72 | "total": 8 73 | } 74 | -- Public: users -- 75 | [ 76 | { 77 | "id": "5cf92552-0d0b-46b8-812d-3530173c594d", 78 | "name": "Admin", 79 | "username": "admin", 80 | "passwordHash": "$2b$10$HLxs7wtjtkP39j8NHpsiT.UY.iusbzbr1U39m21tl.HF208oVqsIe", 81 | "role": "admin", 82 | "friendIds": [] 83 | }, 84 | { 85 | "id": "c55d289a-78b0-40a6-8af0-9944d4dc2a7d", 86 | "name": "Alice", 87 | "username": "alice", 88 | "passwordHash": "$2b$10$kaR.oWKQE8qeP3o80Q/6tuSiJlHqTdH09ca6LtYUO0qXV28DPLk0i", 89 | "role": "user", 90 | "friendIds": [] 91 | } 92 | ] 93 | ADMIN_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1Y2Y5MjU1Mi0wZDBiLTQ2YjgtODEyZC0zNTMwMTczYzU5NGQiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NTkxMzM2MDIsImV4cCI6MTc1OTEzNzIwMn0.and0LXNlY3JldC5leUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKemRXSWlPaUkxWTJZNU1qVTFNaTB3WkRCaUxUUTJZamd0T0RFeVpDMHpOVE13TVRjell6VTVOR1FpTENKeWIyeGxJam9pWVdSdGFXNGlMQ0pwWVhRaU9qRTNOVGt4TXpNMk1ESXNJbVY0Y0NJNk1UYzFPVEV6TnpJd01uMA 94 | -- Create: Clean Code -- 95 | {"data":{"id":"7d9577cf-590a-43a9-96d2-37a52808e5ba","title":"Clean Code","author":"Robert C. Martin","totalPages":464,"publishDate":"2008-08-01","uploadedAt":"2025-09-29T08:13:22.232Z"},"meta":{"timestamp":"2025-09-29T08:13:22.232Z"}} 96 | -- Create: The Pragmatic Programmer -- 97 | {"data":{"id":"27a66250-d214-4975-b338-8b5ee0ca96ab","title":"The Pragmatic Programmer","author":"Andrew Hunt","totalPages":352,"publishDate":"1999-10-30","uploadedAt":"2025-09-29T08:13:22.240Z"},"meta":{"timestamp":"2025-09-29T08:13:22.240Z"}} 98 | -- Search q='the' sorted by publishDate desc, page 1 size 2 -- 99 | { 100 | "items": [ 101 | { 102 | "id": "94a497d7-1341-4974-9b79-b7fb6407b80e", 103 | "title": "The Name of the Wind", 104 | "author": "Patrick Rothfuss", 105 | "totalPages": 662, 106 | "publishDate": "2007-03-27", 107 | "uploadedAt": "2025-09-29T08:10:09.120Z" 108 | }, 109 | { 110 | "id": "8a42ae3e-b20e-4535-bbc1-3e1e43ab26da", 111 | "title": "The Pragmatic Programmer", 112 | "author": "Andrew Hunt", 113 | "totalPages": 352, 114 | "publishDate": "1999-10-30", 115 | "uploadedAt": "2025-09-29T08:10:09.120Z" 116 | } 117 | ], 118 | "page": 1, 119 | "pageSize": 2, 120 | "total": 4 121 | } 122 | ALICE_TOKEN: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjNTVkMjg5YS03OGIwLTQwYTYtOGFmMC05OTQ0ZDRkYzJhN2QiLCJyb2xlIjoidXNlciIsImlhdCI6MTc1OTEzMzYwMiwiZXhwIjoxNzU5MTM3MjAyfQ.and0LXNlY3JldC5leUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKemRXSWlPaUpqTlRWa01qZzVZUzAzT0dJd0xUUXdZVFl0T0dGbU1DMDVPVFEwWkRSa1l6SmhOMlFpTENKeWIyeGxJam9pZFhObGNpSXNJbWxoZENJNk1UYzFPVEV6TXpZd01pd2laWGh3SWpveE56VTVNVE0zTWpBeWZR 123 | BOOK1 Hobbit: b772cd76-d5b2-4c93-97bd-c423593171c6 BOOK2 CleanCode: a6d78ef3-e52e-4ea3-b328-b71d4cf9adaf ALICE_ID: c55d289a-78b0-40a6-8af0-9944d4dc2a7d 124 | {"data":{"userId":"c55d289a-78b0-40a6-8af0-9944d4dc2a7d","bookId":"b772cd76-d5b2-4c93-97bd-c423593171c6","currentPage":0,"status":"want-to-read","updatedAt":"2025-09-29T08:13:22.473Z"},"meta":{"timestamp":"2025-09-29T08:13:22.473Z"}} 125 | {"data":{"userId":"c55d289a-78b0-40a6-8af0-9944d4dc2a7d","bookId":"a6d78ef3-e52e-4ea3-b328-b71d4cf9adaf","currentPage":120,"status":"in-progress","updatedAt":"2025-09-29T08:13:22.482Z"},"meta":{"timestamp":"2025-09-29T08:13:22.482Z"}} 126 | [ 127 | { 128 | "bookId": "a6d78ef3-e52e-4ea3-b328-b71d4cf9adaf", 129 | "title": "Clean Code", 130 | "author": "Robert C. Martin", 131 | "totalPages": 464, 132 | "currentPage": 120, 133 | "status": "in-progress", 134 | "progress": 0.25862068965517243, 135 | "updatedAt": "2025-09-29T08:13:22.482Z" 136 | }, 137 | { 138 | "bookId": "d0678180-2ddd-425c-a36b-babf2ad9d1f7", 139 | "title": "Dune", 140 | "author": "Frank Herbert", 141 | "totalPages": 412, 142 | "currentPage": 100, 143 | "status": "in-progress", 144 | "progress": 0.24271844660194175, 145 | "updatedAt": "2025-09-29T08:10:09.120Z" 146 | } 147 | ] 148 | -- Catalog sorted by avgProgress desc -- 149 | { 150 | "items": [ 151 | { 152 | "id": "8a42ae3e-b20e-4535-bbc1-3e1e43ab26da", 153 | "title": "The Pragmatic Programmer", 154 | "author": "Andrew Hunt", 155 | "totalPages": 352, 156 | "publishDate": "1999-10-30", 157 | "uploadedAt": "2025-09-29T08:10:09.120Z" 158 | }, 159 | { 160 | "id": "a6d78ef3-e52e-4ea3-b328-b71d4cf9adaf", 161 | "title": "Clean Code", 162 | "author": "Robert C. Martin", 163 | "totalPages": 464, 164 | "publishDate": "2008-08-01", 165 | "uploadedAt": "2025-09-29T08:10:09.120Z" 166 | }, 167 | { 168 | "id": "d0678180-2ddd-425c-a36b-babf2ad9d1f7", 169 | "title": "Dune", 170 | "author": "Frank Herbert", 171 | "totalPages": 412, 172 | "publishDate": "1965-08-01", 173 | "uploadedAt": "2025-09-29T08:10:09.120Z" 174 | }, 175 | { 176 | "id": "b772cd76-d5b2-4c93-97bd-c423593171c6", 177 | "title": "The Hobbit", 178 | "author": "J.R.R. Tolkien", 179 | "totalPages": 310, 180 | "publishDate": "1937-09-21", 181 | "uploadedAt": "2025-09-29T08:10:09.120Z" 182 | }, 183 | { 184 | "id": "b01d73bb-abaa-48f5-93ee-e357ab46f3ff", 185 | "title": "1984", 186 | "author": "George Orwell", 187 | "totalPages": 328, 188 | "publishDate": "1949-06-08", 189 | "uploadedAt": "2025-09-29T08:10:09.120Z" 190 | }, 191 | { 192 | "id": "7ad28510-0f49-40a7-a8d1-f74b3c530621", 193 | "title": "To Kill a Mockingbird", 194 | "author": "Harper Lee", 195 | "totalPages": 281, 196 | "publishDate": "1960-07-11", 197 | "uploadedAt": "2025-09-29T08:10:09.120Z" 198 | }, 199 | { 200 | "id": "94a497d7-1341-4974-9b79-b7fb6407b80e", 201 | "title": "The Name of the Wind", 202 | "author": "Patrick Rothfuss", 203 | "totalPages": 662, 204 | "publishDate": "2007-03-27", 205 | "uploadedAt": "2025-09-29T08:10:09.120Z" 206 | }, 207 | { 208 | "id": "40162d55-9413-4357-85ae-a55d98c44cbe", 209 | "title": "Sapiens", 210 | "author": "Yuval Noah Harari", 211 | "totalPages": 443, 212 | "publishDate": "2011-01-01", 213 | "uploadedAt": "2025-09-29T08:10:09.120Z" 214 | }, 215 | { 216 | "id": "7d9577cf-590a-43a9-96d2-37a52808e5ba", 217 | "title": "Clean Code", 218 | "author": "Robert C. Martin", 219 | "totalPages": 464, 220 | "publishDate": "2008-08-01", 221 | "uploadedAt": "2025-09-29T08:13:22.232Z" 222 | }, 223 | { 224 | "id": "27a66250-d214-4975-b338-8b5ee0ca96ab", 225 | "title": "The Pragmatic Programmer", 226 | "author": "Andrew Hunt", 227 | "totalPages": 352, 228 | "publishDate": "1999-10-30", 229 | "uploadedAt": "2025-09-29T08:13:22.240Z" 230 | } 231 | ], 232 | "page": 1, 233 | "pageSize": 10, 234 | "total": 10 235 | } 236 | -- Stats for The Hobbit -- 237 | { 238 | "bookId": "b772cd76-d5b2-4c93-97bd-c423593171c6", 239 | "readers": 2, 240 | "avgCurrentPage": 25, 241 | "completionRate": 0, 242 | "avgProgressPct": 0.08064516129032258 243 | } 244 | { 245 | "userId": "c55d289a-78b0-40a6-8af0-9944d4dc2a7d", 246 | "totalPagesRead": 220, 247 | "booksInShelf": 3, 248 | "booksCompleted": 0, 249 | "avgProgressPct": 0.18549747048903878 250 | } 251 | == Done == 252 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Reading Tracker 7 | 8 | 9 | 10 | 11 | 12 | 25 | 26 | 27 |
28 | 29 |
30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 | 38 | 39 |
40 |
41 | 42 | 43 |
44 | 45 |
46 |
47 | 65 |
66 |
67 | 68 | 69 |
70 |

Users

71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
IDNameUsernameActions
83 |

 84 |       
85 |
86 | 87 | 88 | 141 | 142 | 143 | 204 | 205 | 206 | 273 | 274 | 275 | 298 |
299 | 300 | 301 | 302 | 303 | 304 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | // SPA logic for Reading Tracker (Users, Books, Reading Progress) 2 | document.addEventListener('DOMContentLoaded', () => { 3 | // --- Auth State & Helpers --- 4 | const authStatus = document.getElementById('authStatus'); 5 | const loginForm = document.getElementById('loginForm'); 6 | const registerForm = document.getElementById('registerForm'); 7 | const showLoginBtn = document.getElementById('showLogin'); 8 | const showRegisterBtn = document.getElementById('showRegister'); 9 | const loginMessage = document.getElementById('loginMessage'); 10 | const registerMessage = document.getElementById('registerMessage'); 11 | 12 | let token = localStorage.getItem('token') || ''; 13 | let currentUser = null; // { userId, role } 14 | 15 | // Unwrap API payloads that may be nested as 16 | // { data: {...}, meta } and/or { success: true, data: {...} } 17 | function unwrap(json) { 18 | let payload = json; 19 | try { 20 | for (let i = 0; i < 3; i++) { 21 | if (payload && typeof payload === 'object' && 'data' in payload && ((payload && payload.success === true) || ('meta' in payload))) { 22 | payload = payload.data; 23 | continue; 24 | } 25 | break; 26 | } 27 | } catch {} 28 | return payload; 29 | } 30 | function decodeJwt(t) { 31 | try { 32 | const payload = t.split('.')[1]; 33 | const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); 34 | return JSON.parse(json); 35 | } catch { return null; } 36 | } 37 | 38 | function shortId(id) { return (typeof id === 'string') ? id.slice(0, 8) : id; } 39 | 40 | function setAuth(newToken) { 41 | token = newToken || ''; 42 | if (token) { 43 | localStorage.setItem('token', token); 44 | const payload = decodeJwt(token); 45 | currentUser = payload ? { userId: payload.sub, role: payload.role } : null; 46 | } else { 47 | localStorage.removeItem('token'); 48 | currentUser = null; 49 | } 50 | renderAuthStatus(); 51 | refreshVisibility(); 52 | // If Friends tab is currently active, refresh its data so 53 | // newly logged-in user can see incoming requests immediately. 54 | const friendsSection = document.getElementById('section-friends'); 55 | if (friendsSection && !friendsSection.classList.contains('hidden')) { 56 | if (typeof loadFriendsTab === 'function') { 57 | loadFriendsTab(); 58 | } 59 | } 60 | } 61 | 62 | function renderAuthStatus() { 63 | if (currentUser) { 64 | authStatus.innerHTML = `Logged in as ${shortId(currentUser.userId)} (${currentUser.role})`; 65 | document.getElementById('logoutBtn').onclick = () => setAuth(''); 66 | document.getElementById('authForms').classList.add('hidden'); 67 | } else { 68 | authStatus.textContent = 'Not logged in'; 69 | document.getElementById('authForms').classList.remove('hidden'); 70 | } 71 | } 72 | 73 | showLoginBtn.onclick = () => { loginForm.classList.remove('hidden'); registerForm.classList.add('hidden'); }; 74 | showRegisterBtn.onclick = () => { registerForm.classList.remove('hidden'); loginForm.classList.add('hidden'); }; 75 | 76 | loginForm.addEventListener('submit', async (e) => { 77 | e.preventDefault(); loginMessage.textContent = ''; 78 | const username = document.getElementById('loginUsername').value.trim(); 79 | const password = document.getElementById('loginPassword').value.trim(); 80 | const res = await fetch('/auth/login', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ username, password }) }); 81 | const raw = await res.json().catch(() => null); 82 | const json = unwrap(raw); 83 | if (!res.ok) { loginMessage.textContent = (raw && raw.error && raw.error.message) || 'Login failed'; return; } 84 | const tokenVal = json && json.access_token; 85 | setAuth(tokenVal); 86 | }); 87 | 88 | registerForm.addEventListener('submit', async (e) => { 89 | e.preventDefault(); registerMessage.textContent = ''; 90 | const name = document.getElementById('registerName').value.trim(); 91 | const username = document.getElementById('registerUsername').value.trim(); 92 | const password = document.getElementById('registerPassword').value.trim(); 93 | const res = await fetch('/auth/register', { method: 'POST', headers: { 'Content-Type':'application/json' }, body: JSON.stringify({ name, username, password }) }); 94 | if (!res.ok) { registerMessage.textContent = 'Registration failed'; return; } 95 | registerMessage.textContent = 'Registered! You can now login.'; 96 | showLoginBtn.click(); 97 | }); 98 | 99 | setAuth(token); 100 | const tabs = document.querySelectorAll('.tab-btn'); 101 | const sections = document.querySelectorAll('.tab-section'); 102 | 103 | // Switch tab and show corresponding section 104 | function showSection(name) { 105 | sections.forEach(sec => { 106 | sec.id === `section-${name}` ? sec.classList.remove('hidden') : sec.classList.add('hidden'); 107 | }); 108 | tabs.forEach(btn => { 109 | btn.id === `tab-${name}` ? btn.classList.add('text-blue-500') : btn.classList.remove('text-blue-500'); 110 | }); 111 | if (name === 'users') { 112 | loadUsers(); 113 | } else if (name === 'books') { 114 | loadBooks(); 115 | } else if (name === 'reading') { 116 | loadUserOptions(); 117 | loadBookOptions(); 118 | loadSearchBookOptions(); 119 | readingOutput.textContent = ''; 120 | searchReadingOutput.textContent = ''; 121 | } else if (name === 'friends') { 122 | loadFriendsTab(); 123 | } else if (name === 'admin') { 124 | loadLogs(); 125 | // The first request to /admin/logs is only logged after it completes. 126 | // Refresh shortly after to include that entry as well. 127 | setTimeout(loadLogs, 400); 128 | } 129 | } 130 | tabs.forEach(btn => btn.addEventListener('click', () => showSection(btn.id.split('-')[1]))); 131 | showSection('users'); 132 | 133 | // --- Users CRUD --- 134 | const usersTableBody = document.getElementById('usersTableBody'); 135 | const userStatsOutput = document.getElementById('userStatsOutput'); 136 | const friendsUsersTableBody = document.getElementById('friendsUsersTableBody'); 137 | const friendRequestMessage = document.getElementById('friendRequestMessage'); 138 | 139 | async function loadUsers() { 140 | const res = await fetch('/users'); 141 | if (!res.ok) return; 142 | const raw = await res.json(); 143 | const j = unwrap(raw); 144 | const users = Array.isArray(j) ? j : []; 145 | usersTableBody.innerHTML = ''; 146 | users.forEach(u => { 147 | const tr = document.createElement('tr'); 148 | const adminOnly = (currentUser && currentUser.role === 'admin'); 149 | const actions = ` 150 | 151 | ${adminOnly ? ` 152 | ` : 'Admin only'}`; 153 | tr.innerHTML = ` 154 | ${shortId(u.id)} 155 | ${u.name} 156 | ${u.username} 157 | ${actions}`; 158 | usersTableBody.appendChild(tr); 159 | }); 160 | document.querySelectorAll('.stats-user').forEach(btn => { 161 | btn.addEventListener('click', async () => { 162 | const id = btn.dataset.id; 163 | const res = await fetch(`/users/${id}/stats`, { headers: token ? { 'Authorization': `Bearer ${token}` } : {} }); 164 | const raw = await res.json().catch(() => null); 165 | if (!res.ok) userStatsOutput.textContent = (raw && raw.error && raw.error.message) || 'Failed to load user stats'; 166 | else userStatsOutput.textContent = JSON.stringify(unwrap(raw), null, 2); 167 | }); 168 | }); 169 | document.querySelectorAll('.edit-user').forEach(btn => { 170 | btn.addEventListener('click', async () => { 171 | const id = btn.dataset.id; 172 | const currentName = btn.dataset.name || ''; 173 | const currentUsername = btn.dataset.username || ''; 174 | const name = prompt('Enter new name:', currentName); 175 | if (name === null) return; 176 | const username = prompt('Enter new username:', currentUsername); 177 | if (username === null) return; 178 | await fetch(`/users/${id}`, { 179 | method: 'PATCH', headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify({ name, username }) 180 | }); 181 | loadUsers(); 182 | }); 183 | }); 184 | document.querySelectorAll('.delete-user').forEach(btn => { 185 | btn.addEventListener('click', async () => { 186 | const id = btn.dataset.id; 187 | if (!confirm('Delete this user?')) return; 188 | await fetch(`/users/${id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); 189 | loadUsers(); 190 | }); 191 | }); 192 | } 193 | // Create User UI removed; registration flow handles new users 194 | 195 | // --- Books CRUD & Catalog --- 196 | const booksTableBody = document.getElementById('booksTableBody'); 197 | const createBookForm = document.getElementById('createBookForm'); 198 | const booksSearch = document.getElementById('booksSearch'); 199 | const booksSortBy = document.getElementById('booksSortBy'); 200 | const booksOrder = document.getElementById('booksOrder'); 201 | const booksPage = document.getElementById('booksPage'); 202 | const booksPageSize = document.getElementById('booksPageSize'); 203 | const booksApply = document.getElementById('booksApply'); 204 | const booksMeta = document.getElementById('booksMeta'); 205 | const bookStatsOutput = document.getElementById('bookStatsOutput'); 206 | 207 | function normalizeBooksResponse(raw) { 208 | if (raw && raw.data && Array.isArray(raw.data.items)) return raw.data; 209 | if (raw && Array.isArray(raw?.data)) return { items: raw.data, page: 1, pageSize: raw.data.length, total: raw.data.length }; 210 | if (raw && Array.isArray(raw.items)) return raw; 211 | if (Array.isArray(raw)) return { items: raw, page: 1, pageSize: raw.length, total: raw.length }; 212 | return { items: [], page: 1, pageSize: 0, total: 0 }; 213 | } 214 | 215 | async function loadBooks() { 216 | const params = new URLSearchParams(); 217 | if (booksSearch && booksSearch.value.trim()) params.set('q', booksSearch.value.trim()); 218 | if (booksSortBy && booksSortBy.value) params.set('sortBy', booksSortBy.value); 219 | if (booksOrder && booksOrder.value) params.set('order', booksOrder.value); 220 | if (booksPage && booksPage.value) params.set('page', booksPage.value); 221 | if (booksPageSize && booksPageSize.value) params.set('pageSize', booksPageSize.value); 222 | const url = `/books${params.toString() ? '?' + params.toString() : ''}`; 223 | const res = await fetch(url); 224 | if (!res.ok) return; 225 | const raw = await res.json(); 226 | const j = unwrap(raw); 227 | const { items: books, page, pageSize, total } = normalizeBooksResponse(j); 228 | booksTableBody.innerHTML = ''; 229 | books.forEach(b => { 230 | const tr = document.createElement('tr'); 231 | const adminActions = (currentUser && currentUser.role === 'admin') 232 | ? ` 233 | ` 234 | : 'Admin only'; 235 | tr.innerHTML = ` 236 | ${shortId(b.id)} 237 | ${b.title} 238 | ${b.author} 239 | ${b.totalPages ?? '-'} 240 | ${b.publishDate ?? '-'} 241 | ${b.uploadedAt ? String(b.uploadedAt).substring(0,19).replace('T',' ') : '-'} 242 | 243 | 244 | ${adminActions} 245 | `; 246 | booksTableBody.appendChild(tr); 247 | }); 248 | booksMeta.textContent = `Showing ${books.length} of ${total} (page ${page}, size ${pageSize})`; 249 | document.querySelectorAll('.edit-book').forEach(btn => { 250 | btn.addEventListener('click', async () => { 251 | const id = btn.dataset.id; 252 | const title = prompt('Enter new title:'); if (title===null) return; 253 | const author = prompt('Enter new author:'); if (author===null) return; 254 | await fetch(`/books/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify({ title, author }) }); 255 | loadBooks(); 256 | }); 257 | }); 258 | document.querySelectorAll('.delete-book').forEach(btn => { 259 | btn.addEventListener('click', async () => { 260 | const id = btn.dataset.id; 261 | if (!confirm('Delete this book?')) return; 262 | await fetch(`/books/${id}`, { method:'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); 263 | loadBooks(); 264 | }); 265 | }); 266 | document.querySelectorAll('.stats-book').forEach(btn => { 267 | btn.addEventListener('click', async () => { 268 | const id = btn.dataset.id; 269 | const res = await fetch(`/books/${id}/stats`); 270 | const raw = await res.json().catch(() => null); 271 | if (!res.ok) { 272 | bookStatsOutput.textContent = (raw && raw.error && raw.error.message) || 'Failed to fetch stats'; 273 | } else { 274 | bookStatsOutput.textContent = JSON.stringify(unwrap(raw), null, 2); 275 | } 276 | }); 277 | }); 278 | } 279 | if (booksApply) booksApply.addEventListener('click', (e) => { e.preventDefault(); loadBooks(); }); 280 | createBookForm.addEventListener('submit', async e => { 281 | e.preventDefault(); 282 | const title = document.getElementById('newBookTitle').value.trim(); 283 | const author = document.getElementById('newBookAuthor').value.trim(); 284 | const totalPages = parseInt(document.getElementById('newBookPages').value); 285 | const publishDate = document.getElementById('newBookPublishDate').value.trim(); 286 | if (!title || !author || !totalPages) return; 287 | const body = { title, author, totalPages }; 288 | if (publishDate) body.publishDate = publishDate; 289 | await fetch('/books', { method:'POST', headers:{'Content-Type':'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify(body) }); 290 | createBookForm.reset(); 291 | loadBooks(); 292 | }); 293 | 294 | // --- Reading Progress --- 295 | const readingUserSelect = document.getElementById('readingUserSelect'); 296 | const readingBookSelect = document.getElementById('readingBookSelect'); 297 | const updateReadingForm = document.getElementById('updateReadingForm'); 298 | const readingOutput = document.getElementById('readingOutput'); 299 | const searchBookSelect = document.getElementById('searchBookSelect'); 300 | const searchReadingBtn = document.getElementById('searchReadingBtn'); 301 | const searchReadingOutput = document.getElementById('searchReadingOutput'); 302 | 303 | // --- Friends Management --- 304 | const incomingRequestsBody = document.getElementById('incomingRequestsBody'); 305 | const incomingMessage = document.getElementById('incomingMessage'); 306 | const refreshIncomingBtn = document.getElementById('refreshIncomingBtn'); 307 | const friendsUserSelect = document.getElementById('friendsUserSelect'); 308 | const loadFriendsBtn = document.getElementById('loadFriendsBtn'); 309 | const friendsList = document.getElementById('friendsList'); 310 | const friendProgress = document.getElementById('friendProgress'); 311 | 312 | async function loadFriendsTab() { 313 | // Populate users for sending requests and selection 314 | await loadFriendsUsersTable(); 315 | await loadFriendsUserSelect(); 316 | await loadIncomingRequests(); 317 | friendRequestMessage.textContent = ''; 318 | incomingMessage.textContent = ''; 319 | friendsList.innerHTML = ''; 320 | friendProgress.textContent = ''; 321 | } 322 | 323 | async function loadFriendsUsersTable() { 324 | const res = await fetch('/users'); 325 | if (!res.ok) return; 326 | const raw = await res.json(); 327 | const j = unwrap(raw); 328 | const users = Array.isArray(j) ? j : []; 329 | friendsUsersTableBody.innerHTML = ''; 330 | // Optionally fetch current user's friends to disable request for existing friends 331 | let myFriends = []; 332 | if (currentUser) { 333 | try { 334 | const r = await fetch(`/users/${currentUser.userId}/friends`, { headers: { 'Authorization': `Bearer ${token}` } }); 335 | const jr = await r.json(); 336 | const un = unwrap(jr); 337 | myFriends = Array.isArray(un) ? un : []; 338 | } catch {} 339 | } 340 | const friendIds = new Set(myFriends.map(f => f.id)); 341 | users.forEach(u => { 342 | const tr = document.createElement('tr'); 343 | let btnHtml = ''; 344 | if (!currentUser) { 345 | btnHtml = 'Login required'; 346 | } else if (u.id === currentUser.userId) { 347 | btnHtml = 'This is you'; 348 | } else if (friendIds.has(u.id)) { 349 | btnHtml = 'Already friends'; 350 | } else { 351 | btnHtml = ``; 352 | } 353 | tr.innerHTML = ` 354 | ${shortId(u.id)} 355 | ${u.name} 356 | ${u.username} 357 | ${btnHtml}`; 358 | friendsUsersTableBody.appendChild(tr); 359 | }); 360 | document.querySelectorAll('.send-friend').forEach(btn => { 361 | btn.addEventListener('click', async () => { 362 | const id = btn.dataset.id || ''; 363 | friendRequestMessage.textContent = ''; 364 | if (!currentUser) { friendRequestMessage.textContent = 'Please login first.'; return; } 365 | const res = await fetch('/friends/request', { method: 'POST', headers: { 'Content-Type':'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ recipientId: id }) }); 366 | const json = await res.json().catch(() => null); 367 | if (!res.ok) { 368 | friendRequestMessage.textContent = (json && json.error && json.error.message) || 'Failed to send request'; 369 | } else { 370 | friendRequestMessage.textContent = 'Request sent!'; 371 | loadIncomingRequests(); 372 | loadFriendsUsersTable(); 373 | } 374 | }); 375 | }); 376 | } 377 | 378 | async function loadIncomingRequests() { 379 | if (!currentUser) { incomingRequestsBody.innerHTML = ''; incomingMessage.textContent = 'Login to view incoming requests.'; return; } 380 | const res = await fetch('/friends/requests', { headers: { 'Authorization': `Bearer ${token}` } }); 381 | const raw = await res.json().catch(() => null); 382 | if (!res.ok) { 383 | incomingRequestsBody.innerHTML = ''; 384 | incomingMessage.textContent = (raw && raw.error && raw.error.message) || 'Failed to load incoming requests'; 385 | return; 386 | } 387 | const j2 = unwrap(raw); 388 | const requests = Array.isArray(j2) ? j2 : []; 389 | incomingRequestsBody.innerHTML = ''; 390 | if (!requests.length) { 391 | incomingRequestsBody.innerHTML = 'No pending requests.'; 392 | return; 393 | } 394 | // We need usernames for senders; fetch the users list 395 | const allUsersRes = await fetch('/users'); 396 | const allUsersJson = await allUsersRes.json().catch(() => null); 397 | const allUsers = (() => { const u = unwrap(allUsersJson); return Array.isArray(u) ? u : []; })(); 398 | const userMap = new Map(allUsers.map(u => [u.id, u])); 399 | requests.forEach(r => { 400 | const sender = userMap.get(r.senderId); 401 | const tr = document.createElement('tr'); 402 | tr.innerHTML = ` 403 | ${shortId(r.id)} 404 | ${sender ? sender.name + ' (@' + sender.username + ')' : r.senderId} 405 | 406 | 407 | 408 | `; 409 | incomingRequestsBody.appendChild(tr); 410 | }); 411 | document.querySelectorAll('.handle-req').forEach(btn => { 412 | btn.addEventListener('click', async () => { 413 | const id = btn.dataset.id || ''; 414 | const status = btn.dataset.status; 415 | const res = await fetch(`/friends/requests/${id}`, { method: 'PATCH', headers: { 'Content-Type':'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ status }) }); 416 | const raw = await res.json().catch(() => null); 417 | if (!res.ok) { 418 | incomingMessage.textContent = (raw && raw.error && raw.error.message) || 'Failed to handle request'; 419 | } else { 420 | incomingMessage.textContent = 'Request updated.'; 421 | loadIncomingRequests(); 422 | loadFriendsUsersTable(); 423 | } 424 | }); 425 | }); 426 | } 427 | 428 | async function loadFriendsUserSelect() { 429 | const res = await fetch('/users'); 430 | if (!res.ok) return; 431 | const raw = await res.json(); 432 | const j = unwrap(raw); 433 | const users = Array.isArray(j) ? j : []; 434 | friendsUserSelect.innerHTML = ''; 435 | users.forEach(u => { friendsUserSelect.innerHTML += ``; }); 436 | } 437 | 438 | if (refreshIncomingBtn) refreshIncomingBtn.addEventListener('click', loadIncomingRequests); 439 | if (loadFriendsBtn) loadFriendsBtn.addEventListener('click', async () => { 440 | const userId = friendsUserSelect.value || ''; 441 | friendsList.innerHTML = ''; 442 | friendProgress.textContent = ''; 443 | if (!userId) return; 444 | const res = await fetch(`/users/${userId}/friends`, { headers: { 'Authorization': `Bearer ${token}` } }); 445 | const raw = await res.json().catch(() => null); 446 | if (!res.ok) { 447 | friendsList.innerHTML = `
  • ${(raw && raw.error && raw.error.message) || 'Failed to load friends'}
  • `; 448 | return; 449 | } 450 | const j = unwrap(raw); 451 | const friends = Array.isArray(j) ? j : []; 452 | if (!friends.length) { 453 | friendsList.innerHTML = '
  • No friends yet.
  • '; 454 | return; 455 | } 456 | friends.forEach(f => { 457 | const li = document.createElement('li'); 458 | li.innerHTML = `${shortId(f.id)} - ${f.name} (@${f.username}) `; 459 | friendsList.appendChild(li); 460 | }); 461 | document.querySelectorAll('.view-progress').forEach(btn => { 462 | btn.addEventListener('click', async () => { 463 | const friendId = btn.dataset.id || ''; 464 | friendProgress.textContent = ''; 465 | const res = await fetch(`/friends/${friendId}/progress`, { headers: { 'Authorization': `Bearer ${token}` } }); 466 | const raw = await res.json().catch(() => null); 467 | if (!res.ok) { 468 | friendProgress.textContent = (raw && raw.error && raw.error.message) || 'Failed to load progress'; 469 | return; 470 | } 471 | const j = unwrap(raw); 472 | const sessions = Array.isArray(j) ? j : []; 473 | if (!sessions.length) { 474 | friendProgress.textContent = 'No reading sessions.'; 475 | } else { 476 | friendProgress.innerHTML = '
      ' + sessions.map(s => `
    • Book ${shortId(s.bookId)}: page ${s.currentPage}
    • `).join('') + '
    '; 477 | } 478 | }); 479 | }); 480 | }); 481 | 482 | async function loadUserOptions() { 483 | const res = await fetch('/users'); 484 | if (!res.ok) return; 485 | const raw = await res.json(); 486 | const j = unwrap(raw); 487 | const users = Array.isArray(j) ? j : []; 488 | readingUserSelect.innerHTML = ''; 489 | users.forEach(u => { readingUserSelect.innerHTML += ``; }); 490 | // Enforce Owner-or-Admin UX: non-admins can only update themselves 491 | if (currentUser && currentUser.role !== 'admin') { 492 | readingUserSelect.value = String(currentUser.userId); 493 | readingUserSelect.disabled = true; 494 | } else { 495 | readingUserSelect.disabled = false; 496 | } 497 | } 498 | async function loadBookOptions() { 499 | const res = await fetch('/books'); 500 | if (!res.ok) return; 501 | const raw = await res.json(); 502 | const norm = normalizeBooksResponse(unwrap(raw)); 503 | const books = norm.items || []; 504 | readingBookSelect.innerHTML = ''; 505 | books.forEach(b => { readingBookSelect.innerHTML += ``; }); 506 | } 507 | async function loadSearchBookOptions() { 508 | const res = await fetch('/books'); 509 | if (!res.ok) return; 510 | const raw = await res.json(); 511 | const norm = normalizeBooksResponse(unwrap(raw)); 512 | const books = norm.items || []; 513 | searchBookSelect.innerHTML = ''; 514 | books.forEach(b => { searchBookSelect.innerHTML += ``; }); 515 | } 516 | updateReadingForm.addEventListener('submit', async e => { 517 | e.preventDefault(); 518 | if (!currentUser) { readingOutput.textContent = 'Please login to update progress.'; return; } 519 | const isAdmin = !!(currentUser && currentUser.role === 'admin'); 520 | const userId = isAdmin ? (readingUserSelect.value) : (currentUser ? currentUser.userId : ''); 521 | const bookId = (readingBookSelect.value); 522 | const currentPage = parseInt(document.getElementById('readingCurrentPage').value); 523 | const status = document.getElementById('readingStatusSelect').value; 524 | if (!bookId || !userId) { readingOutput.textContent = 'Please select user and book.'; return; } 525 | const body = { userId, bookId }; 526 | if (status === 'want-to-read') { body.currentPage = 0; body.status = status; } 527 | else { 528 | if (Number.isNaN(currentPage)) { readingOutput.textContent = 'Enter current page or choose status.'; return; } 529 | body.currentPage = currentPage; 530 | if (status) body.status = status; 531 | } 532 | const res = await fetch('/reading/progress', { method:'PATCH', headers:{'Content-Type':'application/json', 'Authorization': `Bearer ${token}`}, body: JSON.stringify(body) }); 533 | const raw = await res.json().catch(() => null); 534 | if (!res.ok) { 535 | const msg = raw?.error?.message || 'Error updating progress'; 536 | readingOutput.textContent = msg; 537 | return; 538 | } 539 | const payload = unwrap(raw); 540 | readingOutput.textContent = JSON.stringify(payload, null, 2); 541 | }); 542 | // Shelf 543 | const loadShelfBtn = document.getElementById('loadShelfBtn'); 544 | const shelfStatus = document.getElementById('shelfStatus'); 545 | const shelfSortBy = document.getElementById('shelfSortBy'); 546 | const shelfOrder = document.getElementById('shelfOrder'); 547 | const shelfOutput = document.getElementById('shelfOutput'); 548 | if (loadShelfBtn) loadShelfBtn.addEventListener('click', async () => { 549 | if (!currentUser) { shelfOutput.textContent = 'Login required.'; return; } 550 | const params = new URLSearchParams(); 551 | if (shelfStatus && shelfStatus.value) params.set('status', shelfStatus.value); 552 | if (shelfSortBy && shelfSortBy.value) params.set('sortBy', shelfSortBy.value); 553 | if (shelfOrder && shelfOrder.value) params.set('order', shelfOrder.value); 554 | const res = await fetch(`/reading/shelf?${params.toString()}`, { headers: { 'Authorization': `Bearer ${token}` } }); 555 | const raw = await res.json().catch(() => null); 556 | if (!res.ok) { shelfOutput.textContent = (raw && raw.error && raw.error.message) || 'Failed to load shelf'; return; } 557 | const itemsRaw = unwrap(raw); 558 | const items = Array.isArray(itemsRaw) ? itemsRaw : []; 559 | if (!items.length) { shelfOutput.textContent = 'Shelf is empty.'; return; } 560 | shelfOutput.innerHTML = '' + 561 | items.map(i => ``).join('') + 562 | '
    TitleAuthorStatusProgressUpdated
    ${i.title}${i.author}${i.status}${Math.round((i.progress||0)*100)}%${i.updatedAt||'-'}
    '; 563 | }); 564 | searchReadingBtn.addEventListener('click', async () => { 565 | const bookId = searchBookSelect.value; 566 | if (!bookId) { 567 | searchReadingOutput.textContent = 'Please select a book.'; 568 | return; 569 | } 570 | const res = await fetch(`/reading/progress/${bookId}`); 571 | const raw = await res.json().catch(() => null); 572 | if (!res.ok) { 573 | const msg = raw?.error?.message || 'Error fetching progress'; 574 | searchReadingOutput.textContent = msg; 575 | return; 576 | } 577 | const j = unwrap(raw); 578 | const data = Array.isArray(j) ? j : []; 579 | if (!data.length) { 580 | searchReadingOutput.textContent = 'No progress for this book.'; 581 | } else { 582 | searchReadingOutput.innerHTML = '
      ' + data.map(item => `
    • ${shortId(item.user.id)} - ${item.user.name}: ${item.currentPage}
    • `).join('') + '
    '; 583 | } 584 | }); 585 | 586 | function refreshVisibility() { 587 | // Show/hide book creation form (query element to avoid early ReferenceErrors) 588 | const createForm = document.getElementById('createBookForm'); 589 | if (!createForm) return; 590 | if (currentUser?.role === 'admin') { 591 | createForm.classList.remove('hidden'); 592 | } else { 593 | createForm.classList.add('hidden'); 594 | } 595 | // Admin tab visibility 596 | const adminTab = document.getElementById('tab-admin'); 597 | if (adminTab) { 598 | if (currentUser?.role === 'admin') adminTab.classList.remove('hidden'); 599 | else adminTab.classList.add('hidden'); 600 | } 601 | } 602 | 603 | // Initial load 604 | loadUsers(); loadBooks(); loadSearchBookOptions(); 605 | 606 | // --- Admin Logs Viewer --- 607 | const logsTableBody = document.getElementById('logsTableBody'); 608 | const logsLimit = document.getElementById('logsLimit'); 609 | const refreshLogsBtn = document.getElementById('refreshLogs'); 610 | const logsMessage = document.getElementById('logsMessage'); 611 | 612 | async function loadLogs() { 613 | if (!currentUser || currentUser.role !== 'admin') { 614 | logsMessage.textContent = 'Admin only'; 615 | logsTableBody.innerHTML = ''; 616 | return; 617 | } 618 | const limit = parseInt(logsLimit.value) || 20; 619 | const res = await fetch(`/admin/logs?limit=${limit}`, { headers: { 'Authorization': `Bearer ${token}` } }); 620 | const raw = await res.json().catch(() => null); 621 | if (!res.ok) { 622 | logsMessage.textContent = (raw && raw.error && raw.error.message) || 'Failed to load logs'; 623 | logsTableBody.innerHTML = ''; 624 | return; 625 | } 626 | logsMessage.textContent = ''; 627 | const j = unwrap(raw); 628 | const logs = Array.isArray(j) ? j : []; 629 | logsTableBody.innerHTML = ''; 630 | logs.forEach(l => { 631 | const tr = document.createElement('tr'); 632 | tr.innerHTML = ` 633 | ${l.at} 634 | ${l.method} 635 | ${l.url} 636 | ${l.status} 637 | ${l.ms}`; 638 | logsTableBody.appendChild(tr); 639 | }); 640 | } 641 | if (refreshLogsBtn) refreshLogsBtn.addEventListener('click', loadLogs); 642 | }); 643 | --------------------------------------------------------------------------------