From 0d7e54023c2d991642d7f7cc4d50de7a87fbb2ab Mon Sep 17 00:00:00 2001 From: Nevo David Date: Tue, 3 Mar 2026 17:42:17 +0700 Subject: [PATCH] feat: login change and appsumo --- apps/backend/src/api/api.module.ts | 18 +++ .../src/api/routes/appsumo.controller.ts | 40 ++++++ .../backend/src/services/auth/auth.service.ts | 21 ++-- .../src/services/auth/providers.interface.ts | 35 +++++- .../auth/providers/appsumo.provider.ts | 114 ++++++++++++++++++ .../auth/providers/farcaster.provider.ts | 13 +- .../auth/providers/github.provider.ts | 8 +- .../auth/providers/google.provider.ts | 9 +- .../services/auth/providers/oauth.provider.ts | 81 ++++++------- .../auth/providers/providers.factory.ts | 24 ---- .../auth/providers/providers.manager.ts | 23 ++++ .../auth/providers/wallet.provider.ts | 12 +- .../src/database/prisma/schema.prisma | 1 + .../subscriptions/subscription.repository.ts | 14 ++- .../subscriptions/subscription.service.ts | 8 +- .../src/services/appsumo.service.ts | 110 +++++++++++++++++ 16 files changed, 428 insertions(+), 103 deletions(-) create mode 100644 apps/backend/src/api/routes/appsumo.controller.ts create mode 100644 apps/backend/src/services/auth/providers/appsumo.provider.ts delete mode 100644 apps/backend/src/services/auth/providers/providers.factory.ts create mode 100644 apps/backend/src/services/auth/providers/providers.manager.ts create mode 100644 libraries/nestjs-libraries/src/services/appsumo.service.ts diff --git a/apps/backend/src/api/api.module.ts b/apps/backend/src/api/api.module.ts index 1f89b42c..af2dbbce 100644 --- a/apps/backend/src/api/api.module.ts +++ b/apps/backend/src/api/api.module.ts @@ -36,6 +36,15 @@ import { EnterpriseController } from '@gitroom/backend/api/routes/enterprise.con import { OAuthAppController } from '@gitroom/backend/api/routes/oauth-app.controller'; import { ApprovedAppsController } from '@gitroom/backend/api/routes/approved-apps.controller'; import { OAuthController, OAuthAuthorizedController } from '@gitroom/backend/api/routes/oauth.controller'; +import { AppSumoController } from '@gitroom/backend/api/routes/appsumo.controller'; +import { AppSumoService } from '@gitroom/nestjs-libraries/services/appsumo.service'; +import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager'; +import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider'; +import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider'; +import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider'; +import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider'; +import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider'; +import { AppSumoProvider } from '@gitroom/backend/services/auth/providers/appsumo.provider'; const authenticatedController = [ UsersController, @@ -61,6 +70,7 @@ const authenticatedController = [ controllers: [ RootController, StripeController, + AppSumoController, AuthController, PublicController, MonitorController, @@ -72,6 +82,7 @@ const authenticatedController = [ providers: [ AuthService, StripeService, + AppSumoService, OpenaiService, ExtractContentService, AuthMiddleware, @@ -82,6 +93,13 @@ const authenticatedController = [ TrackService, ShortLinkService, Nowpayments, + AuthProviderManager, + GithubProvider, + GoogleProvider, + FarcasterProvider, + WalletProvider, + OauthProvider, + AppSumoProvider, ], get exports() { return [...this.imports, ...this.providers]; diff --git a/apps/backend/src/api/routes/appsumo.controller.ts b/apps/backend/src/api/routes/appsumo.controller.ts new file mode 100644 index 00000000..03703d9f --- /dev/null +++ b/apps/backend/src/api/routes/appsumo.controller.ts @@ -0,0 +1,40 @@ +import { + Controller, + HttpException, + Post, + RawBodyRequest, + Req, +} from '@nestjs/common'; +import { AppSumoService } from '@gitroom/nestjs-libraries/services/appsumo.service'; +import { ApiTags } from '@nestjs/swagger'; + +@ApiTags('AppSumo') +@Controller('/appsumo') +export class AppSumoController { + constructor(private readonly _appSumoService: AppSumoService) {} + + @Post('/') + async webhook(@Req() req: RawBodyRequest) { + // @ts-ignore + const timestamp = req.headers['x-appsumo-timestamp'] as string; + // @ts-ignore + const signature = req.headers['x-appsumo-signature'] as string; + + if (!timestamp || !signature) { + throw new HttpException('Missing signature headers', 401); + } + + this._appSumoService.validateSignature( + req.rawBody!, + timestamp, + signature + ); + + try { + const payload = JSON.parse(req.rawBody!.toString()); + return this._appSumoService.handleWebhook(payload); + } catch (e) { + throw new HttpException(e, 500); + } + } +} diff --git a/apps/backend/src/services/auth/auth.service.ts b/apps/backend/src/services/auth/auth.service.ts index 3b639fb6..67310d2e 100644 --- a/apps/backend/src/services/auth/auth.service.ts +++ b/apps/backend/src/services/auth/auth.service.ts @@ -5,7 +5,7 @@ import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; import { AuthService as AuthChecker } from '@gitroom/helpers/auth/auth.service'; -import { ProvidersFactory } from '@gitroom/backend/services/auth/providers/providers.factory'; +import { AuthProviderManager } from '@gitroom/backend/services/auth/providers/providers.manager'; import dayjs from 'dayjs'; import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; import { ForgotReturnPasswordDto } from '@gitroom/nestjs-libraries/dtos/auth/forgot-return.password.dto'; @@ -18,7 +18,8 @@ export class AuthService { private _userService: UsersService, private _organizationService: OrganizationService, private _notificationService: NotificationService, - private _emailService: EmailService + private _emailService: EmailService, + private _providerManager: AuthProviderManager ) {} async canRegister(provider: string) { if ( @@ -136,7 +137,7 @@ export class AuthService { ip: string, userAgent: string ) { - const providerInstance = ProvidersFactory.loadProvider(provider); + const providerInstance = this._providerManager.getProvider(provider); const providerUser = await providerInstance.getUser(body.providerToken); if (!providerUser) { @@ -174,6 +175,12 @@ export class AuthService { await NewsletterService.register(providerUser.email); + try { + await providerInstance.postRegistration(body.providerToken, create.id); + } catch (err) { + // Don't fail registration if postRegistration fails + } + return create.users[0].user; } @@ -277,16 +284,12 @@ export class AuthService { } oauthLink(provider: string, query?: any) { - const providerInstance = ProvidersFactory.loadProvider( - provider as Provider - ); + const providerInstance = this._providerManager.getProvider(provider); return providerInstance.generateLink(query); } async checkExists(provider: string, code: string) { - const providerInstance = ProvidersFactory.loadProvider( - provider as Provider - ); + const providerInstance = this._providerManager.getProvider(provider); const token = await providerInstance.getToken(code); const user = await providerInstance.getUser(token); if (!user) { diff --git a/apps/backend/src/services/auth/providers.interface.ts b/apps/backend/src/services/auth/providers.interface.ts index 8000cc35..a166d5fb 100644 --- a/apps/backend/src/services/auth/providers.interface.ts +++ b/apps/backend/src/services/auth/providers.interface.ts @@ -1,7 +1,34 @@ -export interface ProvidersInterface { - generateLink(query?: any): Promise | string; - getToken(code: string): Promise; - getUser( +import { Injectable } from '@nestjs/common'; + +export abstract class AuthProviderAbstract { + abstract generateLink(query?: any): Promise | string; + abstract getToken(code: string): Promise; + abstract getUser( providerToken: string ): Promise<{ email: string; id: string }> | false; + async postRegistration( + providerToken: string, + orgId: string + ): Promise {} +} + +export interface AuthProviderParams { + provider: string; +} + +export function AuthProvider(params: AuthProviderParams) { + return function (target: any) { + Injectable()(target); + + const existingMetadata = + Reflect.getMetadata('auth-provider', AuthProviderAbstract) || []; + + existingMetadata.push({ target, provider: params.provider }); + + Reflect.defineMetadata( + 'auth-provider', + existingMetadata, + AuthProviderAbstract + ); + }; } diff --git a/apps/backend/src/services/auth/providers/appsumo.provider.ts b/apps/backend/src/services/auth/providers/appsumo.provider.ts new file mode 100644 index 00000000..7b9a58ec --- /dev/null +++ b/apps/backend/src/services/auth/providers/appsumo.provider.ts @@ -0,0 +1,114 @@ +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; + +const APPSUMO_OPENID_BASE = 'https://appsumo.com/openid'; + +const APPSUMO_TIER_MAP: Record = { + 1: 'STANDARD', + 2: 'TEAM', + 3: 'PRO', +}; + +@AuthProvider({ provider: 'APPSUMO' }) +export class AppSumoProvider extends AuthProviderAbstract { + constructor(private _subscriptionService: SubscriptionService) { + super(); + } + + generateLink() { + const params = new URLSearchParams({ + client_id: process.env.APPSUMO_CLIENT_ID!, + redirect_uri: `${process.env.FRONTEND_URL}/settings`, + response_type: 'code', + scope: 'openid', + }); + + return `${APPSUMO_OPENID_BASE}/authorize/?${params.toString()}`; + } + + async getToken(code: string) { + const clientId = process.env.APPSUMO_CLIENT_ID; + const clientSecret = process.env.APPSUMO_CLIENT_SECRET; + if (!clientId || !clientSecret) { + return ''; + } + + const tokenResponse = await fetch(`${APPSUMO_OPENID_BASE}/token/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: `${process.env.FRONTEND_URL}/settings`, + }), + }); + + if (!tokenResponse.ok) { + return ''; + } + + const { access_token } = await tokenResponse.json(); + return access_token || ''; + } + + async getUser(accessToken: string) { + if (!accessToken) { + return { id: '', email: '' }; + } + + const response = await fetch( + `${APPSUMO_OPENID_BASE}/license_key/?access_token=${accessToken}`, + ); + + if (!response.ok) { + return { id: '', email: '' }; + } + + const data = await response.json(); + if (!data.license_key) { + return { id: '', email: '' }; + } + + return { + id: String(`appsumo_${data.license_key}`), + email: String(`appsumo_${data.license_key}`), + }; + } + + async postRegistration(accessToken: string, orgId: string) { + if (!accessToken) { + return; + } + + const response = await fetch( + `${APPSUMO_OPENID_BASE}/license_key/?access_token=${accessToken}`, + ); + + if (!response.ok) { + return; + } + + const data = await response.json(); + const billing = APPSUMO_TIER_MAP[data.tier] || 'STANDARD'; + + await this._subscriptionService.createOrUpdateSubscription( + false, + data.license_key, + data.license_key, + pricing[billing].channel!, + billing, + 'YEARLY', + null, + data.license_key, + orgId + ); + } +} diff --git a/apps/backend/src/services/auth/providers/farcaster.provider.ts b/apps/backend/src/services/auth/providers/farcaster.provider.ts index 91262c80..e5700dfb 100644 --- a/apps/backend/src/services/auth/providers/farcaster.provider.ts +++ b/apps/backend/src/services/auth/providers/farcaster.provider.ts @@ -1,11 +1,15 @@ -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; import { NeynarAPIClient } from '@neynar/nodejs-sdk'; const client = new NeynarAPIClient({ apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000', }); -export class FarcasterProvider implements ProvidersInterface { +@AuthProvider({ provider: 'FARCASTER' }) +export class FarcasterProvider extends AuthProviderAbstract { generateLink() { return ''; } @@ -29,11 +33,6 @@ export class FarcasterProvider implements ProvidersInterface { }; } - // const { client, oauth2 } = clientAndYoutube(); - // client.setCredentials({ access_token: providerToken }); - // const user = oauth2(client); - // const { data } = await user.userinfo.get(); - return { id: String('farcaster_' + status.fid), email: String('farcaster_' + status.fid), diff --git a/apps/backend/src/services/auth/providers/github.provider.ts b/apps/backend/src/services/auth/providers/github.provider.ts index 2d8b95ea..0e47f81a 100644 --- a/apps/backend/src/services/auth/providers/github.provider.ts +++ b/apps/backend/src/services/auth/providers/github.provider.ts @@ -1,6 +1,10 @@ -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; -export class GithubProvider implements ProvidersInterface { +@AuthProvider({ provider: 'GITHUB' }) +export class GithubProvider extends AuthProviderAbstract { generateLink(): string { return `https://github.com/login/oauth/authorize?client_id=${ process.env.GITHUB_CLIENT_ID diff --git a/apps/backend/src/services/auth/providers/google.provider.ts b/apps/backend/src/services/auth/providers/google.provider.ts index 67487d78..3841baa8 100644 --- a/apps/backend/src/services/auth/providers/google.provider.ts +++ b/apps/backend/src/services/auth/providers/google.provider.ts @@ -1,7 +1,9 @@ -import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; import { google } from 'googleapis'; import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; const clientAndYoutube = () => { const client = new google.auth.OAuth2({ @@ -31,7 +33,8 @@ const clientAndYoutube = () => { return { client, youtube, oauth2, youtubeAnalytics }; }; -export class GoogleProvider implements ProvidersInterface { +@AuthProvider({ provider: 'GOOGLE' }) +export class GoogleProvider extends AuthProviderAbstract { generateLink() { const state = 'login'; const { client } = clientAndYoutube(); diff --git a/apps/backend/src/services/auth/providers/oauth.provider.ts b/apps/backend/src/services/auth/providers/oauth.provider.ts index b8c49dad..70684b16 100644 --- a/apps/backend/src/services/auth/providers/oauth.provider.ts +++ b/apps/backend/src/services/auth/providers/oauth.provider.ts @@ -1,66 +1,56 @@ -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; -export class OauthProvider implements ProvidersInterface { - private readonly authUrl: string; - private readonly baseUrl: string; - private readonly clientId: string; - private readonly clientSecret: string; - private readonly frontendUrl: string; - private readonly tokenUrl: string; - private readonly userInfoUrl: string; - - constructor() { +@AuthProvider({ provider: 'GENERIC' }) +export class OauthProvider extends AuthProviderAbstract { + private getConfig() { const { POSTIZ_OAUTH_AUTH_URL, POSTIZ_OAUTH_CLIENT_ID, POSTIZ_OAUTH_CLIENT_SECRET, POSTIZ_OAUTH_TOKEN_URL, - POSTIZ_OAUTH_URL, POSTIZ_OAUTH_USERINFO_URL, FRONTEND_URL, } = process.env; - if (!POSTIZ_OAUTH_USERINFO_URL) - throw new Error( - 'POSTIZ_OAUTH_USERINFO_URL environment variable is not set' - ); - if (!POSTIZ_OAUTH_URL) - throw new Error('POSTIZ_OAUTH_URL environment variable is not set'); - if (!POSTIZ_OAUTH_TOKEN_URL) - throw new Error('POSTIZ_OAUTH_TOKEN_URL environment variable is not set'); - if (!POSTIZ_OAUTH_CLIENT_ID) - throw new Error('POSTIZ_OAUTH_CLIENT_ID environment variable is not set'); - if (!POSTIZ_OAUTH_CLIENT_SECRET) - throw new Error( - 'POSTIZ_OAUTH_CLIENT_SECRET environment variable is not set' - ); - if (!POSTIZ_OAUTH_AUTH_URL) - throw new Error('POSTIZ_OAUTH_AUTH_URL environment variable is not set'); - if (!FRONTEND_URL) - throw new Error('FRONTEND_URL environment variable is not set'); + if ( + !POSTIZ_OAUTH_USERINFO_URL || + !POSTIZ_OAUTH_TOKEN_URL || + !POSTIZ_OAUTH_CLIENT_ID || + !POSTIZ_OAUTH_CLIENT_SECRET || + !POSTIZ_OAUTH_AUTH_URL || + !FRONTEND_URL + ) { + throw new Error('POSTIZ_OAUTH environment variables are not set'); + } - this.authUrl = POSTIZ_OAUTH_AUTH_URL; - this.baseUrl = POSTIZ_OAUTH_URL; - this.clientId = POSTIZ_OAUTH_CLIENT_ID; - this.clientSecret = POSTIZ_OAUTH_CLIENT_SECRET; - this.frontendUrl = FRONTEND_URL; - this.tokenUrl = POSTIZ_OAUTH_TOKEN_URL; - this.userInfoUrl = POSTIZ_OAUTH_USERINFO_URL; + return { + authUrl: POSTIZ_OAUTH_AUTH_URL, + clientId: POSTIZ_OAUTH_CLIENT_ID, + clientSecret: POSTIZ_OAUTH_CLIENT_SECRET, + tokenUrl: POSTIZ_OAUTH_TOKEN_URL, + userInfoUrl: POSTIZ_OAUTH_USERINFO_URL, + frontendUrl: FRONTEND_URL, + }; } generateLink(): string { + const { authUrl, clientId, frontendUrl } = this.getConfig(); const params = new URLSearchParams({ - client_id: this.clientId, + client_id: clientId, scope: 'openid profile email', response_type: 'code', - redirect_uri: `${this.frontendUrl}/settings`, + redirect_uri: `${frontendUrl}/settings`, }); - return `${this.authUrl}?${params.toString()}`; + return `${authUrl}?${params.toString()}`; } async getToken(code: string): Promise { - const response = await fetch(`${this.tokenUrl}`, { + const { tokenUrl, clientId, clientSecret, frontendUrl } = this.getConfig(); + const response = await fetch(`${tokenUrl}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -68,10 +58,10 @@ export class OauthProvider implements ProvidersInterface { }, body: new URLSearchParams({ grant_type: 'authorization_code', - client_id: this.clientId, - client_secret: this.clientSecret, + client_id: clientId, + client_secret: clientSecret, code, - redirect_uri: `${this.frontendUrl}/settings`, + redirect_uri: `${frontendUrl}/settings`, }), }); @@ -85,7 +75,8 @@ export class OauthProvider implements ProvidersInterface { } async getUser(access_token: string): Promise<{ email: string; id: string }> { - const response = await fetch(`${this.userInfoUrl}`, { + const { userInfoUrl } = this.getConfig(); + const response = await fetch(`${userInfoUrl}`, { headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/json', diff --git a/apps/backend/src/services/auth/providers/providers.factory.ts b/apps/backend/src/services/auth/providers/providers.factory.ts deleted file mode 100644 index 2815bc3e..00000000 --- a/apps/backend/src/services/auth/providers/providers.factory.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Provider } from '@prisma/client'; -import { GithubProvider } from '@gitroom/backend/services/auth/providers/github.provider'; -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; -import { GoogleProvider } from '@gitroom/backend/services/auth/providers/google.provider'; -import { FarcasterProvider } from '@gitroom/backend/services/auth/providers/farcaster.provider'; -import { WalletProvider } from '@gitroom/backend/services/auth/providers/wallet.provider'; -import { OauthProvider } from '@gitroom/backend/services/auth/providers/oauth.provider'; - -export class ProvidersFactory { - static loadProvider(provider: Provider): ProvidersInterface { - switch (provider) { - case Provider.GITHUB: - return new GithubProvider(); - case Provider.GOOGLE: - return new GoogleProvider(); - case Provider.FARCASTER: - return new FarcasterProvider(); - case Provider.WALLET: - return new WalletProvider(); - case Provider.GENERIC: - return new OauthProvider(); - } - } -} diff --git a/apps/backend/src/services/auth/providers/providers.manager.ts b/apps/backend/src/services/auth/providers/providers.manager.ts new file mode 100644 index 00000000..8795d332 --- /dev/null +++ b/apps/backend/src/services/auth/providers/providers.manager.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { AuthProviderAbstract } from '@gitroom/backend/services/auth/providers.interface'; + +@Injectable() +export class AuthProviderManager { + constructor(private _moduleRef: ModuleRef) {} + + getProvider(provider: string): AuthProviderAbstract { + const metadata = + Reflect.getMetadata('auth-provider', AuthProviderAbstract) || []; + + const found = metadata.find( + (m: any) => m.provider === provider + ); + + if (!found) { + throw new Error(`Auth provider ${provider} not found`); + } + + return this._moduleRef.get(found.target, { strict: false }); + } +} diff --git a/apps/backend/src/services/auth/providers/wallet.provider.ts b/apps/backend/src/services/auth/providers/wallet.provider.ts index 3f606e70..aaf46e3c 100644 --- a/apps/backend/src/services/auth/providers/wallet.provider.ts +++ b/apps/backend/src/services/auth/providers/wallet.provider.ts @@ -1,16 +1,17 @@ -import { ProvidersInterface } from '@gitroom/backend/services/auth/providers.interface'; +import { + AuthProvider, + AuthProviderAbstract, +} from '@gitroom/backend/services/auth/providers.interface'; import { randomBytes } from 'crypto'; import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; import bs58 from 'bs58'; import nacl from 'tweetnacl'; function hexToUint8Array(hex) { - // Remove any potential "0x" prefix if (hex.startsWith('0x')) { hex = hex.slice(2); } - // Ensure the hex string has an even length if (hex.length % 2 !== 0) { throw new Error('Invalid hex string. It must have an even length.'); } @@ -19,16 +20,15 @@ function hexToUint8Array(hex) { const uint8Array = new Uint8Array(byteLength); for (let i = 0; i < byteLength; i++) { - // Get two characters from the hex string const byteHex = hex.substr(i * 2, 2); - // Parse the two characters as a hexadecimal number uint8Array[i] = parseInt(byteHex, 16); } return uint8Array; } -export class WalletProvider implements ProvidersInterface { +@AuthProvider({ provider: 'WALLET' }) +export class WalletProvider extends AuthProviderAbstract { async generateLink(params: { publicKey: string }) { if (!params.publicKey) { return; diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index c1bacd57..eda20f1f 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -923,6 +923,7 @@ enum Provider { FARCASTER WALLET GENERIC + APPSUMO } enum Role { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts index 6bfd2ec5..656b4124 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -132,7 +132,7 @@ export class SubscriptionRepository { identifier: string, customerId: string, totalChannels: number, - billing: 'STANDARD' | 'PRO', + billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', period: 'MONTHLY' | 'YEARLY', cancelAt: number | null, code?: string, @@ -197,6 +197,18 @@ export class SubscriptionRepository { } } + getSubscriptionByIdentifier(identifier: string) { + return this._subscription.model.subscription.findFirst({ + where: { + identifier, + deletedAt: null, + }, + include: { + organization: true, + }, + }); + } + getSubscription(organizationId: string) { return this._subscription.model.subscription.findFirst({ where: { diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts index ca202428..a41d61ca 100644 --- a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -56,7 +56,7 @@ export class SubscriptionService { async modifySubscription( customerId: string, totalChannels: number, - billing: 'FREE' | 'STANDARD' | 'PRO' + billing: 'FREE' | 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE' ) { if (!customerId) { return false; @@ -121,7 +121,7 @@ export class SubscriptionService { identifier: string, customerId: string, totalChannels: number, - billing: 'STANDARD' | 'PRO', + billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', period: 'MONTHLY' | 'YEARLY', cancelAt: number | null, code?: string, @@ -154,6 +154,10 @@ export class SubscriptionService { ); } + getSubscriptionByIdentifier(identifier: string) { + return this._subscriptionRepository.getSubscriptionByIdentifier(identifier); + } + async getSubscription(organizationId: string) { return this._subscriptionRepository.getSubscription(organizationId); } diff --git a/libraries/nestjs-libraries/src/services/appsumo.service.ts b/libraries/nestjs-libraries/src/services/appsumo.service.ts new file mode 100644 index 00000000..fa355663 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/appsumo.service.ts @@ -0,0 +1,110 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import crypto from 'crypto'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; + +const APPSUMO_TIER_MAP: Record = { + 1: 'STANDARD', + 2: 'TEAM', + 3: 'PRO', +}; + +export interface AppSumoWebhookPayload { + license_key: string; + event: 'purchase' | 'activate' | 'upgrade' | 'downgrade' | 'deactivate' | 'migrate'; + license_status: string; + event_timestamp: number; + created_at: number; + tier: number; + prev_license_key?: string; + test?: boolean; +} + +@Injectable() +export class AppSumoService { + constructor(private _subscriptionService: SubscriptionService) {} + + validateSignature( + rawBody: Buffer, + timestamp: string, + signature: string + ) { + const apiKey = process.env.APPSUMO_API_KEY; + if (!apiKey) { + throw new HttpException('AppSumo API key not configured', 500); + } + + const message = timestamp + rawBody.toString(); + const expectedSignature = crypto + .createHmac('sha256', apiKey) + .update(message) + .digest('hex'); + + const isValid = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + + if (!isValid) { + throw new HttpException('Invalid signature', 401); + } + } + + async handleWebhook(payload: AppSumoWebhookPayload) { + if (payload.test) { + return { success: true, event: payload.event }; + } + + switch (payload.event) { + case 'purchase': + case 'activate': + return { success: true, event: payload.event }; + case 'upgrade': + return this.handleUpgradeOrDowngrade(payload); + case 'downgrade': + return this.handleUpgradeOrDowngrade(payload); + case 'deactivate': + return this.handleDeactivate(payload); + default: + return { success: true, event: payload.event }; + } + } + + private async handleUpgradeOrDowngrade(payload: AppSumoWebhookPayload) { + const identifier = payload.prev_license_key || payload.license_key; + const subscription = + await this._subscriptionService.getSubscriptionByIdentifier(identifier); + + if (subscription) { + const billing = APPSUMO_TIER_MAP[payload.tier] || 'STANDARD'; + await this._subscriptionService.createOrUpdateSubscription( + false, + payload.license_key, + payload.license_key, + pricing[billing].channel!, + billing, + 'YEARLY', + null, + payload.license_key, + subscription.organizationId + ); + } + + return { success: true, event: payload.event }; + } + + private async handleDeactivate(payload: AppSumoWebhookPayload) { + const subscription = + await this._subscriptionService.getSubscriptionByIdentifier( + payload.license_key + ); + + if (subscription) { + await this._subscriptionService.deleteSubscription( + subscription.organization.paymentId || subscription.organizationId + ); + } + + return { success: true, event: payload.event }; + } +}