diff --git a/.github/.keep b/.github/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000000..db9d3c2360 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: + - main + - staging + pull_request: + types: [opened, synchronize] + +jobs: + install: + name: Install Dependencies + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./project + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + # We un-hoist packages to ensure that each package has the necessary + # dependencies and do not rely implicitly on dependencies from other packages. + - name: Un-hoist packages + run: | + sed -i 's/node-linker=hoisted/node-linker=isolated/g' .npmrc + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + lint: + name: Type Check & Lint + needs: + - install + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./project + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Un-hoist packages + run: | + sed -i 's/node-linker=hoisted/node-linker=isolated/g' .npmrc + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Cache turbo build setup + uses: actions/cache@v4 + with: + path: .turbo + key: ${{ runner.os }}-turbo-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo- + + - name: Setup Node.js environment + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - name: Run type checking + run: pnpm type-check + + - name: Run linting + run: pnpm lint diff --git a/README.md b/README.md index 259f7bba2e..5ddee1cbf1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ +[![Review Assignment Due Date](https://classroom.github.com/assets/deadline-readme-button-22041afd0340ce965d47ae6ef1cefeee28c7c493a6346c4f15d667ab976d596c.svg)](https://classroom.github.com/a/bzPrOe11) # CS3219 Project (PeerPrep) - AY2425S1 ## Group: Gxx diff --git a/project/.gitignore b/project/.gitignore new file mode 100644 index 0000000000..96fab4fed3 --- /dev/null +++ b/project/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +node_modules +.pnp +.pnp.js + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Testing +coverage + +# Turbo +.turbo + +# Vercel +.vercel + +# Build Outputs +.next/ +out/ +build +dist + + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem diff --git a/project/.npmrc b/project/.npmrc new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project/.prettierrc b/project/.prettierrc new file mode 100644 index 0000000000..1ca87ab7d8 --- /dev/null +++ b/project/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": false +} diff --git a/project/.vscode/settings.json b/project/.vscode/settings.json new file mode 100644 index 0000000000..44a73ec3a9 --- /dev/null +++ b/project/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "eslint.workingDirectories": [ + { + "mode": "auto" + } + ] +} diff --git a/project/README.md b/project/README.md new file mode 100644 index 0000000000..52f5cc51b9 --- /dev/null +++ b/project/README.md @@ -0,0 +1,136 @@ +# Project + +This Turborepo includes a Next.js frontend, a NestJS API gateway, and NestJS microservices, using shadcn/ui for CSS and Zod for validation. + +## Layout + +This Turborepo includes the following packages/apps: + +### Apps + +- `web`: [Next.js](https://nextjs.org/) app for the frontend +- `api-gateway`: [NestJS](https://nestjs.com/) backend serving as an entry point into microservices +- `questions-service`: [NestJS](https://nestjs.com/) backend handling all questions related functions + +```mermaid +graph TD + A[Web Client] -->|HTTP Request| B(API Gateway) + B -->|RPC Call| C[Questions Service] + B -->|RPC Call| D[Other Microservice 1] + B -->|RPC Call| E[Other Microservice 2] + + subgraph Microservices + C + D + E + end +``` + +### Packages + +- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) +- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo +- `@repo/dtos`: Shared DTOs and Zod schemas +- `@repo/pipes`: Shared NestJS pipes + +Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Utilities + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting +- [TurboRepo](https://turbo.build/repo/docs) for easily managing monorepo +- [shadcn/ui](https://ui.shadcn.com/) for CSS +- [Zod](https://zod.dev/) for validation +- [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) for querying + +## Getting Started + +### Build + +To build all apps and packages, run the following command: + +``` +pnpm build +``` + +### Setup Environment Variables + +Each `project/apps` has a `.env` that you should create. +Copy the `.env.example` file in each of these apps to create a new `.env` file: + +```bash +$ cp .env.example .env +``` + +Then, replace the variables accordingly. + +### Develop + +To develop all apps and packages, run the following command: + +``` +pnpm install +pnpm dev +``` + +## Development Guide + +### Frontend Development + +The frontend is located in `apps/web`. This is a Next.js application where you'll develop your user interface. + +### Creating a New Microservice + +To create a new microservice: + +1. Navigate to the `apps` directory +2. Run the following command: + ``` + nest new {service} --strict --skip-git --package-manager=pnpm + ``` + Replace `{service}` with the name of your new microservice. + +### Integrating a New Microservice + +After creating a new microservice, you need to integrate it with the API gateway: + +1. Register the microservice client in `api-gateway/src/api-gateway-module.ts` +2. Create a new controller within `api-gateway/src/{service}` which redirects requests to the microservice. You can follow the format of `api-gateway/src/questions/questions.controller.ts` + +### Shared Packages + +- `packages/dtos`: This is where we define our schemas and DTOs using Zod. These can be shared across the frontend and backend, and are used for validation pipes on the backend. +- `packages/pipes`: This is where we store pipes to be shared across NestJS apps. + +### Using Shared Packages + +To use packages in other packages or apps: + +1. Add the package name (found in that package's `package.json`) to the `package.json` of your target app or package. +2. You can then import it in your code. + +### Validation + +We use Zod for validation across the project. Define your schemas in `packages/dtos`, and use them for both frontend and backend validation. + +### Styling + +We use shadcn/ui for CSS. Refer to the [shadcn/ui documentation](https://ui.shadcn.com/) for usage instructions. + +## Best Practices + +- Keep your microservices small and focused on a specific domain. +- Use the shared DTOs for consistency between frontend and backend. +- Write unit tests for your services and controllers. +- Use Zod schemas for both runtime validation and TypeScript type inference. + +## Troubleshooting + +If you encounter any issues: + +1. Ensure all dependencies are installed with `pnpm install` +2. Check that you're using the correct Node.js version (specified in `.nvmrc`) +3. Clear your build cache with `pnpm clean` +4. If problems persist, please open an issue in the repository diff --git a/project/apps/api-gateway/.env.example b/project/apps/api-gateway/.env.example new file mode 100644 index 0000000000..f82ecd5ae1 --- /dev/null +++ b/project/apps/api-gateway/.env.example @@ -0,0 +1,2 @@ +SUPABASE_URL=your-supabase-url +SUPABASE_KEY=your-supabase-key \ No newline at end of file diff --git a/project/apps/api-gateway/.eslintrc.js b/project/apps/api-gateway/.eslintrc.js new file mode 100644 index 0000000000..9273465b05 --- /dev/null +++ b/project/apps/api-gateway/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@repo/eslint-config/nest.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, +}; diff --git a/project/apps/api-gateway/.prettierrc b/project/apps/api-gateway/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/project/apps/api-gateway/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/project/apps/api-gateway/README.md b/project/apps/api-gateway/README.md new file mode 100644 index 0000000000..3be11fdd95 --- /dev/null +++ b/project/apps/api-gateway/README.md @@ -0,0 +1,93 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

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

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +Copy the `.env.example` file to create a new `.env` file: + +```bash +$ cp .env.example .env +``` + +Modify the `.env` file with your environment-specific configuration. + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +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). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/project/apps/api-gateway/nest-cli.json b/project/apps/api-gateway/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/project/apps/api-gateway/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/project/apps/api-gateway/package.json b/project/apps/api-gateway/package.json new file mode 100644 index 0000000000..996efc1074 --- /dev/null +++ b/project/apps/api-gateway/package.json @@ -0,0 +1,81 @@ +{ + "name": "api-gateway", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "update-types": "npx supabase gen types --lang=typescript --project-id kamxbsekjfdzemvoevgz > src/supabase/database.types.ts" + }, + "dependencies": { + "@repo/eslint-config": "workspace:*", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.4.3", + "@nestjs/platform-express": "^10.0.0", + "@repo/dtos": "workspace:*", + "@repo/pipes": "workspace:*", + "@supabase/supabase-js": "^2.45.4", + "cookie-parser": "^1.4.6", + "nestjs-pino": "^4.1.0", + "pino-pretty": "^11.2.2", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/cookie-parser": "^1.4.7", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/supertest": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/project/apps/api-gateway/src/api-gateway.module.ts b/project/apps/api-gateway/src/api-gateway.module.ts new file mode 100644 index 0000000000..bae8eb606f --- /dev/null +++ b/project/apps/api-gateway/src/api-gateway.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { QuestionsController } from './questions/questions.controller'; +import { SupabaseService } from './supabase/supabase.service'; +import { ConfigModule } from '@nestjs/config'; +import { AuthController } from './auth/auth.controller'; +import { AuthService } from './auth/auth.service'; +import { LoggerModule } from 'nestjs-pino'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + LoggerModule.forRoot({ + pinoHttp: { + transport: { + target: 'pino-pretty', + }, + }, + }), + // Client for Questions Service + ClientsModule.register([ + { + name: 'QUESTIONS_SERVICE', + transport: Transport.TCP, + options: { + port: 3001, + }, + }, + ]), + ], + controllers: [QuestionsController, AuthController], + providers: [SupabaseService, AuthService], +}) +export class ApiGatewayModule {} diff --git a/project/apps/api-gateway/src/auth/auth.controller.ts b/project/apps/api-gateway/src/auth/auth.controller.ts new file mode 100644 index 0000000000..22cedc31a7 --- /dev/null +++ b/project/apps/api-gateway/src/auth/auth.controller.ts @@ -0,0 +1,71 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Req, + Res, + UsePipes, +} from '@nestjs/common'; + +import { + SignInDto, + signInSchema, + SignUpDto, + signUpSchema, +} from '@repo/dtos/auth'; +import { ZodValidationPipe } from '@repo/pipes/zod-validation-pipe.pipe'; +import { Request, Response } from 'express'; +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('signup') + @UsePipes(new ZodValidationPipe(signUpSchema)) + async signUp(@Body() body: SignUpDto, @Res() res: Response) { + const { userData, session } = await this.authService.signUp(body); + res.cookie('token', session.access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 * 1000, + }); + + return res.status(HttpStatus.OK).json({ userData }); + } + + @Post('signin') + @UsePipes(new ZodValidationPipe(signInSchema)) + async signIn(@Body() body: SignInDto, @Res() res: Response) { + const { userData, session } = await this.authService.signIn(body); + res.cookie('token', session.access_token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 7 * 1000, + }); + + return res.status(HttpStatus.OK).json({ userData }); + } + + @Post('signout') + async signOut(@Res() res: Response) { + res.clearCookie('token'); + return res + .status(HttpStatus.OK) + .json({ message: 'Signed out successfully' }); + } + + @Get('me') + async me(@Req() request: Request, @Res() res: Response) { + const token = request.cookies['token']; + if (!token) { + return res.status(HttpStatus.UNAUTHORIZED).json({ user: null }); + } + const { userData } = await this.authService.me(token); + return res.status(HttpStatus.OK).json({ userData }); + } +} diff --git a/project/apps/api-gateway/src/auth/auth.guard.ts b/project/apps/api-gateway/src/auth/auth.guard.ts new file mode 100644 index 0000000000..c36ccf2907 --- /dev/null +++ b/project/apps/api-gateway/src/auth/auth.guard.ts @@ -0,0 +1,32 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { SupabaseService } from '../supabase/supabase.service'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor(private readonly supabaseService: SupabaseService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = request.cookies['token']; + + if (!token) { + throw new UnauthorizedException('No token found'); + } + + const { data, error } = await this.supabaseService + .getClient() + .auth.getUser(token); + + if (error || !data) { + throw new UnauthorizedException('Invalid token'); + } + + request.user = data; + return true; + } +} diff --git a/project/apps/api-gateway/src/auth/auth.service.ts b/project/apps/api-gateway/src/auth/auth.service.ts new file mode 100644 index 0000000000..d7078b44f3 --- /dev/null +++ b/project/apps/api-gateway/src/auth/auth.service.ts @@ -0,0 +1,114 @@ +import { + Injectable, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { SupabaseService } from '../supabase/supabase.service'; +import { SignInDto, SignUpDto } from '@repo/dtos/auth'; +import { UserDetails } from 'src/supabase/collection'; + +@Injectable() +export class AuthService { + constructor(private readonly supabaseService: SupabaseService) {} + private readonly PROFILES_TABLE = 'profiles'; + + async me(token: string) { + const { data, error } = await this.supabaseService + .getClient() + .auth.getUser(token); + + if (error) { + throw new UnauthorizedException(error.message); + } + const { user } = data; + if (!user || !data) { + throw new BadRequestException('Unexpected sign-in response.'); + } + const { data: userData, error: profileError } = await this.supabaseService + .getClient() + .from(this.PROFILES_TABLE) + .select(`id, email, username`) + .eq('id', user.id) + .returns() + .single(); + if (profileError) { + throw new BadRequestException(profileError.message); + } + return { userData }; + } + + async signUp(signUpDto: SignUpDto) { + const { email, password, username } = signUpDto; + + // Step 1: Create user in Supabase Auth + const { data, error } = await this.supabaseService.getClient().auth.signUp({ + email, + password, + options: { + data: { + username, + }, + }, + }); + if (error) { + throw new BadRequestException(error.message); + } + const { user, session } = data; + + if (!user || !session) { + throw new BadRequestException('Unexpected error occured'); + } + + // Step 2: Insert profile data into profiles table + const { data: userData, error: profileError } = await this.supabaseService + .getClient() + .from(this.PROFILES_TABLE) + .insert([ + { + id: user.id, + username, + email, + }, + ]) + .returns() + .single(); + + if (profileError) { + // Delete the created user if profile creation fails + await this.supabaseService.getClient().auth.admin.deleteUser(user.id); + throw new BadRequestException(profileError.message); + } + + // Return user and session information + return { userData, session }; + } + + async signIn(signInDto: SignInDto) { + const { email, password } = signInDto; + const { data, error } = await this.supabaseService + .getClient() + .auth.signInWithPassword({ email, password }); + + if (error) { + throw new BadRequestException(error.message); + } + const { user, session } = data; + if (!user || !data) { + throw new BadRequestException('Unexpected sign-in response.'); + } + + const { data: userData, error: profileError } = await this.supabaseService + .getClient() + .from(this.PROFILES_TABLE) + .select(`id, email, username`) + .eq('id', user.id) + .returns() + .single(); + + if (profileError) { + throw new BadRequestException(profileError.message); + } + + return { userData, session }; + } +} diff --git a/project/apps/api-gateway/src/filters/rpc-exception.filter.ts b/project/apps/api-gateway/src/filters/rpc-exception.filter.ts new file mode 100644 index 0000000000..cabe818bb3 --- /dev/null +++ b/project/apps/api-gateway/src/filters/rpc-exception.filter.ts @@ -0,0 +1,16 @@ +import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common'; +import { RpcException } from '@nestjs/microservices'; +import { Response } from 'express'; + +@Catch(RpcException) +export class RpcExceptionFilter implements ExceptionFilter { + catch(exception: RpcException, host: ArgumentsHost) { + const error: any = exception.getError(); + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + const statusCode = error?.statusCode || 500; + + response.status(statusCode).json(error); + } +} diff --git a/project/apps/api-gateway/src/interceptors/rpc-exception.interceptor.ts b/project/apps/api-gateway/src/interceptors/rpc-exception.interceptor.ts new file mode 100644 index 0000000000..5cc25d5b71 --- /dev/null +++ b/project/apps/api-gateway/src/interceptors/rpc-exception.interceptor.ts @@ -0,0 +1,24 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { RpcException } from '@nestjs/microservices'; + +@Injectable() +export class RpcExceptionInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + catchError((error) => { + if (error.response) { + // This is likely an RPC error + return throwError(() => new RpcException(error.response)); + } + return throwError(() => error); + }), + ); + } +} diff --git a/project/apps/api-gateway/src/main.ts b/project/apps/api-gateway/src/main.ts new file mode 100644 index 0000000000..0bb90c3e68 --- /dev/null +++ b/project/apps/api-gateway/src/main.ts @@ -0,0 +1,20 @@ +import { NestFactory } from '@nestjs/core'; +import { ApiGatewayModule } from './api-gateway.module'; +import { RpcExceptionFilter } from './filters/rpc-exception.filter'; +import { RpcExceptionInterceptor } from './interceptors/rpc-exception.interceptor'; +import * as cookieParser from 'cookie-parser'; +import { Logger } from 'nestjs-pino'; + +async function bootstrap() { + const app = await NestFactory.create(ApiGatewayModule, { bufferLogs: true }); + app.use(cookieParser()); + app.useLogger(app.get(Logger)); + app.enableCors({ + origin: 'http://localhost:3000', + credentials: true, + }); + app.useGlobalFilters(new RpcExceptionFilter()); + app.useGlobalInterceptors(new RpcExceptionInterceptor()); + await app.listen(4000); +} +bootstrap(); diff --git a/project/apps/api-gateway/src/questions/questions.controller.ts b/project/apps/api-gateway/src/questions/questions.controller.ts new file mode 100644 index 0000000000..83ff449841 --- /dev/null +++ b/project/apps/api-gateway/src/questions/questions.controller.ts @@ -0,0 +1,76 @@ +// apps/backend/api-gateway/src/questions/questions.controller.ts + +import { + Controller, + Get, + Param, + Inject, + Body, + Post, + // UseGuards, + Put, + Delete, + Query, + UsePipes, + BadRequestException, +} from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +// import { AuthGuard } from 'src/auth/auth.guard'; +import { + CreateQuestionDto, + createQuestionSchema, + GetQuestionsQueryDto, + getQuestionsQuerySchema, + UpdateQuestionDto, + updateQuestionSchema, +} from '@repo/dtos/questions'; +import { ZodValidationPipe } from '@repo/pipes/zod-validation-pipe.pipe'; + +@Controller('questions') +// @UseGuards(AuthGuard) // comment out if we dw auth for now +export class QuestionsController { + constructor( + @Inject('QUESTIONS_SERVICE') + private readonly questionsServiceClient: ClientProxy, + ) {} + + @Get() + @UsePipes(new ZodValidationPipe(getQuestionsQuerySchema)) + async getQuestions(@Query() filters: GetQuestionsQueryDto) { + return this.questionsServiceClient.send({ cmd: 'get_questions' }, filters); + } + + @Get(':id') + async getQuestionById(@Param('id') id: string) { + return this.questionsServiceClient.send({ cmd: 'get_question' }, id); + } + + @Post() + @UsePipes(new ZodValidationPipe(createQuestionSchema)) + async createQuestion(@Body() createQuestionDto: CreateQuestionDto) { + return this.questionsServiceClient.send( + { cmd: 'create_question' }, + createQuestionDto, + ); + } + + @Put(':id') + async updateQuestion( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateQuestionSchema)) // validation on the body only + updateQuestionDto: UpdateQuestionDto, + ) { + if (id != updateQuestionDto.id) { + throw new BadRequestException('ID in URL does not match ID in body'); + } + return this.questionsServiceClient.send( + { cmd: 'update_question' }, + updateQuestionDto, + ); + } + + @Delete(':id') + async deleteQuestion(@Param('id') id: string) { + return this.questionsServiceClient.send({ cmd: 'delete_question' }, id); + } +} diff --git a/project/apps/api-gateway/src/supabase/collection.ts b/project/apps/api-gateway/src/supabase/collection.ts new file mode 100644 index 0000000000..ee1257594d --- /dev/null +++ b/project/apps/api-gateway/src/supabase/collection.ts @@ -0,0 +1,2 @@ +import { Tables } from './database.types'; +export type UserDetails = Tables<'profiles'>; diff --git a/project/apps/api-gateway/src/supabase/database.types.ts b/project/apps/api-gateway/src/supabase/database.types.ts new file mode 100644 index 0000000000..6bc788b413 --- /dev/null +++ b/project/apps/api-gateway/src/supabase/database.types.ts @@ -0,0 +1,140 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[]; + +export type Database = { + public: { + Tables: { + profiles: { + Row: { + created_at: string; + email: string; + id: string; + role: Database['public']['Enums']['role']; + username: string; + }; + Insert: { + created_at?: string; + email: string; + id: string; + role?: Database['public']['Enums']['role']; + username: string; + }; + Update: { + created_at?: string; + email?: string; + id?: string; + role?: Database['public']['Enums']['role']; + username?: string; + }; + Relationships: [ + { + foreignKeyName: 'profiles_id_fkey'; + columns: ['id']; + isOneToOne: true; + referencedRelation: 'users'; + referencedColumns: ['id']; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + [_ in never]: never; + }; + Enums: { + role: 'user' | 'admin'; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; +}; + +type PublicSchema = Database[Extract]; + +export type Tables< + PublicTableNameOrOptions extends + | keyof (PublicSchema['Tables'] & PublicSchema['Views']) + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof (Database[PublicTableNameOrOptions['schema']]['Tables'] & + Database[PublicTableNameOrOptions['schema']]['Views']) + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? (Database[PublicTableNameOrOptions['schema']]['Tables'] & + Database[PublicTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R; + } + ? R + : never + : PublicTableNameOrOptions extends keyof (PublicSchema['Tables'] & + PublicSchema['Views']) + ? (PublicSchema['Tables'] & + PublicSchema['Views'])[PublicTableNameOrOptions] extends { + Row: infer R; + } + ? R + : never + : never; + +export type TablesInsert< + PublicTableNameOrOptions extends + | keyof PublicSchema['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I; + } + ? I + : never + : PublicTableNameOrOptions extends keyof PublicSchema['Tables'] + ? PublicSchema['Tables'][PublicTableNameOrOptions] extends { + Insert: infer I; + } + ? I + : never + : never; + +export type TablesUpdate< + PublicTableNameOrOptions extends + | keyof PublicSchema['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U; + } + ? U + : never + : PublicTableNameOrOptions extends keyof PublicSchema['Tables'] + ? PublicSchema['Tables'][PublicTableNameOrOptions] extends { + Update: infer U; + } + ? U + : never + : never; + +export type Enums< + PublicEnumNameOrOptions extends + | keyof PublicSchema['Enums'] + | { schema: keyof Database }, + EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = PublicEnumNameOrOptions extends { schema: keyof Database } + ? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName] + : PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] + ? PublicSchema['Enums'][PublicEnumNameOrOptions] + : never; diff --git a/project/apps/api-gateway/src/supabase/supabase.service.ts b/project/apps/api-gateway/src/supabase/supabase.service.ts new file mode 100644 index 0000000000..cb165b02e1 --- /dev/null +++ b/project/apps/api-gateway/src/supabase/supabase.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import { ConfigService } from '@nestjs/config'; +import { Database } from './database.types'; + +@Injectable() +export class SupabaseService { + private supabase: SupabaseClient; + + constructor(private configService: ConfigService) { + const supabaseUrl = this.configService.get('SUPABASE_URL'); + const supabaseKey = this.configService.get('SUPABASE_KEY'); + + if (!supabaseUrl || !supabaseKey) { + throw new Error('Supabase URL and key must be provided'); + } + + this.supabase = createClient(supabaseUrl, supabaseKey); + } + + getClient(): SupabaseClient { + return this.supabase; + } +} diff --git a/project/apps/api-gateway/tsconfig.build.json b/project/apps/api-gateway/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/project/apps/api-gateway/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/project/apps/api-gateway/tsconfig.json b/project/apps/api-gateway/tsconfig.json new file mode 100644 index 0000000000..a1c778d1e7 --- /dev/null +++ b/project/apps/api-gateway/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/project/apps/questions-service/.env.example b/project/apps/questions-service/.env.example new file mode 100644 index 0000000000..f82ecd5ae1 --- /dev/null +++ b/project/apps/questions-service/.env.example @@ -0,0 +1,2 @@ +SUPABASE_URL=your-supabase-url +SUPABASE_KEY=your-supabase-key \ No newline at end of file diff --git a/project/apps/questions-service/.eslintrc.js b/project/apps/questions-service/.eslintrc.js new file mode 100644 index 0000000000..9273465b05 --- /dev/null +++ b/project/apps/questions-service/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ['@repo/eslint-config/nest.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + }, +}; diff --git a/project/apps/questions-service/.prettierrc b/project/apps/questions-service/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/project/apps/questions-service/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/project/apps/questions-service/README.md b/project/apps/questions-service/README.md new file mode 100644 index 0000000000..d20c6f3134 --- /dev/null +++ b/project/apps/questions-service/README.md @@ -0,0 +1,96 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

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

+

+NPM Version +Package License +NPM Downloads +CircleCI +Coverage +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +1. Install dependencies: + + ```bash + $ pnpm install + ``` + +2. Copy the `.env.example` file to create a new `.env` file: + + ```bash + $ cp .env.example .env + ``` + + Modify the `.env` file with your environment-specific configuration. + + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm run test:cov +``` + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +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). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/project/apps/questions-service/nest-cli.json b/project/apps/questions-service/nest-cli.json new file mode 100644 index 0000000000..f9aa683b1a --- /dev/null +++ b/project/apps/questions-service/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/project/apps/questions-service/package.json b/project/apps/questions-service/package.json new file mode 100644 index 0000000000..535e86000e --- /dev/null +++ b/project/apps/questions-service/package.json @@ -0,0 +1,79 @@ +{ + "name": "questions-service", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@repo/pipes": "workspace:*", + "@repo/dtos": "workspace:*", + "@repo/eslint-config": "workspace:*", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.2.3", + "@nestjs/core": "^10.0.0", + "@nestjs/microservices": "^10.4.3", + "@nestjs/platform-express": "^10.0.0", + "@repo/dtos": "workspace:*", + "@repo/pipes": "workspace:*", + "@supabase/supabase-js": "^2.45.4", + "dotenv": "^16.4.5", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/node": "^20.3.1", + "@types/supertest": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/project/apps/questions-service/src/main.ts b/project/apps/questions-service/src/main.ts new file mode 100644 index 0000000000..788e9e8eeb --- /dev/null +++ b/project/apps/questions-service/src/main.ts @@ -0,0 +1,18 @@ +import { NestFactory } from '@nestjs/core'; +import { QuestionsModule } from './questions.module'; +import { Transport, MicroserviceOptions } from '@nestjs/microservices'; + +async function bootstrap() { + const app = await NestFactory.createMicroservice( + QuestionsModule, + { + transport: Transport.TCP, + options: { + port: 3001, + }, + }, + ); + await app.listen(); + console.log('Questions Service is listening...'); +} +bootstrap(); diff --git a/project/apps/questions-service/src/questions.controller.ts b/project/apps/questions-service/src/questions.controller.ts new file mode 100644 index 0000000000..4e64cc3581 --- /dev/null +++ b/project/apps/questions-service/src/questions.controller.ts @@ -0,0 +1,37 @@ +import { Controller } from '@nestjs/common'; +import { MessagePattern, Payload } from '@nestjs/microservices'; +import { QuestionsService } from './questions.service'; +import { + CreateQuestionDto, + GetQuestionsQueryDto, + UpdateQuestionDto, +} from '@repo/dtos/questions'; +@Controller() +export class QuestionsController { + constructor(private readonly questionsService: QuestionsService) {} + + @MessagePattern({ cmd: 'get_questions' }) + async getQuestions(@Payload() filters: GetQuestionsQueryDto) { + return await this.questionsService.findAll(filters); + } + + @MessagePattern({ cmd: 'get_question' }) + async getQuestionById(id: string) { + return await this.questionsService.findById(id); + } + + @MessagePattern({ cmd: 'create_question' }) + async createQuestion(@Payload() createQuestionDto: CreateQuestionDto) { + return await this.questionsService.create(createQuestionDto); + } + + @MessagePattern({ cmd: 'update_question' }) + async updateQuestion(@Payload() updateQuestionDto: UpdateQuestionDto) { + return await this.questionsService.update(updateQuestionDto); + } + + @MessagePattern({ cmd: 'delete_question' }) + async deleteQuestionById(id: string) { + return await this.questionsService.deleteById(id); + } +} diff --git a/project/apps/questions-service/src/questions.module.ts b/project/apps/questions-service/src/questions.module.ts new file mode 100644 index 0000000000..10ea7912ad --- /dev/null +++ b/project/apps/questions-service/src/questions.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { QuestionsController } from './questions.controller'; +import { QuestionsService } from './questions.service'; +import { ConfigModule } from '@nestjs/config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + }), + ], + controllers: [QuestionsController], + providers: [QuestionsService], +}) +export class QuestionsModule {} diff --git a/project/apps/questions-service/src/questions.service.ts b/project/apps/questions-service/src/questions.service.ts new file mode 100644 index 0000000000..6e7c22a885 --- /dev/null +++ b/project/apps/questions-service/src/questions.service.ts @@ -0,0 +1,182 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { RpcException } from '@nestjs/microservices'; +import { + CreateQuestionDto, + GetQuestionsQueryDto, + QuestionDto, + UpdateQuestionDto, +} from '@repo/dtos/questions'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; + +@Injectable() +export class QuestionsService { + private supabase: SupabaseClient; + private readonly logger = new Logger(QuestionsService.name); + + private readonly QUESTIONS_TABLE = 'question_bank'; + + constructor(private configService: ConfigService) { + const supabaseUrl = this.configService.get('SUPABASE_URL'); + const supabaseKey = this.configService.get('SUPABASE_KEY'); + + if (!supabaseUrl || !supabaseKey) { + throw new Error('Supabase URL and key must be provided'); + } + + this.supabase = createClient(supabaseUrl, supabaseKey); + } + + /** + * Handles errors by logging the error message and throwing an RpcException. + * + * @private + * @param {string} operation - The name of the operation where the error occurred. + * @param {any} error - The error object that was caught. This can be any type of error, including a NestJS HttpException. + * @throws {RpcException} - Throws an RpcException wrapping the original error. + */ + private handleError(operation: string, error: any): never { + this.logger.error(`Error at ${operation}: ${error.message}`); + + throw new RpcException(error); + } + + async findAll(filters: GetQuestionsQueryDto): Promise { + const { title, category, complexity, includeDeleted } = filters; + + let query = this.supabase.from(this.QUESTIONS_TABLE).select(); + + if (title) { + query = query.ilike('q_title', `%${title}%`); + } + if (category) { + query = query.contains('q_category', [category]); + } + if (complexity) { + query = query.eq('q_complexity', complexity); + } + if (!includeDeleted) { + query = query.is('deleted_at', null); + } + + const { data, error } = await query; + + if (error) { + this.handleError('fetch questions', error); + } + + this.logger.log( + `fetched ${data.length} questions with filters: ${JSON.stringify(filters)}`, + ); + return data; + } + + async findById(id: string): Promise { + const { data, error } = await this.supabase + .from(this.QUESTIONS_TABLE) + .select() + .eq('id', id) + .single(); + + if (error) { + this.handleError('fetch question by id', error); + } + + this.logger.log(`fetched question with id ${id}`); + return data; + } + + async create(question: CreateQuestionDto): Promise { + const { data: existingQuestion } = await this.supabase + .from(this.QUESTIONS_TABLE) + .select() + .eq('q_title', question.q_title) + .single(); + + if (existingQuestion) { + this.handleError( + 'create question', + new BadRequestException( + `Question with title ${question.q_title} already exists`, + ), + ); + } + + const { data, error } = await this.supabase + .from(this.QUESTIONS_TABLE) + .insert(question) + .select() + .single(); + + if (error) { + this.handleError('create question', error); + } + + this.logger.log(`created question ${data.id}`); + return data; + } + + async update(question: UpdateQuestionDto): Promise { + // check if the question is soft deleted + const { data: deletedQuestion } = await this.supabase + .from(this.QUESTIONS_TABLE) + .select() + .eq('id', question.id) + .neq('deleted_at', null) + .single(); + + if (deletedQuestion) { + this.handleError( + 'update question', + new BadRequestException('Cannot update a deleted question'), + ); + } + + // check if a question with the same title already exists + const { data: existingQuestion } = await this.supabase + .from(this.QUESTIONS_TABLE) + .select() + .eq('q_title', question.q_title) + .neq('id', question.id) + .single(); + + if (existingQuestion) { + this.handleError( + 'update question', + new BadRequestException( + `Question with title ${question.q_title} already exists`, + ), + ); + } + + const { data, error } = await this.supabase + .from(this.QUESTIONS_TABLE) + .update(question) + .eq('id', question.id) + .select() + .single(); + + if (error) { + this.handleError('update question', error); + } + + this.logger.log(`updated question with id ${question.id}`); + return data; + } + + async deleteById(id: string): Promise { + const { data, error } = await this.supabase + .from(this.QUESTIONS_TABLE) + .update({ deleted_at: new Date() }) + .eq('id', id) + .select() + .single(); + + if (error) { + this.handleError('delete question', error); + } + + this.logger.log(`deleted question with id ${id}`); + return data; + } +} diff --git a/project/apps/questions-service/tsconfig.build.json b/project/apps/questions-service/tsconfig.build.json new file mode 100644 index 0000000000..64f86c6bd2 --- /dev/null +++ b/project/apps/questions-service/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/project/apps/questions-service/tsconfig.json b/project/apps/questions-service/tsconfig.json new file mode 100644 index 0000000000..a1c778d1e7 --- /dev/null +++ b/project/apps/questions-service/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/project/apps/web/.env.example b/project/apps/web/.env.example new file mode 100644 index 0000000000..d184bcea13 --- /dev/null +++ b/project/apps/web/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL=http://localhost:4000 \ No newline at end of file diff --git a/project/apps/web/.eslintrc.js b/project/apps/web/.eslintrc.js new file mode 100644 index 0000000000..7d644a4ca0 --- /dev/null +++ b/project/apps/web/.eslintrc.js @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + extends: ["@repo/eslint-config/next.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/project/apps/web/README.md b/project/apps/web/README.md new file mode 100644 index 0000000000..b19f43f426 --- /dev/null +++ b/project/apps/web/README.md @@ -0,0 +1,44 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Copy the `.env.example` file to create a new `.env` file: + +```bash +$ cp .env.example .env +``` + +Modify the `.env` file with your environment-specific configuration. + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/project/apps/web/app/auth/SignInForm.tsx b/project/apps/web/app/auth/SignInForm.tsx new file mode 100644 index 0000000000..21222e534d --- /dev/null +++ b/project/apps/web/app/auth/SignInForm.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { signInSchema, SignInDto } from "@repo/dtos/auth"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { signIn } from "@/lib/api/auth"; +import { useZodForm } from "@/lib/form"; +import { useLoginState } from "@/contexts/LoginStateContext"; +import { useToast } from "@/hooks/use-toast"; +import { QUERY_KEYS } from "@/constants/queryKeys"; + +export function SignInForm() { + const form = useZodForm({ schema: signInSchema }); + const { setHasLoginStateFlag } = useLoginState(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: signIn, + onSuccess: async () => { + setHasLoginStateFlag(); + await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.Me] }); + }, + onError(error) { + toast({ + title: "Error", + description: error.message, + variant: "error", + }); + }, + }); + function onSubmit(values: SignInDto) { + mutation.mutate(values); + } + return ( +
+ + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + + ); +} diff --git a/project/apps/web/app/auth/SignUpForm.tsx b/project/apps/web/app/auth/SignUpForm.tsx new file mode 100644 index 0000000000..4234b09fe9 --- /dev/null +++ b/project/apps/web/app/auth/SignUpForm.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { signUpSchema, SignUpDto } from "@repo/dtos/auth"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { signUp } from "@/lib/api/auth"; +import { useZodForm } from "@/lib/form"; +import { useLoginState } from "@/contexts/LoginStateContext"; +import { QUERY_KEYS } from "@/constants/queryKeys"; +import { useToast } from "@/hooks/use-toast"; + +export function SignUpForm() { + const form = useZodForm({ schema: signUpSchema }); + const { setHasLoginStateFlag } = useLoginState(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const mutation = useMutation({ + mutationFn: signUp, + onSuccess: async () => { + setHasLoginStateFlag(); + await queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.Me] }); + }, + onError: (error) => { + toast({ + description: error.message, + variant: "error", + title: "Error", + }); + }, + }); + function onSubmit(values: SignUpDto) { + mutation.mutate(values); + } + return ( +
+ + ( + + Username + + + + + + )} + /> + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Confirm Password + + + + + + )} + /> + + + + ); +} diff --git a/project/apps/web/app/auth/page.tsx b/project/apps/web/app/auth/page.tsx new file mode 100644 index 0000000000..11af8ad8c0 --- /dev/null +++ b/project/apps/web/app/auth/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { SignInForm } from "./SignInForm"; +import { SignUpForm } from "./SignUpForm"; +import { PublicPageWrapper } from "@/components/auth-wrappers/PublicPageWrapper"; +import { LANDING } from "@/lib/routes"; + +export default function AuthPage() { + const [isSignUp, setIsSignUp] = useState(false); + + return ( + +
+ + + + {isSignUp ? "Create an account" : "Sign in to your account"} + + + {isSignUp + ? "Enter your details to create a new account" + : "Enter your credentials to access your account"} + + + + {isSignUp ? : }{" "} +
+
+ +
+
+
+ + + +
+
+
+ ); +} diff --git a/project/apps/web/app/globals.css b/project/apps/web/app/globals.css new file mode 100644 index 0000000000..8283a8ca6e --- /dev/null +++ b/project/apps/web/app/globals.css @@ -0,0 +1,104 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + padding: 0; + font-family: var(--font-roboto), sans-serif; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: var(--font-inter), sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 100% 50%; + --destructive-foreground: 210 40% 98%; + + --success: 120 100% 25%; + --success-foreground: 0 0% 100%; + + --error: 0 78.4% 47.3%; + --error-foreground: 0 75% 98.4%; + + --ring: 215 20.2% 65.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 224 71% 4%; + --foreground: 213 31% 91%; + + --muted: 223 47% 11%; + --muted-foreground: 215.4 16.3% 56.9%; + + --accent: 216 34% 17%; + --accent-foreground: 210 40% 98%; + + --popover: 224 71% 4%; + --popover-foreground: 215 20.2% 65.1%; + + --border: 216 34% 17%; + --input: 216 34% 17%; + + --card: 224 71% 4%; + --card-foreground: 213 31% 91%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 222.2 47.4% 11.2%; + --secondary-foreground: 210 40% 98%; + + --destructive: 0 63% 31%; + --destructive-foreground: 210 40% 98%; + + --ring: 216 34% 17%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + font-feature-settings: + "rlig" 1, + "calt" 1; + } +} diff --git a/project/apps/web/app/layout.tsx b/project/apps/web/app/layout.tsx new file mode 100644 index 0000000000..b0621415c0 --- /dev/null +++ b/project/apps/web/app/layout.tsx @@ -0,0 +1,38 @@ +import { Inter, Roboto } from "next/font/google"; +import ReactQueryProvider from "@/components/ReactQueryProvider"; +import Suspense from "@/components/Suspense"; +import { Skeleton } from "@/components/ui/skeleton"; +import { LoginStateProvider } from "@/contexts/LoginStateContext"; +import { Toaster } from "@/components/ui/toaster"; + +import "./globals.css"; + +const inter = Inter({ + subsets: ["latin"], + weight: ["400", "500"], + display: "swap", +}); + +const roboto = Roboto({ + subsets: ["latin"], + weight: ["400", "500", "700"], +}); + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + }> + {children} + + + + + + ); +} diff --git a/project/apps/web/app/page.module.css b/project/apps/web/app/page.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project/apps/web/app/page.tsx b/project/apps/web/app/page.tsx new file mode 100644 index 0000000000..f87ed7ece8 --- /dev/null +++ b/project/apps/web/app/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import Link from "next/link"; +import { Button } from "@/components/ui/button"; + +const Dashboard = () => { + return ( +
+
+
Dashboard Page
+ + + +
+
+ ); +}; + +export default Dashboard; diff --git a/project/apps/web/app/question/[id]/components/DeleteModal.tsx b/project/apps/web/app/question/[id]/components/DeleteModal.tsx new file mode 100644 index 0000000000..89e3d67147 --- /dev/null +++ b/project/apps/web/app/question/[id]/components/DeleteModal.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface DeleteModalProps { + open: boolean; + setOpen: (open: boolean) => void; + onDelete: () => void; + questionTitle: string; +} + +export default function DeleteModal({ + open, + setOpen, + onDelete, + questionTitle, +}: DeleteModalProps) { + return ( + + + + Delete Question + + +
+ Are you sure you want to delete the question "{questionTitle}"? +
+
This action cannot be undone.
+
+ + + + +
+
+ ); +} diff --git a/project/apps/web/app/question/[id]/components/EditModal.tsx b/project/apps/web/app/question/[id]/components/EditModal.tsx new file mode 100644 index 0000000000..498d89c856 --- /dev/null +++ b/project/apps/web/app/question/[id]/components/EditModal.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useEffect } from "react"; +import { useZodForm } from "@/lib/form"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { UpdateQuestionDto, updateQuestionSchema } from "@repo/dtos/questions"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { CATEGORY, COMPLEXITY } from "@/constants/question"; + +interface EditModalProps { + open: boolean; + setOpen: (open: boolean) => void; + onSubmit: (data: UpdateQuestionDto) => void; + initialValues: UpdateQuestionDto; +} + +export default function EditModal({ + open, + setOpen, + onSubmit, + initialValues, +}: EditModalProps) { + const form = useZodForm({ + schema: updateQuestionSchema, + defaultValues: { + q_title: initialValues.q_title, + q_desc: initialValues.q_desc, + q_complexity: initialValues.q_complexity, + q_category: initialValues.q_category, + }, + }); + + const categories = Object.values(CATEGORY); + + const handleSubmit = (data: UpdateQuestionDto) => { + const updatedData: UpdateQuestionDto = { + ...data, + id: initialValues.id, + }; + onSubmit(updatedData); + }; + + useEffect(() => { + if (open) { + form.reset({ + q_title: initialValues.q_title, + q_desc: initialValues.q_desc, + q_complexity: initialValues.q_complexity, + q_category: initialValues.q_category, + id: initialValues.id, + }); + } else { + form.reset(); + form.clearErrors(); + } + }, [open, form, initialValues]); + + return ( + + + + Edit Question + + +
+ + {/* Title Field */} + ( + + Title + + + + + + )} + /> + + {/* Description Field */} + ( + + Description + +