feat: lifetime

This commit is contained in:
Nevo David 2024-05-18 23:37:51 +07:00
parent c7fc90f86c
commit 57ac90b334
24 changed files with 565 additions and 82 deletions

View File

@ -24,6 +24,7 @@ import { MarketplaceController } from '@gitroom/backend/api/routes/marketplace.c
import { MessagesController } from '@gitroom/backend/api/routes/messages.controller';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
const authenticatedController = [
UsersController,
@ -66,6 +67,7 @@ const authenticatedController = [
AuthMiddleware,
PoliciesGuard,
PermissionsService,
CodesService,
IntegrationManager,
],
get exports() {

View File

@ -4,7 +4,7 @@ import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto';
import {ApiTags} from "@nestjs/swagger";
import { ApiTags } from '@nestjs/swagger';
@ApiTags('Billing')
@Controller('/billing')
@ -58,9 +58,17 @@ export class BillingController {
@Post('/prorate')
prorate(
@GetOrgFromRequest() org: Organization,
@Body() body: BillingSubscribeDto
@GetOrgFromRequest() org: Organization,
@Body() body: BillingSubscribeDto
) {
return this._stripeService.prorate(org.id, body);
}
@Post('/lifetime')
async lifetime(
@GetOrgFromRequest() org: Organization,
@Body() body: { code: string }
) {
return this._stripeService.lifetimeDeal(org.id, body.code);
}
}

View File

@ -1,11 +1,23 @@
import { Controller, Post, RawBodyRequest, Req } from '@nestjs/common';
import {
Controller,
Get,
Header,
Param,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { ApiTags } from '@nestjs/swagger';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
@ApiTags('Stripe')
@Controller('/stripe')
export class StripeController {
constructor(private readonly _stripeService: StripeService) {}
constructor(
private readonly _stripeService: StripeService,
private readonly _codesService: CodesService
) {}
@Post('/connect')
stripeConnect(@Req() req: RawBodyRequest<Request>) {
const event = this._stripeService.validateRequest(
@ -61,4 +73,11 @@ export class StripeController {
return { ok: true };
}
}
@Get('/lifetime-deal-codes/:provider')
@Header('Content-disposition', 'attachment; filename=codes.csv')
@Header('Content-type', 'text/csv')
async getStripeCodes(@Param('provider') providerToken: string) {
return this._codesService.generateCodes(providerToken);
}
}

View File

@ -54,6 +54,8 @@ export class UsersController {
tier: organization?.subscription?.subscriptionTier || 'FREE',
// @ts-ignore
role: organization?.users[0]?.role,
// @ts-ignore
isLifetime: !!organization?.subscription?.isLifetime,
};
}

View File

@ -14,6 +14,7 @@ const nextConfig = {
},
env: {
isBillingEnabled: String(!!process.env.STRIPE_PUBLISHABLE_KEY),
isGeneral: String(!!process.env.IS_GENERAL),
}
};

View File

@ -0,0 +1,14 @@
import { LifetimeDeal } from '@gitroom/frontend/components/billing/lifetime.deal';
export const dynamic = 'force-dynamic';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Lifetime deal',
description: '',
};
export default async function Page() {
return <LifetimeDeal />;
}

View File

@ -0,0 +1,212 @@
'use client';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useCallback, useMemo, useState } from 'react';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { Input } from '@gitroom/react/form/input';
import { Button } from '@gitroom/react/form/button';
import { useSWRConfig } from 'swr';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useRouter } from 'next/navigation';
export const LifetimeDeal = () => {
const fetch = useFetch();
const user = useUser();
const [code, setCode] = useState('');
const toast = useToaster();
const { mutate } = useSWRConfig();
const router = useRouter();
const claim = useCallback(async () => {
const { success } = await (
await fetch('/billing/lifetime', {
body: JSON.stringify({ code }),
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
).json();
if (success) {
mutate('/user/self');
toast.show('Successfully claimed the code');
} else {
toast.show('Code already claimed or invalid code', 'warning');
}
setCode('');
}, [code]);
const nextPackage = useMemo(() => {
if (user?.tier?.current === 'STANDARD') {
return 'PRO';
}
return 'STANDARD';
}, [user?.tier]);
const features = useMemo(() => {
if (!user?.tier) {
return [];
}
const currentPricing = user?.tier;
const channelsOr = currentPricing.channel;
const list = [];
list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`);
list.push(
`${
currentPricing.posts_per_month > 10000
? 'Unlimited'
: currentPricing.posts_per_month
} posts per month`
);
if (currentPricing.team_members) {
list.push(`Unlimited team members`);
}
if (currentPricing.import_from_channels) {
list.push(`Import content from channels (coming soon)`);
}
if (currentPricing.community_features) {
list.push(`Community features (coming soon)`);
}
if (currentPricing.ai) {
list.push(`AI auto-complete (coming soon)`);
}
if (currentPricing.featured_by_gitroom) {
list.push(`Become featured by Gitroom (coming soon)`);
}
return list;
}, [user]);
const nextFeature = useMemo(() => {
if (!user?.tier) {
return [];
}
const currentPricing = pricing[nextPackage];
const channelsOr = currentPricing.channel;
const list = [];
list.push(`${channelsOr} ${channelsOr === 1 ? 'channel' : 'channels'}`);
list.push(
`${
currentPricing.posts_per_month > 10000
? 'Unlimited'
: currentPricing.posts_per_month
} posts per month`
);
if (currentPricing.team_members) {
list.push(`Unlimited team members`);
}
if (currentPricing.import_from_channels) {
list.push(`Import content from channels (coming soon)`);
}
if (currentPricing.community_features) {
list.push(`Community features (coming soon)`);
}
if (currentPricing.ai) {
list.push(`AI auto-complete`);
}
if (currentPricing.featured_by_gitroom) {
list.push(`Become featured by Gitroom (coming soon)`);
}
return list;
}, [user, nextPackage]);
if (!user?.tier) {
return null;
}
if (user?.id && user?.tier?.current !== 'FREE' && !user?.isLifetime) {
router.replace('/billing');
return null;
}
return (
<div className="flex gap-[30px]">
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
<div className="text-[30px]">
Current Package: {user?.tier?.current}
</div>
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
{features.map((feature) => (
<div key={feature} className="flex gap-[20px]">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.2806 10.2806L11.0306 15.5306C10.961 15.6004 10.8783 15.6557 10.7872 15.6934C10.6962 15.7312 10.5986 15.7506 10.5 15.7506C10.4014 15.7506 10.3038 15.7312 10.2128 15.6934C10.1218 15.6557 10.039 15.6004 9.96938 15.5306L7.71938 13.2806C7.57865 13.1399 7.49959 12.949 7.49959 12.75C7.49959 12.551 7.57865 12.3601 7.71938 12.2194C7.86011 12.0786 8.05098 11.9996 8.25 11.9996C8.44903 11.9996 8.6399 12.0786 8.78063 12.2194L10.5 13.9397L15.2194 9.21937C15.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
fill="#06ff00"
/>
</svg>
</div>
<div>{feature}</div>
</div>
))}
</div>
</div>
{user?.tier?.current !== 'PRO' && (
<div className="border border-[#172034] bg-[#0B101B] p-[24px] flex flex-col gap-[20px] flex-1 rounded-[4px]">
<div className="text-[30px]">Next Package: {nextPackage}</div>
<div className="flex flex-col gap-[10px] justify-center text-[16px] text-[#AAA]">
{nextFeature.map((feature) => (
<div key={feature} className="flex gap-[20px]">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M16.2806 9.21937C16.3504 9.28903 16.4057 9.37175 16.4434 9.46279C16.4812 9.55384 16.5006 9.65144 16.5006 9.75C16.5006 9.84856 16.4812 9.94616 16.4434 10.0372C16.4057 10.1283 16.3504 10.211 16.2806 10.2806L11.0306 15.5306C10.961 15.6004 10.8783 15.6557 10.7872 15.6934C10.6962 15.7312 10.5986 15.7506 10.5 15.7506C10.4014 15.7506 10.3038 15.7312 10.2128 15.6934C10.1218 15.6557 10.039 15.6004 9.96938 15.5306L7.71938 13.2806C7.57865 13.1399 7.49959 12.949 7.49959 12.75C7.49959 12.551 7.57865 12.3601 7.71938 12.2194C7.86011 12.0786 8.05098 11.9996 8.25 11.9996C8.44903 11.9996 8.6399 12.0786 8.78063 12.2194L10.5 13.9397L15.2194 9.21937C15.289 9.14964 15.3718 9.09432 15.4628 9.05658C15.5538 9.01884 15.6514 8.99941 15.75 8.99941C15.8486 8.99941 15.9462 9.01884 16.0372 9.05658C16.1283 9.09432 16.211 9.14964 16.2806 9.21937ZM21.75 12C21.75 13.9284 21.1782 15.8134 20.1068 17.4168C19.0355 19.0202 17.5127 20.2699 15.7312 21.0078C13.9496 21.7458 11.9892 21.9389 10.0979 21.5627C8.20656 21.1865 6.46928 20.2579 5.10571 18.8943C3.74215 17.5307 2.81355 15.7934 2.43735 13.9021C2.06114 12.0108 2.25422 10.0504 2.99218 8.26884C3.73013 6.48726 4.97982 4.96451 6.58319 3.89317C8.18657 2.82183 10.0716 2.25 12 2.25C14.585 2.25273 17.0634 3.28084 18.8913 5.10872C20.7192 6.93661 21.7473 9.41498 21.75 12ZM20.25 12C20.25 10.3683 19.7661 8.77325 18.8596 7.41655C17.9531 6.05984 16.6646 5.00242 15.1571 4.37799C13.6497 3.75357 11.9909 3.59019 10.3905 3.90852C8.79017 4.22685 7.32016 5.01259 6.16637 6.16637C5.01259 7.32015 4.22685 8.79016 3.90853 10.3905C3.5902 11.9908 3.75358 13.6496 4.378 15.1571C5.00242 16.6646 6.05984 17.9531 7.41655 18.8596C8.77326 19.7661 10.3683 20.25 12 20.25C14.1873 20.2475 16.2843 19.3775 17.8309 17.8309C19.3775 16.2843 20.2475 14.1873 20.25 12Z"
fill="#06ff00"
/>
</svg>
</div>
<div>{feature}</div>
</div>
))}
<div className="mt-[20px] flex items-center gap-[10px]">
<div className="flex-1">
<Input
label="Code"
placeholder="Enter your code"
disableForm={true}
name="code"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
</div>
<div>
<Button disabled={code.length < 4} onClick={claim}>
Claim
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -18,6 +18,7 @@ import { FAQComponent } from '@gitroom/frontend/components/billing/faq.component
import { useSWRConfig } from 'swr';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import interClass from '@gitroom/react/helpers/inter.font';
import { useRouter } from 'next/navigation';
export interface Tiers {
month: Array<{
@ -155,6 +156,7 @@ export const MainBillingComponent: FC<{
const fetch = useFetch();
const toast = useToaster();
const user = useUser();
const router = useRouter();
const [subscription, setSubscription] = useState<Subscription | undefined>(
sub
@ -307,6 +309,11 @@ export const MainBillingComponent: FC<{
[monthlyOrYearly, subscription, user]
);
if (user?.isLifetime) {
router.replace('/billing/lifetime');
return null;
}
return (
<div className="flex flex-col gap-[16px]">
<div className="flex flex-row">

View File

@ -11,12 +11,16 @@ import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details
import { useToaster } from '@gitroom/react/toaster/toaster';
import { useSWRConfig } from 'swr';
import clsx from 'clsx';
import { TeamsComponent } from '@gitroom/frontend/components/settings/teams.component';
import { isGeneral } from '@gitroom/react/helpers/is.general';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
const { getRef } = props;
const fetch = useFetch();
const toast = useToaster();
const swr = useSWRConfig();
const user = useUser();
const resolver = useMemo(() => {
return classValidatorResolver(UserDetailDto);
@ -52,7 +56,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
});
if (getRef) {
return ;
return;
}
toast.show('Profile updated');
@ -67,7 +71,9 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
{!!getRef && <button type="submit" className="hidden" ref={getRef}></button>}
{!!getRef && (
<button type="submit" className="hidden" ref={getRef}></button>
)}
<div
className={clsx(
'w-full max-w-[920px] mx-auto bg-[#0B101B] gap-[24px] flex flex-col relative',
@ -180,6 +186,7 @@ export const SettingsPopup: FC<{ getRef?: Ref<any> }> = (props) => {
<Button type="submit">Save</Button>
</div>
)}
{!!user?.tier?.team_members && isGeneral() && <TeamsComponent />}
</div>
</form>
</FormProvider>

View File

@ -5,24 +5,35 @@ import Link from 'next/link';
import clsx from 'clsx';
import { usePathname } from 'next/navigation';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { isGeneral } from '@gitroom/react/helpers/is.general';
const general = isGeneral();
export const menuItems = [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
...(!general
? [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
]
: []),
{
name: 'Launches',
icon: 'launches',
path: '/launches',
},
{
name: 'Settings',
icon: 'settings',
path: '/settings',
role: ['ADMIN', 'SUPERADMIN'],
},
...(!general
? [
{
name: 'Settings',
icon: 'settings',
path: '/settings',
role: ['ADMIN', 'SUPERADMIN'],
},
]
: []),
{
name: 'Marketplace',
icon: 'marketplace',

View File

@ -14,6 +14,7 @@ export const UserContext = createContext<
tier: PricingInnerInterface;
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
totalChannels: number;
isLifetime?: boolean;
})
>(undefined);

View File

@ -8,10 +8,14 @@ import { GithubOnboarding } from '@gitroom/frontend/components/onboarding/github
import { SettingsPopup } from '@gitroom/frontend/components/layout/settings.component';
import { Button } from '@gitroom/react/form/button';
import { ConnectChannels } from '@gitroom/frontend/components/onboarding/connect.channels';
import { isGeneral } from '@gitroom/react/helpers/is.general';
export const Step: FC<{ step: number; title: string; currentStep: number, lastStep: number }> = (
props
) => {
export const Step: FC<{
step: number;
title: string;
currentStep: number;
lastStep: number;
}> = (props) => {
const { step, title, currentStep, lastStep } = props;
return (
<div className="flex flex-col">
@ -87,40 +91,51 @@ const SkipOnboarding: FC = () => {
const onSkip = useCallback(() => {
const keys = Array.from(searchParams.keys());
const buildNewQuery = keys.reduce((all, current) => {
if (current === 'onboarding') {
const buildNewQuery = keys
.reduce((all, current) => {
if (current === 'onboarding') {
return all;
}
const value = searchParams.get(current);
all.push(`${current}=${value}`);
return all;
}
const value = searchParams.get(current);
all.push(`${current}=${value}`);
return all;
}, [] as string[]).join('&');
}, [] as string[])
.join('&');
router.push(`?${buildNewQuery}`);
}, [searchParams]);
return (
<Button secondary={true} className="border-[2px] border-[#506490]" onClick={onSkip}>Skip onboarding</Button>
<Button
secondary={true}
className="border-[2px] border-[#506490]"
onClick={onSkip}
>
Skip onboarding
</Button>
);
}
};
const Welcome: FC = () => {
const [step, setStep] = useState(1);
const ref = useRef();
const router = useRouter();
const nextStep = useCallback(() => {
setStep(step + 1);
}, [step]);
const nextStep = useCallback(
(stepIt?: number) => () => {
setStep(stepIt ? stepIt : step + 1);
},
[step]
);
const firstNext = useCallback(() => {
// @ts-ignore
ref?.current?.click();
nextStep();
nextStep(isGeneral() ? 3 : 2)();
}, [nextStep]);
const goToAnalytics = useCallback(() => {
router.push('/analytics');
}, []);
const goToLaunches = useCallback(() => {
const goToLaunches = useCallback(() => {
router.push('/launches');
}, []);
@ -130,9 +145,23 @@ const Welcome: FC = () => {
<div className="flex">
<Step title="Profile" step={1} currentStep={step} lastStep={4} />
<StepSpace />
<Step title="Connect Github" step={2} currentStep={step} lastStep={4} />
<StepSpace />
<Step title="Connect Channels" step={3} currentStep={step} lastStep={4} />
{!isGeneral() && (
<>
<Step
title="Connect Github"
step={2}
currentStep={step}
lastStep={4}
/>
<StepSpace />
</>
)}
<Step
title="Connect Channels"
step={3}
currentStep={step}
lastStep={4}
/>
<StepSpace />
<Step title="Finish" step={4} currentStep={step} lastStep={4} />
</div>
@ -152,7 +181,7 @@ const Welcome: FC = () => {
<GithubOnboarding />
<div className="flex justify-end gap-[8px]">
<SkipOnboarding />
<Button onClick={nextStep}>Next</Button>
<Button onClick={nextStep(3)}>Next</Button>
</div>
</div>
)}
@ -161,7 +190,7 @@ const Welcome: FC = () => {
<ConnectChannels />
<div className="flex justify-end gap-[8px]">
<SkipOnboarding />
<Button onClick={nextStep}>Next</Button>
<Button onClick={nextStep(4)}>Next</Button>
</div>
</div>
)}
@ -171,10 +200,20 @@ const Welcome: FC = () => {
<img src="/success.svg" alt="success" />
</div>
<div className="text-[18px] text-center">
You are done, you can now video your GitHub analytics or<br />schedule new posts
You are done, you can now video your GitHub analytics or
<br />
schedule new posts
</div>
<div className="flex gap-[8px]">
<Button onClick={goToAnalytics} secondary={true} className="border-[2px] border-[#506490]">View Analytics</Button>
{!isGeneral() && (
<Button
onClick={goToAnalytics}
secondary={true}
className="border-[2px] border-[#506490]"
>
View Analytics
</Button>
)}
<Button onClick={goToLaunches}>Schedule a new post</Button>
</div>
</div>

View File

@ -8,6 +8,9 @@ import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { LoadingComponent } from '@gitroom/frontend/components/layout/loading';
import { useRouter } from 'next/navigation';
import { isGeneral } from '@gitroom/react/helpers/is.general';
const general = isGeneral();
export const SettingsComponent = () => {
const user = useUser();
@ -50,22 +53,24 @@ export const SettingsComponent = () => {
return (
<div className="flex flex-col gap-[68px]">
<div className="flex flex-col">
<h3 className="text-[20px]">Your Git Repository</h3>
<div className="text-[#AAA] mt-[4px]">
Connect your GitHub repository to receive updates and analytics
{!general && (
<div className="flex flex-col">
<h3 className="text-[20px]">Your Git Repository</h3>
<div className="text-[#AAA] mt-[4px]">
Connect your GitHub repository to receive updates and analytics
</div>
<GithubComponent
github={loadAll.github}
organizations={loadAll.organizations}
/>
{/*<div className="flex gap-[5px]">*/}
{/* <div>*/}
{/* <Checkbox disableForm={true} checked={true} name="Send Email" />*/}
{/* </div>*/}
{/* <div>Show news with everybody in Gitroom</div>*/}
{/*</div>*/}
</div>
<GithubComponent
github={loadAll.github}
organizations={loadAll.organizations}
/>
{/*<div className="flex gap-[5px]">*/}
{/* <div>*/}
{/* <Checkbox disableForm={true} checked={true} name="Send Email" />*/}
{/* </div>*/}
{/* <div>Show news with everybody in Gitroom</div>*/}
{/*</div>*/}
</div>
)}
{!!user?.tier?.team_members && <TeamsComponent />}
</div>
);

View File

@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { fetchBackend } from '@gitroom/helpers/utils/custom.fetch.func';
import { removeSubdomain } from '@gitroom/helpers/subdomain/subdomain.management';
import { isGeneral } from '@gitroom/react/helpers/is.general';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
@ -86,8 +87,16 @@ export async function middleware(request: NextRequest) {
return redirect;
}
if (isGeneral() && (nextUrl.pathname.indexOf('/analytics') > -1 || nextUrl.pathname.indexOf('/settings') > -1)) {
return NextResponse.redirect(
new URL('/launches', nextUrl.href)
);
}
if (nextUrl.pathname === '/') {
return NextResponse.redirect(new URL(`/analytics`, nextUrl.href));
return NextResponse.redirect(
new URL(isGeneral() ? '/launches' : `/analytics`, nextUrl.href)
);
}
const next = NextResponse.next();

View File

@ -1,17 +1,44 @@
import {sign, verify} from 'jsonwebtoken';
import {hashSync, compareSync} from 'bcrypt';
import { sign, verify } from 'jsonwebtoken';
import { hashSync, compareSync } from 'bcrypt';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
export class AuthService {
static hashPassword (password: string) {
static hashPassword(password: string) {
return hashSync(password, 10);
}
static comparePassword (password: string, hash: string) {
}
static comparePassword(password: string, hash: string) {
return compareSync(password, hash);
}
static signJWT (value: object) {
return sign(value, process.env.JWT_SECRET!);
}
static verifyJWT (token: string) {
return verify(token, process.env.JWT_SECRET!);
}
}
static signJWT(value: object) {
return sign(value, process.env.JWT_SECRET!);
}
static verifyJWT(token: string) {
return verify(token, process.env.JWT_SECRET!);
}
static fixedEncryption(value: string) {
// encryption algorithm
const algorithm = 'aes-256-cbc';
// create a cipher object
const cipher = crypto.createCipher(algorithm, process.env.JWT_SECRET);
// encrypt the plain text
let encrypted = cipher.update(value, 'utf8', 'hex');
encrypted += cipher.final('hex');
return encrypted;
}
static fixedDecryption(hash: string) {
const algorithm = 'aes-256-cbc';
const decipher = crypto.createDecipher(algorithm, process.env.JWT_SECRET);
// decrypt the encrypted text
let decrypted = decipher.update(hash, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}

View File

@ -35,6 +35,7 @@ export class OrganizationRepository {
select: {
subscriptionTier: true,
totalChannels: true,
isLifetime: true,
},
},
},

View File

@ -67,6 +67,15 @@ model User {
@@index([pictureId])
}
model UsedCodes {
id String @id @default(uuid())
code String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([code])
}
model UserOrganization {
id String @id @default(uuid())
user User @relation(fields: [userId], references: [id])
@ -163,6 +172,7 @@ model Subscription {
cancelAt DateTime?
period Period
totalChannels Int
isLifetime Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?

View File

@ -1,4 +1,5 @@
export interface PricingInnerInterface {
current: string;
month_price: number;
year_price: number;
channel?: number;
@ -14,6 +15,7 @@ export interface PricingInterface {
}
export const pricing: PricingInterface = {
FREE: {
current: 'FREE',
month_price: 0,
year_price: 0,
channel: 2,
@ -25,6 +27,7 @@ export const pricing: PricingInterface = {
import_from_channels: false,
},
STANDARD: {
current: 'STANDARD',
month_price: 30,
year_price: 288,
channel: 5,
@ -36,6 +39,7 @@ export const pricing: PricingInterface = {
import_from_channels: true,
},
PRO: {
current: 'PRO',
month_price: 40,
year_price: 384,
channel: 8,

View File

@ -6,7 +6,8 @@ export class SubscriptionRepository {
constructor(
private readonly _subscription: PrismaRepository<'subscription'>,
private readonly _organization: PrismaRepository<'organization'>,
private readonly _user: PrismaRepository<'user'>
private readonly _user: PrismaRepository<'user'>,
private _usedCodes: PrismaRepository<'usedCodes'>
) {}
getUserAccount(userId: string) {
@ -21,6 +22,14 @@ export class SubscriptionRepository {
});
}
getCode(code: string) {
return this._usedCodes.model.usedCodes.findFirst({
where: {
code,
},
});
}
updateAccount(userId: string, account: string) {
return this._user.model.user.update({
where: {
@ -107,27 +116,37 @@ export class SubscriptionRepository {
totalChannels: number,
billing: 'STANDARD' | 'PRO',
period: 'MONTHLY' | 'YEARLY',
cancelAt: number | null
cancelAt: number | null,
code?: string,
org?: { id: string }
) {
const findOrg = (await this.getOrganizationByCustomerId(customerId))!;
const findOrg =
org || (await this.getOrganizationByCustomerId(customerId))!;
await this._subscription.model.subscription.upsert({
where: {
organizationId: findOrg.id,
organization: {
paymentId: customerId,
},
...(!code
? {
organization: {
paymentId: customerId,
},
}
: {}),
},
update: {
subscriptionTier: billing,
totalChannels,
period,
identifier,
isLifetime: !!code,
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
deletedAt: null,
},
create: {
organizationId: findOrg.id,
subscriptionTier: billing,
isLifetime: !!code,
totalChannels,
period,
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
@ -135,6 +154,14 @@ export class SubscriptionRepository {
deletedAt: null,
},
});
if (code) {
await this._usedCodes.model.usedCodes.create({
data: {
code,
},
});
}
}
getSubscription(organizationId: string) {

View File

@ -18,6 +18,10 @@ export class SubscriptionService {
);
}
getCode(code: string) {
return this._subscriptionRepository.getCode(code);
}
updateAccount(userId: string, account: string) {
return this._subscriptionRepository.updateAccount(userId, account);
}
@ -52,7 +56,10 @@ export class SubscriptionService {
}
updateConnectedStatus(account: string, accountCharges: boolean) {
return this._subscriptionRepository.updateConnectedStatus(account, accountCharges);
return this._subscriptionRepository.updateConnectedStatus(
account,
accountCharges
);
}
async modifySubscription(
@ -60,7 +67,10 @@ export class SubscriptionService {
totalChannels: number,
billing: 'FREE' | 'STANDARD' | 'PRO'
) {
const getOrgByCustomerId = await this._subscriptionRepository.getOrganizationByCustomerId(customerId);
const getOrgByCustomerId =
await this._subscriptionRepository.getOrganizationByCustomerId(
customerId
);
const getCurrentSubscription =
(await this._subscriptionRepository.getSubscriptionByCustomerId(
@ -120,16 +130,22 @@ export class SubscriptionService {
totalChannels: number,
billing: 'STANDARD' | 'PRO',
period: 'MONTHLY' | 'YEARLY',
cancelAt: number | null
cancelAt: number | null,
code?: string,
org?: string
) {
await this.modifySubscription(customerId, totalChannels, billing);
if (!code) {
await this.modifySubscription(customerId, totalChannels, billing);
}
return this._subscriptionRepository.createOrUpdateSubscription(
identifier,
customerId,
totalChannels,
billing,
period,
cancelAt
cancelAt,
code,
org ? { id: org } : undefined
);
}

View File

@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
@Injectable()
export class CodesService {
generateCodes(providerToken: string) {
try {
const decrypt = AuthService.fixedDecryption(providerToken);
return [...new Array(10000)].map((_, index) => {
return AuthService.fixedEncryption(`${decrypt}:${index}`);
}).join('\n');
} catch (error) {
return '';
}
}
}

View File

@ -8,6 +8,7 @@ import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/bill
import { capitalize, groupBy } from 'lodash';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-04-10',
@ -511,4 +512,45 @@ export class StripeService {
transfer_group: orderId,
});
}
async lifetimeDeal(organizationId: string, code: string) {
const getCurrentSubscription =
await this._subscriptionService.getSubscriptionByOrganizationId(
organizationId
);
if (getCurrentSubscription && !getCurrentSubscription?.isLifetime) {
throw new Error('You already have a non lifetime subscription');
}
try {
const testCode = AuthService.fixedDecryption(code);
const findCode = await this._subscriptionService.getCode(testCode);
if (findCode) {
return {
success: false,
};
}
const nextPackage = !getCurrentSubscription ? 'STANDARD' : 'PRO';
const findPricing = pricing[nextPackage];
await this._subscriptionService.createOrUpdateSubscription(
makeId(10),
organizationId,
findPricing.channel!,
nextPackage,
'MONTHLY',
null,
testCode,
organizationId
);
return {
success: true,
};
} catch (err) {
console.log(err);
return {
success: false,
};
}
}
}

View File

@ -0,0 +1,3 @@
export const isGeneral = () => {
return process.env.isGeneral === 'true';
}

View File

@ -10,7 +10,7 @@
"docs": "nx run docs:serve:development",
"workers": "nx run workers:serve:development",
"cron": "nx run cron:serve:development",
"command": "nx run commands:build && nx run commands:command",
"command": "rm -rf dist/apps/commands && nx run commands:build && nx run commands:command",
"prisma-generate": "cd ./libraries/nestjs-libraries/src/database/prisma && prisma generate",
"prisma-db-push": "cd ./libraries/nestjs-libraries/src/database/prisma && prisma db push"
},