diff --git a/.example.env b/.example.env index 459ffbd8..2cc3db13 100644 --- a/.example.env +++ b/.example.env @@ -8,11 +8,17 @@ NODE_ENV=development RATE_LIMIT_TTL=60 RATE_LIMIT_COUNT=100 +# PADDLE - (Payment Gateway) +PADDLE_ENABLE=false +PADDLE_WEBHOOK_KEY= +PADDLE_SECRET_KEY= + # LOGGER LOGGER_CONSOLE_THRESHOLD=INFO # DEBUG, INFO, WARN, ERROR, FATAL # FRONTEND DOMAIN=localhost +PUBLIC_PADDLE_KEY= # Not needed for local development CLIENTSIDE_API_DOMAIN=http://localhost:3000 # Use this verible, while making client side API calls API_DOMAIN=http://localhost:3000 # If you are running with docker compose change this to http://backend:3000 STORAGE_DOMAIN=Get it from https://cloud.digitalocean.com/spaces diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a35f76ba..d3b1e1cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -40,4 +40,4 @@ jobs: - name: Build & Push Affected Images to Registry id: deploy-images-to-registry run: | - npx nx affected -t "push-image-to-registry" --repository=${{ github.repository }} --github-sha="$GITHUB_SHA" --apiDomain=${{ secrets.API_DOMAIN }} --clientSideApiDomain=${{ secrets.CLIENTSIDE_API_DOMAIN }} --domain=${{ secrets.DOMAIN }} --storageDomain=${{ secrets.STORAGE_DOMAIN }} + npx nx affected -t "push-image-to-registry" --repository=${{ github.repository }} --github-sha="$GITHUB_SHA" --apiDomain=${{ secrets.API_DOMAIN }} --clientSideApiDomain=${{ secrets.CLIENTSIDE_API_DOMAIN }} --domain=${{ secrets.DOMAIN }} --storageDomain=${{ secrets.STORAGE_DOMAIN }} --publicPaddleKey=${{ secrets.PUBLIC_PADDLE_KEY }} diff --git a/.vscode/launch.json b/.vscode/launch.json index dadb0f37..c95e8e02 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "runtimeExecutable": "npx", "runtimeArgs": ["nx", "serve", "backend"], "console": "integratedTerminal", - "cwd": "${workspaceFolder}" + "cwd": "${workspaceFolder}/apps/backend" }, { "name": "Debug tracker", diff --git a/README.md b/README.md index bef58219..ca8e1e51 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ For the minimal configuration you can just rename the `.example.env` files to `. - **RATE_LIMIT_TTL**: Rate limit TTL (time to live) - **RATE_LIMIT_COUNT**: Number of requests within the ttl +###### Paddle - (Payment Gateway - https://www.paddle.com/ - Optional) + +- **PADDLE_ENABLE**: Wethter to enable Paddle or not +- **PADDLE_WEBHOOK_KEY**: Get it from your Paddle account +- **PADDLE_SECRET_KEY**: Get it from your Paddle account + ###### Logger - **LOGGER_CONSOLE_THRESHOLD**: Threshold level of the console transporter. @@ -183,6 +189,7 @@ For the minimal configuration you can just rename the `.example.env` files to `. ###### Frontend - **DOMAIN**: Domain of your frontend app +- **PUBLIC_PADDLE_KEY**: Get it from your Paddle account (Not needed when running locally) - **API_DOMAIN**: Domain of your backend instance (used for server side requests) - **CLIENTSIDE_API_DOMAIN**: Domain of your backend instance (used for client side requests) - **STORAGE_DOMAIN**=Domain of your bucket (used for storing images) @@ -211,6 +218,25 @@ For the minimal configuration you can just rename the `.example.env` files to `. Happy Hacking ! +### Change my plan on development + +If you want to change your plan on developemnt (Assuming you have a local instance of PostgreSQL running on port 5432 and you don't have Paddle configured): + +1. Register locally on the app. +2. Go to your database and create a new row in the `Subscription` table: + - `id`: 1 + - `userId`: (you can find your user id in the `User` table) + - `plan`: (FREE / PRO / BUSINESS) + - `status`: active + - `endDate`: Choose a date in the future + - `scheduledToBeCancelled`: false + - `endDate`: empty (NULL) + - `nextBilledAt`: empty (NULL) + - `createdAt`: current date + - `updatedAt`: current date +3. Relogin to the app (refresh the JWT token) +4. You can now access the premium features. +

(back to top)

