From 3edc543b10d5d46cfb6c5090cd8f2921e8cd6604 Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:19:25 -0500 Subject: [PATCH 1/9] Add subscription management: - Implement `SubscriptionController` with endpoints for subscribing, canceling, checking status, and Stripe webhook handling. - Add `SubscriptionService` to manage subscription creation, updates, cancellations, and webhook events from Stripe. - Introduce `SubscriptionsModule` with TypeORM and Stripe configuration support. - Add tests for both `SubscriptionController` and `SubscriptionService`. --- .../subscriptions.controller.spec.ts | 18 ++ .../subscriptions/subscriptions.controller.ts | 61 +++++ .../subscriptions.service.spec.ts | 18 ++ .../subscriptions/subscriptions.service.ts | 224 ++++++++++++++++++ src/subscriptions/subscriptions.module.ts | 19 ++ 5 files changed, 340 insertions(+) create mode 100644 src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts create mode 100644 src/subscriptions/controllers/subscriptions/subscriptions.controller.ts create mode 100644 src/subscriptions/services/subscriptions/subscriptions.service.spec.ts create mode 100644 src/subscriptions/services/subscriptions/subscriptions.service.ts create mode 100644 src/subscriptions/subscriptions.module.ts diff --git a/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts b/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts new file mode 100644 index 0000000..a42f381 --- /dev/null +++ b/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionsController } from './subscriptions.controller'; + +describe('SubscriptionsController', () => { + let controller: SubscriptionsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SubscriptionsController], + }).compile(); + + controller = module.get(SubscriptionsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts b/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts new file mode 100644 index 0000000..1918577 --- /dev/null +++ b/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts @@ -0,0 +1,61 @@ +import { Controller, Post, Get, UseGuards, Req, Res, RawBodyRequest } from "@nestjs/common"; +import { Response, Request } from "express"; +import { ConfigService } from "@nestjs/config"; +import { SubscriptionService } from "../../services/subscriptions/subscriptions.service"; +import { AuthenticatedGuard } from "../../../utils/guards/local.guard"; +import { RequestType } from "express-serve-static-core"; +import { Stripe } from "stripe"; + +@Controller("subscriptions") +export class SubscriptionController { + constructor( + private subscriptionService: SubscriptionService, + private configService: ConfigService + ) { + } + + @UseGuards(AuthenticatedGuard) + @Post("subscribe") + async subscribe(@Req() req: RequestType) { + return this.subscriptionService.createSubscription(req.user.id); + } + + @Post("webhook") + async handleWebhook( + @Req() request: RawBodyRequest, + @Res() response: Response + ) { + const signature = request.headers["stripe-signature"]; + const webhookSecret = this.configService.get("STRIPE_WEBHOOK_SECRET"); + + try { + const stripe = new Stripe(this.configService.get("STRIPE_SECRET_KEY"), { + apiVersion: "2025-01-27.acacia" + }); + + const event = stripe.webhooks.constructEvent( + request.rawBody, + signature, + webhookSecret + ); + + await this.subscriptionService.handleWebhookEvent(event); + response.send({ received: true }); + } catch (err) { + response.status(400).send(`Webhook Error: ${err.message}`); + } + } + + @UseGuards(AuthenticatedGuard) + @Post("cancel") + async cancel(@Req() req: RequestType): Promise { + return this.subscriptionService.cancelSubscription(req.user.id); + } + + @UseGuards(AuthenticatedGuard) + @Get("status") + async getStatus(@Req() req: RequestType): Promise<{ isSubscribed: boolean }> { + const isSubscribed = await this.subscriptionService.isUserSubscribed(req.user.id); + return { isSubscribed }; + } +} \ No newline at end of file diff --git a/src/subscriptions/services/subscriptions/subscriptions.service.spec.ts b/src/subscriptions/services/subscriptions/subscriptions.service.spec.ts new file mode 100644 index 0000000..176f847 --- /dev/null +++ b/src/subscriptions/services/subscriptions/subscriptions.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SubscriptionsService } from './subscriptions.service'; + +describe('SubscriptionsService', () => { + let service: SubscriptionsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SubscriptionsService], + }).compile(); + + service = module.get(SubscriptionsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/subscriptions/services/subscriptions/subscriptions.service.ts b/src/subscriptions/services/subscriptions/subscriptions.service.ts new file mode 100644 index 0000000..9d21bb9 --- /dev/null +++ b/src/subscriptions/services/subscriptions/subscriptions.service.ts @@ -0,0 +1,224 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from "../../../typeorm/entities/user"; +import { Subscription } from "../../../typeorm/entities/subscription"; +import { Stripe } from "stripe"; +import { InjectStripeClient } from "@golevelup/nestjs-stripe"; + +@Injectable() +export class SubscriptionService { + constructor( + @InjectRepository(Subscription) + private subscriptionRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @InjectStripeClient() + private stripe: Stripe, + ) {} + + private async handleSuccessfulPayment(invoice: Stripe.Invoice) { + if (typeof invoice.customer === 'string') { + const customer = await this.stripe.customers.retrieve(invoice.customer); + + // Check if customer is not deleted + if (!customer.deleted && 'metadata' in customer) { + const userId = parseInt(customer.metadata.userId); + + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + 1); + + await this.subscriptionRepository.save({ + userId, + status: 'active', + endDate, + autoRenew: true, + amount: 20.00 + }); + } + } + } + + private async handleSubscriptionCancelled(subscription: Stripe.Subscription) { + if (typeof subscription.customer === 'string') { + const customer = await this.stripe.customers.retrieve(subscription.customer); + + // Check if customer is not deleted and has metadata + if (!customer.deleted && 'metadata' in customer) { + const userId = parseInt(customer.metadata.userId); + await this.cancelSubscription(userId); + } + } + } + + async createSubscription(userId: number): Promise<{ clientSecret: string, subscriptionId: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) { + throw new NotFoundException('User not found'); + } + + // Create or get Stripe customer + if (!user.stripeCustomerId) { + const customer = await this.stripe.customers.create({ + email: user.email, + metadata: { userId: user.id.toString() } + }); + + user.stripeCustomerId = customer.id; + await this.userRepository.save(user); + } + + // Create the subscription in Stripe + const subscription = await this.stripe.subscriptions.create({ + customer: user.stripeCustomerId, + items: [{ + price_data: { + currency: 'usd', + product: 'Monthly_Subscription', + unit_amount: 2000, // $20.00 in cents + recurring: { + interval: 'month', + }, + }, + }], + payment_behavior: 'default_incomplete', + expand: ['latest_invoice.payment_intent'], + }); + + // Type assertion to handle the expanded invoice and payment intent + const invoice = subscription.latest_invoice as Stripe.Invoice; + const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent; + + return { + subscriptionId: subscription.id, + clientSecret: paymentIntent.client_secret, + }; + } + + async handleWebhookEvent(event: Stripe.Event) { + switch (event.type) { + case 'customer.subscription.created': + const createdSubscription = event.data.object as Stripe.Subscription; + // Only handle if subscription is active + if (createdSubscription.status === 'active') { + await this.handleNewSubscription(createdSubscription); + } + break; + + case 'customer.subscription.updated': + const updatedSubscription = event.data.object as Stripe.Subscription; + await this.handleSubscriptionUpdate(updatedSubscription); + break; + + case 'customer.subscription.deleted': + const deletedSubscription = event.data.object as Stripe.Subscription; + await this.handleSubscriptionCancelled(deletedSubscription); + break; + + case 'invoice.paid': + const paidInvoice = event.data.object as Stripe.Invoice; + await this.handleSuccessfulPayment(paidInvoice); + break; + + case 'invoice.payment_failed': + const failedInvoice = event.data.object as Stripe.Invoice; + await this.handleFailedPayment(failedInvoice); + break; + } + } + + private async handleNewSubscription(subscription: Stripe.Subscription) { + if (typeof subscription.customer === 'string') { + const customer = await this.stripe.customers.retrieve(subscription.customer); + + if (!customer.deleted && 'metadata' in customer) { + const userId = parseInt(customer.metadata.userId); + const endDate = new Date(); + endDate.setMonth(endDate.getMonth() + 1); + + await this.subscriptionRepository.save({ + userId, + status: 'active', + endDate, + autoRenew: true, + amount: subscription.items.data[0].price.unit_amount / 100 // Convert from cents + }); + } + } + } + + private async handleSubscriptionUpdate(subscription: Stripe.Subscription) { + if (typeof subscription.customer === 'string') { + const customer = await this.stripe.customers.retrieve(subscription.customer); + + if (!customer.deleted && 'metadata' in customer) { + const userId = parseInt(customer.metadata.userId); + + // Find existing subscription + const existingSub = await this.subscriptionRepository.findOne({ + where: { userId } + }); + + if (existingSub) { + // Update status based on Stripe subscription status + existingSub.status = subscription.status === 'active' ? 'active' : 'cancelled'; + existingSub.autoRenew = subscription.cancel_at_period_end === false; + + if (subscription.current_period_end) { + existingSub.endDate = new Date(subscription.current_period_end * 1000); + } + + await this.subscriptionRepository.save(existingSub); + } + } + } + } + + private async handleFailedPayment(invoice: Stripe.Invoice) { + if (typeof invoice.customer === 'string') { + const customer = await this.stripe.customers.retrieve(invoice.customer); + + if (!customer.deleted && 'metadata' in customer) { + const userId = parseInt(customer.metadata.userId); + const subscription = await this.subscriptionRepository.findOne({ + where: { userId } + }); + + if (subscription) { + // Mark subscription as expired if payment failed + subscription.status = 'expired'; + subscription.autoRenew = false; + await this.subscriptionRepository.save(subscription); + + // TODO: Implement notification system to alert user of payment failure + // await this.notificationService.sendPaymentFailedNotification(userId); + } + } + } + } + + async getActiveSubscription(userId: number): Promise { + return this.subscriptionRepository.findOne({ + where: { + userId, + status: 'active', + }, + }); + } + + async cancelSubscription(userId: number): Promise { + const subscription = await this.getActiveSubscription(userId); + if (!subscription) { + throw new NotFoundException('No active subscription found'); + } + + subscription.status = 'cancelled'; + subscription.autoRenew = false; + await this.subscriptionRepository.save(subscription); + } + + async isUserSubscribed(userId: number): Promise { + const subscription = await this.getActiveSubscription(userId); + return !!subscription && subscription.endDate > new Date(); + } +} \ No newline at end of file diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..0d3f5dd --- /dev/null +++ b/src/subscriptions/subscriptions.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Subscription } from "../typeorm/entities/subscription"; +import { User } from "../typeorm/entities/user"; +import { SubscriptionController } from "./controllers/subscriptions/subscriptions.controller"; +import { SubscriptionService } from "./services/subscriptions/subscriptions.service"; +import { StripeConfig } from "../utils/config/stripe.config"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Subscription, User]), + StripeConfig + ], + controllers: [SubscriptionController], + providers: [SubscriptionService], + exports: [SubscriptionService] +}) +export class SubscriptionsModule { +} \ No newline at end of file From 02a7ea6a39e5331448cde3bd3064a6350e0dbe2d Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:19:49 -0500 Subject: [PATCH 2/9] Add SubscriptionGuard to check user subscription status: - Created `SubscriptionGuard` implementing `CanActivate` to verify if a user is subscribed. - Integrated `SubscriptionService` for subscription validation logic. --- src/utils/guards/subscription.guard.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/utils/guards/subscription.guard.ts diff --git a/src/utils/guards/subscription.guard.ts b/src/utils/guards/subscription.guard.ts new file mode 100644 index 0000000..8a134c1 --- /dev/null +++ b/src/utils/guards/subscription.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { SubscriptionService } from "../../subscriptions/services/subscriptions/subscriptions.service"; + +@Injectable() +export class SubscriptionGuard implements CanActivate { + constructor(private subscriptionService: SubscriptionService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + return false; + } + + return this.subscriptionService.isUserSubscribed(user.id); + } +} \ No newline at end of file From 6dc671324ae486f9cadddc6d53c870a329270212 Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:20:00 -0500 Subject: [PATCH 3/9] Add Stripe Configuration: - Integrated StripeModule using `forRootAsync` with dynamic configuration. - Configured API key and webhook settings via `ConfigService`. --- src/utils/config/stripe.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/utils/config/stripe.config.ts diff --git a/src/utils/config/stripe.config.ts b/src/utils/config/stripe.config.ts new file mode 100644 index 0000000..339fadc --- /dev/null +++ b/src/utils/config/stripe.config.ts @@ -0,0 +1,15 @@ +import { ConfigService } from '@nestjs/config'; +import { StripeModule } from '@golevelup/nestjs-stripe'; + +export const StripeConfig = StripeModule.forRootAsync({ + useFactory: (configService: ConfigService) => ({ + apiKey: configService.get('STRIPE_SECRET_KEY'), + webhookConfig: { + stripeSecrets: { + account: configService.get('STRIPE_WEBHOOK_SECRET'), + }, + requestBodyProperty: 'rawBody', + }, + }), + inject: [ConfigService], +}); \ No newline at end of file From 775a101ff175ca78886deb7cc3cf8e01e1cfc836 Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:20:15 -0500 Subject: [PATCH 4/9] Integrate Stripe Payment Support: - Added `@golevelup/nestjs-stripe` and `stripe` dependencies to enable Stripe payment integration. - Configured Stripe module with dynamic settings (`apiKey` and webhook secrets) using `@nestjs/config`. - Updated package-lock and package.json to include related dependencies (`dotenv`, `dotenv-expand`, etc.). --- package-lock.json | 78 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 3 ++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index fbccd21..70226e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@golevelup/nestjs-stripe": "^0.9.2", "@google/generative-ai": "^0.21.0", "@langchain/core": "^0.3.40", "@langchain/google-genai": "^0.1.8", "@langchain/groq": "^0.1.3", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/common": "^10.4.4", + "@nestjs/config": "^4.0.0", "@nestjs/core": "^10.4.4", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.0.5", @@ -34,6 +36,7 @@ "passport-local": "^1.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^17.6.0", "typeorm": "^0.3.20", "uuid": "^9.0.1" }, @@ -1189,6 +1192,31 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@golevelup/nestjs-discovery": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-discovery/-/nestjs-discovery-4.0.3.tgz", + "integrity": "sha512-8w3CsXHN7+7Sn2i419Eal1Iw/kOjAd6Kb55M/ZqKBBwACCMn4WiEuzssC71LpBMI1090CiDxuelfPRwwIrQK+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.x || ^11.0.0", + "@nestjs/core": "^10.x || ^11.0.0" + } + }, + "node_modules/@golevelup/nestjs-stripe": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@golevelup/nestjs-stripe/-/nestjs-stripe-0.9.2.tgz", + "integrity": "sha512-NYDSOSemzucjT3iOL+2EXOnTnSs006PBNPl8IDlvUUsA1p5l7NK+QIn2pk70JaihBwkDeDkjl09wt1bjfZ9hlA==", + "license": "MIT", + "dependencies": { + "@golevelup/nestjs-discovery": "^4.0.3" + }, + "peerDependencies": { + "stripe": "^17.6.0" + } + }, "node_modules/@google/generative-ai": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz", @@ -2275,6 +2303,21 @@ } } }, + "node_modules/@nestjs/config": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.0.tgz", + "integrity": "sha512-hyhUMtVwlT+tavtPNyekl8iP0QTU1U6awKrgdOSxhMhp3TQMltx7hz2yqGTcARp+19zWPfgJudyxthuD3lPp/Q==", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.7", + "dotenv-expand": "12.0.1", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/core": { "version": "10.4.4", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.4.tgz", @@ -5189,10 +5232,25 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", + "integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, "engines": { "node": ">=12" }, @@ -8799,7 +8857,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.includes": { @@ -12214,6 +12271,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.6.0.tgz", + "integrity": "sha512-+HB6+SManp0gSRB0dlPmXO+io18krlAe0uimXhhIkL/RG/VIRigkfoM3QDJPkqbuSW0XsA6uzsivNCJU1ELEDA==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", diff --git a/package.json b/package.json index 2587c59..dccd471 100644 --- a/package.json +++ b/package.json @@ -24,12 +24,14 @@ "migration:revert": "npm run typeorm migration:revert -- -d src/config/typeorm.config.ts" }, "dependencies": { + "@golevelup/nestjs-stripe": "^0.9.2", "@google/generative-ai": "^0.21.0", "@langchain/core": "^0.3.40", "@langchain/google-genai": "^0.1.8", "@langchain/groq": "^0.1.3", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/common": "^10.4.4", + "@nestjs/config": "^4.0.0", "@nestjs/core": "^10.4.4", "@nestjs/jwt": "^10.2.0", "@nestjs/mapped-types": "^2.0.5", @@ -49,6 +51,7 @@ "passport-local": "^1.0.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "stripe": "^17.6.0", "typeorm": "^0.3.20", "uuid": "^9.0.1" }, From d3f42fa05871acd02cd77b1464e3b6929d72810e Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:20:43 -0500 Subject: [PATCH 5/9] Add Premium Subscription Handling: - Introduced `Subscription` entity with attributes to manage user subscription details. - Integrated `SubscriptionsModule` into the RAG module for enhanced functionality. - Added a new `/rag/premium` endpoint secured by `AuthenticatedGuard` and `SubscriptionGuard` to handle premium queries. --- src/rag/controllers/rag/rag.controller.ts | 17 ++++++++++++- src/rag/rag.module.ts | 2 ++ src/typeorm/entities/subscription.ts | 29 +++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/typeorm/entities/subscription.ts diff --git a/src/rag/controllers/rag/rag.controller.ts b/src/rag/controllers/rag/rag.controller.ts index 6acf3e3..1625198 100644 --- a/src/rag/controllers/rag/rag.controller.ts +++ b/src/rag/controllers/rag/rag.controller.ts @@ -1,6 +1,8 @@ -import { Body, Controller, HttpException, HttpStatus, Post } from "@nestjs/common"; +import { Body, Controller, HttpException, HttpStatus, Post, UseGuards } from "@nestjs/common"; import { RagRequest, RagResponse } from "../../dtos/rag.dto"; import { RagService } from "../../services/rag/rag.service"; +import { AuthenticatedGuard } from "../../../utils/guards/local.guard"; +import { SubscriptionGuard } from "../../../utils/guards/subscription.guard"; @Controller('rag') export class RagController { @@ -17,4 +19,17 @@ export class RagController { ); } } + + @Post('premium') + @UseGuards(AuthenticatedGuard, SubscriptionGuard) + async handlePremiumQuery(@Body() request: RagRequest): Promise { + try { + return await this.ragService.processQuery(request); + } catch (error) { + throw new HttpException( + 'Failed to process RAG query: ' + error.message, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } } diff --git a/src/rag/rag.module.ts b/src/rag/rag.module.ts index b0717dc..315aa94 100644 --- a/src/rag/rag.module.ts +++ b/src/rag/rag.module.ts @@ -6,10 +6,12 @@ import { Word } from "../typeorm/entities/word"; import { Definition } from "../typeorm/entities/definition"; import { ModelService } from "./services/model/model.service"; import { VectorStoreService } from "./services/vector-store/vector-store.service"; +import { SubscriptionsModule } from "../subscriptions/subscriptions.module"; @Module({ imports: [ TypeOrmModule.forFeature([Word, Definition]), + SubscriptionsModule ], controllers: [RagController], providers: [RagService, ModelService, VectorStoreService] diff --git a/src/typeorm/entities/subscription.ts b/src/typeorm/entities/subscription.ts new file mode 100644 index 0000000..311ef20 --- /dev/null +++ b/src/typeorm/entities/subscription.ts @@ -0,0 +1,29 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, CreateDateColumn } from 'typeorm'; +import { User } from './user'; + +@Entity() +export class Subscription { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User) + user: User; + + @Column() + userId: number; + + @Column() + status: 'active' | 'cancelled' | 'expired'; + + @CreateDateColumn() + startDate: Date; + + @Column() + endDate: Date; + + @Column({ default: false }) + autoRenew: boolean; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 20.00 }) + amount: number; +} \ No newline at end of file From 35b36775548743d657ef004c87c9ac6ed4788942 Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:21:00 -0500 Subject: [PATCH 6/9] Refactor AuthenticatedGuard imports and enhance configurations: - Moved `AuthenticatedGuard` import paths to `utils/guards/local.guard` across all controllers for better organization. - Added `SubscriptionsModule` and global `ConfigModule` in `AppModule` for enhanced modularization and configuration management. - Updated `User` entity: removed relationships with `Country`, `Definition`, and `Word` while adding a `stripeCustomerId` column. --- src/app.module.ts | 8 +++++++- .../authentication/auth.controller.ts | 2 +- .../definition-likes-dislikes.controller.ts | 2 +- .../definition-reports.controller.ts | 2 +- .../definitions/definitions.controller.ts | 2 +- src/typeorm/entities/user.ts | 19 +++++++++++-------- .../controllers/users/users.controller.ts | 2 +- src/utils/{ => guards}/local.guard.ts | 0 .../word-reports/word-reports.controller.ts | 2 +- .../controllers/words/words.controller.ts | 2 +- 10 files changed, 25 insertions(+), 16 deletions(-) rename src/utils/{ => guards}/local.guard.ts (100%) diff --git a/src/app.module.ts b/src/app.module.ts index 2679593..ad5a30f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,7 +27,9 @@ import { DefinitionReportsModule } from './definition-reports/definition-reports import { DefinitionReport } from './typeorm/entities/definition-report'; import { RagModule } from './rag/rag.module'; import { ModelService } from './rag/services/model/model.service'; -import { VectorStoreService } from './rag/services/vector-store/vector-store.service'; +import { SubscriptionsModule } from "./subscriptions/subscriptions.module"; +import { ConfigModule } from "@nestjs/config"; + dotenv.config({ path: './safe/.env' }); @@ -81,6 +83,9 @@ dotenv.config({ path: './safe/.env' }); }, }), }), + ConfigModule.forRoot({ + isGlobal: true, + }), PassportModule.register({ session: true }), UsersModule, CountriesModule, @@ -91,6 +96,7 @@ dotenv.config({ path: './safe/.env' }); AuthModule, DefinitionReportsModule, RagModule, + SubscriptionsModule, ], controllers: [AppController, AuthController, DefinitionReportsController], providers: [AppService, ModelService], diff --git a/src/authentication/controllers/authentication/auth.controller.ts b/src/authentication/controllers/authentication/auth.controller.ts index 56f8437..3d8025a 100644 --- a/src/authentication/controllers/authentication/auth.controller.ts +++ b/src/authentication/controllers/authentication/auth.controller.ts @@ -10,7 +10,7 @@ import { import { Request as ExpressRequest, Response, NextFunction } from 'express'; import { Session } from 'express-session'; import { Throttle } from '@nestjs/throttler'; -import { AuthenticatedGuard, LocalAuthGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard, LocalAuthGuard } from '../../../utils/guards/local.guard'; @Controller('auth') export class AuthController { diff --git a/src/definition-likes-dislikes/controllers/definition-likes-dislikes/definition-likes-dislikes.controller.ts b/src/definition-likes-dislikes/controllers/definition-likes-dislikes/definition-likes-dislikes.controller.ts index b4f4130..e8c04db 100644 --- a/src/definition-likes-dislikes/controllers/definition-likes-dislikes/definition-likes-dislikes.controller.ts +++ b/src/definition-likes-dislikes/controllers/definition-likes-dislikes/definition-likes-dislikes.controller.ts @@ -9,7 +9,7 @@ import { Logger, Body } from "@nestjs/common"; import { DefinitionLikesDislikesService } from '../../services/definition-likes-dislikes/definition-likes-dislikes.service'; -import { AuthenticatedGuard, LocalAuthGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard, LocalAuthGuard } from '../../../utils/guards/local.guard'; import { RequestType } from 'express-serve-static-core'; import { Throttle } from '@nestjs/throttler'; import { UserRequest } from '../../../utils/types'; diff --git a/src/definition-reports/controllers/definition-reports/definition-reports.controller.ts b/src/definition-reports/controllers/definition-reports/definition-reports.controller.ts index f45d09f..47c01e0 100644 --- a/src/definition-reports/controllers/definition-reports/definition-reports.controller.ts +++ b/src/definition-reports/controllers/definition-reports/definition-reports.controller.ts @@ -13,7 +13,7 @@ import { import { DefinitionReportsService } from '../../services/definition-reports/definition-reports.service'; import { CreateDefinitionReportDto } from '../../dtos/create-definition-report.dto'; import { UpdateDefinitionReportDto } from '../../dtos/update-definition-report.dto'; -import { AuthenticatedGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard } from '../../../utils/guards/local.guard'; import { RequestType } from 'express-serve-static-core'; import { DefinitionReport } from '../../../typeorm/entities/definition-report'; diff --git a/src/definitions/controllers/definitions/definitions.controller.ts b/src/definitions/controllers/definitions/definitions.controller.ts index 7341d64..8e680ff 100644 --- a/src/definitions/controllers/definitions/definitions.controller.ts +++ b/src/definitions/controllers/definitions/definitions.controller.ts @@ -16,7 +16,7 @@ import { CreateDefinitionDto } from '../../dtos/create-definition.dto'; import { UpdateDefinitionDto } from '../../dtos/update-definition.dto'; import { DeleteResult, UpdateResult } from 'typeorm'; import { Throttle } from '@nestjs/throttler'; -import { AuthenticatedGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard } from '../../../utils/guards/local.guard'; import { RequestType } from 'express-serve-static-core'; import { Country } from '../../../typeorm/entities/country'; diff --git a/src/typeorm/entities/user.ts b/src/typeorm/entities/user.ts index 00f1991..c481f06 100644 --- a/src/typeorm/entities/user.ts +++ b/src/typeorm/entities/user.ts @@ -12,6 +12,15 @@ import { Word } from './word'; @Entity({ name: 'users' }) export class User { + @ManyToOne(() => Country) + country: Country; + + @OneToMany(() => Definition, (definition) => definition.user) + definitions: Definition[]; + + @OneToMany(() => Word, (word) => word.user) + words: Word[]; + @PrimaryGeneratedColumn({ type: 'bigint' }) id: number; @@ -51,12 +60,6 @@ export class User { @Column() createdAt: Date; - @ManyToOne(() => Country) - country: Country; - - @OneToMany(() => Definition, (definition) => definition.user) - definitions: Definition[]; - - @OneToMany(() => Word, (word) => word.user) - words: Word[]; + @Column({ nullable: true }) + stripeCustomerId: string; } diff --git a/src/users/controllers/users/users.controller.ts b/src/users/controllers/users/users.controller.ts index 592a4a3..219fb09 100644 --- a/src/users/controllers/users/users.controller.ts +++ b/src/users/controllers/users/users.controller.ts @@ -21,7 +21,7 @@ import { } from '../../../../safe/new-password-hashing'; import { validateFields } from '../../utils/validation'; import { plainToClass } from 'class-transformer'; -import { AuthenticatedGuard } from "../../../utils/local.guard"; +import { AuthenticatedGuard } from "../../../utils/guards/local.guard"; import { UserRequest } from "../../../utils/types"; import { Throttle } from "@nestjs/throttler"; diff --git a/src/utils/local.guard.ts b/src/utils/guards/local.guard.ts similarity index 100% rename from src/utils/local.guard.ts rename to src/utils/guards/local.guard.ts diff --git a/src/word-reports/controllers/word-reports/word-reports.controller.ts b/src/word-reports/controllers/word-reports/word-reports.controller.ts index 83fcde6..a2773c0 100644 --- a/src/word-reports/controllers/word-reports/word-reports.controller.ts +++ b/src/word-reports/controllers/word-reports/word-reports.controller.ts @@ -12,7 +12,7 @@ import { import { WordReport } from '../../../typeorm/entities/word-report'; import { WordReportsService } from '../../services/word-reports/word-reports.service'; import { CreateWordReportDto } from '../../dtos/create-word-report.dto'; -import { AuthenticatedGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard } from '../../../utils/guards/local.guard'; import { RequestType } from 'express-serve-static-core'; @Controller('word-reports') diff --git a/src/words/controllers/words/words.controller.ts b/src/words/controllers/words/words.controller.ts index f52f157..0dd41e3 100644 --- a/src/words/controllers/words/words.controller.ts +++ b/src/words/controllers/words/words.controller.ts @@ -14,7 +14,7 @@ import { Word } from '../../../typeorm/entities/word'; import { CreateWordDto } from '../../dtos/create-word.dto'; import { UpdateWordDto } from '../../dtos/update-word.dto'; import { UpdateResult } from 'typeorm'; -import { AuthenticatedGuard } from '../../../utils/local.guard'; +import { AuthenticatedGuard } from '../../../utils/guards/local.guard'; import { RequestType } from 'express-serve-static-core'; @Controller('word') From 4ae9fc59353c8fa1ac1d4c34b5cdbf3a0233c4a1 Mon Sep 17 00:00:00 2001 From: antho Date: Wed, 19 Feb 2025 19:25:47 -0500 Subject: [PATCH 7/9] Rename SubscriptionService and related references to SubscriptionsService: - Updated class, import paths, and references in service, controller, module, and guard files to consistently use `SubscriptionsService` and `SubscriptionsController`. --- .../subscriptions/subscriptions.controller.spec.ts | 2 +- .../subscriptions/subscriptions.controller.ts | 6 +++--- .../services/subscriptions/subscriptions.service.ts | 2 +- src/subscriptions/subscriptions.module.ts | 10 +++++----- src/utils/guards/subscription.guard.ts | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts b/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts index a42f381..317eb23 100644 --- a/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts +++ b/src/subscriptions/controllers/subscriptions/subscriptions.controller.spec.ts @@ -1,5 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { SubscriptionsController } from './subscriptions.controller'; +import { SubscriptionsController } from "./subscriptions.controller"; describe('SubscriptionsController', () => { let controller: SubscriptionsController; diff --git a/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts b/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts index 1918577..95bf146 100644 --- a/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts +++ b/src/subscriptions/controllers/subscriptions/subscriptions.controller.ts @@ -1,15 +1,15 @@ import { Controller, Post, Get, UseGuards, Req, Res, RawBodyRequest } from "@nestjs/common"; import { Response, Request } from "express"; import { ConfigService } from "@nestjs/config"; -import { SubscriptionService } from "../../services/subscriptions/subscriptions.service"; +import { SubscriptionsService } from "../../services/subscriptions/subscriptions.service"; import { AuthenticatedGuard } from "../../../utils/guards/local.guard"; import { RequestType } from "express-serve-static-core"; import { Stripe } from "stripe"; @Controller("subscriptions") -export class SubscriptionController { +export class SubscriptionsController { constructor( - private subscriptionService: SubscriptionService, + private subscriptionService: SubscriptionsService, private configService: ConfigService ) { } diff --git a/src/subscriptions/services/subscriptions/subscriptions.service.ts b/src/subscriptions/services/subscriptions/subscriptions.service.ts index 9d21bb9..0795cce 100644 --- a/src/subscriptions/services/subscriptions/subscriptions.service.ts +++ b/src/subscriptions/services/subscriptions/subscriptions.service.ts @@ -7,7 +7,7 @@ import { Stripe } from "stripe"; import { InjectStripeClient } from "@golevelup/nestjs-stripe"; @Injectable() -export class SubscriptionService { +export class SubscriptionsService { constructor( @InjectRepository(Subscription) private subscriptionRepository: Repository, diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts index 0d3f5dd..0ac28e3 100644 --- a/src/subscriptions/subscriptions.module.ts +++ b/src/subscriptions/subscriptions.module.ts @@ -2,8 +2,8 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Subscription } from "../typeorm/entities/subscription"; import { User } from "../typeorm/entities/user"; -import { SubscriptionController } from "./controllers/subscriptions/subscriptions.controller"; -import { SubscriptionService } from "./services/subscriptions/subscriptions.service"; +import { SubscriptionsController } from "./controllers/subscriptions/subscriptions.controller"; +import { SubscriptionsService } from "./services/subscriptions/subscriptions.service"; import { StripeConfig } from "../utils/config/stripe.config"; @Module({ @@ -11,9 +11,9 @@ import { StripeConfig } from "../utils/config/stripe.config"; TypeOrmModule.forFeature([Subscription, User]), StripeConfig ], - controllers: [SubscriptionController], - providers: [SubscriptionService], - exports: [SubscriptionService] + controllers: [SubscriptionsController], + providers: [SubscriptionsService], + exports: [SubscriptionsService] }) export class SubscriptionsModule { } \ No newline at end of file diff --git a/src/utils/guards/subscription.guard.ts b/src/utils/guards/subscription.guard.ts index 8a134c1..5829a7d 100644 --- a/src/utils/guards/subscription.guard.ts +++ b/src/utils/guards/subscription.guard.ts @@ -1,9 +1,9 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; -import { SubscriptionService } from "../../subscriptions/services/subscriptions/subscriptions.service"; +import { SubscriptionsService } from "../../subscriptions/services/subscriptions/subscriptions.service"; @Injectable() export class SubscriptionGuard implements CanActivate { - constructor(private subscriptionService: SubscriptionService) {} + constructor(private subscriptionService: SubscriptionsService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); From abedfbb1a25dfdfabbc5fc403632aeb682d67642 Mon Sep 17 00:00:00 2001 From: antho Date: Thu, 20 Feb 2025 14:30:53 -0500 Subject: [PATCH 8/9] Add support for new AI models in Rag DTO: - Updated `AiModel` type to include `groq-3` and `gemini-pro`. - Added placeholders for handling `groq-3` and `gemini-pro` in the model service but left unimplemented. --- src/rag/dtos/rag.dto.ts | 2 +- src/rag/services/model/model.service.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rag/dtos/rag.dto.ts b/src/rag/dtos/rag.dto.ts index 20c3257..67afa8b 100644 --- a/src/rag/dtos/rag.dto.ts +++ b/src/rag/dtos/rag.dto.ts @@ -1,4 +1,4 @@ -export type AiModel = 'groq' | 'gemini' | 'gpt4' | 'mistral'; +export type AiModel = 'groq' | 'gemini' | 'groq-3'| 'gemini-pro' | 'gpt4' | 'mistral'; export class RagRequest { query: string; diff --git a/src/rag/services/model/model.service.ts b/src/rag/services/model/model.service.ts index 22e0af3..8c290ed 100644 --- a/src/rag/services/model/model.service.ts +++ b/src/rag/services/model/model.service.ts @@ -43,6 +43,8 @@ export class ModelService { return this.generateGroqResponse(fullPrompt); case 'gemini': return this.generateGeminiResponse(fullPrompt); + case 'groq-3': + case 'gemini-pro': case 'gpt4': case 'mistral': throw new Error(`Model ${model} not implemented yet`); From aa25fa808a428197b7c3bc3854e3de17ba15a5c2 Mon Sep 17 00:00:00 2001 From: antho Date: Thu, 20 Feb 2025 14:32:31 -0500 Subject: [PATCH 9/9] Add model restrictions based on subscription tiers: - Introduced a `modelTiers` configuration in the `RagService` to define accessible models for free and premium users. - Integrated subscription validation using `SubscriptionsService` to determine user access level and enforce model restrictions. - Updated `processQuery` to check subscription status and throw a `ForbiddenException` if the model requires a higher tier. - Modified `RagController` to pass `userId` to `processQuery` by accessing the authenticated user's details from the request object. --- src/rag/controllers/rag/rag.controller.ts | 11 ++++++----- src/rag/services/rag/rag.service.ts | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/rag/controllers/rag/rag.controller.ts b/src/rag/controllers/rag/rag.controller.ts index 1625198..686892b 100644 --- a/src/rag/controllers/rag/rag.controller.ts +++ b/src/rag/controllers/rag/rag.controller.ts @@ -1,17 +1,18 @@ -import { Body, Controller, HttpException, HttpStatus, Post, UseGuards } from "@nestjs/common"; +import { Body, Controller, HttpException, HttpStatus, Post, Req, UseGuards } from "@nestjs/common"; import { RagRequest, RagResponse } from "../../dtos/rag.dto"; import { RagService } from "../../services/rag/rag.service"; import { AuthenticatedGuard } from "../../../utils/guards/local.guard"; import { SubscriptionGuard } from "../../../utils/guards/subscription.guard"; +import { RequestType } from "express-serve-static-core"; @Controller('rag') export class RagController { constructor(private readonly ragService: RagService) {} @Post() - async handleQuery(@Body() request: RagRequest): Promise { + async handleQuery(@Body() request: RagRequest, @Req() req: RequestType): Promise { try { - return await this.ragService.processQuery(request); + return await this.ragService.processQuery(request, req.user.id); } catch (error) { throw new HttpException( 'Failed to process RAG query: ' + error.message, @@ -22,9 +23,9 @@ export class RagController { @Post('premium') @UseGuards(AuthenticatedGuard, SubscriptionGuard) - async handlePremiumQuery(@Body() request: RagRequest): Promise { + async handlePremiumQuery(@Body() request: RagRequest, @Req() req: RequestType): Promise { try { - return await this.ragService.processQuery(request); + return await this.ragService.processQuery(request, req.user.id); } catch (error) { throw new HttpException( 'Failed to process RAG query: ' + error.message, diff --git a/src/rag/services/rag/rag.service.ts b/src/rag/services/rag/rag.service.ts index 7213b05..cf6c566 100644 --- a/src/rag/services/rag/rag.service.ts +++ b/src/rag/services/rag/rag.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { Word } from "../../../typeorm/entities/word"; import { Like, Repository } from "typeorm"; @@ -7,6 +7,8 @@ import { In } from 'typeorm'; import { RagRequest, RagResponse } from "../../dtos/rag.dto"; import { VectorStoreService } from "../vector-store/vector-store.service"; import { ModelService } from "../model/model.service"; +import { SubscriptionsService } from "../../../subscriptions/services/subscriptions/subscriptions.service"; +import { Subscription } from "../../../typeorm/entities/subscription"; interface CitedWord { id: number; @@ -23,12 +25,24 @@ export class RagService { @InjectRepository(Word) private wordRepository: Repository, @InjectRepository(Definition) - private definitionRepository: Repository, private modelService: ModelService, private vectorStore: VectorStoreService, + private subscriptionsService: SubscriptionsService ) {} - async processQuery(request: RagRequest): Promise { + private readonly modelTiers = { + free: ['groq', 'gemini'], + premium: ['groq-3', 'gemini-pro', 'gpt4', 'mistral'] + }; + + async processQuery(request: RagRequest, userId: number): Promise { + const subscription: Subscription = await this.subscriptionsService.getActiveSubscription(userId); + const tier: 'premium' | 'free' = subscription?.status === 'active' ? 'premium' : 'free'; + + if (!this.modelTiers[tier].includes(request.model)) { + throw new ForbiddenException(`Model ${request.model} requires premium subscription`); + } + // 1. Extract words from the query const words: string[] = this.extractWords(request.query);