diff --git a/.gitignore b/.gitignore index a53a7b9..e17e1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ package-lock.json # PDF *.pdf + +# Pem files +*.pem diff --git a/README.md b/README.md index 780647e..c6d88d3 100644 --- a/README.md +++ b/README.md @@ -1,94 +1 @@ -# SaaS Boilerplate with Turborepo - -This is a modern SaaS boilerplate built with Turborepo, providing a scalable foundation for building your next SaaS application. It combines the power of Next.js, TypeScript, and other modern technologies to help you launch faster. - -## Features - -- 🚀 **Modern Stack**: Built with Next.js, TypeScript, and Turborepo -- 🎨 **Beautiful UI**: Pre-configured with Tailwind CSS and modern UI components -- 🔒 **Authentication**: Ready-to-use authentication system -- 💳 **Payments**: Integrated payment processing capabilities -- 📱 **Responsive**: Mobile-first design approach -- 🔧 **Developer Experience**: Hot reloading, TypeScript, ESLint, and Prettier - -## What's inside? - -This Turborepo includes the following packages/apps: - -### Apps and Packages - -- `docs`: a [Next.js](https://nextjs.org/) app for documentation -- `web`: the main [Next.js](https://nextjs.org/) SaaS application -- `@repo/ui`: a shared React component library with pre-built UI components -- `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) -- `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo - -Each package/app is 100% [TypeScript](https://www.typescriptlang.org/). - -### Key Technologies - -- [Next.js](https://nextjs.org/) - React framework for production -- [TypeScript](https://www.typescriptlang.org/) - Static type checking -- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework -- [Turborepo](https://turbo.build/) - High-performance build system -- [ESLint](https://eslint.org/) - Code linting -- [Prettier](https://prettier.io) - Code formatting - -### Utilities - -This Turborepo has some additional tools already setup for you: - -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting - -### Build - -To build all apps and packages, run the following command: - -``` -cd my-turborepo -pnpm build -``` - -### Develop - -To develop all apps and packages, run the following command: - -``` -cd my-turborepo -pnpm dev -``` - -### Remote Caching - -> [!TIP] -> Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache). - -Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. - -By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands: - -``` -cd my-turborepo -npx turbo login -``` - -This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). - -Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo: - -``` -npx turbo link -``` - -## Useful Links - -Learn more about the power of Turborepo: - -- [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks) -- [Caching](https://turbo.build/repo/docs/core-concepts/caching) -- [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) -- [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering) -- [Configuration Options](https://turbo.build/repo/docs/reference/configuration) -- [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference) +# Angry Beard Bot diff --git a/apps/api/.env.example b/apps/api/.env.example index 13ddac8..e7d19ca 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -18,3 +18,12 @@ SUPABASE_BUCKET_NAME= # Logger LOG_LEVEL= + +# Workflow +WORKFLOW_URL= + +# Github +GITHUB_APP_ID= +GITHUB_PRIVATE_KEY= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/apps/api/docker/Dockerfile b/apps/api/docker/Dockerfile new file mode 100644 index 0000000..793c590 --- /dev/null +++ b/apps/api/docker/Dockerfile @@ -0,0 +1,34 @@ +# Stage 1: Build +FROM node:18-alpine AS builder + +# Create working directory +WORKDIR /app + +# Copy dependency files +COPY ../../yarn.lock ../../package.json ./ + +# Install dependencies +RUN yarn install + +# Copy rest of project +COPY ../../ . + +# Build application +RUN yarn build + +# Stage 2: Production +FROM node:18-alpine + +WORKDIR /app + +# Copy only necessary files from build stage +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/yarn.lock ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist + +# Default Nest port +EXPOSE 3000 + +# Command to start app +CMD ["node", "dist/main"] diff --git a/apps/api/package.json b/apps/api/package.json index 2ac60d9..e462a5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -50,6 +50,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "graphql": "^16.10.0", + "jsonwebtoken": "^9.0.2", "passport": "^0.7.0", "prisma": "^6.5.0", "reflect-metadata": "^0.2.0", @@ -63,6 +64,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^5.0.0", "@types/jest": "^29.5.2", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts index cce879e..2cede91 100644 --- a/apps/api/src/app.controller.ts +++ b/apps/api/src/app.controller.ts @@ -1,12 +1,20 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +@ApiTags('Health') @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() - getHello(): string { - return this.appService.getHello(); + @ApiOperation({ summary: 'Get API health status' }) + @ApiResponse({ + status: 200, + description: 'Returns OK if the API is healthy', + type: String, + }) + async getHealth(): Promise { + return this.appService.getHealth(); } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 06b5541..cab1296 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,11 +7,14 @@ import { StripeModule } from './stripe/stripe.module'; import { ConfigModule } from '@nestjs/config'; import { SupabaseModule } from './supabase/supabase.module'; import { AuthModule } from './auth/auth.module'; -import { GithubModule } from './github/github.module'; import { RepositoryModule } from './repository/repository.module'; import { PullRequestModule } from './pull-request/pull-request.module'; import { UserModule } from './user/user.module'; import { SubscriptionModule } from './subscription/subscription.module'; +import { ReviewModule } from './review/review.module'; +import { WorkflowModule } from './workflow/workflow.module'; +import { GithubModule } from './github/github.module'; +import { BotConfigModule } from './bot-config/bot-config.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), @@ -24,6 +27,9 @@ import { SubscriptionModule } from './subscription/subscription.module'; PullRequestModule, UserModule, SubscriptionModule, + ReviewModule, + WorkflowModule, + BotConfigModule, ], controllers: [AppController], providers: [AppService, PrismaService], diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts index 927d7cc..5a29d58 100644 --- a/apps/api/src/app.service.ts +++ b/apps/api/src/app.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { - getHello(): string { - return 'Hello World!'; + async getHealth(): Promise { + return 'OK'; } } diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 1fa04a0..1a60344 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,8 +1,10 @@ import { Controller, Post, Body, Get, Logger } from '@nestjs/common'; import { SupabaseService } from '../supabase/supabase.service'; import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; -import { GitHubUser } from './interface/github-user.interface'; +import { AuthRequest } from './interface/auth-request.interface'; import { UserService } from 'src/user/user.service'; +import { Public } from './decorators/public.decorator'; + @ApiTags('Authentication') @Controller('auth') export class AuthController { @@ -15,6 +17,7 @@ export class AuthController { this.logger = new Logger(AuthController.name); } + @Public() @Post('register') @ApiOperation({ summary: 'Register a new user' }) @ApiBody({ @@ -35,6 +38,7 @@ export class AuthController { return { user }; } + @Public() @Post('login') @ApiOperation({ summary: 'Login user' }) @ApiBody({ @@ -65,32 +69,34 @@ export class AuthController { return { session }; } + @Public() @Post('github') @ApiOperation({ summary: 'Register or update user with GitHub data' }) - @ApiBody({ type: GitHubUser }) + //@ApiBody({ type: AuthRequest as ObjectType }) @ApiResponse({ status: 201, description: 'User successfully registered/updated' }) @ApiResponse({ status: 400, description: 'Invalid input' }) - async handleGithubAuth(@Body() githubUser: GitHubUser) { - this.logger.log(`Processing GitHub authentication for user: ${githubUser.email}`); + async handleGithubAuth(@Body() authRequest: AuthRequest) { + this.logger.log(`Processing GitHub authentication for user: ${JSON.stringify(authRequest, null, 2)}`); try { - if (!githubUser.email) { + if (!authRequest.user.email) { throw new Error('Email is required'); } - const existingUser = await this.userService.getUserByEmail(githubUser.email); + const existingUser = await this.userService.getUserBySupabaseId(authRequest.user.id); - let user = existingUser; + let currentUser = existingUser; if (!existingUser) { - user = await this.userService.createUser({ - email: githubUser.email, - name: githubUser.user_metadata.name, - githubId: githubUser.id, - providerId: githubUser.user_metadata.provider_id, + currentUser = await this.userService.createUser({ + email: authRequest.user.email, + name: authRequest.user.user_metadata.name, + githubId: authRequest.user.id, + providerId: authRequest.user.user_metadata.provider_id, + supabaseId: authRequest.user.id, }); } - return { user }; + return { user: currentUser }; } catch (error) { this.logger.error(`Error processing GitHub authentication: ${error.message}`); throw error; diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 2389b3c..f8314df 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -3,9 +3,18 @@ import { AuthController } from './auth.controller'; import { SupabaseService } from 'src/supabase/supabase.service'; import { PrismaModule } from 'src/prisma/prisma.module'; import { UserModule } from 'src/user/user.module'; +import { APP_GUARD } from '@nestjs/core'; +import { AuthGuard } from './guards/auth.guard'; + @Module({ imports: [PrismaModule, UserModule], - providers: [SupabaseService], + providers: [ + SupabaseService, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], controllers: [AuthController], }) export class AuthModule {} diff --git a/apps/api/src/auth/decorators/public.decorator.ts b/apps/api/src/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/apps/api/src/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api/src/auth/guards/auth.guard.ts b/apps/api/src/auth/guards/auth.guard.ts new file mode 100644 index 0000000..efcd550 --- /dev/null +++ b/apps/api/src/auth/guards/auth.guard.ts @@ -0,0 +1,56 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private supabaseService: SupabaseService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Check if the route is marked as public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new UnauthorizedException('No authorization header'); + } + + const [type, token] = authHeader.split(' '); + + if (type !== 'Bearer') { + throw new UnauthorizedException('Invalid authorization type'); + } + + if (!token) { + throw new UnauthorizedException('No token provided'); + } + + try { + // Verify the token with Supabase + const { + data: { user }, + error, + } = await this.supabaseService.client.auth.getUser(token); + + if (error || !user) { + throw new UnauthorizedException('Invalid token'); + } + + // Attach the user to the request for later use + request.user = user; + return true; + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + } +} diff --git a/apps/api/src/auth/interface/github-user.interface.ts b/apps/api/src/auth/interface/auth-request.interface.ts similarity index 58% rename from apps/api/src/auth/interface/github-user.interface.ts rename to apps/api/src/auth/interface/auth-request.interface.ts index d46168c..b2c23a9 100644 --- a/apps/api/src/auth/interface/github-user.interface.ts +++ b/apps/api/src/auth/interface/auth-request.interface.ts @@ -17,11 +17,25 @@ export interface UserMetadata { user_name: string; } +export interface IdentityData { + avatar_url: string; + email: string; + email_verified: boolean; + full_name: string; + iss: string; + name: string; + phone_verified: boolean; + preferred_username: string; + provider_id: string; + sub: string; + user_name: string; +} + export interface Identity { identity_id: string; id: string; user_id: string; - identity_data: any; + identity_data: IdentityData; provider: string; last_sign_in_at: string; created_at: string; @@ -29,7 +43,23 @@ export interface Identity { email: string; } -export class GitHubUser { +export interface Session { + access_token: string; + token_type: string; + expires_in: number; + expires_at: number; + refresh_token: string; + user: User; + provider_token: string; +} + +export interface AuthRequest { + session: Session; + user: User; + redirectType: string | null; +} + +export interface User { id: string; aud: string; role: string; diff --git a/apps/api/src/bot-config/bot-config.controller.spec.ts b/apps/api/src/bot-config/bot-config.controller.spec.ts new file mode 100644 index 0000000..3a3f327 --- /dev/null +++ b/apps/api/src/bot-config/bot-config.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BotConfigController } from './bot-config.controller'; + +describe('BotConfigController', () => { + let controller: BotConfigController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BotConfigController], + }).compile(); + + controller = module.get(BotConfigController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/bot-config/bot-config.controller.ts b/apps/api/src/bot-config/bot-config.controller.ts new file mode 100644 index 0000000..3fdd6be --- /dev/null +++ b/apps/api/src/bot-config/bot-config.controller.ts @@ -0,0 +1,89 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Logger } from '@nestjs/common'; +import { BotConfigService } from './bot-config.service'; +import { BotConfigDto, CreateBotConfigDto, UpdateBotConfigDto } from './dto/bot-config.dto'; +import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { UserService } from 'src/user/user.service'; +@ApiTags('Bot Configuration') +@Controller('bot-config') +export class BotConfigController { + private readonly logger; + + constructor( + private readonly botConfigService: BotConfigService, + private readonly userService: UserService, + ) { + this.logger = new Logger(BotConfigController.name); + } + + @ApiOperation({ summary: 'Get bot configuration by ID' }) + @ApiParam({ name: 'id', description: 'Bot configuration ID' }) + @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto }) + @ApiResponse({ status: 404, description: 'Bot configuration not found' }) + @Get(':id') + async getBotConfigById(@Param('id') id: string): Promise { + this.logger.debug(`Getting bot config for id: ${id}`); + const botConfig = await this.botConfigService.getBotConfigById(id); + this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`); + return botConfig; + } + + @ApiOperation({ summary: 'Get bot configuration by user ID' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto }) + @ApiResponse({ status: 404, description: 'Bot configuration not found' }) + @Get('user/:userId') + async getBotConfigByUserId(@Param('userId') userId: string): Promise { + this.logger.debug(`Getting bot config for userId: ${userId}`); + const botConfig = await this.botConfigService.getBotConfigByUserId(userId); + this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`); + return botConfig; + } + + @ApiOperation({ summary: 'Get bot configuration by supabase ID' }) + @ApiParam({ name: 'supabaseId', description: 'Supabase ID' }) + @ApiResponse({ status: 200, description: 'Bot configuration found', type: BotConfigDto }) + @ApiResponse({ status: 404, description: 'Bot configuration not found' }) + @Get('supabase/:supabaseId') + async getBotConfigBySupabaseId(@Param('supabaseId') supabaseId: string): Promise { + this.logger.debug(`Getting bot config for supabaseId: ${supabaseId}`); + const user = await this.userService.getUserBySupabaseId(supabaseId); + this.logger.debug(`User: ${JSON.stringify(user)}`); + return user.botConfig; + } + + @ApiOperation({ summary: 'Create new bot configuration' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiBody({ type: CreateBotConfigDto }) + @ApiResponse({ status: 201, description: 'Bot configuration created', type: BotConfigDto }) + @Post(':userId') + async createBotConfig(@Body() botConfig: CreateBotConfigDto, @Param('userId') userId: string): Promise { + this.logger.debug(`Creating bot config: ${JSON.stringify(botConfig)}`); + const newBotConfig = await this.botConfigService.createBotConfig(userId, botConfig); + this.logger.debug(`New bot config: ${JSON.stringify(newBotConfig)}`); + return newBotConfig; + } + + @ApiOperation({ summary: 'Update bot configuration' }) + @ApiParam({ name: 'id', description: 'Bot configuration ID' }) + @ApiBody({ type: UpdateBotConfigDto }) + @ApiResponse({ status: 200, description: 'Bot configuration updated', type: BotConfigDto }) + @ApiResponse({ status: 404, description: 'Bot configuration not found' }) + @Put(':id') + async updateBotConfig(@Param('id') id: string, @Body() botConfig: UpdateBotConfigDto): Promise { + this.logger.debug(`Updating bot config for id: ${id}`); + const updatedBotConfig = await this.botConfigService.updateBotConfig(id, botConfig); + this.logger.debug(`Updated bot config: ${JSON.stringify(updatedBotConfig)}`); + return updatedBotConfig; + } + + @ApiOperation({ summary: 'Delete bot configuration' }) + @ApiParam({ name: 'id', description: 'Bot configuration ID' }) + @ApiResponse({ status: 200, description: 'Bot configuration deleted successfully' }) + @ApiResponse({ status: 404, description: 'Bot configuration not found' }) + @Delete(':id') + async deleteBotConfig(@Param('id') id: string): Promise { + this.logger.debug(`Deleting bot config for id: ${id}`); + await this.botConfigService.deleteBotConfig(id); + this.logger.debug(`Bot config deleted for id: ${id}`); + } +} diff --git a/apps/api/src/bot-config/bot-config.module.ts b/apps/api/src/bot-config/bot-config.module.ts new file mode 100644 index 0000000..ae08bda --- /dev/null +++ b/apps/api/src/bot-config/bot-config.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BotConfigService } from './bot-config.service'; +import { BotConfigController } from './bot-config.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { UserService } from 'src/user/user.service'; +@Module({ + imports: [PrismaModule], + providers: [BotConfigService, UserService], + controllers: [BotConfigController], + exports: [BotConfigService], +}) +export class BotConfigModule {} diff --git a/apps/api/src/bot-config/bot-config.service.spec.ts b/apps/api/src/bot-config/bot-config.service.spec.ts new file mode 100644 index 0000000..3e3f6cc --- /dev/null +++ b/apps/api/src/bot-config/bot-config.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BotConfigService } from './bot-config.service'; + +describe('BotConfigService', () => { + let service: BotConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BotConfigService], + }).compile(); + + service = module.get(BotConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/bot-config/bot-config.service.ts b/apps/api/src/bot-config/bot-config.service.ts new file mode 100644 index 0000000..4a8eefc --- /dev/null +++ b/apps/api/src/bot-config/bot-config.service.ts @@ -0,0 +1,78 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { BotConfigDto, CreateBotConfigDto, UpdateBotConfigDto } from './dto/bot-config.dto'; +import { PrismaService } from 'src/prisma/prisma.service'; + +@Injectable() +export class BotConfigService { + private readonly logger; + + constructor(private readonly prisma: PrismaService) { + this.logger = new Logger(BotConfigService.name); + } + + /** + * Retrieves a bot configuration by its ID + * @param id - The unique identifier of the bot configuration + * @returns A promise that resolves to the bot configuration + * @throws {NotFoundException} If no configuration is found with the given ID + */ + async getBotConfigById(id: string): Promise { + this.logger.debug(`Getting bot config for id: ${id}`); + const botConfig = await this.prisma.botConfig.findUnique({ where: { id } }); + this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`); + return botConfig; + } + + /** + * Retrieves a bot configuration by user ID + * @param userId - The ID of the user whose configuration to retrieve + * @returns A promise that resolves to the bot configuration + * @throws {NotFoundException} If no configuration is found for the user + */ + async getBotConfigByUserId(userId: string): Promise { + this.logger.debug(`Getting bot config for userId: ${userId}`); + const botConfig = await this.prisma.botConfig.findUnique({ where: { userId } }); + this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`); + return botConfig; + } + + /** + * Creates a new bot configuration for a user + * @param userId - The ID of the user to create the configuration for + * @param botConfig - The configuration data to create + * @returns A promise that resolves to the created bot configuration + */ + async createBotConfig(userId: string, botConfig: CreateBotConfigDto): Promise { + this.logger.debug(`Creating bot config for userId: ${userId}`); + const newBotConfig = await this.prisma.botConfig.create({ data: { ...botConfig, userId } }); + this.logger.debug(`New bot config: ${JSON.stringify(newBotConfig)}`); + return newBotConfig; + } + + /** + * Updates an existing bot configuration + * @param id - The ID of the bot configuration to update + * @param botConfig - The updated configuration data + * @returns A promise that resolves to the updated bot configuration + * @throws {NotFoundException} If no configuration is found with the given ID + */ + async updateBotConfig(id: string, botConfig: UpdateBotConfigDto): Promise { + this.logger.debug(`Updating bot config for id: ${id}`); + this.logger.debug(`Bot config: ${JSON.stringify(botConfig)}`); + const updatedBotConfig = await this.prisma.botConfig.update({ where: { id }, data: botConfig }); + this.logger.debug(`Updated bot config: ${JSON.stringify(updatedBotConfig)}`); + return updatedBotConfig; + } + + /** + * Deletes a bot configuration + * @param userId - The ID of the user whose configuration to delete + * @returns A promise that resolves when the configuration is deleted + * @throws {NotFoundException} If no configuration is found for the user + */ + async deleteBotConfig(userId: string): Promise { + this.logger.debug(`Deleting bot config for userId: ${userId}`); + await this.prisma.botConfig.delete({ where: { userId } }); + this.logger.debug(`Bot config deleted for userId: ${userId}`); + } +} diff --git a/apps/api/src/bot-config/dto/bot-config.dto.ts b/apps/api/src/bot-config/dto/bot-config.dto.ts new file mode 100644 index 0000000..966c24b --- /dev/null +++ b/apps/api/src/bot-config/dto/bot-config.dto.ts @@ -0,0 +1,69 @@ +import { BotLevel } from '@prisma/client'; +import { IsUUID, IsEnum, IsString, IsBoolean, IsArray } from 'class-validator'; +import { OmitType, PartialType } from '@nestjs/mapped-types'; +import { ApiProperty } from '@nestjs/swagger'; + +export class BotConfigDto { + @ApiProperty({ + description: 'Unique identifier for the bot configuration', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsUUID() + id: string; + + @ApiProperty({ + description: 'User ID associated with this bot configuration', + example: '123e4567-e89b-12d3-a456-426614174001', + }) + @IsUUID() + userId: string; + + @ApiProperty({ + description: 'Level of grumpiness for the bot personality', + enum: BotLevel, + example: BotLevel.MODERATE, + }) + @IsEnum(BotLevel) + grumpinessLevel: BotLevel; + + @ApiProperty({ + description: 'Level of technical language used by the bot', + enum: BotLevel, + example: BotLevel.EXTREME, + }) + @IsEnum(BotLevel) + technicalityLevel: BotLevel; + + @ApiProperty({ + description: 'Level of detail in bot responses', + enum: BotLevel, + example: BotLevel.MODERATE, + }) + @IsEnum(BotLevel) + detailLevel: BotLevel; + + @ApiProperty({ + description: 'Language for bot responses', + example: 'en', + }) + @IsString() + language: string; + + @ApiProperty({ + description: 'Whether to automatically approve changes', + example: false, + }) + @IsBoolean() + autoApprove: boolean; + + @ApiProperty({ + description: 'File extensions to ignore during code review', + example: ['log', 'txt', 'md'], + }) + @IsArray() + ignoredExtensions: string[]; +} + +export class CreateBotConfigDto extends OmitType(BotConfigDto, ['id']) {} + +export class UpdateBotConfigDto extends PartialType(CreateBotConfigDto) {} diff --git a/apps/api/src/github/dto/github-comment.dto.ts b/apps/api/src/github/dto/github-comment.dto.ts new file mode 100644 index 0000000..e3879b8 --- /dev/null +++ b/apps/api/src/github/dto/github-comment.dto.ts @@ -0,0 +1,67 @@ +export interface GithubCommentDto { + body: string; + commit_id: string; + path: string; + line: number; +} + +interface GithubCommentUser { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + site_admin: boolean; +} + +interface GithubCommentLinks { + self: { + href: string; + }; + html: { + href: string; + }; + pull_request: { + href: string; + }; +} + +export interface GithubCommentOutputDto { + url: string; + pull_request_review_id: number; + id: number; + node_id: string; + diff_hunk: string; + path: string; + position: number; + original_position: number; + commit_id: string; + original_commit_id: string; + in_reply_to_id: number; + user: GithubCommentUser; + body: string; + created_at: string; + updated_at: string; + html_url: string; + pull_request_url: string; + author_association: string; + _links: GithubCommentLinks; + start_line: number; + original_start_line: number; + start_side: string; + line: number; + original_line: number; + side: string; +} diff --git a/apps/api/src/github/dto/github-commit.dto.ts b/apps/api/src/github/dto/github-commit.dto.ts new file mode 100644 index 0000000..dd9db79 --- /dev/null +++ b/apps/api/src/github/dto/github-commit.dto.ts @@ -0,0 +1,68 @@ +export interface GithubCommitAuthor { + name: string; + email: string; + date: string; +} + +export interface GithubCommitTree { + sha: string; + url: string; +} + +export interface GithubCommitVerification { + verified: boolean; + reason: string; + signature: string; + payload: string; + verified_at: string; +} + +export interface GithubCommitDetails { + author: GithubCommitAuthor; + committer: GithubCommitAuthor; + message: string; + tree: GithubCommitTree; + url: string; + comment_count: number; + verification: GithubCommitVerification; +} + +export interface GithubCommitUser { + login: string; + id: number; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + user_view_type: string; + site_admin: boolean; +} + +export interface GithubCommitParent { + sha: string; + url: string; + html_url: string; +} + +export interface GithubCommit { + sha: string; + node_id: string; + commit: GithubCommitDetails; + url: string; + html_url: string; + comments_url: string; + author: GithubCommitUser; + committer: GithubCommitUser; + parents: GithubCommitParent[]; +} diff --git a/apps/api/src/github/dto/github-pull-request-file.dto.ts b/apps/api/src/github/dto/github-pull-request-file.dto.ts new file mode 100644 index 0000000..e108dfa --- /dev/null +++ b/apps/api/src/github/dto/github-pull-request-file.dto.ts @@ -0,0 +1,21 @@ +export interface PullRequestFileDto { + pullRequestNumber: number; + pullRequestUrl: string; + sha: string; + filename: string; + status: string; + additions: number; + deletions: number; + changes: number; + blob_url: string; + raw_url: string; + contents_url: string; + patch?: string; + normalizedPatch?: NormalizedPatchDto[]; +} + +export interface NormalizedPatchDto { + startLine: number; + endLine: number; + content: string; +} diff --git a/apps/api/src/github/dto/webhook.github.dto.ts b/apps/api/src/github/dto/webhook.github.dto.ts index 317ab9a..a7f51a3 100644 --- a/apps/api/src/github/dto/webhook.github.dto.ts +++ b/apps/api/src/github/dto/webhook.github.dto.ts @@ -195,7 +195,7 @@ export interface GithubReview { } export interface GithubInstallation { - id: string; + id: number; node_id: string; } diff --git a/apps/api/src/github/github-api.service.ts b/apps/api/src/github/github-api.service.ts new file mode 100644 index 0000000..49931ec --- /dev/null +++ b/apps/api/src/github/github-api.service.ts @@ -0,0 +1,271 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; +import axios from 'axios'; +import { PullRequestFileDto, NormalizedPatchDto } from './dto/github-pull-request-file.dto'; +import { GithubCommentDto } from './dto/github-comment.dto'; +import { GithubCommit } from './dto/github-commit.dto'; +@Injectable() +export class GithubApiService { + private readonly logger; + private readonly appId: string; + private readonly privateKey: string; + private token: string; + + constructor(private readonly configService: ConfigService) { + this.logger = new Logger(GithubApiService.name); + this.appId = this.configService.get('GITHUB_APP_ID'); + this.privateKey = this.configService.get('GITHUB_PRIVATE_KEY'); + } + + /** + * Generates a JWT token for GitHub App authentication + * @returns JWT token string + * @private + */ + private generateJwt(): string { + this.logger.debug(`Generating JWT token for GitHub App ID: ${this.appId}`); + + const now = Math.floor(Date.now() / 1000); + + return jwt.sign( + { + iat: now, + exp: now + 600, + iss: this.appId, + }, + this.privateKey, + { algorithm: 'RS256' }, + ); + } + + /** + * Gets an installation access token for a specific GitHub App installation + * @param installationId - The ID of the GitHub App installation + * @returns Promise resolving to the installation access token + */ + async getInstallationToken(installationId: number): Promise { + this.logger.debug(`Getting installation token for installation ID: ${installationId}`); + const jwtToken = this.generateJwt(); + + const response = await axios.post( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + {}, + { + headers: { + Authorization: `Bearer ${jwtToken}`, + Accept: 'application/vnd.github+json', + }, + }, + ); + + this.token = response.data.token; + + return response.data.token; + } + + /** + * Retrieves the list of files modified in a pull request + * @param token - GitHub access token + * @param owner - Repository owner + * @param repo - Repository name + * @param prNumber - Pull request number + * @returns Promise resolving to an array of modified files with their details + */ + async getPullRequestFiles(token: string, owner: string, repo: string, prNumber: number): Promise { + this.logger.debug(`Getting pull request files for owner: ${owner}, repo: ${repo}, prNumber: ${prNumber}`); + const response = await axios.get(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/files`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + const normalizedFiles = await Promise.all( + response.data.map(async (file: PullRequestFileDto) => { + const normalizedPatch = await this.normalizeDiffPatch(file.patch); + return { ...file, normalizedPatch }; + }), + ); + + return normalizedFiles; + } + + /** + * Retrieves the files changed in the last commit of a pull request + * @param token - GitHub access token + * @param owner - Repository owner + * @param repo - Repository name + * @param prNumber - Pull request number + * @param ignoredExtensions - Array of file extensions to ignore + * @returns Promise resolving to an array of modified files with their details + * @throws Error if unable to fetch commits or commit details + */ + async getFilesFromLastCommitOfPullRequest( + token: string, + owner: string, + repo: string, + prNumber: number, + ignoredExtensions: string[], + ): Promise<{ pullRequestFiles: PullRequestFileDto[]; commitSha: string }> { + this.logger.debug(`Getting files from last commit of pull request ${prNumber} for owner: ${owner}, repo: ${repo}`); + + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }; + + const lastPageUrl = await this.getLastPageUrl(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/commits`, headers); + + this.logger.debug(`Last page URL: ${lastPageUrl}`); + + const pullRequestCommits = await axios.get(lastPageUrl, { headers }); + + if (pullRequestCommits.status !== 200) { + throw new Error('Error getting pull request commits'); + } + + const commits: GithubCommit[] = pullRequestCommits.data; + this.logger.debug(`Pull request commits from last page: ${JSON.stringify(commits)}`); + + const lastCommit = commits[commits.length - 1]; + const lastCommitSha = lastCommit?.sha; + this.logger.debug(`Last commit SHA: ${lastCommitSha}`); + + if (!lastCommitSha) throw new Error('No se encontró el último commit de la PR'); + + const commitDetails = await axios.get(`https://api.github.com/repos/${owner}/${repo}/commits/${lastCommitSha}`, { headers }); + + if (commitDetails.status !== 200) { + throw new Error('Error getting commit details'); + } + + const files = commitDetails.data.files; + this.logger.debug(`Commit details files: ${JSON.stringify(files)}`); + + const filteredFiles = files.filter(file => !ignoredExtensions.includes(file.filename.split('.').pop())); + this.logger.debug(`Filtered files: ${JSON.stringify(filteredFiles)}`); + + const normalizedFiles = await Promise.all( + filteredFiles.map(async (file: PullRequestFileDto) => { + const normalizedPatch = await this.normalizeDiffPatch(file.patch); + return { ...file, normalizedPatch }; + }), + ); + + return { pullRequestFiles: normalizedFiles, commitSha: lastCommitSha }; + } + + /** + * Gets the URL of the last page from the Link header + * @param url - The initial API URL + * @param headers - Request headers + * @returns Promise resolving to the URL of the last page + * @private + */ + private async getLastPageUrl(url: string, headers: Record): Promise { + try { + const response = await axios.get(url, { headers }); + + // Check if there's a Link header + const linkHeader = response.headers.link; + + if (!linkHeader) { + // If no Link header, this is the only page + return url; + } + + // Parse the Link header to find the "last" page URL + const links = linkHeader.split(','); + for (const link of links) { + const [urlPart, relPart] = link.split(';'); + if (relPart.includes('rel="last"')) { + // Extract the URL from the Link header format + const lastPageUrl = urlPart.trim().replace(/[<>]/g, ''); + return lastPageUrl; + } + } + + // If no "last" link is found, return the original URL + return url; + } catch (error) { + this.logger.error(`Error getting last page URL: ${error.message}`); + // Return the original URL in case of error + return url; + } + } + + /** + * Normalizes a git diff patch into a structured format + * @param patch - The raw git diff patch string + * @returns Promise resolving to an array of normalized patch lines with line numbers and content + */ + private async normalizeDiffPatch(patch: string): Promise { + if (!patch) + return { + startLine: 0, + endLine: 0, + content: '', + }; + + const lines = patch.split('\n'); + const headerLine = lines.find(line => line.startsWith('@@')); + + if (!headerLine) { + throw new Error('No se encontró un encabezado de cambio en el patch'); + } + + const match = headerLine.match(/@@ -(\d+),\d+ \+(\d+),(\d+) @@/); + + if (!match) { + throw new Error('No se pudo extraer la información de las líneas del patch'); + } + + const startLine = parseInt(match[2], 10); + const numLines = parseInt(match[3], 10); + const endLine = startLine + numLines - 1; + + const content = lines.slice(lines.indexOf(headerLine) + 1, lines.indexOf(headerLine) + 1 + numLines); + + return { + startLine, + endLine, + content: content.join('\n'), + }; + } + + async postCommentReview(url: string, body: GithubCommentDto, installationId: number) { + this.logger.debug(`Posting comment review for pull request ${url}`); + + const token = await this.getInstallationToken(installationId); + this.logger.debug(`Token: ${token}`); + + const urlParts = url.split('/'); + const owner = urlParts[urlParts.length - 4]; + const repo = urlParts[urlParts.length - 3]; + const prNumber = urlParts[urlParts.length - 1]; + + this.logger.debug(`Extracted owner: ${owner}, repo: ${repo}, prNumber: ${prNumber}`); + + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`; + this.logger.debug(`Using API URL: ${apiUrl}`); + + const payload = { + body: body.body.toString().trim(), + commit_id: body.commit_id.toString().trim(), + path: body.path.toString().trim(), + line: body.line, + }; + + this.logger.debug(`Payload: ${JSON.stringify(payload)}`); + + const response = await axios.post(apiUrl, payload, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + this.logger.debug(`Comment review posted for pull request ${url}`); + return response.data; + } +} diff --git a/apps/api/src/github/github.controller.spec.ts b/apps/api/src/github/github.controller.spec.ts index 4adff09..9959b74 100644 --- a/apps/api/src/github/github.controller.spec.ts +++ b/apps/api/src/github/github.controller.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { GithubWebhookController } from './github.controller'; +import { GithubController } from './github.controller'; describe('GithubController', () => { - let controller: GithubWebhookController; + let controller: GithubController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [GithubWebhookController], + controllers: [GithubController], }).compile(); - controller = module.get(GithubWebhookController); + controller = module.get(GithubController); }); it('should be defined', () => { diff --git a/apps/api/src/github/github.controller.ts b/apps/api/src/github/github.controller.ts index ab447c5..16e5fdc 100644 --- a/apps/api/src/github/github.controller.ts +++ b/apps/api/src/github/github.controller.ts @@ -1,17 +1,36 @@ -import { Controller, Post, Body, Logger } from '@nestjs/common'; +import { Controller, Logger, Post, Body } from '@nestjs/common'; import { GithubWebhookDto } from './dto/webhook.github.dto'; -import { RepositoryService } from 'src/repository/repository.service'; import { GithubService } from './github.service'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/auth/decorators/public.decorator'; -@Controller('github/webhook') -export class GithubWebhookController { +@Public() +@ApiTags('GitHub') +@Controller('github') +export class GithubController { private readonly logger; constructor(private readonly githubService: GithubService) { - this.logger = new Logger(GithubWebhookController.name); + this.logger = new Logger(GithubController.name); } - @Post() + @ApiOperation({ + summary: 'Handle GitHub webhook events', + description: 'Processes incoming GitHub webhook events for pull request and repository actions', + }) + @ApiResponse({ + status: 200, + description: 'Webhook processed successfully', + schema: { + properties: { + message: { + type: 'string', + example: 'Webhook received', + }, + }, + }, + }) + @Post('/webhook') async webhook(@Body() body: GithubWebhookDto) { this.logger.debug(`Webhook received with action: ${body.action}`); @@ -28,12 +47,14 @@ export class GithubWebhookController { this.logger.debug(`Repository edited: ${body.repository.id}`); break; case 'submitted': - this.logger.debug(`Review submitted: ${body.repository.id}`); + this.logger.debug(`Review submitted for pull request: ${body.pull_request.id}`); break; case 'synchronize': this.logger.debug(`Pull request synchronized: ${body.pull_request.id}`); this.githubService.handlePullRequestSynchronized(body); - + break; + case 'created': + this.logger.debug(`Review created for repository: ${body.repository.id}`); break; default: this.logger.debug(`Unknown action: ${body.action}`); diff --git a/apps/api/src/github/github.module.ts b/apps/api/src/github/github.module.ts index 2b9ace5..b1ce72f 100644 --- a/apps/api/src/github/github.module.ts +++ b/apps/api/src/github/github.module.ts @@ -1,14 +1,21 @@ import { Module } from '@nestjs/common'; -import { GithubWebhookController } from './github.controller'; +import { GithubController } from './github.controller'; import { GithubService } from './github.service'; +import { GithubApiService } from './github-api.service'; +import { ConfigModule } from '@nestjs/config'; import { RepositoryModule } from 'src/repository/repository.module'; import { PullRequestModule } from 'src/pull-request/pull-request.module'; import { UserModule } from 'src/user/user.module'; +import { ReviewModule } from 'src/review/review.module'; +import { WorkflowModule } from 'src/workflow/workflow.module'; import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper'; +import { GithubMapper } from './mapper/github.mapper'; +import { BotConfigService } from 'src/bot-config/bot-config.service'; +import { BotConfigModule } from 'src/bot-config/bot-config.module'; @Module({ - imports: [RepositoryModule, PullRequestModule, UserModule], - controllers: [GithubWebhookController], - providers: [GithubService, PullRequestMapper], - exports: [GithubService], + imports: [ConfigModule, RepositoryModule, PullRequestModule, UserModule, ReviewModule, WorkflowModule, BotConfigModule], + controllers: [GithubController], + providers: [GithubService, GithubApiService, PullRequestMapper, GithubMapper], + exports: [GithubService, GithubApiService], }) export class GithubModule {} diff --git a/apps/api/src/github/github.service.ts b/apps/api/src/github/github.service.ts index c2e6407..9c7daf5 100644 --- a/apps/api/src/github/github.service.ts +++ b/apps/api/src/github/github.service.ts @@ -1,18 +1,30 @@ import { Injectable, Logger } from '@nestjs/common'; import { GithubWebhookDto } from './dto/webhook.github.dto'; -import { RepositoryService } from 'src/repository/repository.service'; +import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper'; import { PullRequestService } from 'src/pull-request/pull-request.service'; +import { RepositoryService } from 'src/repository/repository.service'; +import { ReviewService } from 'src/review/review.service'; import { UserService } from 'src/user/user.service'; +import { WorkflowService } from 'src/workflow/workflow.service'; import { RepositoryDto } from 'src/repository/dto/repository.dto'; -import { PullRequestMapper } from 'src/pull-request/mapper/pull-request.mapper'; +import { GithubApiService } from './github-api.service'; +import { PullRequestFileDto } from './dto/github-pull-request-file.dto'; +import { WorkflowMetadata } from 'src/workflow/interface/workflow-metadata.interface'; +import { TriggerWorkflowDto } from 'src/workflow/dto/trigger-workflow.dto'; +import { GithubMapper } from './mapper/github.mapper'; +import { BotConfigService } from 'src/bot-config/bot-config.service'; @Injectable() export class GithubService { private readonly logger; + constructor( private readonly repositoryService: RepositoryService, private readonly pullRequestService: PullRequestService, private readonly pullRequestMapper: PullRequestMapper, private readonly userService: UserService, + private readonly workflowService: WorkflowService, + private readonly githubApiService: GithubApiService, + private readonly githubMapper: GithubMapper, ) { this.logger = new Logger(GithubService.name); } @@ -25,7 +37,7 @@ export class GithubService { async handlePullRequestOpened(webhookDto: GithubWebhookDto) { this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`); try { - const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto); + await this.processGitHubWebhook(webhookDto); } catch (error) { this.logger.error(`Error handling pull request opened: ${error}`); } @@ -39,7 +51,7 @@ export class GithubService { async handlePullRequestClosed(webhookDto: GithubWebhookDto) { this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`); try { - const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto); + await this.processGitHubWebhook(webhookDto); } catch (error) { this.logger.error(`Error handling pull request closed: ${error}`); } @@ -54,7 +66,7 @@ export class GithubService { this.logger.debug(`Webhook received: ${JSON.stringify(webhookDto)}`); try { - const { user, repository, pullRequest } = await this.processGitHubWebhook(webhookDto); + await this.processGitHubWebhook(webhookDto); } catch (error) { this.logger.error(`Error handling pull request synchronized: ${error}`); } @@ -72,7 +84,7 @@ export class GithubService { * 4. Finding or creating the pull request record * @private */ - private async processGitHubWebhook(webhookDto: GithubWebhookDto) { + private async preProcessGitHubWebhook(webhookDto: GithubWebhookDto) { const { repository, pull_request } = webhookDto; const user = await this.userService.getUserByProviderId(repository.owner.id.toString()); @@ -106,6 +118,59 @@ export class GithubService { user, repository: existingRepository, pullRequest: existingPullRequest, + installation: webhookDto.installation, + botConfig: user.botConfig, }; } + + /** + * Processes a GitHub webhook event by: + * 1. Pre-processing the webhook data to get or create necessary records + * 2. Triggering the associated workflow + * 3. Returns the processed entities + * @param webhookDto The GitHub webhook payload + * @returns Object containing the processed user, repository, pull request and review + */ + async processGitHubWebhook(webhookDto: GithubWebhookDto) { + this.logger.debug(`Processing GitHub webhook: ${JSON.stringify(webhookDto)}`); + try { + const { user, repository, pullRequest, installation, botConfig } = await this.preProcessGitHubWebhook(webhookDto); + + const canProceed = await this.workflowService.canProceed(user.subscription, user._count.reviews); + this.logger.debug(`Can proceed: ${canProceed}`); + + if (!canProceed) { + this.logger.debug(`User has reached the maximum number of credits, skipping workflow`); + // TODO: Post a comment to the user to upgrade their subscription (just one time) + return { user, repository, pullRequest, workflowResponse: null }; + } + + const installationToken = await this.githubApiService.getInstallationToken(installation.id); + + const { pullRequestFiles, commitSha } = await this.githubApiService.getFilesFromLastCommitOfPullRequest( + installationToken, + webhookDto.repository.owner.login, + webhookDto.repository.name, + webhookDto.pull_request.number, + botConfig.ignoredExtensions, + ); + this.logger.debug(`Pull request files: ${JSON.stringify(pullRequestFiles)}`); + + const payload = this.githubMapper.toPullRequestWorkflowPayload(user.id, pullRequestFiles, pullRequest, botConfig, installation.id, commitSha); + this.logger.debug(`Payload: ${JSON.stringify(payload)}`); + + if (payload.workflowData.pullRequestFiles.length === 0) { + this.logger.debug(`No files to process, skipping workflow`); + return { user, repository, pullRequest, workflowResponse: null }; + } + + const workflowResponse = await this.workflowService.triggerWorkflow(payload); + this.logger.debug(`Workflow response: ${JSON.stringify(workflowResponse)}`); + + return { user, repository, pullRequest, workflowResponse }; + } catch (error) { + this.logger.error(`Error processing GitHub webhook: ${error}`); + throw error; + } + } } diff --git a/apps/api/src/github/mapper/github.mapper.ts b/apps/api/src/github/mapper/github.mapper.ts new file mode 100644 index 0000000..e54329d --- /dev/null +++ b/apps/api/src/github/mapper/github.mapper.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { GithubWebhookDto } from '../dto/webhook.github.dto'; +import { TriggerWorkflowDto } from 'src/workflow/dto/trigger-workflow.dto'; +import { v4 as uuidv4 } from 'uuid'; +import { WorkflowMetadata } from 'src/workflow/interface/workflow-metadata.interface'; +import { PullRequestFileDto } from '../dto/github-pull-request-file.dto'; +import { PullRequestDto } from 'src/pull-request/dto/pull-request.dto'; +import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto'; +import { WorkflowSource } from 'src/workflow/enum/workflow-source.enum'; +@Injectable() +export class GithubMapper { + private readonly logger; + + constructor() { + this.logger = new Logger(GithubMapper.name); + } + + private toWorkflowMetadata(installationId: number, commitSha: string): WorkflowMetadata { + this.logger.debug('Mapping workflow metadata'); + return { + requestId: uuidv4(), + source: WorkflowSource.GITHUB, + installationId: installationId, + commitSha: commitSha, + }; + } + + toPullRequestWorkflowPayload( + userId: string, + files: PullRequestFileDto[], + pullRequest: PullRequestDto, + botConfig: BotConfigDto, + installationId: number, + commitSha: string, + ): TriggerWorkflowDto { + this.logger.debug('Mapping pull request workflow payload'); + files.map(file => { + file.pullRequestNumber = pullRequest.number; + file.pullRequestUrl = pullRequest.url; + }); + + return { + workflowMetadata: this.toWorkflowMetadata(installationId, commitSha), + workflowData: { + userId, + pullRequestId: pullRequest.id, + pullRequestFiles: files, + botConfig, + }, + }; + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 50407a5..f1681fb 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -8,8 +8,11 @@ async function bootstrap() { // Global configuration app.enableCors(); + logger.log('CORS enabled'); app.setGlobalPrefix('api/v1'); + logger.log('Global prefix set to api/v1'); app.useGlobalPipes(new ValidationPipe()); + logger.log('Validation pipe enabled'); // Swagger configuration const config = new DocumentBuilder() @@ -28,17 +31,21 @@ async function bootstrap() { 'access-token', ) .build(); + logger.log('Swagger configuration completed'); const document = SwaggerModule.createDocument(app, config); + logger.log('Swagger document created'); + SwaggerModule.setup('docs', app, document, { swaggerOptions: { persistAuthorization: true, }, customSiteTitle: 'API Documentation', }); + logger.log('Swagger setup completed'); // Start server - await app.listen(process.env.API_PORT ?? 3000); + await app.listen(process.env.PORT ?? 3000); const appUrl = await app.getUrl(); logger.log(`🚀 Server ready at ${appUrl}`); logger.log(`🚀 API Documentation: ${appUrl}/docs`); diff --git a/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql b/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql new file mode 100644 index 0000000..f2d23f7 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250407174957_add_max_reviews/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "maxReviews" INTEGER NOT NULL DEFAULT 100; diff --git a/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql b/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql new file mode 100644 index 0000000..d9b6e58 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250407185517_add_reviews/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Review" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "pullRequestId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Review_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_pullRequestId_fkey" FOREIGN KEY ("pullRequestId") REFERENCES "PullRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql b/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql new file mode 100644 index 0000000..0455a0c --- /dev/null +++ b/apps/api/src/prisma/migrations/20250409160043_add_comments_on_reviews/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "comment" TEXT; diff --git a/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql b/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql new file mode 100644 index 0000000..7aa850a --- /dev/null +++ b/apps/api/src/prisma/migrations/20250409172712_add_filename_and_patch_to_reviews/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "filename" TEXT, +ADD COLUMN "patch" TEXT; diff --git a/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql b/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql new file mode 100644 index 0000000..040cf5a --- /dev/null +++ b/apps/api/src/prisma/migrations/20250409180435_add_ignored_extensions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BotConfig" ADD COLUMN "ignoredExtensions" TEXT[] DEFAULT ARRAY['md', 'txt', 'lock', 'json', 'yml', 'yaml', 'env', 'toml', 'ini', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp', 'mp4', 'webm', 'mp3', 'wav', 'd.ts', 'map', 'snap', 'golden', 'wasm', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'scss', 'sass']::TEXT[]; diff --git a/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql b/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql new file mode 100644 index 0000000..d2ed812 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250409183603_add_bot_config_custom_rules/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BotConfig" ADD COLUMN "customRules" TEXT; diff --git a/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql b/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql new file mode 100644 index 0000000..8526e96 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250410095557_update_subscription_credits/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `maxReviews` on the `Subscription` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Subscription" DROP COLUMN "maxReviews", +ADD COLUMN "credits" INTEGER NOT NULL DEFAULT 20; diff --git a/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql b/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql new file mode 100644 index 0000000..4303b4b --- /dev/null +++ b/apps/api/src/prisma/migrations/20250410114803_add_credit_used/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "creditsUsed" INTEGER NOT NULL DEFAULT 1; diff --git a/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql b/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql new file mode 100644 index 0000000..23f65ef --- /dev/null +++ b/apps/api/src/prisma/migrations/20250410154039_add_company_context/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BotConfig" ADD COLUMN "companyContext" TEXT; diff --git a/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql b/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql new file mode 100644 index 0000000..0021e60 --- /dev/null +++ b/apps/api/src/prisma/migrations/20250410160205_update_bot_config_relations/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - You are about to drop the column `companyContext` on the `BotConfig` table. All the data in the column will be lost. + - You are about to drop the column `customRules` on the `BotConfig` table. All the data in the column will be lost. + - You are about to drop the column `repositoryId` on the `BotConfig` table. All the data in the column will be lost. + - You are about to drop the column `botConfigId` on the `Repository` table. All the data in the column will be lost. + - You are about to drop the `ReviewFocusArea` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[userId]` on the table `BotConfig` will be added. If there are existing duplicate values, this will fail. + - Added the required column `userId` to the `BotConfig` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "BotConfig" DROP CONSTRAINT "BotConfig_repositoryId_fkey"; + +-- DropForeignKey +ALTER TABLE "ReviewFocusArea" DROP CONSTRAINT "ReviewFocusArea_botConfigId_fkey"; + +-- DropIndex +DROP INDEX "BotConfig_repositoryId_key"; + +-- AlterTable +ALTER TABLE "BotConfig" DROP COLUMN "companyContext", +DROP COLUMN "customRules", +DROP COLUMN "repositoryId", +ADD COLUMN "userId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "Repository" DROP COLUMN "botConfigId"; + +-- DropTable +DROP TABLE "ReviewFocusArea"; + +-- CreateIndex +CREATE UNIQUE INDEX "BotConfig_userId_key" ON "BotConfig"("userId"); + +-- AddForeignKey +ALTER TABLE "BotConfig" ADD CONSTRAINT "BotConfig_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql b/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql new file mode 100644 index 0000000..e65c77d --- /dev/null +++ b/apps/api/src/prisma/migrations/20250411144328_add_github_comment_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "githubCommentId" INTEGER; diff --git a/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql b/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql new file mode 100644 index 0000000..a1d298d --- /dev/null +++ b/apps/api/src/prisma/migrations/20250411153300_add_reviewsource/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "ReviewSource" AS ENUM ('GITHUB', 'GITLAB'); + +-- AlterTable +ALTER TABLE "Review" ADD COLUMN "source" "ReviewSource"; diff --git a/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql b/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql new file mode 100644 index 0000000..db1d79e --- /dev/null +++ b/apps/api/src/prisma/migrations/20250412154359_add_supabase_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[supabaseId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "supabaseId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_supabaseId_key" ON "User"("supabaseId"); diff --git a/apps/api/src/prisma/schema.prisma b/apps/api/src/prisma/schema.prisma index 9c98c95..fdbaabf 100644 --- a/apps/api/src/prisma/schema.prisma +++ b/apps/api/src/prisma/schema.prisma @@ -33,14 +33,22 @@ enum PullRequestStatus { MERGED } +enum ReviewSource { + GITHUB + GITLAB +} + model User { id String @id @default(uuid()) email String @unique name String? + supabaseId String? @unique githubId String? @unique providerId String? @unique subscription Subscription? repositories Repository[] + reviews Review[] + botConfig BotConfig? onboarded Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -52,8 +60,9 @@ model Subscription { user User @relation(fields: [userId], references: [id], onDelete: Cascade) plan SubscriptionPlan @default(FREE) status SubscriptionStatus @default(ACTIVE) + credits Int @default(20) startDate DateTime @default(now()) - endDate DateTime? + endDate DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -66,9 +75,6 @@ model Repository { hasWiki Boolean @default(false) githubId String? @unique - botConfigId String? - botConfig BotConfig? - ownerId String owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) @@ -80,29 +86,18 @@ model Repository { model BotConfig { id String @id @default(uuid()) - repositoryId String @unique - repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) grumpinessLevel BotLevel @default(MODERATE) technicalityLevel BotLevel @default(MODERATE) detailLevel BotLevel @default(MODERATE) language String @default("en") autoApprove Boolean @default(false) - reviewFocusAreas ReviewFocusArea[] + ignoredExtensions String[] @default(["md", "txt", "lock", "json", "yml", "yaml", "env", "toml", "ini", "png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "mp4", "webm", "mp3", "wav", "d.ts", "map", "snap", "golden", "wasm", "woff", "woff2", "ttf", "otf", "eot", "scss", "sass"]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -model ReviewFocusArea { - id String @id @default(uuid()) - name String // e.g., "Code Style", "Best Practices", "Documentation" - botConfigId String - botConfig BotConfig @relation(fields: [botConfigId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@unique([name, botConfigId]) -} - model PullRequest { id String @id @default(uuid()) title String? @@ -130,6 +125,7 @@ model PullRequest { repositoryId String repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) status PullRequestStatus @default(PENDING) + reviews Review[] comments Comment[] @@ -148,3 +144,19 @@ model Comment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model Review { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + comment String? + source ReviewSource? + githubCommentId Int? + filename String? + patch String? + creditsUsed Int @default(1) + pullRequestId String + pullRequest PullRequest @relation(fields: [pullRequestId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/apps/api/src/pull-request/pull-request.controller.ts b/apps/api/src/pull-request/pull-request.controller.ts index 101e788..1e80e71 100644 --- a/apps/api/src/pull-request/pull-request.controller.ts +++ b/apps/api/src/pull-request/pull-request.controller.ts @@ -1,32 +1,88 @@ -import { Controller, Get, Param, Post, Body, Put, Delete, Logger } from '@nestjs/common'; +import { Controller, Get, Param, Post, Body, Put, Delete, Logger, Query } from '@nestjs/common'; import { PullRequestService } from './pull-request.service'; import { PullRequestDto } from './dto/pull-request.dto'; - +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UserService } from 'src/user/user.service'; +@ApiTags('Pull Requests') @Controller('pull-request') export class PullRequestController { private readonly logger; - constructor(private readonly pullRequestService: PullRequestService) { + constructor( + private readonly pullRequestService: PullRequestService, + private readonly userService: UserService, + ) { this.logger = new Logger(PullRequestController.name); } + @ApiOperation({ + summary: 'Get pull request by ID', + description: 'Retrieves a pull request using its unique identifier', + }) + @ApiResponse({ + status: 200, + description: 'Pull request found successfully', + }) + @ApiResponse({ status: 404, description: 'Pull request not found' }) @Get(':id') async getPullRequestById(@Param('id') id: string) { this.logger.debug(`Getting pull request by id: ${id}`); return this.pullRequestService.getPullRequestById(id); } + @ApiOperation({ + summary: 'Get pull requests by user ID', + description: 'Retrieves all pull requests for a specific user', + }) + @ApiResponse({ + status: 200, + description: 'Pull requests retrieved successfully', + }) + @ApiResponse({ status: 404, description: 'No pull requests found' }) + @Get('user/:supabaseId') + async getPullRequestsBySupabaseId(@Param('supabaseId') supabaseId: string, @Query('page') page: number) { + this.logger.debug(`Getting pull requests by user id: ${supabaseId}`); + const user = await this.userService.getUserBySupabaseId(supabaseId); + return this.pullRequestService.getPullRequestsByRepositories(user.repositories, page); + } + + @ApiOperation({ + summary: 'Create new pull request', + description: 'Creates a new pull request with the provided data', + }) + @ApiResponse({ + status: 201, + description: 'Pull request created successfully', + }) @Post() async createPullRequest(@Body() pullRequestDto: PullRequestDto) { this.logger.debug(`Creating pull request: ${pullRequestDto}`); return this.pullRequestService.createPullRequest(pullRequestDto, pullRequestDto.repositoryId); } + @ApiOperation({ + summary: 'Update pull request', + description: 'Updates an existing pull request with new data', + }) + @ApiResponse({ + status: 200, + description: 'Pull request updated successfully', + }) + @ApiResponse({ status: 404, description: 'Pull request not found' }) @Put(':id') async updatePullRequest(@Param('id') id: string, @Body() pullRequestDto: PullRequestDto) { this.logger.debug(`Updating pull request: ${pullRequestDto}`); return this.pullRequestService.updatePullRequest(pullRequestDto); } + @ApiOperation({ + summary: 'Delete pull request', + description: 'Deletes a pull request by its ID', + }) + @ApiResponse({ + status: 200, + description: 'Pull request deleted successfully', + }) + @ApiResponse({ status: 404, description: 'Pull request not found' }) @Delete(':id') async deletePullRequest(@Param('id') id: string) { this.logger.debug(`Deleting pull request: ${id}`); diff --git a/apps/api/src/pull-request/pull-request.module.ts b/apps/api/src/pull-request/pull-request.module.ts index c55325f..55dbf23 100644 --- a/apps/api/src/pull-request/pull-request.module.ts +++ b/apps/api/src/pull-request/pull-request.module.ts @@ -3,9 +3,11 @@ import { PullRequestService } from './pull-request.service'; import { PrismaModule } from 'src/prisma/prisma.module'; import { PullRequestController } from './pull-request.controller'; import { PullRequestMapper } from './mapper/pull-request.mapper'; +import { RepositoryService } from '../repository/repository.service'; +import { UserService } from 'src/user/user.service'; @Module({ imports: [PrismaModule], - providers: [PullRequestService, PullRequestMapper], + providers: [PullRequestService, PullRequestMapper, UserService], exports: [PullRequestService, PullRequestMapper], controllers: [PullRequestController], }) diff --git a/apps/api/src/pull-request/pull-request.service.ts b/apps/api/src/pull-request/pull-request.service.ts index 970448e..58bbf8c 100644 --- a/apps/api/src/pull-request/pull-request.service.ts +++ b/apps/api/src/pull-request/pull-request.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { PullRequestDto } from './dto/pull-request.dto'; import { CreatePullRequestDto } from './dto/create-pull-request.dto'; +import { Repository } from '@prisma/client'; @Injectable() export class PullRequestService { private readonly logger; @@ -23,6 +24,22 @@ export class PullRequestService { }); } + async getPullRequestsByRepositoryId(repositoryId: string) { + this.logger.debug(`Getting pull requests by repository id: ${repositoryId}`); + return this.prisma.pullRequest.findMany({ + where: { repositoryId }, + }); + } + + async getPullRequestsByRepositories(repositories: Repository[], page: number) { + this.logger.debug(`Getting pull requests by repositories: ${repositories.map(repository => repository.id)}`); + return this.prisma.pullRequest.findMany({ + where: { repositoryId: { in: repositories.map(repository => repository.id) } }, + skip: (page - 1) * 10, + take: 10, + }); + } + async createPullRequest(pullRequestDto: CreatePullRequestDto, repositoryId: string) { this.logger.debug(`Creating pull request: ${pullRequestDto}`); return this.prisma.pullRequest.create({ diff --git a/apps/api/src/repository/dto/repository.dto.ts b/apps/api/src/repository/dto/repository.dto.ts index 1243331..e8658c2 100644 --- a/apps/api/src/repository/dto/repository.dto.ts +++ b/apps/api/src/repository/dto/repository.dto.ts @@ -1,3 +1,5 @@ +import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto'; + export interface RepositoryDto { id?: string; name: string; @@ -5,5 +7,5 @@ export interface RepositoryDto { language?: string; hasWiki?: boolean; githubId?: string; - botConfigId?: string; + botConfig?: BotConfigDto; } diff --git a/apps/api/src/repository/repository.controller.ts b/apps/api/src/repository/repository.controller.ts index ed581f9..2b2e853 100644 --- a/apps/api/src/repository/repository.controller.ts +++ b/apps/api/src/repository/repository.controller.ts @@ -1,16 +1,28 @@ import { Controller, Logger, Post, Body } from '@nestjs/common'; import { RepositoryService } from './repository.service'; import { RepositoryDto } from './dto/repository.dto'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +@ApiTags('Repository') @Controller('repository') export class RepositoryController { private readonly logger; + constructor(private readonly repositoryService: RepositoryService) { this.logger = new Logger(RepositoryController.name); } + @ApiOperation({ + summary: 'Create new repository', + description: 'Creates a new repository with the provided data and user ID', + }) + @ApiResponse({ + status: 201, + description: 'Repository created successfully', + }) @Post() async createRepository(@Body() repositoryDto: RepositoryDto, @Body() userId: string) { + this.logger.debug(`Creating repository: ${JSON.stringify(repositoryDto)}`); return this.repositoryService.createRepository(repositoryDto, userId); } } diff --git a/apps/api/src/repository/repository.service.ts b/apps/api/src/repository/repository.service.ts index b2a26fe..d528342 100644 --- a/apps/api/src/repository/repository.service.ts +++ b/apps/api/src/repository/repository.service.ts @@ -42,17 +42,9 @@ export class RepositoryService { id: userId, }, }, - botConfig: { - create: { - grumpinessLevel: BotLevel.MODERATE, - technicalityLevel: BotLevel.MODERATE, - detailLevel: BotLevel.MODERATE, - }, - }, }, include: { owner: true, - botConfig: true, }, }); } @@ -61,7 +53,13 @@ export class RepositoryService { this.logger.debug(`Updating repository: ${repository}`); return await this.prisma.repository.update({ where: { id: repository.id }, - data: repository, + data: { + name: repository.name, + url: repository.url, + language: repository.language, + hasWiki: repository.hasWiki, + githubId: repository.githubId, + }, }); } diff --git a/apps/api/src/review/dto/create-review.dto.ts b/apps/api/src/review/dto/create-review.dto.ts new file mode 100644 index 0000000..78b621b --- /dev/null +++ b/apps/api/src/review/dto/create-review.dto.ts @@ -0,0 +1,31 @@ +import { IsNotEmpty, IsOptional, IsString, IsNumber } from 'class-validator'; + +export class CreateReviewDto { + @IsString() + @IsNotEmpty() + userId: string; + + @IsString() + @IsNotEmpty() + pullRequestId: string; + + @IsString() + @IsOptional() + comment?: string; + + @IsString() + @IsOptional() + filename?: string; + + @IsString() + @IsOptional() + patch?: string; + + @IsNumber() + @IsOptional() + creditsUsed?: number; + + @IsNumber() + @IsOptional() + githubCommentId?: number; +} diff --git a/apps/api/src/review/review.controller.spec.ts b/apps/api/src/review/review.controller.spec.ts new file mode 100644 index 0000000..b1b0458 --- /dev/null +++ b/apps/api/src/review/review.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewController } from './review.controller'; + +describe('ReviewController', () => { + let controller: ReviewController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReviewController], + }).compile(); + + controller = module.get(ReviewController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/review/review.controller.ts b/apps/api/src/review/review.controller.ts new file mode 100644 index 0000000..f43ae9f --- /dev/null +++ b/apps/api/src/review/review.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, Logger, Param, Post, Put, Query } from '@nestjs/common'; +import { ReviewService } from './review.service'; +import { UserService } from 'src/user/user.service'; +import { CreateReviewDto } from './dto/create-review.dto'; + +@Controller('review') +export class ReviewController { + private readonly logger; + + constructor( + private readonly reviewService: ReviewService, + private readonly userService: UserService, + ) { + this.logger = new Logger(ReviewController.name); + } + + @Get('/user/:supabaseId') + async getReviewsByUserSupabaseId(@Param('supabaseId') supabaseId: string, @Query('page') page: number) { + this.logger.debug(`Getting reviews by user supabase id: ${supabaseId}`); + const user = await this.userService.getUserBySupabaseId(supabaseId); + return this.reviewService.getReviewsByUserId(user.id, page); + } + + @Get('/pull-request/:pullRequestId') + async getReviewsByPullRequestId(@Param('pullRequestId') pullRequestId: string) { + this.logger.debug(`Getting reviews by pull request id: ${pullRequestId}`); + return this.reviewService.getReviewsByPullRequestId(pullRequestId); + } + + @Post() + async createReview(@Body() review: CreateReviewDto) { + this.logger.debug(`Creating review: ${review}`); + return this.reviewService.createReview(review); + } + + // @Put(':id') + // async updateReview(@Param('id') id: string, @Body() review: UpdateReviewDto) { + // this.logger.debug(`Updating review: ${review}`); + // return this.reviewService.updateReview(id, review); + // } + + @Delete(':id') + async deleteReview(@Param('id') id: string) { + this.logger.debug(`Deleting review: ${id}`); + return this.reviewService.deleteReview(id); + } +} diff --git a/apps/api/src/review/review.module.ts b/apps/api/src/review/review.module.ts new file mode 100644 index 0000000..87efde5 --- /dev/null +++ b/apps/api/src/review/review.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ReviewService } from './review.service'; +import { ReviewController } from './review.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; +import { UserModule } from 'src/user/user.module'; +@Module({ + imports: [PrismaModule, UserModule], + providers: [ReviewService], + controllers: [ReviewController], + exports: [ReviewService], +}) +export class ReviewModule {} diff --git a/apps/api/src/review/review.service.spec.ts b/apps/api/src/review/review.service.spec.ts new file mode 100644 index 0000000..b0fc157 --- /dev/null +++ b/apps/api/src/review/review.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ReviewService } from './review.service'; + +describe('ReviewService', () => { + let service: ReviewService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReviewService], + }).compile(); + + service = module.get(ReviewService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/review/review.service.ts b/apps/api/src/review/review.service.ts new file mode 100644 index 0000000..a8bab7f --- /dev/null +++ b/apps/api/src/review/review.service.ts @@ -0,0 +1,111 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Review } from '@prisma/client'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { CreateReviewDto } from './dto/create-review.dto'; +@Injectable() +export class ReviewService { + private readonly logger; + constructor(private readonly prisma: PrismaService) { + this.logger = new Logger(ReviewService.name); + } + + /** + * Retrieves a review by its ID + * @param id - The unique identifier of the review + * @returns {Promise} The review if found, null otherwise + */ + async getReviewById(id: string) { + this.logger.debug(`Getting review by id: ${id}`); + return this.prisma.review.findUnique({ + where: { + id, + }, + }); + } + + /** + * Gets all reviews created by a specific user + * @param userId - The unique identifier of the user + * @returns {Promise} Array of reviews by the user + */ + async getReviewsByUserId(userId: string, page: number) { + this.logger.debug(`Getting reviews by user id: ${userId}`); + return this.prisma.review.findMany({ + where: { + userId, + }, + skip: (page - 1) * 10, + take: 10, + }); + } + + /** + * Gets all reviews created by a specific user + * @param userId - The unique identifier of the user + * @returns {Promise} Array of reviews by the user + */ + async getAllReviewsByUserId(userId: string) { + this.logger.debug(`Getting all reviews by user id: ${userId}`); + return this.prisma.review.findMany({ + where: { + userId, + }, + }); + } + + /** + * Gets all reviews for a specific pull request + * @param pullRequestId - The unique identifier of the pull request + * @returns {Promise} Array of reviews for the pull request + */ + async getReviewsByPullRequestId(pullRequestId: string) { + this.logger.debug(`Getting reviews by pull request id: ${pullRequestId}`); + return this.prisma.review.findMany({ + where: { + pullRequestId, + }, + }); + } + + /** + * Creates a new review + * @param review - The review data to create + * @returns {Promise} The created review + */ + async createReview(review: CreateReviewDto) { + this.logger.debug(`Creating review: ${JSON.stringify(review)}`); + return this.prisma.review.create({ + data: review, + }); + } + + /** + * Updates an existing review + * @param id - The unique identifier of the review to update + * @param review - The updated review data + * @returns {Promise} The updated review + */ + async updateReview(id: string, review: Review) { + this.logger.debug(`Updating review: ${JSON.stringify(review)}`); + return this.prisma.review.update({ + where: { + id, + }, + data: review, + }); + } + + /** + * Deletes a review by its ID + * @param id - The unique identifier of the review to delete + * @returns {Promise} The deleted review + */ + async deleteReview(id: string) { + this.logger.debug(`Deleting review: ${id}`); + return this.prisma.review.delete({ + where: { + id, + }, + }); + } +} diff --git a/apps/api/src/subscription/dto/subscription.dto.ts b/apps/api/src/subscription/dto/subscription.dto.ts new file mode 100644 index 0000000..e51e4bf --- /dev/null +++ b/apps/api/src/subscription/dto/subscription.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsEnum, IsInt, IsOptional, IsDate } from 'class-validator'; +import { SubscriptionPlan } from '../enum/plans.enum'; +import { SubscriptionStatus } from '../enum/status.enum'; + +export class SubscriptionDto { + @IsString() + id: string; + + @IsString() + userId: string; + + @IsEnum(SubscriptionPlan) + plan: SubscriptionPlan; + + @IsEnum(SubscriptionStatus) + status: SubscriptionStatus; + + @IsInt() + credits: number; + + @IsDate() + startDate: Date; + + @IsOptional() + @IsDate() + endDate?: Date; + + @IsDate() + createdAt: Date; + + @IsDate() + updatedAt: Date; +} diff --git a/apps/api/src/subscription/enum/credits.enum.ts b/apps/api/src/subscription/enum/credits.enum.ts new file mode 100644 index 0000000..fd0ce2f --- /dev/null +++ b/apps/api/src/subscription/enum/credits.enum.ts @@ -0,0 +1,5 @@ +export enum SubscriptionCredits { + FREE = 20, + PRO = 1000, + ENTERPRISE = 5000, +} diff --git a/apps/api/src/subscription/enum/plans.enum.ts b/apps/api/src/subscription/enum/plans.enum.ts new file mode 100644 index 0000000..4886330 --- /dev/null +++ b/apps/api/src/subscription/enum/plans.enum.ts @@ -0,0 +1,9 @@ +import { SubscriptionPlan as PrismaSubscriptionPlan } from '@prisma/client'; + +export const SubscriptionPlan = { + FREE: PrismaSubscriptionPlan.FREE, + PRO: PrismaSubscriptionPlan.PRO, + ENTERPRISE: PrismaSubscriptionPlan.ENTERPRISE, +} as const; + +export type SubscriptionPlan = (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan]; diff --git a/apps/api/src/subscription/enum/status.enum.ts b/apps/api/src/subscription/enum/status.enum.ts new file mode 100644 index 0000000..3a42169 --- /dev/null +++ b/apps/api/src/subscription/enum/status.enum.ts @@ -0,0 +1,9 @@ +import { SubscriptionStatus as PrismaSubscriptionStatus } from '@prisma/client'; + +export const SubscriptionStatus = { + ACTIVE: PrismaSubscriptionStatus.ACTIVE, + CANCELED: PrismaSubscriptionStatus.CANCELED, + EXPIRED: PrismaSubscriptionStatus.EXPIRED, +} as const; + +export type SubscriptionStatus = (typeof SubscriptionStatus)[keyof typeof SubscriptionStatus]; diff --git a/apps/api/src/subscription/subscription.controller.spec.ts b/apps/api/src/subscription/subscription.controller.spec.ts new file mode 100644 index 0000000..9ce1825 --- /dev/null +++ b/apps/api/src/subscription/subscription.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionController } from './subscription.controller'; + +describe('SubscriptionController', () => { + let controller: SubscriptionController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SubscriptionController], + }).compile(); + + controller = module.get(SubscriptionController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/subscription/subscription.controller.ts b/apps/api/src/subscription/subscription.controller.ts new file mode 100644 index 0000000..47fca0c --- /dev/null +++ b/apps/api/src/subscription/subscription.controller.ts @@ -0,0 +1,96 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Logger } from '@nestjs/common'; +import { SubscriptionService } from './subscription.service'; +import { Subscription } from '@prisma/client'; +import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('Subscription') +@Controller('subscription') +export class SubscriptionController { + private readonly logger; + + constructor(private readonly subscriptionService: SubscriptionService) { + this.logger = new Logger(SubscriptionController.name); + } + + @ApiOperation({ summary: 'Get subscription by ID' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiResponse({ status: 200, description: 'Returns the subscription' }) + @Get(':id') + async getSubscription(@Param('id') id: string): Promise { + this.logger.log(`Getting subscription by ID: ${id}`); + return this.subscriptionService.getSubscriptionById(id); + } + + @ApiOperation({ summary: 'Get subscription by user ID' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'Returns the user subscription' }) + @Get('user/:userId') + async getSubscriptionByUserId(@Param('userId') userId: string): Promise { + this.logger.log(`Getting subscription by user ID: ${userId}`); + return this.subscriptionService.getSubscriptionByUserId(userId); + } + + @ApiOperation({ summary: 'Create a new subscription' }) + @ApiBody({ description: 'Subscription data' }) + @ApiResponse({ status: 201, description: 'Subscription created successfully' }) + @Post() + async createSubscription(@Body() subscription: Subscription): Promise { + this.logger.log(`Creating subscription: ${JSON.stringify(subscription)}`); + return this.subscriptionService.createSubscription(subscription); + } + + @ApiOperation({ summary: 'Update subscription by ID' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiBody({ description: 'Updated subscription data' }) + @ApiResponse({ status: 200, description: 'Subscription updated successfully' }) + @Put(':id') + async updateSubscription(@Param('id') id: string, @Body() subscription: Subscription): Promise { + this.logger.log(`Updating subscription: ${id} with data: ${JSON.stringify(subscription)}`); + return this.subscriptionService.updateSubscription(id, subscription); + } + + @ApiOperation({ summary: 'Delete subscription by ID' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiResponse({ status: 200, description: 'Subscription deleted successfully' }) + @Delete(':id') + async deleteSubscription(@Param('id') id: string): Promise { + this.logger.log(`Deleting subscription: ${id}`); + return this.subscriptionService.deleteSubscription(id); + } + + @ApiOperation({ summary: 'Mark subscription as expired' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiResponse({ status: 200, description: 'Subscription marked as expired' }) + @Put('expire/:id') + async expireSubscription(@Param('id') id: string): Promise { + this.logger.log(`Marking subscription as expired: ${id}`); + return this.subscriptionService.expireSubscription(id); + } + + @ApiOperation({ summary: 'Activate subscription' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiResponse({ status: 200, description: 'Subscription activated successfully' }) + @Put('activate/:id') + async activateSubscription(@Param('id') id: string): Promise { + this.logger.log(`Activating subscription: ${id}`); + return this.subscriptionService.activateSubscription(id); + } + + @ApiOperation({ summary: 'Cancel subscription' }) + @ApiParam({ name: 'id', description: 'Subscription ID' }) + @ApiResponse({ status: 200, description: 'Subscription cancelled successfully' }) + @Put('cancel/:id') + async cancelSubscription(@Param('id') id: string): Promise { + this.logger.log(`Cancelling subscription: ${id}`); + return this.subscriptionService.cancelSubscription(id); + } + + @ApiOperation({ summary: 'Get remaining credits for user ID' }) + @ApiParam({ name: 'supabaseId', description: 'Supabase User ID' }) + @ApiResponse({ status: 200, description: 'Returns the remaining credits for the user' }) + @Get('user/:supabaseId/credits-info') + async getCreditsInfo(@Param('supabaseId') supabaseId: string): Promise<{ remainingCredits: number; totalCredits: number; usedCredits: number }> { + this.logger.log(`Getting credits info for user ID: ${supabaseId}`); + return this.subscriptionService.getCreditsInfo(supabaseId); + } +} diff --git a/apps/api/src/subscription/subscription.module.ts b/apps/api/src/subscription/subscription.module.ts index 56671bf..2336103 100644 --- a/apps/api/src/subscription/subscription.module.ts +++ b/apps/api/src/subscription/subscription.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from 'src/prisma/prisma.module'; import { SubscriptionService } from './subscription.service'; - +import { SubscriptionController } from './subscription.controller'; +import { UserService } from '../user/user.service'; +import { ReviewService } from '../review/review.service'; @Module({ imports: [PrismaModule], - providers: [SubscriptionService], + providers: [SubscriptionService, UserService, ReviewService], exports: [SubscriptionService], + controllers: [SubscriptionController], }) export class SubscriptionModule {} diff --git a/apps/api/src/subscription/subscription.service.ts b/apps/api/src/subscription/subscription.service.ts index 9bf44f0..a00eba1 100644 --- a/apps/api/src/subscription/subscription.service.ts +++ b/apps/api/src/subscription/subscription.service.ts @@ -1,4 +1,185 @@ -import { Injectable } from '@nestjs/common'; - +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Subscription } from '@prisma/client'; +import { SubscriptionCredits } from './enum/credits.enum'; +import { SubscriptionPlan } from './enum/plans.enum'; +import { SubscriptionStatus } from './enum/status.enum'; +import { UserService } from '../user/user.service'; +import { ReviewService } from '../review/review.service'; @Injectable() -export class SubscriptionService {} +export class SubscriptionService { + private readonly logger; + + constructor( + private readonly prisma: PrismaService, + private readonly userService: UserService, + private readonly reviewService: ReviewService, + ) { + this.logger = new Logger(SubscriptionService.name); + } + + /** + * Retrieves a subscription by its ID + * @param id - The unique identifier of the subscription + * @returns {Promise} The subscription if found, null otherwise + */ + async getSubscriptionById(id: string) { + this.logger.log(`Getting subscription by ID: ${id}`); + return this.prisma.subscription.findUnique({ where: { id } }); + } + + /** + * Gets a subscription by user ID + * @param userId - The unique identifier of the user + * @returns {Promise} The subscription if found, null otherwise + */ + async getSubscriptionByUserId(userId: string) { + this.logger.log(`Getting subscription by user ID: ${userId}`); + return this.prisma.subscription.findUnique({ where: { userId } }); + } + + /** + * Creates a new subscription + * @param subscription - The subscription data to create + * @returns {Promise} The created subscription + */ + async createSubscription(subscription: Subscription) { + this.logger.log(`Creating subscription: ${JSON.stringify(subscription)}`); + return this.prisma.subscription.create({ data: subscription }); + } + + /** + * Updates an existing subscription + * @param id - The unique identifier of the subscription to update + * @param subscription - The updated subscription data + * @returns {Promise} The updated subscription + */ + async updateSubscription(id: string, subscription: Subscription) { + this.logger.log(`Updating subscription: ${id} with data: ${JSON.stringify(subscription)}`); + return this.prisma.subscription.update({ where: { id }, data: subscription }); + } + + /** + * Updates a subscription to a free plan + * @param id - The unique identifier of the subscription to update + * @returns {Promise} The updated subscription + */ + async updateSubscriptionToFree(id: string) { + this.logger.log(`Updating subscription: ${id} to free plan`); + return this.prisma.subscription.update({ + where: { id }, + data: { + plan: SubscriptionPlan.FREE, + credits: SubscriptionCredits.FREE, + }, + }); + } + + /** + * Updates a subscription to a paid plan + * @param id - The unique identifier of the subscription to update + * @returns {Promise} The updated subscription + */ + async updateSubscriptionToPro(id: string) { + this.logger.log(`Updating subscription: ${id} to pro plan`); + return this.prisma.subscription.update({ + where: { id }, + data: { + plan: SubscriptionPlan.PRO, + credits: SubscriptionCredits.PRO, + }, + }); + } + + /** + * Updates a subscription to an enterprise plan + * @param id - The unique identifier of the subscription to update + * @returns {Promise} The updated subscription + */ + async updateSubscriptionToEnterprise(id: string) { + this.logger.log(`Updating subscription: ${id} to enterprise plan`); + return this.prisma.subscription.update({ + where: { id }, + data: { + plan: SubscriptionPlan.ENTERPRISE, + credits: SubscriptionCredits.ENTERPRISE, + }, + }); + } + + /** + * Deletes a subscription by its ID + * @param id - The unique identifier of the subscription to delete + * @returns {Promise} The deleted subscription + */ + async deleteSubscription(id: string) { + this.logger.log(`Deleting subscription: ${id}`); + return this.prisma.subscription.delete({ where: { id } }); + } + + /** + * Marks a subscription as expired + * @param id - The unique identifier of the subscription to expire + * @returns {Promise} The updated subscription with expired status + */ + async expireSubscription(id: string) { + this.logger.log(`Expiring subscription: ${id}`); + return this.prisma.subscription.update({ + where: { id }, + data: { status: SubscriptionStatus.EXPIRED }, + }); + } + + /** + * Activates a subscription + * @param id - The unique identifier of the subscription to activate + * @returns {Promise} The updated subscription with active status + */ + async activateSubscription(id: string) { + this.logger.log(`Activating subscription: ${id}`); + return this.prisma.subscription.update({ + where: { id }, + data: { status: SubscriptionStatus.ACTIVE }, + }); + } + + /** + * Cancels a subscription + * @param id - The unique identifier of the subscription to cancel + * @returns {Promise} The updated subscription with canceled status + */ + async cancelSubscription(id: string) { + this.logger.log(`Cancelling subscription: ${id}`); + return this.prisma.subscription.update({ + where: { id }, + data: { status: SubscriptionStatus.CANCELED }, + }); + } + + /** + * Checks if a subscription is active + * @param status - The status of the subscription + * @returns {Promise} True if the subscription is active, false otherwise + */ + async isSubscriptionActive(status: SubscriptionStatus) { + this.logger.log(`Checking subscription status: ${status}`); + return status === SubscriptionStatus.ACTIVE; + } + + /** + * Gets the remaining credits for a user + * @param supabaseId - The unique identifier of the user + * @returns {Promise} The remaining credits for the user + */ + async getCreditsInfo(supabaseId: string) { + this.logger.log(`Getting remaining credits for user: ${supabaseId}`); + const user = await this.userService.getUserBySupabaseId(supabaseId); + const reviews = await this.reviewService.getAllReviewsByUserId(user.id); + const currentCredits = reviews.reduce((acc, review) => acc + review.creditsUsed, 0); + return { + remainingCredits: user.subscription.credits - currentCredits, + totalCredits: user.subscription.credits, + usedCredits: currentCredits, + }; + } +} diff --git a/apps/api/src/supabase/supabase.service.ts b/apps/api/src/supabase/supabase.service.ts index dfbb2f4..0d88c55 100644 --- a/apps/api/src/supabase/supabase.service.ts +++ b/apps/api/src/supabase/supabase.service.ts @@ -1,11 +1,11 @@ // src/supabase/supabase.service.ts import { Injectable, Logger } from '@nestjs/common'; -import { createClient } from '@supabase/supabase-js'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { ConfigService } from '@nestjs/config'; @Injectable() export class SupabaseService { - private supabase; + private readonly supabase: SupabaseClient; private readonly logger; private bucketName; @@ -22,6 +22,11 @@ export class SupabaseService { } } + // Expose the Supabase client for the auth guard + get client(): SupabaseClient { + return this.supabase; + } + /** * Creates a new user account in Supabase * @param email - User's email address @@ -33,7 +38,10 @@ export class SupabaseService { try { this.logger.log(`Creating new user with email: ${email}`); this.logger.debug('Attempting user signup'); - const { user, error } = await this.supabase.auth.signUp({ email, password }); + const { + data: { user }, + error, + } = await this.supabase.auth.signUp({ email, password }); if (error) throw error; return user; } catch (error) { @@ -53,7 +61,10 @@ export class SupabaseService { try { this.logger.log(`Attempting login for user: ${email}`); this.logger.debug('Processing login request'); - const { user, error } = await this.supabase.auth.signInWithPassword({ + const { + data: { user }, + error, + } = await this.supabase.auth.signInWithPassword({ email, password, }); @@ -73,7 +84,9 @@ export class SupabaseService { async getSession() { try { this.logger.debug('Retrieving current session'); - const session = this.supabase.auth.session(); + const { + data: { session }, + } = await this.supabase.auth.getSession(); this.logger.log(session ? 'Session found' : 'No active session'); return session; } catch (error) { diff --git a/apps/api/src/user/dto/create-user.dto.ts b/apps/api/src/user/dto/create-user.dto.ts index be60184..cc559ab 100644 --- a/apps/api/src/user/dto/create-user.dto.ts +++ b/apps/api/src/user/dto/create-user.dto.ts @@ -8,10 +8,15 @@ export class CreateUserDto { name: string; @IsString() - githubId: string; + @IsOptional() + githubId?: string; + + @IsString() + @IsOptional() + providerId?: string; @IsString() - providerId: string; + supabaseId: string; @IsBoolean() @IsOptional() diff --git a/apps/api/src/user/user.controller.spec.ts b/apps/api/src/user/user.controller.spec.ts new file mode 100644 index 0000000..7057a1a --- /dev/null +++ b/apps/api/src/user/user.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UserController } from './user.controller'; + +describe('UserController', () => { + let controller: UserController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UserController], + }).compile(); + + controller = module.get(UserController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/user/user.controller.ts b/apps/api/src/user/user.controller.ts new file mode 100644 index 0000000..e4dcc30 --- /dev/null +++ b/apps/api/src/user/user.controller.ts @@ -0,0 +1,66 @@ +import { Controller, Get, Param, Logger, Delete, Put, Body, Post } from '@nestjs/common'; +import { UserService } from './user.service'; +import { User } from '@prisma/client'; +import { ApiTags, ApiOperation, ApiParam, ApiBody, ApiResponse } from '@nestjs/swagger'; +import { CreateUserDto } from './dto/create-user.dto'; + +@ApiTags('Users') +@Controller('user') +export class UserController { + private readonly logger; + + constructor(private readonly userService: UserService) { + this.logger = new Logger(UserController.name); + } + + @ApiOperation({ summary: 'Get user by ID' }) + @ApiParam({ name: 'id', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'Returns the user' }) + @ApiResponse({ status: 404, description: 'User not found' }) + @Get(':id') + async getUserById(@Param('id') id: string) { + this.logger.debug(`Getting user by id: ${id}`); + return this.userService.getUserById(id); + } + + @ApiOperation({ summary: 'Get user by Supabase ID' }) + @ApiParam({ name: 'supabaseId', description: 'Supabase User ID' }) + @ApiResponse({ status: 200, description: 'Returns the user with related data' }) + @ApiResponse({ status: 404, description: 'User not found' }) + @Get('supabase/:supabaseId') + async getUserBySupabaseId(@Param('supabaseId') supabaseId: string) { + this.logger.debug(`Getting user by supabase id: ${supabaseId}`); + return this.userService.getUserBySupabaseId(supabaseId); + } + + @ApiOperation({ summary: 'Create new user' }) + @ApiBody({ type: CreateUserDto, description: 'User data' }) + @ApiResponse({ status: 201, description: 'User created successfully' }) + @ApiResponse({ status: 400, description: 'Invalid user data' }) + @Post() + async createUser(@Body() user: User) { + this.logger.debug(`Creating user: ${JSON.stringify(user)}`); + return this.userService.createUser(user); + } + + @ApiOperation({ summary: 'Update existing user' }) + @ApiParam({ name: 'id', description: 'User ID to update' }) + //@ApiBody({ type: User, description: 'Updated user data' }) + @ApiResponse({ status: 200, description: 'User updated successfully' }) + @ApiResponse({ status: 404, description: 'User not found' }) + @Put(':id') + async updateUser(@Param('id') id: string, @Body() user: User) { + this.logger.debug(`Updating user: ${JSON.stringify(user)}`); + return this.userService.updateUser(id, user); + } + + @ApiOperation({ summary: 'Delete user' }) + @ApiParam({ name: 'id', description: 'User ID to delete' }) + @ApiResponse({ status: 200, description: 'User deleted successfully' }) + @ApiResponse({ status: 404, description: 'User not found' }) + @Delete(':id') + async deleteUser(@Param('id') id: string) { + this.logger.debug(`Deleting user: ${id}`); + return this.userService.deleteUser(id); + } +} diff --git a/apps/api/src/user/user.module.ts b/apps/api/src/user/user.module.ts index 6a0a723..433eec0 100644 --- a/apps/api/src/user/user.module.ts +++ b/apps/api/src/user/user.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { UserService } from './user.service'; import { PrismaModule } from 'src/prisma/prisma.module'; +import { UserController } from './user.controller'; @Module({ imports: [PrismaModule], providers: [UserService], exports: [UserService], + controllers: [UserController], }) export class UserModule {} diff --git a/apps/api/src/user/user.service.ts b/apps/api/src/user/user.service.ts index 4ffeeeb..9eb1d95 100644 --- a/apps/api/src/user/user.service.ts +++ b/apps/api/src/user/user.service.ts @@ -1,7 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; -import { User } from '@prisma/client'; +import { BotLevel, User } from '@prisma/client'; import { PrismaService } from 'src/prisma/prisma.service'; import { CreateUserDto } from './dto/create-user.dto'; + +/** + * Service responsible for handling user-related operations + */ @Injectable() export class UserService { private readonly logger; @@ -10,30 +14,76 @@ export class UserService { this.logger = new Logger(UserService.name); } + /** + * Retrieves a user by their ID + * @param id - The unique identifier of the user + * @returns The user if found, null otherwise + */ async getUserById(id: string) { this.logger.debug(`Getting user by id: ${id}`); return this.prismaService.user.findUnique({ where: { id } }); } + /** + * Retrieves a user by their Supabase ID including related data + * @param supabaseId - The Supabase identifier of the user + * @returns The user with subscription, repositories, bot config and review count if found, null otherwise + */ + async getUserBySupabaseId(supabaseId: string) { + this.logger.debug(`Getting user by supabase id: ${supabaseId}`); + return this.prismaService.user.findUnique({ + where: { supabaseId }, + include: { + subscription: true, + repositories: true, + botConfig: true, + _count: { select: { reviews: true } }, + }, + }); + } + + /** + * Retrieves a user by their email address + * @param email - The email address of the user + * @returns The user if found, null otherwise + */ async getUserByEmail(email: string) { this.logger.debug(`Getting user by email: ${email}`); return this.prismaService.user.findUnique({ where: { email } }); } + /** + * Retrieves a user by their GitHub ID + * @param githubId - The GitHub identifier of the user + * @returns The user if found, null otherwise + */ async getUserByGithubId(githubId: string) { this.logger.debug(`Getting user by github id: ${githubId}`); return this.prismaService.user.findUnique({ where: { githubId } }); } + /** + * Retrieves a user by their provider ID including related data + * @param providerId - The provider identifier of the user + * @returns The user with subscription, bot config and review count if found, null otherwise + */ async getUserByProviderId(providerId: string) { this.logger.debug(`Getting user by provider id: ${providerId}`); - const user = await this.prismaService.user.findUnique({ where: { providerId } }); + const user = await this.prismaService.user.findUnique({ + where: { providerId }, + include: { subscription: true, botConfig: true, _count: { select: { reviews: true } } }, + }); this.logger.debug(`User found: ${JSON.stringify(user)}`); return user; } + /** + * Creates a new user with default subscription and bot configuration + * @param user - The user data transfer object containing user information + * @returns The created user with subscription, bot config, repositories and review count + */ async createUser(user: CreateUserDto) { - this.logger.debug(`Creating user: ${user}`); + this.logger.debug(`Creating user: ${JSON.stringify(user)}`); return this.prismaService.user.create({ data: { ...user, @@ -44,18 +94,39 @@ export class UserService { startDate: new Date(), }, }, + botConfig: { + create: { + grumpinessLevel: BotLevel.MODERATE, + technicalityLevel: BotLevel.MODERATE, + detailLevel: BotLevel.MODERATE, + }, + }, }, include: { subscription: true, + botConfig: true, + repositories: true, + _count: { select: { reviews: true } }, }, }); } + /** + * Updates an existing user's information + * @param id - The unique identifier of the user to update + * @param user - The updated user data + * @returns The updated user + */ async updateUser(id: string, user: User) { - this.logger.debug(`Updating user: ${user}`); + this.logger.debug(`Updating user: ${JSON.stringify(user)}`); return this.prismaService.user.update({ where: { id }, data: user }); } + /** + * Deletes a user from the system + * @param id - The unique identifier of the user to delete + * @returns The deleted user + */ async deleteUser(id: string) { this.logger.debug(`Deleting user: ${id}`); return this.prismaService.user.delete({ where: { id } }); diff --git a/apps/api/src/workflow/dto/callback-workflow.dto.ts b/apps/api/src/workflow/dto/callback-workflow.dto.ts new file mode 100644 index 0000000..fcec46c --- /dev/null +++ b/apps/api/src/workflow/dto/callback-workflow.dto.ts @@ -0,0 +1,51 @@ +import { IsUUID, IsString, IsOptional, IsNumber, IsEnum, IsArray } from 'class-validator'; +import { WorkflowSource } from 'src/workflow/enum/workflow-source.enum'; +export class WorkflowCallbackDto { + @IsUUID() + userId: string; + + @IsEnum(WorkflowSource) + source: WorkflowSource; + + @IsUUID() + pullRequestId: string; + + @IsString() + @IsOptional() + pullRequestUrl?: string; + + @IsArray() + @IsOptional() + output?: LLMOutputDto[]; + + @IsString() + @IsOptional() + filename?: string; + + @IsString() + @IsOptional() + patch?: string; + + @IsString() + @IsOptional() + commitSha?: string; + + @IsNumber() + @IsOptional() + startLine?: number; + + @IsNumber() + @IsOptional() + endLine?: number; + + @IsNumber() + installationId: number; +} + +export class LLMOutputDto { + @IsString() + comment?: string; + + @IsNumber() + line?: number; +} diff --git a/apps/api/src/workflow/dto/trigger-workflow.dto.ts b/apps/api/src/workflow/dto/trigger-workflow.dto.ts new file mode 100644 index 0000000..5321666 --- /dev/null +++ b/apps/api/src/workflow/dto/trigger-workflow.dto.ts @@ -0,0 +1,6 @@ +import { WorkflowMetadata } from '../interface/workflow-metadata.interface'; +import { WorkflowDataInterface } from '../interface/workflow-data-interface'; +export class TriggerWorkflowDto { + workflowMetadata?: WorkflowMetadata; + workflowData?: WorkflowDataInterface; +} diff --git a/apps/api/src/workflow/enum/workflow-source.enum.ts b/apps/api/src/workflow/enum/workflow-source.enum.ts new file mode 100644 index 0000000..c215d0b --- /dev/null +++ b/apps/api/src/workflow/enum/workflow-source.enum.ts @@ -0,0 +1,4 @@ +export enum WorkflowSource { + GITHUB = 'Github', + GITLAB = 'Gitlab', +} diff --git a/apps/api/src/workflow/interface/workflow-data-interface.ts b/apps/api/src/workflow/interface/workflow-data-interface.ts new file mode 100644 index 0000000..1194034 --- /dev/null +++ b/apps/api/src/workflow/interface/workflow-data-interface.ts @@ -0,0 +1,9 @@ +import { BotConfigDto } from 'src/bot-config/dto/bot-config.dto'; +import { PullRequestFileDto } from 'src/github/dto/github-pull-request-file.dto'; + +export interface WorkflowDataInterface { + userId: string; + pullRequestId: string; + pullRequestFiles: PullRequestFileDto[]; + botConfig: BotConfigDto; +} diff --git a/apps/api/src/workflow/interface/workflow-metadata.interface.ts b/apps/api/src/workflow/interface/workflow-metadata.interface.ts new file mode 100644 index 0000000..63ce021 --- /dev/null +++ b/apps/api/src/workflow/interface/workflow-metadata.interface.ts @@ -0,0 +1,8 @@ +import { WorkflowSource } from '../enum/workflow-source.enum'; + +export interface WorkflowMetadata { + requestId: string; + source: WorkflowSource; + installationId: number; + commitSha: string; +} diff --git a/apps/api/src/workflow/workflow.controller.spec.ts b/apps/api/src/workflow/workflow.controller.spec.ts new file mode 100644 index 0000000..fb92dde --- /dev/null +++ b/apps/api/src/workflow/workflow.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkflowController } from './workflow.controller'; + +describe('WorkflowController', () => { + let controller: WorkflowController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkflowController], + }).compile(); + + controller = module.get(WorkflowController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/api/src/workflow/workflow.controller.ts b/apps/api/src/workflow/workflow.controller.ts new file mode 100644 index 0000000..df494f2 --- /dev/null +++ b/apps/api/src/workflow/workflow.controller.ts @@ -0,0 +1,53 @@ +import { Controller, Logger, Post, Body, BadRequestException } from '@nestjs/common'; +import { WorkflowService } from './workflow.service'; +import { WorkflowCallbackDto } from './dto/callback-workflow.dto'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Public } from 'src/auth/decorators/public.decorator'; + +@Public() +@ApiTags('Workflow') +@Controller('workflow') +export class WorkflowController { + private readonly logger; + + constructor(private readonly workflowService: WorkflowService) { + this.logger = new Logger(WorkflowController.name); + } + + @ApiOperation({ + summary: 'Handle workflow callback', + description: 'Receives and processes a callback from the workflow service with review results', + }) + @ApiResponse({ + status: 201, + description: 'The review has been successfully created', + schema: { + properties: { + review: { + type: 'object', + properties: { + id: { type: 'string' }, + userId: { type: 'string' }, + pullRequestId: { type: 'string' }, + comment: { type: 'string' }, + filename: { type: 'string' }, + patch: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, + updatedAt: { type: 'string', format: 'date-time' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @Post('/review/callback') + async callback(@Body() body: WorkflowCallbackDto) { + this.logger.debug(`Workflow callback received: ${JSON.stringify(body)}`); + try { + return this.workflowService.handleWorkflowCallback(body); + } catch (error) { + this.logger.error(`Error handling workflow callback: ${error}`); + throw new BadRequestException('Error handling workflow callback'); + } + } +} diff --git a/apps/api/src/workflow/workflow.module.ts b/apps/api/src/workflow/workflow.module.ts new file mode 100644 index 0000000..62f8dfb --- /dev/null +++ b/apps/api/src/workflow/workflow.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { WorkflowService } from './workflow.service'; +import { WorkflowController } from './workflow.controller'; +import { ConfigModule } from '@nestjs/config'; +import { ReviewModule } from 'src/review/review.module'; +import { SubscriptionModule } from 'src/subscription/subscription.module'; +import { GithubApiService } from 'src/github/github-api.service'; +@Module({ + imports: [ConfigModule, ReviewModule, SubscriptionModule], + providers: [WorkflowService, GithubApiService], + controllers: [WorkflowController], + exports: [WorkflowService], +}) +export class WorkflowModule {} diff --git a/apps/api/src/workflow/workflow.service.spec.ts b/apps/api/src/workflow/workflow.service.spec.ts new file mode 100644 index 0000000..eaf228a --- /dev/null +++ b/apps/api/src/workflow/workflow.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WorkflowService } from './workflow.service'; + +describe('WorkflowService', () => { + let service: WorkflowService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [WorkflowService], + }).compile(); + + service = module.get(WorkflowService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/api/src/workflow/workflow.service.ts b/apps/api/src/workflow/workflow.service.ts new file mode 100644 index 0000000..db0a725 --- /dev/null +++ b/apps/api/src/workflow/workflow.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TriggerWorkflowDto } from './dto/trigger-workflow.dto'; +import axios from 'axios'; +import { WorkflowCallbackDto } from './dto/callback-workflow.dto'; +import { ReviewService } from 'src/review/review.service'; +import { SubscriptionDto } from 'src/subscription/dto/subscription.dto'; +import { SubscriptionService } from 'src/subscription/subscription.service'; +import { WorkflowSource } from './enum/workflow-source.enum'; +import { GithubApiService } from 'src/github/github-api.service'; +import { GithubCommentDto, GithubCommentOutputDto } from 'src/github/dto/github-comment.dto'; +@Injectable() +export class WorkflowService { + private readonly logger; + private readonly workflowUrl; + + constructor( + private readonly configService: ConfigService, + private readonly reviewService: ReviewService, + private readonly subscriptionService: SubscriptionService, + private readonly githubApiService: GithubApiService, + ) { + this.logger = new Logger(WorkflowService.name); + this.workflowUrl = this.configService.get('WORKFLOW_URL'); + } + + /** + * Checks if a workflow can proceed based on subscription status and credits + * @param subscription - The subscription data to check + * @param currentCredits - The current number of credits used + * @returns {Promise} True if the workflow can proceed, false otherwise + */ + async canProceed(subscription: SubscriptionDto, currentCredits: number) { + this.logger.log(`Checking subscription status for user: ${subscription.userId}`); + + let canProceed = true; + + const isActive = await this.subscriptionService.isSubscriptionActive(subscription.status); + + if (!isActive) { + this.logger.log(`Subscription is not active for user: ${subscription.userId}`); + canProceed = false; + } + + if (currentCredits >= subscription.credits) { + this.logger.log(`User has reached the maximum number of credits for user: ${subscription.userId}`); + canProceed = false; + } + + return canProceed; + } + + /** + * Triggers a workflow with the provided data + * @param triggerWorkflowDto - The data needed to trigger the workflow + * @returns {Promise} The workflow response data + */ + async triggerWorkflow(triggerWorkflowDto: TriggerWorkflowDto) { + this.logger.debug(`Triggering workflow: ${JSON.stringify(triggerWorkflowDto)}`); + const response = await axios.post(this.workflowUrl, triggerWorkflowDto); + return response.data; + } + + /** + * Handles the callback from a workflow execution + * @param body - The workflow callback data + * @returns {Promise<{review: any}>} Object containing the created review + */ + async handleWorkflowCallback(body: WorkflowCallbackDto) { + this.logger.debug(`Handling workflow callback: ${JSON.stringify(body)}`); + + const reviews = []; + + for (const output of body.output.filter(output => output.line >= 0)) { + let githubComment: GithubCommentOutputDto | null = null; + + if (body.source === WorkflowSource.GITHUB) { + this.logger.debug('Handling Github workflow callback'); + + const githubCommentPayload: GithubCommentDto = { + body: output.comment, + commit_id: body.commitSha, + path: body.filename, + line: output.line + body.startLine, + }; + this.logger.debug(`Github comment payload: ${JSON.stringify(githubCommentPayload)}`); + + githubComment = await this.githubApiService.postCommentReview(body.pullRequestUrl, githubCommentPayload, body.installationId); + } + + const review = await this.reviewService.createReview({ + userId: body.userId, + pullRequestId: body.pullRequestId, + comment: output.comment, + filename: body.filename, + patch: body.patch, + githubCommentId: githubComment?.id, + }); + + reviews.push(review); + } + + return { reviews }; + } +} diff --git a/apps/landing/package.json b/apps/landing/package.json index ceef770..6acdda9 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -46,6 +46,7 @@ "@types/canvas-confetti": "^1.9.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.1", + "animejs": "^4.0.1", "astro": "^5.6.0", "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", diff --git a/apps/landing/src/components/HowItWorks.astro b/apps/landing/src/components/HowItWorks.astro index 5a96ba3..9917d71 100644 --- a/apps/landing/src/components/HowItWorks.astro +++ b/apps/landing/src/components/HowItWorks.astro @@ -16,22 +16,18 @@

-
- -