diff --git a/apps/backend/src/analytics/analytics.controller.ts b/apps/backend/src/analytics/analytics.controller.ts index 969bded5..db65bb7c 100644 --- a/apps/backend/src/analytics/analytics.controller.ts +++ b/apps/backend/src/analytics/analytics.controller.ts @@ -4,8 +4,9 @@ import { UserCtx } from '../shared/decorators'; import { JwtAuthGuard } from '../auth/guards/jwt.guard'; import { UserContext } from '../auth/interfaces/user-context'; import { PrismaService } from '@reduced.to/prisma'; +import { RestrictDays } from './analytics.guard'; -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RestrictDays) @Controller('analytics') export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService, private readonly prismaService: PrismaService) {} diff --git a/apps/backend/src/analytics/analytics.guard.ts b/apps/backend/src/analytics/analytics.guard.ts new file mode 100644 index 00000000..eb56f67e --- /dev/null +++ b/apps/backend/src/analytics/analytics.guard.ts @@ -0,0 +1,29 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { UserContext } from '../auth/interfaces/user-context'; +import { PLAN_LEVELS } from '@reduced.to/subscription-manager'; + +@Injectable() +export class RestrictDays implements CanActivate { + canActivate(context: ExecutionContext): boolean | Promise | Observable { + const request = context.switchToHttp().getRequest(); + const user = request.user as UserContext; + const plan = user.plan || 'FREE'; + const planConfig = PLAN_LEVELS[plan]; + const maxDays = planConfig.FEATURES.ANALYTICS.value; + const query = request.query; + + // Apply logic to modify the 'days' query parameter if it exists + if (query.days) { + const days = parseInt(query.days, 10); + if (isNaN(days) || days < 0) { + query.days = '0'; // Default to 0 if the value is invalid + } else if (days > maxDays) { + query.days = maxDays.toString(); + } + } + + // Allow the request to proceed + return true; + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index ee8864dd..cca9c8b9 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -15,6 +15,8 @@ import { LinksModule } from './core/links/links.module'; import { ReportsModule } from './core/reports/reports.module'; import { MetadataModule } from './metadata/metadata.module'; import { AnalyticsModule } from './analytics/analytics.module'; +import { BillingModule } from './billing/billing.module'; +import { TasksModule } from './tasks/tasks.module'; @Module({ imports: [ @@ -33,8 +35,10 @@ import { AnalyticsModule } from './analytics/analytics.module'; UsersModule, LinksModule, ReportsModule, + BillingModule, MetadataModule, AnalyticsModule, + TasksModule, // Should be imported only once to avoid multiple instances ], providers: [ PrismaService, diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index 889ecf57..df21d915 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UseGuards import { Request, Response } from 'express'; import { AppConfigService } from '@reduced.to/config'; import { NovuService } from '../novu/novu.service'; -import { PrismaService } from '@reduced.to/prisma'; +import { Plan, PrismaService, Subscription } from '@reduced.to/prisma'; import { AuthService } from './auth.service'; import { SignupDto } from './dto/signup.dto'; import { JwtRefreshAuthGuard } from './guards/jwt-refresh.guard'; @@ -57,7 +57,19 @@ export class AuthController { @UseGuards(JwtRefreshAuthGuard) @Post('/refresh') async refresh(@Req() req: Request, @Res() res: Response) { - const tokens = await this.authService.refreshTokens(req.user as UserContext); + const userContext = req.user as UserContext; + let subscription: Subscription; + try { + subscription = await this.prismaService.subscription.findFirst({ + where: { + userId: userContext.id, + }, + }); + } catch (e) { + console.error(e); + } + const newContext = { ...userContext, plan: subscription?.plan || Plan.FREE }; + const tokens = await this.authService.refreshTokens(newContext); res = setAuthCookies(res, this.appConfigService.getConfig().front.domain, tokens); res.send(tokens); diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 76cf7a6a..bce8c35b 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -16,6 +16,7 @@ import { ProvidersController } from './providers/providers.controller'; import { UsersModule } from '../core/users/users.module'; import { StorageModule } from '../storage/storage.module'; import { StorageService } from '../storage/storage.service'; +import { BillingModule } from '../billing/billing.module'; @Module({ imports: [ @@ -30,6 +31,7 @@ import { StorageService } from '../storage/storage.service'; }), NovuModule, StorageModule, + forwardRef(() => BillingModule), forwardRef(() => UsersModule), ], controllers: [AuthController, ProvidersController], diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 48e54fd4..0dbf7aa8 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -8,6 +8,9 @@ import { PrismaService } from '@reduced.to/prisma'; import { AuthService } from './auth.service'; import { SignupDto } from './dto/signup.dto'; import { StorageService } from '../storage/storage.service'; +import { BillingModule } from '../billing/billing.module'; +import { AppLoggerModule } from '@reduced.to/logger'; +import { BillingService } from '../billing/billing.service'; describe('AuthService', () => { let authService: AuthService; @@ -17,7 +20,7 @@ describe('AuthService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [AppConfigModule], + imports: [AppConfigModule, AppLoggerModule], providers: [ AuthService, JwtService, @@ -31,6 +34,10 @@ describe('AuthService', () => { }, }, }, + { + provide: BillingService, + useValue: jest.fn(), + }, { provide: StorageService, useValue: jest.fn(), diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index 3038d4a1..f69be368 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -6,6 +6,7 @@ import * as bcrypt from 'bcryptjs'; import { SignupDto } from './dto/signup.dto'; import { UserContext } from './interfaces/user-context'; import { PROFILE_PICTURE_PREFIX, StorageService } from '../storage/storage.service'; +import { BillingService } from '../billing/billing.service'; @Injectable() export class AuthService { @@ -13,7 +14,8 @@ export class AuthService { private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly storageService: StorageService, - private readonly appConfigService: AppConfigService + private readonly appConfigService: AppConfigService, + private readonly billingService: BillingService ) {} async validateUser(email: string, password: string) { @@ -21,6 +23,9 @@ export class AuthService { where: { email, }, + include: { + subscription: true, + }, }); if (!user) { return null; @@ -28,8 +33,11 @@ export class AuthService { const verified = await bcrypt.compare(password, user.password); if (verified) { - const { password, ...result } = user; - return result; + const { ...result } = user; + return { + ...result, + plan: user.subscription?.plan || 'FREE', + }; } return null; @@ -83,7 +91,15 @@ export class AuthService { }); } - return this.prisma.user.create(createOptions); + createOptions.data['usage'] = { + create: { + linksCount: 0, + clicksCount: 0, + }, + }; + + const createdUser = await this.prisma.user.create(createOptions); + return createdUser; } async verify(user: UserContext): Promise<{ verified: boolean }> { @@ -172,6 +188,7 @@ export class AuthService { email: user.email, name: user.name, role: user.role, + plan: user.plan || 'FREE', verified: user.verified, iss: 'reduced.to', }; @@ -182,12 +199,32 @@ export class AuthService { }); } - async delete(user: UserContext) { + async delete(userCtx: UserContext) { + const user = await this.prisma.user.findUnique({ + where: { + id: userCtx.id, + }, + include: { + subscription: true, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const promises: any[] = [this.storageService.delete(`${PROFILE_PICTURE_PREFIX}/${user.id}`)]; + + if (user.subscription) { + promises.push(this.billingService.cancelSubscription(user.id)); + } + try { - await this.storageService.delete(`${PROFILE_PICTURE_PREFIX}/${user.id}`); + await Promise.all(promises); } catch (error) { // Ignore error } + return this.prisma.user.delete({ where: { id: user.id, diff --git a/apps/backend/src/auth/interfaces/user-context.ts b/apps/backend/src/auth/interfaces/user-context.ts index 35c15065..fe126b1a 100644 --- a/apps/backend/src/auth/interfaces/user-context.ts +++ b/apps/backend/src/auth/interfaces/user-context.ts @@ -1,10 +1,12 @@ import { Role } from '@reduced.to/prisma'; +import { PLAN_LEVELS } from '@reduced.to/subscription-manager'; export interface UserContext { id: string; email: string; name: string; role: Role; + plan?: keyof typeof PLAN_LEVELS; refreshToken?: string; verificationToken?: string; verified: boolean; diff --git a/apps/backend/src/auth/providers/providers.controller.ts b/apps/backend/src/auth/providers/providers.controller.ts index e69f56f1..9e4bc2aa 100644 --- a/apps/backend/src/auth/providers/providers.controller.ts +++ b/apps/backend/src/auth/providers/providers.controller.ts @@ -33,7 +33,7 @@ export class ProvidersController { throw new BadRequestException('User is not exists in request'); } - let user = await this.usersService.findByEmail(req.user.email); + let user = await this.usersService.findUserContextByEmail(req.user.email); if (!user) { user = await this.authService.signup({ diff --git a/apps/backend/src/auth/strategies/google.strategy.ts b/apps/backend/src/auth/strategies/google.strategy.ts index 1e7ece46..e1b55190 100644 --- a/apps/backend/src/auth/strategies/google.strategy.ts +++ b/apps/backend/src/auth/strategies/google.strategy.ts @@ -17,6 +17,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { scope: ['email', 'profile'], }); } + async validate(accessToken: string, refreshToken: string, profile: any, done: VerifyCallback): Promise { const { name, emails, photos, id } = profile; @@ -34,6 +35,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { refreshToken, providerId: id, }; + done(null, user); } } diff --git a/apps/backend/src/auth/strategies/jwt.strategy.ts b/apps/backend/src/auth/strategies/jwt.strategy.ts index d16b1769..53697979 100644 --- a/apps/backend/src/auth/strategies/jwt.strategy.ts +++ b/apps/backend/src/auth/strategies/jwt.strategy.ts @@ -29,6 +29,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { if (!payload) { throw new UnauthorizedException(); } - return { id: payload.id, email: payload.email, role: payload.role, verified: payload.verified }; + return { id: payload.id, email: payload.email, role: payload.role, verified: payload.verified, plan: payload.plan }; } } diff --git a/apps/backend/src/auth/strategies/verify.strategy.ts b/apps/backend/src/auth/strategies/verify.strategy.ts index f17e0fbf..21bbd2df 100644 --- a/apps/backend/src/auth/strategies/verify.strategy.ts +++ b/apps/backend/src/auth/strategies/verify.strategy.ts @@ -17,6 +17,6 @@ export class VerifyStrategy extends PassportStrategy(Strategy, 'verify') { if (!payload) { throw new UnauthorizedException(); } - return { name: payload.name, email: payload.email }; + return { name: payload.name, email: payload.email, plan: payload.plan }; } } diff --git a/apps/backend/src/billing/billing.controller.ts b/apps/backend/src/billing/billing.controller.ts new file mode 100644 index 00000000..de6a2ce0 --- /dev/null +++ b/apps/backend/src/billing/billing.controller.ts @@ -0,0 +1,118 @@ +import { + Controller, + Delete, + Get, + InternalServerErrorException, + NotFoundException, + Patch, + Post, + RawBodyRequest, + Req, + UseGuards, +} from '@nestjs/common'; +import { BillingService } from './billing.service'; +import { JwtAuthGuard } from '../auth/guards/jwt.guard'; +import { UserContext } from '../auth/interfaces/user-context'; +import { UserCtx } from '../shared/decorators'; +import { AppConfigService } from '@reduced.to/config'; +import { Body, Headers } from '@nestjs/common'; +import { EventName, SubscriptionActivatedEvent, SubscriptionCanceledEvent, SubscriptionUpdatedEvent } from '@paddle/paddle-node-sdk'; +import { Request } from 'express'; +import { UpdatePlanDto } from './dto/update-plan.dto'; +import { AppLoggerService } from '@reduced.to/logger'; +import { AuthService } from '../auth/auth.service'; + +@Controller('billing') +export class BillingController { + private webhookSecret?: string; + constructor( + private readonly billingService: BillingService, + private readonly configService: AppConfigService, + private readonly loggerService: AppLoggerService, + private readonly authService: AuthService + ) { + const { paddle } = this.configService.getConfig(); + this.webhookSecret = paddle.webhookSecret; + } + + @UseGuards(JwtAuthGuard) + @Get('/info') + async getBillingInfo(@UserCtx() user: UserContext) { + try { + return await this.billingService.getBillingInfo(user.id); + } catch (err: unknown) { + this.loggerService.error(`Could not fetch billing info for user ${user.id}`, err); + throw new InternalServerErrorException('Could not fetch billing info'); + } + } + + @UseGuards(JwtAuthGuard) + @Delete('/plan') + async cancelPlan(@UserCtx() user: UserContext) { + try { + await this.billingService.cancelSubscription(user.id); + return { message: 'Subscription cancelled' }; + } catch (err: unknown) { + this.loggerService.error(`Could not cancel subscription for user ${user.id}`, err); + throw new InternalServerErrorException('Could not cancel subscription'); + } + } + + @UseGuards(JwtAuthGuard) + @Patch('/plan/resume') + async revertCancellation(@UserCtx() user: UserContext) { + try { + await this.billingService.resumeSubscription(user.id); + return { message: 'Subscription resumed' }; + } catch (err: unknown) { + this.loggerService.error(`Could not resume subscription for user ${user.id}`, err); + throw new InternalServerErrorException('Could not resume subscription'); + } + } + + @UseGuards(JwtAuthGuard) + @Post('/plan') + async updatePlan(@UserCtx() user: UserContext, @Body() updatePlanDto: UpdatePlanDto) { + const { planId, itemId, operationType } = updatePlanDto; + + try { + const updatedUser = await this.billingService.updateSubscription(user.id, planId, itemId, operationType); + const tokens = await this.authService.generateTokens(updatedUser); + return { + message: 'Plan updated', + ...tokens, + }; + } catch (err: unknown) { + this.loggerService.error(`Could not update subscription for user ${user.id} with planId: ${planId}, priceId: ${itemId}`, err); + throw new InternalServerErrorException('Could not update subscription'); + } + } + + @Post('/paddle/webhook') + async handlePaddleWebhook(@Headers('paddle-signature') signature: string, @Req() req: RawBodyRequest) { + if (!this.webhookSecret) { + throw new NotFoundException(); + } + + const responseText = req.rawBody.toString(); + const parsedEvent = this.billingService.verifyWebhookSignature(signature, this.webhookSecret, responseText); + + if (!parsedEvent) { + throw new NotFoundException(); + } + + switch (parsedEvent.eventType) { + case EventName.SubscriptionActivated: + await this.billingService.onSubscriptionAcvivated(parsedEvent as SubscriptionActivatedEvent); + break; + case EventName.SubscriptionCanceled: + await this.billingService.onSubscriptionCancelled(parsedEvent as SubscriptionCanceledEvent); + break; + case EventName.SubscriptionUpdated: + await this.billingService.onSubscriptionModified(parsedEvent as SubscriptionUpdatedEvent); + break; + default: + break; + } + } +} diff --git a/apps/backend/src/billing/billing.module.ts b/apps/backend/src/billing/billing.module.ts new file mode 100644 index 00000000..c76b6670 --- /dev/null +++ b/apps/backend/src/billing/billing.module.ts @@ -0,0 +1,14 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { PrismaModule } from '@reduced.to/prisma'; +import { BillingController } from './billing.controller'; +import { BillingService } from './billing.service'; +import { AuthModule } from '../auth/auth.module'; +import { UsageModule } from '@reduced.to/subscription-manager'; + +@Module({ + imports: [forwardRef(() => AuthModule), PrismaModule, UsageModule], + controllers: [BillingController], + providers: [BillingService], + exports: [BillingService], +}) +export class BillingModule {} diff --git a/apps/backend/src/billing/billing.service.ts b/apps/backend/src/billing/billing.service.ts new file mode 100644 index 00000000..76c5241c --- /dev/null +++ b/apps/backend/src/billing/billing.service.ts @@ -0,0 +1,355 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Plan, PrismaService } from '@reduced.to/prisma'; +import { PLAN_LEVELS, UsageService } from '@reduced.to/subscription-manager'; +import { + Environment, + Paddle, + SubscriptionActivatedEvent, + SubscriptionCanceledEvent, + SubscriptionUpdatedEvent, +} from '@paddle/paddle-node-sdk'; +import { AppConfigService } from '@reduced.to/config'; +import { AppLoggerService } from '@reduced.to/logger'; + +@Injectable() +export class BillingService implements OnModuleInit { + private paddleClient?: Paddle; + private enabled?: boolean; + + constructor( + private readonly prisma: PrismaService, + private readonly appConfigService: AppConfigService, + private readonly logger: AppLoggerService, + private readonly usageService: UsageService + ) { + const { paddle, general } = this.appConfigService.getConfig(); + this.enabled = paddle.enable; + + if (!this.enabled) { + return; + } + + this.paddleClient = new Paddle(paddle.secret, { + environment: general.env === 'development' ? Environment.sandbox : Environment.production, + }); + } + + async onModuleInit() { + if (!this.enabled) { + return; + } + + // Validate paddle plans + const ids = Object.keys(PLAN_LEVELS) + .map((plan) => PLAN_LEVELS[plan].PADDLE_PLAN_ID) + .filter((id) => id !== undefined); + try { + for (const id of ids) { + await this.paddleClient.products.get(id); + } + } catch (e) { + throw new Error('Invalid paddle configuration.'); + } + } + + verifyWebhookSignature(signature: string, secret: string, payload: string) { + if (!this.paddleClient) { + return; + } + + return this.paddleClient.webhooks.unmarshal(payload, secret, signature); + } + + async cancelSubscription(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + subscription: true, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + if (!user.subscription) { + throw new Error('No subscription found'); + } + + const result = await this.paddleClient.subscriptions.cancel(user.subscription.id, { + effectiveFrom: 'next_billing_period', + }); + + const endDate = result.currentBillingPeriod.endsAt; + + //Status will be updated in the webhook on cancel + try { + await this.prisma.subscription.update({ + where: { + id: user.subscription.id, + }, + data: { + endDate, + scheduledToBeCancelled: true, + }, + }); + } catch (e) { + throw new Error('Failed to cancel subscription'); + } + } + + async resumeSubscription(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + subscription: true, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + if (!user.subscription) { + throw new Error('No subscription found'); + } + + if (!user.subscription.scheduledToBeCancelled) { + throw new Error('Subscription is not scheduled to be cancelled'); + } + + const result = await this.paddleClient.subscriptions.update(user.subscription.id, { + scheduledChange: null, + }); + + await this.prisma.subscription.update({ + where: { + id: user.subscription.id, + }, + data: { + endDate: null, + scheduledToBeCancelled: false, + status: result.status, + }, + }); + } + + async updateSubscription(userId: string, newProductId: string, newPriceId: string, operationType: string) { + const user = await this.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + if (!user.subscription) { + throw new Error('No subscription found'); + } + + const currentPaddleProductId = PLAN_LEVELS[user.subscription.plan].PADDLE_PLAN_ID; + if (!currentPaddleProductId) { + throw new Error('Invalid plan'); + } + + if (currentPaddleProductId === newProductId) { + throw new Error('Same plan selected'); + } + + const newPlan = Object.keys(PLAN_LEVELS).find((plan) => PLAN_LEVELS[plan].PADDLE_PLAN_ID === newProductId); + + if (!newPlan) { + throw new Error('Invalid plan id'); + } + + const subscriptionUpdate = await this.paddleClient.subscriptions.update(user.subscription.id, { + items: [{ priceId: newPriceId, quantity: 1 }], + prorationBillingMode: 'prorated_immediately', + }); + + const newSub = await this.prisma.subscription.update({ + where: { + id: user.subscription.id, + }, + data: { + id: subscriptionUpdate.id, + plan: Plan[newPlan], + startDate: subscriptionUpdate.startedAt, + nextBilledAt: subscriptionUpdate.nextBilledAt, + status: subscriptionUpdate.status, + }, + }); + + return { ...user, subscription: newSub }; + } + + async onSubscriptionModified(subData: SubscriptionUpdatedEvent) { + const { id } = subData.data; + const user = await this.extractUserFromWebhookData(subData.data); + + if (!user) { + return; + } + + const mainItem = subData.data.items[0]; + const paddleProductId = mainItem.price.productId; + + let plan = Object.keys(PLAN_LEVELS).find((plan) => PLAN_LEVELS[plan].PADDLE_PLAN_ID === paddleProductId) as Plan; + + if (subData.data.status === 'canceled') { + plan = Plan.FREE; + await this.prisma.subscription.delete({ + where: { + id: id, + }, + }); + } else { + await this.prisma.subscription.update({ + where: { + id: id, + }, + data: { + status: subData.data.status, + plan, + ...(subData.data.nextBilledAt && { nextBilledAt: new Date(subData.data.nextBilledAt) }), + startDate: new Date(subData.data.startedAt), + }, + }); + } + + await this.usageService.updateLimits(user.id, plan); + } + + async onSubscriptionCancelled(subData: SubscriptionCanceledEvent) { + const { id } = subData.data; + const user = await this.extractUserFromWebhookData(subData.data); + + if (!user) { + return; + } + + if (user.subscription) { + await this.prisma.subscription.delete({ + where: { + id, + }, + }); + } + + await this.usageService.updateLimits(user.id, Plan.FREE); + } + + async onSubscriptionAcvivated(subData: SubscriptionActivatedEvent) { + const user = await this.extractUserFromWebhookData(subData.data); + + if (!user) { + return; + } + + const mainItem = subData.data.items[0]; + const paddleProductId = mainItem.price.productId; + const PLAN_KEY = Object.keys(PLAN_LEVELS).find((plan) => PLAN_LEVELS[plan].PADDLE_PLAN_ID === paddleProductId); + if (!PLAN_KEY) { + this.logger.error('Plan not found with id ', paddleProductId, 'subscription =>', subData.data.id); + } + const newSubsription = { + id: subData.data.id, + userId: user.id, + plan: PLAN_KEY as Plan, + startDate: new Date(subData.occurredAt), + nextBilledAt: new Date(subData.data.nextBilledAt), + status: subData.data.status, + }; + + try { + await this.prisma.subscription.upsert({ + where: { + id: user.subscription?.id || subData.data.id, + }, + update: { + id: newSubsription.id, + plan: newSubsription.plan, + startDate: newSubsription.startDate, + nextBilledAt: newSubsription.nextBilledAt, + status: newSubsription.status, + }, + create: { + ...newSubsription, + }, + }); + + await this.usageService.updateLimits(user.id, PLAN_KEY); + } catch (e) { + this.logger.error('Failed to create subscription ', e); + } + } + + async getBillingInfo(userId: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { + subscription: true, + usage: true, + }, + }); + + if (!user) { + throw new Error('User not found'); + } + + const subscription = user.subscription; + const plan = subscription ? subscription.plan : Plan.FREE; + const planConfig = PLAN_LEVELS[plan]; + + const startDate = subscription ? subscription.startDate : user.createdAt; + const endDate = subscription ? subscription.endDate : null; + const nextBillingAt = subscription ? subscription.nextBilledAt : null; + + const usage = { + currentLinkCount: user.usage?.linksCount || 0, + currentTrackedClicks: user.usage?.clicksCount || 0, + }; + + const limits = { + linksCount: planConfig.FEATURES.LINKS_COUNT.value, + trackedClicks: planConfig.FEATURES.TRACKED_CLICKS.value, + }; + + return { + id: subscription?.id || '', + plan, + startDate, + endDate, + scheduledToBeCancelled: subscription?.scheduledToBeCancelled || false, + nextBillingAt, + limits, + usage, + }; + } + + private async extractUserIdFromWebhookData(data: any) { + const { userId } = data.customData as { userId?: string }; + return userId; + } + + private async extractUserFromWebhookData(data: any) { + const userId = await this.extractUserIdFromWebhookData(data); + if (!userId) { + return; + } + + return this.prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + } +} diff --git a/apps/backend/src/billing/dto/update-plan.dto.ts b/apps/backend/src/billing/dto/update-plan.dto.ts new file mode 100644 index 00000000..c92b02af --- /dev/null +++ b/apps/backend/src/billing/dto/update-plan.dto.ts @@ -0,0 +1,18 @@ +import { IsDefined, IsString, MaxLength } from 'class-validator'; + +export class UpdatePlanDto { + @IsString() + @IsDefined() + @MaxLength(40) + itemId: string; + + @IsString() + @IsDefined() + @MaxLength(40) + planId: string; + + @IsString() + @IsDefined() + @MaxLength(40) + operationType: 'upgrade' | 'downgrade'; +} diff --git a/apps/backend/src/core/users/users.service.ts b/apps/backend/src/core/users/users.service.ts index 58d0f45c..ba55c841 100644 --- a/apps/backend/src/core/users/users.service.ts +++ b/apps/backend/src/core/users/users.service.ts @@ -34,17 +34,28 @@ export class UsersService extends EntityService { return this.prismaService.user.count({ where }); } - async findByEmail(email: string): Promise { + async findUserContextByEmail(email: string): Promise { const user = await this.prismaService.user.findUnique({ where: { email, }, + include: { + subscription: true, + }, }); + // We want to return undefined if user is not found + if (!user) { + return undefined; + } + delete user?.password; delete user?.refreshToken; - return user; + return { + ...user, + plan: user?.subscription?.plan || 'FREE', + }; } async updateById(id: string, data: Prisma.UserUpdateInput): Promise { diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 445f6ce6..a215d487 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -9,7 +9,7 @@ import cookieParser from 'cookie-parser'; import bodyParser from 'body-parser'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableVersioning({ type: VersioningType.URI, @@ -18,8 +18,8 @@ async function bootstrap() { }); app.use(cookieParser()); - app.use(bodyParser.json({ limit: '5mb' })); - app.use(bodyParser.urlencoded({ limit: '5mb', extended: true })); + app.useBodyParser('json', { limit: '5mb' }); + app.useBodyParser('urlencoded', { limit: '5mb', extended: true }); app.enableCors({ origin: true, credentials: true }); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); diff --git a/apps/backend/src/shortener/guards/feature.guard.ts b/apps/backend/src/shortener/guards/feature.guard.ts new file mode 100644 index 00000000..5311e1b9 --- /dev/null +++ b/apps/backend/src/shortener/guards/feature.guard.ts @@ -0,0 +1,33 @@ +import { UserContext } from '../../auth/interfaces/user-context'; +import { createParamDecorator, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Role } from '@reduced.to/prisma'; +import { FEATURES, PLAN_LEVELS } from '@reduced.to/subscription-manager'; + +export const GuardFields = createParamDecorator((_, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as UserContext; + const planName = user.plan || 'FREE'; + const plan = PLAN_LEVELS[planName]; + + // Admins can do whatever the fuck they want + if (user.role === Role.ADMIN) { + return request.body; + } + + const disabledFeatures = Object.keys(plan.FEATURES).filter((feature) => !plan.FEATURES[feature].enabled); + for (const field in request.body) { + const isNotPermitted = disabledFeatures.find((df) => { + const apiGuard = FEATURES[df].apiGuard; + if (!apiGuard) { + return false; + } + + return RegExp(apiGuard).test(field); + }); + if (isNotPermitted) { + throw new UnauthorizedException(`This feature is not available for your plan.`); + } + } + + return request.body; +}); diff --git a/apps/backend/src/shortener/shortener.controller.spec.ts b/apps/backend/src/shortener/shortener.controller.spec.ts index cb63b37a..73d8fa5e 100644 --- a/apps/backend/src/shortener/shortener.controller.spec.ts +++ b/apps/backend/src/shortener/shortener.controller.spec.ts @@ -10,6 +10,7 @@ import { ShortenerProducer } from './producer/shortener.producer'; import { QueueManagerModule, QueueManagerService } from '@reduced.to/queue-manager'; import { IClientDetails } from '../shared/decorators/client-details/client-details.decorator'; import { SafeUrlService } from '@reduced.to/safe-url'; +import { UsageService } from '@reduced.to/subscription-manager'; describe('ShortenerController', () => { let shortenerController: ShortenerController; @@ -38,6 +39,12 @@ describe('ShortenerController', () => { isSafeUrl: jest.fn().mockResolvedValue(true), }, }, + { + provide: UsageService, + useValue: { + isEligibleToCreateLink: jest.fn().mockResolvedValue(true), + }, + }, QueueManagerService, ShortenerProducer, ], diff --git a/apps/backend/src/shortener/shortener.controller.ts b/apps/backend/src/shortener/shortener.controller.ts index 402be0fe..dd56f7a2 100644 --- a/apps/backend/src/shortener/shortener.controller.ts +++ b/apps/backend/src/shortener/shortener.controller.ts @@ -12,6 +12,8 @@ import { AppConfigService } from '@reduced.to/config'; import { Link } from '@prisma/client'; import { addUtmParams } from '@reduced.to/utils'; import { JwtAuthGuard } from '../auth/guards/jwt.guard'; +import { UsageService } from '@reduced.to/subscription-manager'; +import { GuardFields } from './guards/feature.guard'; interface LinkResponse extends Partial { url: string; @@ -28,7 +30,8 @@ export class ShortenerController { private readonly logger: AppLoggerService, private readonly shortenerService: ShortenerService, private readonly shortenerProducer: ShortenerProducer, - private readonly safeUrlService: SafeUrlService + private readonly safeUrlService: SafeUrlService, + private readonly usageService: UsageService ) {} @UseGuards(JwtAuthGuard) @@ -73,7 +76,7 @@ export class ShortenerController { @UseGuards(OptionalJwtAuthGuard) @Post() - async shortener(@Body() shortenerDto: ShortenerDto, @Req() req: Request): Promise<{ key: string }> { + async shortener(@GuardFields() @Body() shortenerDto: ShortenerDto, @Req() req: Request): Promise<{ key: string }> { const user = req.user as UserContext; // Check if the url is safe @@ -97,14 +100,19 @@ export class ShortenerController { return this.shortenerService.createShortenedUrl(rest); } + // Only verified users can create shortened urls + if (!user?.verified) { + throw new BadRequestException('You must be verified in to create a shortened url'); + } + // Hash the password if it exists in the request if (shortenerDto.password) { shortenerDto.password = await this.shortenerService.hashPassword(shortenerDto.password); } - // Only verified users can create shortened urls - if (!user?.verified) { - throw new BadRequestException('You must be verified in to create a shortened url'); + const isEligibleToCreateLink = await this.usageService.isEligibleToCreateLink(user.id); + if (!isEligibleToCreateLink) { + throw new BadRequestException('You have reached your link creation limit'); } this.logger.log(`User ${user.id} is creating a shortened url for ${shortenerDto.url}`); diff --git a/apps/backend/src/shortener/shortener.module.ts b/apps/backend/src/shortener/shortener.module.ts index 086bd6c8..716e61d6 100644 --- a/apps/backend/src/shortener/shortener.module.ts +++ b/apps/backend/src/shortener/shortener.module.ts @@ -5,8 +5,9 @@ import { PrismaModule } from '@reduced.to/prisma'; import { ShortenerProducer } from './producer/shortener.producer'; import { QueueManagerModule, QueueManagerService } from '@reduced.to/queue-manager'; import { SafeUrlModule } from '@reduced.to/safe-url'; +import { UsageModule } from '@reduced.to/subscription-manager'; @Module({ - imports: [PrismaModule, QueueManagerModule, SafeUrlModule.forRootAsync()], + imports: [PrismaModule, QueueManagerModule, SafeUrlModule.forRootAsync(), UsageModule], controllers: [ShortenerController], providers: [ShortenerService, QueueManagerService, ShortenerProducer], exports: [ShortenerService], diff --git a/apps/backend/src/shortener/shortener.service.spec.ts b/apps/backend/src/shortener/shortener.service.spec.ts index 03db5bd6..4033866a 100644 --- a/apps/backend/src/shortener/shortener.service.spec.ts +++ b/apps/backend/src/shortener/shortener.service.spec.ts @@ -8,6 +8,7 @@ import { PrismaService } from '@reduced.to/prisma'; import { ShortenerDto } from './dto'; import { BadRequestException } from '@nestjs/common'; import { UserContext } from '../auth/interfaces/user-context'; +import { UsageService } from '@reduced.to/subscription-manager'; const FIXED_SYSTEM_TIME = '1999-01-01T00:00:00Z'; @@ -52,6 +53,13 @@ describe('ShortenerService', () => { }, }), }, + { + provide: UsageService, + useValue: { + isEligibleToCreateLink: jest.fn().mockResolvedValue(true), + incrementLinksCount: jest.fn(), + }, + }, ], }).compile(); diff --git a/apps/backend/src/shortener/shortener.service.ts b/apps/backend/src/shortener/shortener.service.ts index 8d675b97..19a01809 100644 --- a/apps/backend/src/shortener/shortener.service.ts +++ b/apps/backend/src/shortener/shortener.service.ts @@ -7,13 +7,15 @@ import { UserContext } from '../auth/interfaces/user-context'; import { Link } from '@reduced.to/prisma'; import * as argon2 from 'argon2'; import { createUtmObject } from '@reduced.to/utils'; +import { UsageService } from '@reduced.to/subscription-manager'; @Injectable() export class ShortenerService { constructor( private readonly appCacheService: AppCacheService, private readonly prisma: PrismaService, - private readonly appConfigService: AppConfigService + private readonly appConfigService: AppConfigService, + private readonly usageService: UsageService ) {} /** @@ -127,8 +129,8 @@ export class ShortenerService { if (password && shortenerDto.temporary) { throw new BadRequestException('Temporary links cannot be password protected'); } - - return this.prisma.link.create({ data }); + const [_, createdLink] = await Promise.all([this.usageService.incrementLinksCount(user.id), this.prisma.link.create({ data })]); + return createdLink; }; /** diff --git a/apps/backend/src/tasks/tasks.module.ts b/apps/backend/src/tasks/tasks.module.ts new file mode 100644 index 00000000..6fc4cb8a --- /dev/null +++ b/apps/backend/src/tasks/tasks.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsageModule } from '@reduced.to/subscription-manager'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [UsageModule], + providers: [TasksService], + exports: [TasksService], +}) +export class TasksModule {} diff --git a/apps/backend/src/tasks/tasks.service.ts b/apps/backend/src/tasks/tasks.service.ts new file mode 100644 index 00000000..794d372b --- /dev/null +++ b/apps/backend/src/tasks/tasks.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { UsageService } from '@reduced.to/subscription-manager'; + +@Injectable() +export class TasksService { + constructor(private readonly usageService: UsageService) {} + + @Cron('0 0 1 * *') // Runs at midnight on the first day of every month + async handleCron() { + await this.usageService.runOnAllActiveUsers(async (id) => { + await this.usageService.resetUsage(id); + }); + } +} diff --git a/apps/backend/tsconfig.app.json b/apps/backend/tsconfig.app.json index a2ce7652..b98dba1d 100644 --- a/apps/backend/tsconfig.app.json +++ b/apps/backend/tsconfig.app.json @@ -8,5 +8,5 @@ "target": "es2021" }, "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../../libs/subscription-manager/src/lib/usage/usage.service.ts"] } diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 9ff5835f..b0f00211 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -16,6 +16,7 @@ ARG API_DOMAIN ARG CLIENTSIDE_API_DOMAIN ARG DOMAIN ARG STORAGE_DOMAIN +ARG PUBLIC_PADDLE_KEY # Set environment variables ENV NODE_ENV=production diff --git a/apps/frontend/project.json b/apps/frontend/project.json index 10eb7052..a4c84831 100644 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -74,7 +74,7 @@ "executor": "nx:run-commands", "options": { "commands": [ - "docker build -f apps/frontend/Dockerfile . -t frontend --build-arg DOMAIN={args.domain} --build-arg API_DOMAIN={args.apiDomain} --build-arg CLIENTSIDE_API_DOMAIN={args.clientSideApiDomain} --build-arg STORAGE_DOMAIN={args.storageDomain}", + "docker build -f apps/frontend/Dockerfile . -t frontend --build-arg DOMAIN={args.domain} --build-arg API_DOMAIN={args.apiDomain} --build-arg CLIENTSIDE_API_DOMAIN={args.clientSideApiDomain} --build-arg STORAGE_DOMAIN={args.storageDomain} --build-arg PUBLIC_PADDLE_KEY={args.publicPaddleKey}", "docker image tag frontend ghcr.io/{args.repository}/frontend:master", "docker push ghcr.io/{args.repository}/frontend:master" ], diff --git a/apps/frontend/src/assets/svg/hero/polygon-bg-element-dark.svg b/apps/frontend/src/assets/svg/hero/polygon-bg-element-dark.svg deleted file mode 100644 index e96161b8..00000000 --- a/apps/frontend/src/assets/svg/hero/polygon-bg-element-dark.svg +++ /dev/null @@ -1,346 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/assets/svg/hero/polygon-bg-element-light.svg b/apps/frontend/src/assets/svg/hero/polygon-bg-element-light.svg deleted file mode 100644 index 51e1ceb8..00000000 --- a/apps/frontend/src/assets/svg/hero/polygon-bg-element-light.svg +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/frontend/src/components/background/background.tsx b/apps/frontend/src/components/background/background.tsx new file mode 100644 index 00000000..f2742b46 --- /dev/null +++ b/apps/frontend/src/components/background/background.tsx @@ -0,0 +1,9 @@ +import { component$ } from '@builder.io/qwik'; + +export const Background = component$(() => { + return ( +
+
+
+ ); +}); diff --git a/apps/frontend/src/components/dashboard/analytics/contries-chart/countries-chart.tsx b/apps/frontend/src/components/dashboard/analytics/contries-chart/countries-chart.tsx index 71d52a8d..7c496052 100644 --- a/apps/frontend/src/components/dashboard/analytics/contries-chart/countries-chart.tsx +++ b/apps/frontend/src/components/dashboard/analytics/contries-chart/countries-chart.tsx @@ -39,8 +39,12 @@ export const CountriesChart = component$((props: CountriesChartProps) => { }; const filteredData = locations.value.filter((item) => { - const country = countryLookup.byIso(getCountryCode(item)); - return country && country.country.toLowerCase().includes(searchQuery.value.toLowerCase()); + try { + const country = countryLookup.byIso(getCountryCode(item)); + return country && country.country.toLowerCase().includes(searchQuery.value.toLowerCase()); + } catch (e) { + // Do nothing + } }); return ( diff --git a/apps/frontend/src/components/dashboard/delete-modal/delete-modal.tsx b/apps/frontend/src/components/dashboard/generic-modal/generic-modal.tsx similarity index 64% rename from apps/frontend/src/components/dashboard/delete-modal/delete-modal.tsx rename to apps/frontend/src/components/dashboard/generic-modal/generic-modal.tsx index 61738da9..d6369b0e 100644 --- a/apps/frontend/src/components/dashboard/delete-modal/delete-modal.tsx +++ b/apps/frontend/src/components/dashboard/generic-modal/generic-modal.tsx @@ -5,17 +5,20 @@ import { useToaster } from '../../toaster/toaster'; export const DELETE_MODAL_ID = 'delete-modal'; export const DELETE_CONFIRMATION = 'DELETE'; +export const CANCEL_CONFIRMATION = 'CANCEL'; +export const RESUME_CONFIRMATION = 'RESUME'; -export interface DeleteModalProps { +export interface ModalProps { id: string; confirmation: string; idToDelete?: string; type: string; action: ActionStore; + operationType: 'delete' | 'cancel' | 'resume'; onSubmitHandler?: () => void; } -export const DeleteModal = component$(({ id, type, confirmation, idToDelete, onSubmitHandler, action }: DeleteModalProps) => { +export const GenericModal = component$(({ id, type, confirmation, idToDelete, onSubmitHandler, action, operationType }: ModalProps) => { const inputValue = useSignal(''); const toaster = useToaster(); @@ -27,6 +30,30 @@ export const DeleteModal = component$(({ id, type, confirmation, idToDelete, onS } }); + let successMessage: string; + switch (operationType) { + case 'delete': + successMessage = `Your ${type} has been deleted successfully.`; + break; + case 'cancel': + successMessage = `Your ${type} has been canceled successfully.`; + break; + case 'resume': + successMessage = `Your ${type} has been resumed successfully.`; + break; + } + + const operationText = operationType === 'delete' ? 'delete' : operationType === 'cancel' ? 'cancel' : 'resume'; + const errorMessage = `Something went wrong while ${operationText}ing your ${type}. Please try again later.`; + const confirmationMessage = + operationType === 'delete' + ? `This action cannot be undone. This will permanently delete your ${type}.` + : operationType === 'cancel' + ? `You will still be able to use your ${type} until the end of the current billing period.` + : `This will revert your scheduled cancellation and your subscription will remain active.`; + + const buttonClass = operationType === 'delete' ? 'btn-error' : operationType === 'cancel' ? 'btn-error' : 'btn-warning'; + return ( <> @@ -40,7 +67,7 @@ export const DeleteModal = component$(({ id, type, confirmation, idToDelete, onS if (action.value?.failed && action.value.message) { toaster.add({ title: 'Error', - description: `Something went wrong while deleting your ${type}. Please try again later.`, + description: errorMessage, type: 'error', }); return; @@ -48,7 +75,7 @@ export const DeleteModal = component$(({ id, type, confirmation, idToDelete, onS toaster.add({ title: 'Success', - description: `Your ${type} has been deleted successfully.`, + description: successMessage, type: 'info', }); @@ -75,8 +102,7 @@ export const DeleteModal = component$(({ id, type, confirmation, idToDelete, onS

- You are about to delete your {type}. This action cannot be undone. This will permanently delete your {type} and all of its - data associated with it. + You are about to {operationText} your {type}. {confirmationMessage}

)}
-
diff --git a/apps/frontend/src/components/dashboard/links/link-modal/link-modal.tsx b/apps/frontend/src/components/dashboard/links/link-modal/link-modal.tsx index 5893a9ad..9a226d8e 100644 --- a/apps/frontend/src/components/dashboard/links/link-modal/link-modal.tsx +++ b/apps/frontend/src/components/dashboard/links/link-modal/link-modal.tsx @@ -9,12 +9,14 @@ import { UNKNOWN_FAVICON } from '../../../temporary-links/utils'; import { useDebouncer } from '../../../../utils/debouncer'; import { LuEye, LuEyeOff, LuDices } from '@qwikest/icons/lucide'; import { sleep } from '@reduced.to/utils'; +import { useGetCurrentUser } from '../../../../../../frontend/src/routes/layout'; +import { ConditionalWrapper, getRequiredFeatureLevel } from '../../plan-wrapper'; export const LINK_MODAL_ID = 'link-modal'; interface CreateLinkInput { url: string; - key: string; + key?: string; expirationTime?: string | number; passwordProtection?: string; @@ -41,11 +43,26 @@ const CreateLinkInputSchema = z }), key: z .string() - .min(4, { message: 'The short link must be at least 4 characters long.' }) .max(20, { message: 'The short link cannot exceed 20 characters.' }) .regex(/^[a-zA-Z0-9-]*$/, { message: 'The short link can only contain letters, numbers, and dashes.', - }), + }) + .optional() + .refine( + (val) => { + if (!val?.length) { + return true; + } + if (val?.length && val?.length < 4) { + return false; + } + + return true; + }, + { + message: 'The short link must be at least 4 characters long.', + } + ), expirationTime: z.string().optional(), expirationTimeToggle: z.string().optional(), passwordProtection: z @@ -112,7 +129,7 @@ const useCreateLink = globalAction$( const body: CreateLinkInput = { url: normalizeUrl(url), - key, + ...(key && { key: key }), ...(expirationTime && { expirationTime: new Date(expirationTime).getTime() }), ...(passwordProtection && { password: passwordProtection }), @@ -134,10 +151,10 @@ const useCreateLink = globalAction$( body: JSON.stringify(body), }); - const data: { url: string; key: string; message?: string[]; statusCode?: number } = await response.json(); + const data: { url: string; key: string; message?: string[] } = await response.json(); if (response.status !== 201) { - return fail(data?.statusCode || 500, { + return fail(500, { message: data?.message || 'There was an error creating your link. Please try again.', }); } @@ -172,10 +189,14 @@ const initValues = { utm_content: undefined, }; export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { + const user = useGetCurrentUser(); const inputValue = useSignal({ ...initValues }); const faviconUrl = useSignal(null); const previewUrl = useSignal(null); + // Short key input field + const requiredLevelToCustomShortLink = useSignal(getRequiredFeatureLevel(user.value?.plan || 'FREE', 'CUSTOM_SHORT_KEY')); + // Optional fields const isExpirationTimeOpen = useSignal(false); const isPasswordProtectionOpen = useSignal(false); @@ -195,7 +216,7 @@ export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { ); const generateRandomKey = $(async () => { - if (isGeneratingRandomKey.value) { + if (requiredLevelToCustomShortLink.value || isGeneratingRandomKey.value) { return; } @@ -207,28 +228,18 @@ export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { isGeneratingRandomKey.value = false; }); - const toggleOption = $( - (signal: Signal, resetKey: keyof CreateLinkInput | (keyof CreateLinkInput)[], resetValue: any = undefined) => { - signal.value = !signal.value; - if (!signal.value && resetKey !== undefined) { - // Reset field errors - - if (Array.isArray(resetKey)) { - resetKey.forEach((key) => { - inputValue.value[key] = resetValue; - if (action.value?.fieldErrors![key]) { - action.value.fieldErrors[key] = []; - } - }); - } else { - if (action.value?.fieldErrors![resetKey]) { - action.value.fieldErrors[resetKey] = []; - } - inputValue.value[resetKey] = resetValue; + const toggleOption = $((signal: Signal, resetKeys: (keyof CreateLinkInput)[], resetValue: any = undefined) => { + signal.value = !signal.value; + if (!signal.value && resetKeys !== undefined) { + // Reset field errors + resetKeys.forEach((key) => { + inputValue.value[key] = resetValue; + if (action.value?.fieldErrors![key]) { + action.value.fieldErrors[key] = []; } - } + }); } - ); + }); const toggleShowPassword = $(() => { showPassword.value = !showPassword.value; @@ -295,7 +306,7 @@ export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { debounceUrlInput(inputValue.value.url); // if the user is typing nad there is no key, generate one - if (inputValue.value.url.length > 0 && inputValue.value.key.length === 0) { + if (inputValue.value.url.length > 0 && inputValue.value.key?.length === 0) { generateRandomKey(); } }} @@ -312,25 +323,29 @@ export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { -
-
- {isGeneratingRandomKey.value ? ( - - ) : ( - - )} + {requiredLevelToCustomShortLink.value ? ( + + ) : ( +
+
+ {isGeneratingRandomKey.value ? ( + + ) : ( + + )} +
-
+ )}
-
{ @@ -345,7 +360,6 @@ export const LinkModal = component$(({ onSubmitHandler }: LinkModalProps) => { ) : null}
- {action.value?.failed && action.value.message && (
+ ); +}); diff --git a/apps/frontend/src/components/dashboard/plan-modal/plan-modal.tsx b/apps/frontend/src/components/dashboard/plan-modal/plan-modal.tsx new file mode 100644 index 00000000..44b08a17 --- /dev/null +++ b/apps/frontend/src/components/dashboard/plan-modal/plan-modal.tsx @@ -0,0 +1,185 @@ +import { component$, useSignal, $ } from '@builder.io/qwik'; +import { getPlanByPaddleId, PLAN_LEVELS, Plan, FEATURES, FeatureKey } from '@reduced.to/subscription-manager'; +import { Form } from '@builder.io/qwik-city'; +import { useGetCurrentUser } from '../../../routes/layout'; +import { CheckoutEventNames, Paddle } from '@paddle/paddle-js'; +import { DARK_THEME, getCurrentTheme } from '../../theme-switcher/theme-switcher'; +import { useChangePlan } from './use-change-plan'; +import { CONFIRM_MODAL_ID, ConfirmModal } from './confirm-modal'; +import { useRevalidatePlan } from '../../../../../frontend/src/routes/dashboard/settings/billing/use-revalidate-plan'; +import { LuCheck } from '@qwikest/icons/lucide'; +import { CANCEL_PLAN_MODAL_ID, useCancelPlan } from '../../../routes/dashboard/settings/billing/use-cancel-plan'; +import { GenericModal } from '../generic-modal/generic-modal'; + +export const PLAN_MODAL_ID = 'plan-modal'; + +interface PlanModalProps { + id: string; + paddle?: Paddle; +} + +export const PlanModal = component$(({ id, paddle }: PlanModalProps) => { + const changePlanAction = useChangePlan(); + const user = useGetCurrentUser(); + const revalidatePlan = useRevalidatePlan(); + const currentPlan = PLAN_LEVELS[user.value?.plan || 'FREE']; + const filteredPlans = Object.values(PLAN_LEVELS).filter((plan) => plan.YEARLY_PRICE > 0); + + const billingCycle = useSignal<'monthly' | 'yearly'>('yearly'); + + const selectValue = useSignal(filteredPlans[0].PADDLE_PLAN_ID); + const selectedPriceId = useSignal(filteredPlans[0].PADDLE_YEARLY_PRICE_ID); + const selectedPlan = useSignal(getPlanByPaddleId(selectValue.value!)!); + const selectedOperation = useSignal<'upgrade' | 'downgrade' | 'cancel'>('upgrade'); + + const cancelPlanAction = useCancelPlan(); + + const onChangeSubscription = $((operation: 'upgrade' | 'downgrade' | 'cancel') => { + if (!user.value) { + return; + } + + (document.getElementById(id) as any).close(); + if (user.value.plan === 'FREE') { + paddle?.Checkout.open({ + settings: { + displayMode: 'overlay', + locale: 'en', + theme: getCurrentTheme() === DARK_THEME ? 'dark' : 'light', + }, + items: [ + { + priceId: selectedPriceId.value!, + quantity: 1, + }, + ], + customData: { + userId: user.value?.id, + }, + }); + paddle?.Update({ + eventCallback: async (data) => { + if (data.name == CheckoutEventNames.CHECKOUT_COMPLETED) { + setTimeout(revalidatePlan.submit, 2000); + } + }, + }); + return; + } + if (operation === 'cancel') { + (document.getElementById(CANCEL_PLAN_MODAL_ID) as any).showModal(); + return; + } + + // If it's not cancel plan, then it's either upgrade or downgrade + selectedOperation.value = operation; + (document.getElementById(CONFIRM_MODAL_ID) as any).showModal(); + }); + + return ( + <> + + + + + + + + ); +}); diff --git a/apps/frontend/src/components/dashboard/plan-modal/use-change-plan.tsx b/apps/frontend/src/components/dashboard/plan-modal/use-change-plan.tsx new file mode 100644 index 00000000..8f1c545d --- /dev/null +++ b/apps/frontend/src/components/dashboard/plan-modal/use-change-plan.tsx @@ -0,0 +1,34 @@ +import { globalAction$, z, zod$ } from '@builder.io/qwik-city'; +import { setTokensAsCookies } from '../../../shared/auth.service'; + +export const useChangePlan = globalAction$( + async ({ planId, itemId, operationType }, { fail, cookie }) => { + const response: Response = await fetch(`${process.env.API_DOMAIN}/api/v1/billing/plan`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${cookie.get('accessToken')?.value}`, + }, + body: JSON.stringify({ planId, itemId, operationType }), + }); + + const data = await response.json(); + + if (response.status !== 201 && response.status !== 200) { + return fail(500, { + message: data?.message, + }); + } + + const { accessToken, refreshToken } = data; + if (accessToken && refreshToken) { + setTokensAsCookies(accessToken, refreshToken, cookie); + } + return data; + }, + zod$({ + planId: z.string().min(5), + itemId: z.string().min(5), + operationType: z.string(), + }) +); diff --git a/apps/frontend/src/components/dashboard/plan-wrapper.tsx b/apps/frontend/src/components/dashboard/plan-wrapper.tsx new file mode 100644 index 00000000..670060ec --- /dev/null +++ b/apps/frontend/src/components/dashboard/plan-wrapper.tsx @@ -0,0 +1,59 @@ +import { $, component$, Slot } from '@builder.io/qwik'; +import { LuBadgeCheck } from '@qwikest/icons/lucide'; +import { PLAN_LEVELS, FEATURES } from '@reduced.to/subscription-manager'; +import { useGetCurrentUser } from '../../../../frontend/src/routes/layout'; + +interface ConditionalWrapperProps { + access: keyof typeof FEATURES; + cs?: string; +} + +export function getRequiredFeatureLevel(currentPlan: string, access: keyof typeof FEATURES) { + let requiredLevel = ''; + for (const planName in PLAN_LEVELS) { + const plan = PLAN_LEVELS[planName]; + if (plan.FEATURES[access]?.enabled === true) { + if (currentPlan === planName) return null; + if (!requiredLevel) requiredLevel = plan.DISPLAY_NAME; + } + } + return requiredLevel || 'PRO'; +} + +export const ConditionalWrapper = component$(({ access, cs }: ConditionalWrapperProps) => { + const user = useGetCurrentUser(); + const plan = user?.value?.plan || 'FREE'; + const level = getRequiredFeatureLevel(plan, access); + + if (!level) { + return ; + } + + const onClick = $((e: Event) => { + e.preventDefault(); + }); + + return ( +
+
+ + {level} +
+
e.stopPropagation()} + preventdefault:input + onInput$={(e) => e.stopPropagation()} + > + +
+
+ ); +}); diff --git a/apps/frontend/src/components/faq/faq.tsx b/apps/frontend/src/components/faq/faq.tsx new file mode 100644 index 00000000..72f1f147 --- /dev/null +++ b/apps/frontend/src/components/faq/faq.tsx @@ -0,0 +1,89 @@ +import { $, component$, useSignal } from '@builder.io/qwik'; +import { LuChevronDown, LuChevronUp } from '@qwikest/icons/lucide'; + +export const Faq = component$(() => { + const activeAccordion = useSignal(null); + + const toggleAccordion = $((index: number) => { + activeAccordion.value = activeAccordion.value === index ? null : index; + }); + + const faqs = [ + { + question: 'Can I cancel my subscription anytime?', + answer: 'Yes, you can cancel your subscription at any time. Your access will remain active until the end of the billing cycle.', + }, + { + question: 'How do I track the clicks on my shortened URLs?', + answer: + 'Our URL shortener service provides detailed analytics on the number of clicks, geographic location of the clicks, and the referrer. You can access this information from your dashboard.', + }, + { + question: 'Is there a limit to how many URLs I can shorten?', + answer: + 'The Free plan allows you to shorten up to 5 URLs per month. Our Pro and Business plans offer higher limits of 500 and 2000 URLs per month, respectively.', + }, + { + question: 'Can I customize the short URL?', + answer: + 'Yes, you can customize your short URL with a custom alias if you are on the Pro or Business plan. This feature is not available on the Free plan.', + }, + { + question: 'How secure are my shortened URLs?', + answer: + 'We prioritize the security of your data. All shortened URLs are protected with SSL encryption. Additionally, we offer features like password protection and link expiration on our Pro and Business plans.', + }, + { + question: 'Can I generate QR code for all my short links?', + answer: + 'Yes, you can generate QR codes for all your short links. This feature is available on all our plans. You can download the QR code and use it in your marketing materials.', + }, + ]; + + return ( +
+
+
+
+

+ Frequently +
+ asked questions +

+ +
+
+ +
+
+ {faqs.map((faq, index) => ( +
+ +
+

{faq.answer}

+
+
+ ))} +
+
+
+
+ ); +}); diff --git a/apps/frontend/src/components/features/features.tsx b/apps/frontend/src/components/features/features.tsx index 39c08b2e..8c23eff0 100644 --- a/apps/frontend/src/components/features/features.tsx +++ b/apps/frontend/src/components/features/features.tsx @@ -5,239 +5,251 @@ import { GlobalStore } from '../../context'; export const Features = component$(() => { const state = useContext(GlobalStore); return ( -
-
-
-
-
-

Easily Manage Your Links

-

- Our dashboard provides a simple and intuitive interface for managing all your shortened links. Organize, edit, and track your - links effortlessly with our powerful tools. -

-
    -
  • - - - - Comprehensive link management -
  • -
  • - - - - Custom UTM builder -
  • -
  • - - - - Password protected links -
  • -
  • - - - - Branded links -
  • -
  • - - - - User-friendly interface -
  • -
  • - - - - Secure and reliable -
  • -
-
- {state.theme === DARK_THEME ? ( - - ) : ( - - )} + <> +
+
+

+ Optimize and Manage Your + Links + with Ease +

+

+ Because your links deserve the best. Explore our powerful features designed for seamless link management and in-depth analytics. +

-
- {state.theme === DARK_THEME ? ( -
+ ); }); diff --git a/apps/frontend/src/components/hero/hero.tsx b/apps/frontend/src/components/hero/hero.tsx index c44fca11..390288c0 100644 --- a/apps/frontend/src/components/hero/hero.tsx +++ b/apps/frontend/src/components/hero/hero.tsx @@ -7,15 +7,12 @@ export const Hero = component$(() => { const user = useGetCurrentUser(); return ( -
-
+
+
{/* */}
@@ -29,7 +26,7 @@ export const Hero = component$(() => { {/* */}
-

+

Simplify your Links

diff --git a/apps/frontend/src/components/navbar/navbar.tsx b/apps/frontend/src/components/navbar/navbar.tsx index 3cb53191..018d69b2 100644 --- a/apps/frontend/src/components/navbar/navbar.tsx +++ b/apps/frontend/src/components/navbar/navbar.tsx @@ -11,34 +11,18 @@ export const Navbar = component$(() => { const showDropdown = useSignal(false); return ( -