feat: team finalised

This commit is contained in:
Nevo David 2024-03-08 22:09:48 +07:00
parent 5dc0dae924
commit e08c70762e
58 changed files with 3193 additions and 1090 deletions

View File

@ -18,6 +18,8 @@ import { MediaController } from '@gitroom/backend/api/routes/media.controller';
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { CommentsController } from '@gitroom/backend/api/routes/comments.controller';
import { BillingController } from '@gitroom/backend/api/routes/billing.controller';
import { NotificationsController } from '@gitroom/backend/api/routes/notifications.controller';
const authenticatedController = [
UsersController,
@ -27,6 +29,8 @@ const authenticatedController = [
PostsController,
MediaController,
CommentsController,
BillingController,
NotificationsController,
];
@Module({
imports: [

View File

@ -1,23 +1,30 @@
import {Body, Controller, Post, Res} from '@nestjs/common';
import {Response} from 'express';
import { Body, Controller, Post, Req, Res } from '@nestjs/common';
import { Response, Request } from 'express';
import {CreateOrgUserDto} from "@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto";
import {LoginUserDto} from "@gitroom/nestjs-libraries/dtos/auth/login.user.dto";
import {AuthService} from "@gitroom/backend/services/auth/auth.service";
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
import { LoginUserDto } from '@gitroom/nestjs-libraries/dtos/auth/login.user.dto';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
@Controller('/auth')
export class AuthController {
constructor(
private _authService: AuthService
) {
}
constructor(private _authService: AuthService) {}
@Post('/register')
async register(
@Body() body: CreateOrgUserDto,
@Res({ passthrough: true }) response: Response
@Req() req: Request,
@Body() body: CreateOrgUserDto,
@Res({ passthrough: true }) response: Response
) {
try {
const jwt = await this._authService.routeAuth(body.provider, body);
const getOrgFromCookie = this._authService.getOrgFromCookie(
req?.cookies?.org
);
const { jwt, addedOrg } = await this._authService.routeAuth(
body.provider,
body,
getOrgFromCookie
);
response.cookie('auth', jwt, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
secure: true,
@ -25,23 +32,42 @@ export class AuthController {
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
if (typeof addedOrg !== 'boolean') {
response.cookie('showorg', addedOrg.organizationId, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
}
response.header('reload', 'true');
response.status(200).json({
register: true
register: true,
});
}
catch (e) {
} catch (e) {
response.status(400).send(e.message);
}
}
@Post('/login')
async login(
@Body() body: LoginUserDto,
@Res({ passthrough: true }) response: Response
@Req() req: Request,
@Body() body: LoginUserDto,
@Res({ passthrough: true }) response: Response
) {
try {
const jwt = await this._authService.routeAuth(body.provider, body);
const getOrgFromCookie = this._authService.getOrgFromCookie(
req?.cookies?.org
);
const { jwt, addedOrg } = await this._authService.routeAuth(
body.provider,
body,
getOrgFromCookie
);
response.cookie('auth', jwt, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
secure: true,
@ -49,12 +75,22 @@ export class AuthController {
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
if (typeof addedOrg !== 'boolean') {
response.cookie('showorg', addedOrg.organizationId, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
}
response.header('reload', 'true');
response.status(200).json({
login: true
login: true,
});
}
catch (e) {
} catch (e) {
response.status(400).send(e.message);
}
}

View File

@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
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';
@Controller('/billing')
export class BillingController {
constructor(
private _subscriptionService: SubscriptionService,
private _stripeService: StripeService
) {}
@Get('/check/:id')
async checkId(
@GetOrgFromRequest() org: Organization,
@Param('id') body: string
) {
return {
exists: !!(await this._subscriptionService.checkSubscription(
org.id,
body
)),
};
}
@Post('/subscribe')
subscribe(
@GetOrgFromRequest() org: Organization,
@Body() body: BillingSubscribeDto
) {
return this._stripeService.subscribe(org.id, body);
}
@Post('/modify')
async modifyPayment(@GetOrgFromRequest() org: Organization) {
const customer = await this._stripeService.getCustomerByOrganizationId(
org.id
);
const { url } = await this._stripeService.createBillingPortalLink(customer);
return {
portal: url,
};
}
@Get('/')
getCurrentBilling(@GetOrgFromRequest() org: Organization) {
return this._subscriptionService.getSubscriptionByOrganizationId(org.id);
}
@Post('/cancel')
cancel(@GetOrgFromRequest() org: Organization) {
return this._stripeService.setToCancel(org.id);
}
@Post('/prorate')
prorate(
@GetOrgFromRequest() org: Organization,
@Body() body: BillingSubscribeDto
) {
return this._stripeService.prorate(org.id, body);
}
}

View File

@ -7,6 +7,11 @@ import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.reque
import { Organization } from '@prisma/client';
import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.dto';
import { IntegrationFunctionDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.function.dto';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permissions.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
@Controller('/integrations')
export class IntegrationsController {
@ -15,6 +20,7 @@ export class IntegrationsController {
private _integrationService: IntegrationService
) {}
@Get('/')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
getIntegration() {
return this._integrationManager.getAllIntegrations();
}
@ -97,6 +103,7 @@ export class IntegrationsController {
}
@Post('/article/:integration/connect')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
async connectArticle(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,
@ -136,6 +143,7 @@ export class IntegrationsController {
}
@Post('/social/:integration/connect')
@CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL])
async connectSocialMedia(
@GetOrgFromRequest() org: Organization,
@Param('integration') integration: string,

View File

@ -0,0 +1,31 @@
import { Controller, Get } from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { Organization, User } from '@prisma/client';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
@Controller('/notifications')
export class NotificationsController {
constructor(private _notificationsService: NotificationService) {}
@Get('/')
async mainPageList(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
return this._notificationsService.getMainPageCount(
organization.id,
user.id
);
}
@Get('/list')
async notifications(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
return this._notificationsService.getNotifications(
organization.id,
user.id
);
}
}

View File

@ -15,6 +15,8 @@ import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { CommentsService } from '@gitroom/nestjs-libraries/database/prisma/comments/comments.service';
import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service";
import {CheckPolicies} from "@gitroom/backend/services/auth/permissions/permissions.ability";
import {AuthorizationActions, Sections} from "@gitroom/backend/services/auth/permissions/permissions.service";
@Controller('/posts')
export class PostsController {
@ -63,6 +65,7 @@ export class PostsController {
}
@Post('/')
@CheckPolicies([AuthorizationActions.Create, Sections.POSTS_PER_MONTH])
createPost(
@GetOrgFromRequest() org: Organization,
@Body() body: CreatePostDto

View File

@ -1,76 +1,128 @@
import {Body, Controller, Delete, Get, Param, Post} from "@nestjs/common";
import {GetOrgFromRequest} from "@gitroom/nestjs-libraries/user/org.from.request";
import {Organization} from "@prisma/client";
import {StarsService} from "@gitroom/nestjs-libraries/database/prisma/stars/stars.service";
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permissions.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import {AddTeamMemberDto} from "@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto";
@Controller('/settings')
export class SettingsController {
constructor(
private starsService: StarsService,
) {
}
constructor(
private _starsService: StarsService,
private _organizationService: OrganizationService
) {}
@Get('/github')
async getConnectedGithubAccounts(
@GetOrgFromRequest() org: Organization
) {
return {
github: (await this.starsService.getGitHubRepositoriesByOrgId(org.id)).map((repo) => ({
id: repo.id,
login: repo.login,
}))
}
}
@Get('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getConnectedGithubAccounts(@GetOrgFromRequest() org: Organization) {
return {
github: (
await this._starsService.getGitHubRepositoriesByOrgId(org.id)
).map((repo) => ({
id: repo.id,
login: repo.login,
})),
};
}
@Post('/github')
async addGitHub(
@GetOrgFromRequest() org: Organization,
@Body('code') code: string
) {
if (!code) {
throw new Error('No code provided');
}
await this.starsService.addGitHub(org.id, code);
@Post('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async addGitHub(
@GetOrgFromRequest() org: Organization,
@Body('code') code: string
) {
if (!code) {
throw new Error('No code provided');
}
await this._starsService.addGitHub(org.id, code);
}
@Get('/github/url')
authUrl() {
return {
url: `https://github.com/login/oauth/authorize?client_id=${process.env.GITHUB_CLIENT_ID}&scope=${encodeURIComponent('read:org repo')}&redirect_uri=${encodeURIComponent(`${process.env.FRONTEND_URL}/settings`)}`
};
}
@Get('/github/url')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
authUrl() {
return {
url: `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(
'read:org repo'
)}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/settings`
)}`,
};
}
@Get('/organizations/:id')
async getOrganizations(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return {organizations: await this.starsService.getOrganizations(org.id, id)};
}
@Get('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getOrganizations(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return {
organizations: await this._starsService.getOrganizations(org.id, id),
};
}
@Get('/organizations/:id/:github')
async getRepositories(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Param('github') github: string,
) {
return {repositories: await this.starsService.getRepositoriesOfOrganization(org.id, id, github)};
}
@Get('/organizations/:id/:github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getRepositories(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Param('github') github: string
) {
return {
repositories: await this._starsService.getRepositoriesOfOrganization(
org.id,
id,
github
),
};
}
@Post('/organizations/:id')
async updateGitHubLogin(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('login') login: string,
) {
return this.starsService.updateGitHubLogin(org.id, id, login);
}
@Post('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateGitHubLogin(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('login') login: string
) {
return this._starsService.updateGitHubLogin(org.id, id, login);
}
@Delete('/repository/:id')
async deleteRepository(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this.starsService.deleteRepository(org.id, id);
}
}
@Delete('/repository/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async deleteRepository(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._starsService.deleteRepository(org.id, id);
}
@Get('/team')
@CheckPolicies([AuthorizationActions.Create, Sections.TEAM_MEMBERS], [AuthorizationActions.Create, Sections.ADMIN])
async getTeam(@GetOrgFromRequest() org: Organization) {
return this._organizationService.getTeam(org.id);
}
@Post('/team')
@CheckPolicies([AuthorizationActions.Create, Sections.TEAM_MEMBERS], [AuthorizationActions.Create, Sections.ADMIN])
async inviteTeamMember(
@GetOrgFromRequest() org: Organization,
@Body() body: AddTeamMemberDto,
) {
return this._organizationService.inviteTeamMember(org.id, body);
}
@Delete('/team/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.TEAM_MEMBERS], [AuthorizationActions.Create, Sections.ADMIN])
deleteTeamMember(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._organizationService.deleteTeamMember(org, id);
}
}

View File

@ -14,7 +14,7 @@ export class StripeController {
const event = this._stripeService.validateRequest(
req.rawBody,
req.headers['stripe-signature'],
process.env.PAYMENT_SIGNING_SECRET
process.env.STRIPE_SIGNING_KEY
);
// Maybe it comes from another stripe webhook

View File

@ -1,13 +1,112 @@
import {Controller, Get} from '@nestjs/common';
import {GetUserFromRequest} from "@gitroom/nestjs-libraries/user/user.from.request";
import {User} from "@prisma/client";
import {
Body,
Controller,
Get,
HttpException,
Post,
Req,
Res,
} from '@nestjs/common';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { Organization, User } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { Response } from 'express';
import { AuthService } from '@gitroom/backend/services/auth/auth.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import {
AuthorizationActions,
Sections,
} from '@gitroom/backend/services/auth/permissions/permissions.service';
@Controller('/user')
export class UsersController {
@Get('/self')
async getSelf(
@GetUserFromRequest() user: User
) {
return user;
constructor(
private _subscriptionService: SubscriptionService,
private _stripeService: StripeService,
private _authService: AuthService,
private _orgService: OrganizationService
) {}
@Get('/self')
async getSelf(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
if (!organization) {
throw new HttpException('Organization not found', 401);
}
return {
...user,
orgId: organization.id,
// @ts-ignore
tier: organization?.subscription?.subscriptionTier || 'FREE',
// @ts-ignore
role: organization?.users[0]?.role,
};
}
@Get('/subscription')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getSubscription(@GetOrgFromRequest() organization: Organization) {
const subscription =
await this._subscriptionService.getSubscriptionByOrganizationId(
organization.id
);
return subscription ? { subscription } : { subscription: undefined };
}
@Get('/subscription/tiers')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async tiers() {
return this._stripeService.getPackages();
}
@Post('/join-org')
async joinOrg(
@GetUserFromRequest() user: User,
@Body('org') org: string,
@Res({ passthrough: true }) response: Response
) {
const getOrgFromCookie = this._authService.getOrgFromCookie(org);
if (!getOrgFromCookie) {
return response.status(200).json({ id: null });
}
const addedOrg = await this._orgService.addUserToOrg(
user.id,
getOrgFromCookie.id,
getOrgFromCookie.orgId,
getOrgFromCookie.role
);
response.status(200).json({
id: typeof addedOrg !== 'boolean' ? addedOrg.organizationId : null,
});
}
@Get('/organizations')
async getOrgs(@GetUserFromRequest() user: User) {
return this._orgService.getOrgsByUserId(user.id);
}
@Post('/change-org')
changeOrg(
@Body('id') id: string,
@Res({ passthrough: true }) response: Response
) {
response.cookie('showorg', id, {
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
secure: true,
httpOnly: true,
sameSite: 'none',
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
response.status(200).send();
}
}

View File

@ -8,6 +8,7 @@ import {SubscriptionExceptionFilter} from "@gitroom/backend/services/auth/permis
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true,
cors: {
credentials: true,
exposedHeaders: ['reload'],

View File

@ -17,12 +17,16 @@ export class AuthMiddleware implements NestMiddleware {
}
try {
const user = AuthService.verifyJWT(auth) as User | null;
const orgHeader = req.cookies.showorg || req.headers.showorg;
if (!user) {
throw new Error('Unauthorized');
}
delete user.password;
const organization = await this._organizationService.getFirstOrgByUserId(user.id);
const organization = await this._organizationService.getOrgsByUserId(user.id);
const setOrg = organization.find((org) => org.id === orgHeader) || organization[0];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
@ -30,7 +34,7 @@ export class AuthMiddleware implements NestMiddleware {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
req.org = organization;
req.org = setOrg;
}
catch (err) {
throw new Error('Unauthorized');

View File

@ -1,69 +1,117 @@
import {Injectable} from "@nestjs/common";
import {Provider, User} from '@prisma/client';
import {CreateOrgUserDto} from "@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto";
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 { Injectable } from '@nestjs/common';
import { Provider, User } from '@prisma/client';
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
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 dayjs from 'dayjs';
@Injectable()
export class AuthService {
constructor(
private _user: UsersService,
private _organization: OrganizationService,
) {
}
async routeAuth(
provider: Provider,
body: CreateOrgUserDto | LoginUserDto
) {
if (provider === Provider.LOCAL) {
const user = await this._user.getUserByEmail(body.email);
if (body instanceof CreateOrgUserDto) {
if (user) {
throw new Error('User already exists');
}
const create = await this._organization.createOrgAndUser(body);
return this.jwt(create.users[0].user);
}
if (!user || !AuthChecker.comparePassword(body.password, user.password)) {
throw new Error('Invalid user');
}
return this.jwt(user);
}
const user = await this.loginOrRegisterProvider(provider, body as LoginUserDto);
return this.jwt(user);
}
private async loginOrRegisterProvider(provider: Provider, body: LoginUserDto) {
const providerInstance = ProvidersFactory.loadProvider(provider);
const providerUser = await providerInstance.getUser(body.providerToken);
if (!providerUser) {
throw new Error('Invalid provider token');
}
const user = await this._user.getUserByProvider(providerUser.id, provider);
constructor(
private _user: UsersService,
private _organization: OrganizationService,
) {}
async routeAuth(
provider: Provider,
body: CreateOrgUserDto | LoginUserDto,
addToOrg?: boolean | { orgId: string; role: 'USER' | 'ADMIN'; id: string }
) {
if (provider === Provider.LOCAL) {
const user = await this._user.getUserByEmail(body.email);
if (body instanceof CreateOrgUserDto) {
if (user) {
return user;
throw new Error('User already exists');
}
const create = await this._organization.createOrgAndUser({
company: '',
email: providerUser.email,
password: '',
provider,
providerId: providerUser.id
});
const create = await this._organization.createOrgAndUser(body);
const addedOrg =
addToOrg && typeof addToOrg !== 'boolean'
? await this._organization.addUserToOrg(
create.users[0].user.id,
addToOrg.id,
addToOrg.orgId,
addToOrg.role
)
: false;
return { addedOrg, jwt: await this.jwt(create.users[0].user) };
}
return create.users[0].user;
if (!user || !AuthChecker.comparePassword(body.password, user.password)) {
throw new Error('Invalid user');
}
return { jwt: await this.jwt(user) };
}
private async jwt(user: User) {
return AuthChecker.signJWT(user);
const user = await this.loginOrRegisterProvider(
provider,
body as LoginUserDto
);
const addedOrg =
addToOrg && typeof addToOrg !== 'boolean'
? await this._organization.addUserToOrg(
user.id,
addToOrg.id,
addToOrg.orgId,
addToOrg.role
)
: false;
return { addedOrg, jwt: await this.jwt(user) };
}
public getOrgFromCookie(cookie?: string) {
if (!cookie) {
return false;
}
}
try {
const getOrg: any = AuthChecker.verifyJWT(cookie);
if (dayjs(getOrg.timeLimit).isBefore(dayjs())) {
return false;
}
return getOrg as {
email: string;
role: 'USER' | 'ADMIN';
orgId: string;
id: string;
};
} catch (err) {
return false;
}
}
private async loginOrRegisterProvider(
provider: Provider,
body: LoginUserDto
) {
const providerInstance = ProvidersFactory.loadProvider(provider);
const providerUser = await providerInstance.getUser(body.providerToken);
if (!providerUser) {
throw new Error('Invalid provider token');
}
const user = await this._user.getUserByProvider(providerUser.id, provider);
if (user) {
return user;
}
const create = await this._organization.createOrgAndUser({
company: '',
email: providerUser.email,
password: '',
provider,
providerId: providerUser.id,
});
return create.users[0].user;
}
private async jwt(user: User) {
return AuthChecker.signJWT(user);
}
}

View File

@ -33,7 +33,9 @@ export class PoliciesGuard implements CanActivate {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const { org } : {org: Organization} = request;
const ability = await this._authorizationService.check(org.id);
// @ts-ignore
const ability = await this._authorizationService.check(org.id, org.createdAt, org.users[0].role, policyHandlers);
const item = policyHandlers.find((handler) =>
!this.execPolicyHandler(handler, ability),

View File

@ -1,16 +1,20 @@
import {Ability, AbilityBuilder, AbilityClass} from "@casl/ability";
import {Injectable} from "@nestjs/common";
import {pricing} from "@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing";
import {SubscriptionService} from "@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service";
import { Ability, AbilityBuilder, AbilityClass } from '@casl/ability';
import { Injectable } from '@nestjs/common';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import dayjs from 'dayjs';
export enum Sections {
FRIENDS = 'friends',
CROSSPOSTING = 'crossposting',
AI = 'ai',
INTEGRATIONS = 'integrations',
TOTALPOSTS = 'totalPosts',
MEDIAS = 'medias',
INFLUENCERS = 'influencers',
CHANNEL = 'channel',
POSTS_PER_MONTH = 'posts_per_month',
TEAM_MEMBERS = 'team_members',
COMMUNITY_FEATURES = 'community_features',
FEATURED_BY_GITROOM = 'featured_by_gitroom',
AI = 'ai',
IMPORT_FROM_CHANNELS = 'import_from_channels',
ADMIN = 'admin',
}
export enum AuthorizationActions {
@ -26,23 +30,124 @@ export type AppAbility = Ability<[AuthorizationActions, Sections]>;
export class PermissionsService {
constructor(
private _subscriptionService: SubscriptionService,
) {
private _postsService: PostsService,
private _integrationService: IntegrationService
) {}
async getPackageOptions(orgId: string) {
const subscription =
await this._subscriptionService.getSubscriptionByOrganizationId(orgId);
return {
subscription,
options:
pricing[
subscription?.subscriptionTier || !process.env.STRIPE_PUBLISHABLE_KEY
? 'PRO'
: 'FREE'
],
};
}
async getPackageOptions(orgId: string) {
const subscription = await this._subscriptionService.getSubscriptionByOrganizationId(orgId);
return pricing[subscription?.subscriptionTier || !process.env.PAYMENT_PUBLIC_KEY ? 'PRO' : 'FREE'];
async check(
orgId: string,
created_at: Date,
permission: 'USER' | 'ADMIN' | 'SUPERADMIN',
requestedPermission: Array<[AuthorizationActions, Sections]>
) {
const { can, build } = new AbilityBuilder<
Ability<[AuthorizationActions, Sections]>
>(Ability as AbilityClass<AppAbility>);
if (
requestedPermission.length === 0 ||
!process.env.STRIPE_PUBLISHABLE_KEY
) {
return build();
}
async check(orgId: string) {
const { can, build } = new AbilityBuilder<Ability<[AuthorizationActions, Sections]>>(Ability as AbilityClass<AppAbility>);
const { subscription, options } = await this.getPackageOptions(orgId);
for (const [action, section] of requestedPermission) {
// check for the amount of channels
if (section === Sections.CHANNEL) {
const totalChannels = (
await this._integrationService.getIntegrationsList(orgId)
).length;
// const options = await this.getPackageOptions(orgId);
if (
(options.channel && options.channel > totalChannels) ||
(subscription?.totalChannels || 0) > totalChannels
) {
can(action, section);
continue;
}
}
return build({
detectSubjectType: (item) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
item.constructor
});
// check for posts per month
if (section === Sections.POSTS_PER_MONTH) {
const createdAt =
(await this._subscriptionService.getSubscription(orgId))?.createdAt ||
created_at;
const totalMonthPast = Math.abs(
dayjs(createdAt).diff(dayjs(), 'month')
);
const checkFrom = dayjs(createdAt).add(totalMonthPast, 'month');
const count = await this._postsService.countPostsFromDay(
orgId,
checkFrom.toDate()
);
if (count < options.posts_per_month) {
can(action, section);
continue;
}
}
if (section === Sections.TEAM_MEMBERS && options.team_members) {
can(action, section);
continue;
}
if (
section === Sections.ADMIN &&
['ADMIN', 'SUPERADMIN'].includes(permission)
) {
can(action, section);
continue;
}
if (
section === Sections.COMMUNITY_FEATURES &&
options.community_features
) {
can(action, section);
continue;
}
if (
section === Sections.FEATURED_BY_GITROOM &&
options.featured_by_gitroom
) {
can(action, section);
continue;
}
if (section === Sections.AI && options.ai) {
can(action, section);
continue;
}
if (
section === Sections.IMPORT_FROM_CHANNELS &&
options.import_from_channels
) {
can(action, section);
}
}
return build({
detectSubjectType: (item) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
item.constructor,
});
}
}

View File

@ -30,15 +30,15 @@ export class SubscriptionExceptionFilter implements ExceptionFilter {
const getErrorMessage = (error: {section: Sections, action: AuthorizationActions}) => {
switch (error.section) {
case Sections.AI:
case Sections.POSTS_PER_MONTH:
switch (error.action) {
default:
return 'You have reached the maximum number of FAQ\'s for your subscription. Please upgrade your subscription to add more FAQ\'s.';
return 'You have reached the maximum number of posts for your subscription. Please upgrade your subscription to add more posts.';
}
case Sections.CROSSPOSTING:
case Sections.CHANNEL:
switch (error.action) {
default:
return 'You have reached the maximum number of categories for your subscription. Please upgrade your subscription to add more categories.';
return 'You have reached the maximum number of channels for your subscription. Please upgrade your subscription to add more channels.';
}
}
}

View File

@ -0,0 +1,21 @@
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
import { BillingComponent } from '@gitroom/frontend/components/billing/billing.component';
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
export const metadata: Metadata = {
title: 'Gitroom Billing',
description: '',
};
export default async function Page() {
const tiers = await (await internalFetch('/user/subscription/tiers')).json();
if (tiers?.statusCode === 402) {
return redirect('/');
}
const { subscription } = await (
await internalFetch('/user/subscription')
).json();
return <BillingComponent subscription={subscription} tiers={tiers} />;
}

View File

@ -1,28 +1,37 @@
import {SettingsComponent} from "@gitroom/frontend/components/settings/settings.component";
import {internalFetch} from "@gitroom/helpers/utils/internal.fetch";
import {redirect} from "next/navigation";
import {RedirectType} from "next/dist/client/components/redirect";
import {Metadata} from "next";
import { SettingsComponent } from '@gitroom/frontend/components/settings/settings.component';
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
import { redirect } from 'next/navigation';
import { RedirectType } from 'next/dist/client/components/redirect';
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Gitroom Settings',
description: '',
}
export default async function Index({searchParams}: {searchParams: {code: string}}) {
if (searchParams.code) {
await internalFetch('/settings/github', {
method: 'POST',
body: JSON.stringify({code: searchParams.code})
});
return redirect('/settings', RedirectType.replace);
}
const {github} = await (await internalFetch('/settings/github')).json();
const emptyOnes = github.find((p: {login: string}) => !p.login);
const {organizations} = emptyOnes ? await (await internalFetch(`/settings/organizations/${emptyOnes.id}`)).json() : {organizations: []};
return (
<SettingsComponent github={github} organizations={organizations} />
);
};
export default async function Index({
searchParams,
}: {
searchParams: { code: string };
}) {
if (searchParams.code) {
await internalFetch('/settings/github', {
method: 'POST',
body: JSON.stringify({ code: searchParams.code }),
});
return redirect('/settings', RedirectType.replace);
}
const { github } = await (await internalFetch('/settings/github')).json();
if (!github) {
return redirect('/');
}
const emptyOnes = github.find((p: { login: string }) => !p.login);
const { organizations } = emptyOnes
? await (
await internalFetch(`/settings/organizations/${emptyOnes.id}`)
).json()
: { organizations: [] };
return <SettingsComponent github={github} organizations={organizations} />;
}

View File

@ -0,0 +1,14 @@
import { FC } from 'react';
import { Subscription } from '@prisma/client';
import {
NoBillingComponent,
Tiers,
} from '@gitroom/frontend/components/billing/no.billing.component';
export const BillingComponent: FC<{
subscription?: Subscription;
tiers: Tiers;
}> = (props) => {
const { subscription, tiers } = props;
return <NoBillingComponent tiers={tiers} sub={subscription} />;
};

View File

@ -0,0 +1,374 @@
'use client';
import { Slider } from '@gitroom/react/form/slider';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@gitroom/react/form/button';
import { sortBy } from 'lodash';
import { Track } from '@gitroom/react/form/track';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Subscription } from '@prisma/client';
import { useDebouncedCallback } from 'use-debounce';
import ReactLoading from 'react-loading';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useToaster } from '@gitroom/react/toaster/toaster';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import clsx from 'clsx';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import {useRouter} from "next/navigation";
dayjs.extend(utc);
export interface Tiers {
month: Array<{
name: 'Pro' | 'Standard';
recurring: 'month' | 'year';
price: number;
}>;
year: Array<{
name: 'Pro' | 'Standard';
recurring: 'month' | 'year';
price: number;
}>;
}
export const Prorate: FC<{
totalChannels: number;
period: 'MONTHLY' | 'YEARLY';
pack: 'STANDARD' | 'PRO';
}> = (props) => {
const { totalChannels, period, pack } = props;
const fetch = useFetch();
const [price, setPrice] = useState<number | false>(0);
const [loading, setLoading] = useState(false);
const calculatePrice = useDebouncedCallback(async () => {
setLoading(true);
setPrice(
(
await (
await fetch('/billing/prorate', {
method: 'POST',
body: JSON.stringify({
total: totalChannels,
period,
billing: pack,
}),
})
).json()
).price
);
setLoading(false);
}, 500);
useEffect(() => {
setPrice(false);
calculatePrice();
}, [totalChannels, period, pack]);
if (loading) {
return (
<div className="pt-[12px]">
<ReactLoading type="spin" color="#fff" width={20} height={20} />
</div>
);
}
if (price === false) {
return null;
}
return (
<div className="text-[12px] flex pt-[12px]">
(Pay Today ${(price < 0 ? 0 : price).toFixed(1)})
</div>
);
};
export const Features: FC<{
pack: 'FREE' | 'STANDARD' | 'PRO';
channels: number;
}> = (props) => {
const { pack, channels } = props;
const features = useMemo(() => {
const currentPricing = pricing[pack];
const channelsOr = currentPricing.channel || channels;
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;
}, [pack, channels]);
return (
<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="#AAAAAA"
/>
</svg>
</div>
<div>{feature}</div>
</div>
))}
</div>
);
};
export const NoBillingComponent: FC<{
tiers: Tiers;
sub?: Subscription;
}> = (props) => {
const { tiers, sub } = props;
const fetch = useFetch();
const router = useRouter();
const toast = useToaster();
const [subscription, setSubscription] = useState<Subscription | undefined>(
sub
);
const [loading, setLoading] = useState<boolean>(false);
const [period, setPeriod] = useState<'MONTHLY' | 'YEARLY'>(
subscription?.period || 'MONTHLY'
);
const [monthlyOrYearly, setMonthlyOrYearly] = useState<'on' | 'off'>(
period === 'MONTHLY' ? 'off' : 'on'
);
const [initialChannels, setInitialChannels] = useState(
subscription?.totalChannels || 1
);
const [totalChannels, setTotalChannels] = useState<number>(initialChannels);
const currentPackage = useMemo(() => {
if (!subscription) {
return 'FREE';
}
if (initialChannels !== totalChannels) {
return '';
}
if (period === 'YEARLY' && monthlyOrYearly === 'off') {
return '';
}
if (period === 'MONTHLY' && monthlyOrYearly === 'on') {
return '';
}
return subscription?.subscriptionTier;
}, [subscription, totalChannels, initialChannels, monthlyOrYearly, period]);
const currentDisplay = useMemo(() => {
return sortBy(
[
{ name: 'Free', price: 0 },
...(monthlyOrYearly === 'on' ? tiers.year : tiers.month),
],
(p) => ['Free', 'Standard', 'Pro'].indexOf(p.name)
);
}, [monthlyOrYearly]);
const moveToCheckout = useCallback(
(billing: 'STANDARD' | 'PRO' | 'FREE') => async () => {
if (billing === 'FREE') {
if (
subscription?.cancelAt ||
(await deleteDialog(
'Are you sure you want to cancel your subscription?',
'Yes, cancel',
'Cancel Subscription'
))
) {
setLoading(true);
const { cancel_at } = await (
await fetch('/billing/cancel', {
method: 'POST',
})
).json();
setSubscription((subs) => ({ ...subs!, cancelAt: cancel_at }));
if (cancel_at)
toast.show('Subscription set to canceled successfully');
if (!cancel_at) toast.show('Subscription reactivated successfully');
setLoading(false);
}
return;
}
setLoading(true);
const { url, portal } = await (
await fetch('/billing/subscribe', {
method: 'POST',
body: JSON.stringify({
total: totalChannels,
period: monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY',
billing,
}),
})
).json();
if (url) {
window.location.href = url;
return;
}
if (portal) {
if (
await deleteDialog(
'We could not charge your credit card, please update your payment method',
'Update',
'Payment Method Required'
)
) {
window.open(portal);
}
} else {
setTotalChannels(totalChannels);
setInitialChannels(totalChannels);
setPeriod(monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY');
setSubscription((subs) => ({
...subs!,
subscriptionTier: billing,
cancelAt: null,
}));
router.refresh();
toast.show('Subscription updated successfully');
}
setLoading(false);
},
[monthlyOrYearly, totalChannels]
);
return (
<div className="flex flex-col gap-[16px]">
<div className="flex flex-row">
<div className="flex-1 text-[20px]">Plans</div>
<div className="flex items-center gap-[16px]">
<div>MONTHLY</div>
<div>
<Slider value={monthlyOrYearly} onChange={setMonthlyOrYearly} />
</div>
<div>YEARLY</div>
</div>
</div>
<div className="flex flex-col items-center gap-[10px]">
<div>Total Channels</div>
<div className="w-[60%]">
<Track
min={1}
max={60}
value={totalChannels}
onChange={setTotalChannels}
/>
</div>
</div>
<div className="flex gap-[16px]">
{currentDisplay.map((p) => (
<div
key={p.name}
className="flex-1 bg-sixth border border-[#172034] rounded-[4px] p-[24px] gap-[16px] flex flex-col"
>
<div className="text-[18px]">{p.name}</div>
<div className="text-[38px] flex gap-[2px] items-center">
<div>{p.price ? '$' + totalChannels * p.price : p.name}</div>
{!!p.price && (
<div className="text-[14px] font-['Inter'] text-[#AAA]">
{monthlyOrYearly === 'on' ? '/year' : '/month'}
</div>
)}
</div>
<div className="text-[14px] flex gap-[10px]">
{currentPackage === p.name.toUpperCase() &&
subscription?.cancelAt ? (
<div className="gap-[3px] flex flex-col">
<div>
<Button onClick={moveToCheckout('FREE')} loading={loading}>
Reactivate subscription
</Button>
</div>
</div>
) : (
<Button
loading={loading && !!p.price}
disabled={
(!!subscription?.cancelAt &&
p.name.toUpperCase() === 'FREE') ||
currentPackage === p.name.toUpperCase()
}
className={clsx(
subscription &&
p.name.toUpperCase() === 'FREE' &&
'!bg-red-500'
)}
onClick={moveToCheckout(
p.name.toUpperCase() as 'STANDARD' | 'PRO'
)}
>
{currentPackage === p.name.toUpperCase()
? 'Current Plan'
: p.name.toUpperCase() === 'FREE'
? subscription?.cancelAt
? `Downgrade on ${dayjs
.utc(subscription?.cancelAt)
.local()
.format('D MMM, YYYY')}`
: 'Cancel subscription'
: 'Purchase Plan'}
</Button>
)}
{subscription &&
currentPackage !== p.name.toUpperCase() &&
!!p.price && (
<Prorate
period={monthlyOrYearly === 'on' ? 'YEARLY' : 'MONTHLY'}
pack={p.name.toUpperCase() as 'STANDARD' | 'PRO'}
totalChannels={totalChannels}
/>
)}
</div>
<Features
pack={p.name.toUpperCase() as 'FREE' | 'STANDARD' | 'PRO'}
channels={totalChannels}
/>
</div>
))}
</div>
</div>
);
};

View File

@ -1,6 +1,6 @@
'use client';
import { FC, useCallback, useEffect, useMemo, useState, JSX } from 'react';
import { FC, useCallback, useMemo } from 'react';
import {
Integrations,
useCalendar,

View File

@ -2,6 +2,7 @@
import {ReactNode, useCallback} from "react";
import {FetchWrapperComponent} from "@gitroom/helpers/utils/custom.fetch";
import {deleteDialog} from "@gitroom/react/helpers/delete.dialog";
export default async function LayoutContext(params: {children: ReactNode}) {
if (params?.children) {
@ -16,6 +17,15 @@ function LayoutContextInner(params: {children: ReactNode}) {
if (response?.headers?.get('reload')) {
window.location.reload();
}
if (response.status === 402) {
if (await deleteDialog((await response.json()).message, 'Move to billing', 'Payment Required')) {
window.open('/billing', '_blank');
}
return false;
}
return true;
}, []);
return (

View File

@ -7,18 +7,10 @@ import { MantineWrapper } from '@gitroom/react/helpers/mantine.wrapper';
import { ToolTip } from '@gitroom/frontend/components/layout/top.tip';
import { ShowMediaBoxModal } from '@gitroom/frontend/components/media/media.component';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import { Toaster } from '@gitroom/react/toaster/toaster';
import { ShowPostSelector } from '@gitroom/frontend/components/post-url-selector/post.url.selector';
const NotificationComponent = dynamic(
() =>
import('@gitroom/frontend/components/notifications/notification.component'),
{
loading: () => <></>,
ssr: false,
}
);
import { OrganizationSelector } from '@gitroom/frontend/components/layout/organization.selector';
import NotificationComponent from "@gitroom/frontend/components/notifications/notification.component";
export const LayoutSettings = ({ children }: { children: ReactNode }) => {
const user = JSON.parse(headers().get('user')!);
@ -38,8 +30,9 @@ export const LayoutSettings = ({ children }: { children: ReactNode }) => {
<div className="mt-[12px]">Gitroom</div>
</div>
<TopMenu />
<div>
<div className="flex items-center gap-[8px]">
<NotificationComponent />
<OrganizationSelector />
</div>
</div>
<div className="flex-1 flex">

View File

@ -0,0 +1,86 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
export const OrganizationSelector = () => {
const fetch = useFetch();
const user = useUser();
const load = useCallback(async () => {
return await (await fetch('/user/organizations')).json();
}, []);
const { data } = useSWR('organizations', load, {
revalidateIfStale: false,
revalidateOnFocus: false,
refreshWhenOffline: false,
refreshWhenHidden: false,
revalidateOnReconnect: false,
});
const current = useMemo(() => {
return data?.find((d: any) => d.id === user?.orgId);
}, [data]);
const withoutCurrent = useMemo(() => {
return data?.filter((d: any) => d.id !== user?.orgId);
}, [current, data]);
const changeOrg = useCallback(
(org: { name: string; id: string }) => async () => {
await fetch('/user/change-org', {
method: 'POST',
body: JSON.stringify({ id: org.id }),
});
window.location.reload();
},
[]
);
return (
<div className="bg-third h-[48px] flex items-center min-w-[172px] select-none relative group">
<div className="border-tableBorder py-[8px] px-[12px] border w-full h-full flex items-center">
<div className="flex-1">{current?.name || 'Loading...'}</div>
{data?.length > 1 && (
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<g clipPath="url(#clip0_140_1160)">
<path
d="M3.33301 5.66669L7.99967 10.3334L12.6663 5.66669"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_140_1160">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</div>
)}
</div>
{data?.length > 1 && (
<div className="hidden py-[12px] px-[12px] group-hover:flex w-full absolute top-[100%] left-0 bg-third border-tableBorder border-x border-b gap-[12px] cursor-pointer flex-col">
{withoutCurrent?.map((org: { name: string; id: string }) => (
<div key={org.id} onClick={changeOrg(org)}>
{org.name}
</div>
))}
</div>
)}
</div>
);
};

View File

@ -1,51 +1,75 @@
"use client";
'use client';
import {FC} from "react";
import Link from "next/link";
import clsx from "clsx";
import {usePathname} from "next/navigation";
import { FC } from 'react';
import Link from 'next/link';
import clsx from 'clsx';
import { usePathname } from 'next/navigation';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
export const menuItems = [
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
{
name: 'Launches',
icon: 'launches',
path: '/launches',
},
{
name: 'Media',
icon: 'media',
path: '/media',
},
{
name: 'Settings',
icon: 'settings',
path: '/settings',
},
{
name: 'Billing',
icon: 'billing',
path: '/billing',
},
{
name: 'Analytics',
icon: 'analytics',
path: '/analytics',
},
{
name: 'Launches',
icon: 'launches',
path: '/launches',
},
{
name: 'Settings',
icon: 'settings',
path: '/settings',
role: ['ADMIN', 'SUPERADMIN'],
},
{
name: 'Billing',
icon: 'billing',
path: '/billing',
role: ['ADMIN', 'SUPERADMIN'],
},
];
export const TopMenu: FC = () => {
const path = usePathname();
return (
<div className="flex flex-col h-full">
<ul className="gap-5 flex flex-1 items-center text-[18px]">
{menuItems.map((item, index) => (
<li key={item.name}>
<Link prefetch={false} href={item.path} className={clsx("flex gap-2 items-center box", menuItems.map(p => p.path).indexOf(path) === index ? 'text-primary showbox' : 'text-gray')}>
<span>{item.name}</span>
</Link>
</li>
))}
</ul>
</div>
);
}
const path = usePathname();
const user = useUser();
return (
<div className="flex flex-col h-full">
<ul className="gap-5 flex flex-1 items-center text-[18px]">
{menuItems
.filter((f) => {
if (f.role) {
return f.role.includes(user?.role!);
}
return true;
})
.map((item, index) => (
<li key={item.name}>
<Link
prefetch={false}
href={item.path}
className={clsx(
'flex gap-2 items-center box',
menuItems
.filter((f) => {
if (f.role) {
return f.role.includes(user?.role!);
}
return true;
})
.map((p) => p.path)
.indexOf(path) === index
? 'text-primary showbox'
: 'text-gray'
)}
>
<span>{item.name}</span>
</Link>
</li>
))}
</ul>
</div>
);
};

View File

@ -1,16 +1,34 @@
"use client";
'use client';
import {createContext, FC, ReactNode, useContext} from "react";
import {User} from "@prisma/client";
import { createContext, FC, ReactNode, useContext } from 'react';
import { User } from '@prisma/client';
import {
pricing,
PricingInnerInterface,
} from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
export const UserContext = createContext<undefined|User>(undefined);
export const UserContext = createContext<
| undefined
| (User & {
orgId: string;
tier: PricingInnerInterface;
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
})
>(undefined);
export const ContextWrapper: FC<{user: User, children: ReactNode}> = ({user, children}) => {
return (
<UserContext.Provider value={user}>
{children}
</UserContext.Provider>
)
}
export const ContextWrapper: FC<{
user: User & {
orgId: string;
tier: 'FREE' | 'STANDARD' | 'PRO';
role: 'USER' | 'ADMIN' | 'SUPERADMIN';
};
children: ReactNode;
}> = ({ user, children }) => {
return (
<UserContext.Provider value={{ ...user, tier: pricing[user.tier] }}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => useContext(UserContext);
export const useUser = () => useContext(UserContext);

View File

@ -1,20 +1,110 @@
"use client";
'use client';
import {NotificationBell, NovuProvider, PopoverNotificationCenter} from "@novu/notification-center";
import {useUser} from "@gitroom/frontend/components/layout/user.context";
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { FC, useCallback, useState } from 'react';
import clsx from 'clsx';
import { useClickAway } from '@uidotdev/usehooks';
export const ShowNotification: FC<{
notification: { createdAt: string; content: string };
lastReadNotification: string;
}> = (props) => {
const { notification } = props;
const [newNotification] = useState(
new Date(notification.createdAt) > new Date(props.lastReadNotification)
);
return (
<div
className={clsx(
"text-primary px-[16px] py-[10px] border-b border-primary/30 last:border-b-0 transition-colors font-['Inter']",
newNotification && 'font-bold bg-[#d7d7d7] animate-newMessages'
)}
>
{notification.content}
</div>
);
};
export const NotificationOpenComponent = () => {
const fetch = useFetch();
const loadNotifications = useCallback(async () => {
return await (await fetch('/notifications/list')).json();
}, []);
const { data, isLoading } = useSWR('notifications', loadNotifications);
return (
<div className="opacity-0 animate-normalFadeDown mt-[10px] absolute w-[420px] pb-[16px] min-h-[200px] top-[100%] right-0 bg-third text-white rounded-[16px] flex flex-col border border-tableBorder">
<div className="p-[16px] border-b border-tableBorder font-['Inter'] font-bold">
Notifications
</div>
<div className="flex flex-col">
{!isLoading &&
data.notifications.map(
(
notification: { createdAt: string; content: string },
index: number
) => (
<ShowNotification
notification={notification}
lastReadNotification={data.lastReadNotifications}
key={`notifications_${index}`}
/>
)
)}
</div>
</div>
);
};
const NotificationComponent = () => {
const user = useUser();
return (
<NovuProvider
subscriberId={user?.id}
applicationIdentifier={process.env.NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER!}
>
<PopoverNotificationCenter colorScheme="dark">
{({ unseenCount }) => <NotificationBell unseenCount={unseenCount} />}
</PopoverNotificationCenter>
</NovuProvider>
)
}
const fetch = useFetch();
const [show, setShow] = useState(false);
export default NotificationComponent;
const loadNotifications = useCallback(async () => {
return await (await fetch('/notifications')).json();
}, []);
const { data, mutate } = useSWR('notifications-list', loadNotifications);
const changeShow = useCallback(() => {
mutate(
{ ...data, total: 0 },
{
revalidate: false,
}
);
setShow(!show);
}, [show, data]);
const ref = useClickAway<HTMLDivElement>(() => setShow(false));
return (
<div className="relative cursor-pointer select-none" ref={ref}>
<div onClick={changeShow}>
{data && data.total > 0 && (
<div className="w-[13px] h-[13px] bg-red-500 rounded-full absolute -left-[2px] -top-[2px] text-[10px] text-center flex justify-center items-center">
{data.total}
</div>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M20.7927 16.4944C20.2724 15.5981 19.4989 13.0622 19.4989 9.75C19.4989 7.76088 18.7087 5.85322 17.3022 4.4467C15.8957 3.04018 13.988 2.25 11.9989 2.25C10.0098 2.25 8.10214 3.04018 6.69561 4.4467C5.28909 5.85322 4.49891 7.76088 4.49891 9.75C4.49891 13.0631 3.72454 15.5981 3.20423 16.4944C3.07135 16.7222 3.00091 16.9811 3.00001 17.2449C2.9991 17.5086 3.06776 17.768 3.19907 17.9967C3.33037 18.2255 3.51968 18.4156 3.74789 18.5478C3.9761 18.6801 4.23515 18.7498 4.49891 18.75H8.32485C8.49789 19.5967 8.95806 20.3577 9.62754 20.9042C10.297 21.4507 11.1347 21.7492 11.9989 21.7492C12.8631 21.7492 13.7008 21.4507 14.3703 20.9042C15.0398 20.3577 15.4999 19.5967 15.673 18.75H19.4989C19.7626 18.7496 20.0215 18.6798 20.2496 18.5475C20.4777 18.4151 20.6669 18.225 20.7981 17.9963C20.9292 17.7676 20.9978 17.5083 20.9969 17.2446C20.9959 16.9809 20.9255 16.7222 20.7927 16.4944ZM11.9989 20.25C11.5337 20.2499 11.0801 20.1055 10.7003 19.8369C10.3205 19.5683 10.0333 19.1886 9.87829 18.75H14.1195C13.9645 19.1886 13.6773 19.5683 13.2975 19.8369C12.9178 20.1055 12.4641 20.2499 11.9989 20.25ZM4.49891 17.25C5.22079 16.0087 5.99891 13.1325 5.99891 9.75C5.99891 8.1587 6.63105 6.63258 7.75627 5.50736C8.88149 4.38214 10.4076 3.75 11.9989 3.75C13.5902 3.75 15.1163 4.38214 16.2416 5.50736C17.3668 6.63258 17.9989 8.1587 17.9989 9.75C17.9989 13.1297 18.7752 16.0059 19.4989 17.25H4.49891Z"
fill="white"
/>
</svg>
</div>
{show && <NotificationOpenComponent />}
</div>
);
};
export default NotificationComponent;

View File

@ -1,38 +1,34 @@
import {Button} from "@gitroom/react/form/button";
import {Checkbox} from "@gitroom/react/form/checkbox";
import {GithubComponent} from "@gitroom/frontend/components/settings/github.component";
import {FC} from "react";
'use client';
export const SettingsComponent: FC<{organizations: Array<{login: string, id: string}>, github: Array<{id: string, login: string}>}> = (props) => {
const {github, organizations} = props;
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</div>
<GithubComponent github={github} organizations={organizations} />
<div className="flex gap-[5px]">
<div><Checkbox checked={true} /></div>
<div>Show news with everybody in Gitroom</div>
</div>
</div>
<div className="flex flex-col">
<h2 className="text-[24px] mb-[24px]">Team Members</h2>
<h3 className="text-[20px]">Account Managers</h3>
<div className="text-[#AAA] mt-[4px]">Invite your assistant or team member to manage your Gitroom account</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[24px]">
<div className="flex flex-col gap-[16px]">
<div className="flex justify-between">
<div>Nevo David</div>
<div>Administrator</div>
<div>Remove</div>
</div>
</div>
<div>
<Button>Add another member</Button>
</div>
</div>
</div>
import { Checkbox } from '@gitroom/react/form/checkbox';
import { GithubComponent } from '@gitroom/frontend/components/settings/github.component';
import { FC } from 'react';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { TeamsComponent } from '@gitroom/frontend/components/settings/teams.component';
export const SettingsComponent: FC<{
organizations: Array<{ login: string; id: string }>;
github: Array<{ id: string; login: string }>;
}> = (props) => {
const { github, organizations } = props;
const user = useUser();
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
</div>
);
}
<GithubComponent github={github} organizations={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

@ -0,0 +1,247 @@
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import React, { useCallback, useMemo } from 'react';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { capitalize } from 'lodash';
import { useModals } from '@mantine/modals';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Input } from '@gitroom/react/form/input';
import { useForm, FormProvider, useWatch } from 'react-hook-form';
import { Select } from '@gitroom/react/form/select';
import { Checkbox } from '@gitroom/react/form/checkbox';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { AddTeamMemberDto } from '@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import copy from 'copy-to-clipboard';
const roles = [
{
name: 'User',
value: 'USER',
},
{
name: 'Admin',
value: 'ADMIN',
},
];
export const AddMember = () => {
const modals = useModals();
const fetch = useFetch();
const toast = useToaster();
const resolver = useMemo(() => {
return classValidatorResolver(AddTeamMemberDto);
}, []);
const form = useForm({
values: {
email: '',
role: '',
sendEmail: true,
},
resolver,
mode: 'onChange',
});
const sendEmail = useWatch({
control: form.control,
name: 'sendEmail',
});
const submit = useCallback(
async (values: { email: string; role: string; sendEmail: boolean }) => {
const { url } = await (
await fetch('/settings/team', {
method: 'POST',
body: JSON.stringify(values),
})
).json();
if (values.sendEmail) {
modals.closeAll();
toast.show('Invitation link sent');
return;
}
copy(url);
modals.closeAll();
toast.show('Link copied to clipboard');
},
[]
);
const closeModal = useCallback(() => {
return modals.closeAll();
}, []);
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submit)}>
<div className="relative flex gap-[10px] flex-col flex-1 rounded-[4px] border border-[#172034] bg-[#0B101B] p-[16px] pt-0">
<TopTitle title="Add Member" />
<button
onClick={closeModal}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
{sendEmail && (
<Input label="Email" placeholder="Enter email" name="email" />
)}
<Select label="Role" name="role">
<option value="">Select Role</option>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.name}
</option>
))}
</Select>
<div className="flex gap-[5px]">
<div>
<Checkbox name="sendEmail" />
</div>
<div>Send invitation via email?</div>
</div>
<Button type="submit" className="mt-[18px]">
{sendEmail ? 'Send Invitation Link' : 'Copy Link'}
</Button>
</div>
</form>
</FormProvider>
);
};
export const TeamsComponent = () => {
const fetch = useFetch();
const user = useUser();
const modals = useModals();
const myLevel = user?.role === 'USER' ? 0 : user?.role === 'ADMIN' ? 1 : 2;
const getLevel = useCallback(
(role: 'USER' | 'ADMIN' | 'SUPERADMIN') =>
role === 'USER' ? 0 : role === 'ADMIN' ? 1 : 2,
[]
);
const loadTeam = useCallback(async () => {
return (await (await fetch('/settings/team')).json()).users as Array<{
id: string;
role: 'SUPERADMIN' | 'ADMIN' | 'USER';
user: { email: string; id: string };
}>;
}, []);
const addMember = useCallback(() => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-white',
},
withCloseButton: false,
children: <AddMember />,
});
}, []);
const { data, mutate } = useSWR('/api/teams', loadTeam, {
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
});
const remove = useCallback(
(toRemove: { user: { id: string } }) => async () => {
console.log(toRemove);
if (
!(await deleteDialog(
'Are you sure you want to remove this team member?'
))
) {
return;
}
await fetch(`/settings/team/${toRemove.user.id}`, {
method: 'DELETE',
});
await mutate();
},
[]
);
return (
<div className="flex flex-col">
<h2 className="text-[24px] mb-[24px]">Team Members</h2>
<h3 className="text-[20px]">Account Managers</h3>
<div className="text-[#AAA] mt-[4px]">
Invite your assistant or team member to manage your Gitroom account
</div>
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[24px]">
<div className="flex flex-col gap-[16px]">
{(data || []).map((p) => (
<div key={p.user.id} className="flex items-center">
<div className="flex-1">
{capitalize(p.user.email.split('@')[0]).split('.')[0]}
</div>
<div className="flex-1">
{p.role === 'USER'
? 'User'
: p.role === 'ADMIN'
? 'Admin'
: 'Super Admin'}
</div>
{+myLevel > +getLevel(p.role) ? (
<div className="flex-1 flex justify-end">
<Button
className="!bg-[#0b0f1c] !h-[24px] border border-[#506490] rounded-[4px] text-[12px] font-['Inter']"
onClick={remove(p)}
>
<div className="flex justify-center items-center gap-[4px]">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="15"
viewBox="0 0 14 15"
fill="none"
>
<path
d="M11.8125 3.125H9.625V2.6875C9.625 2.3394 9.48672 2.00556 9.24058 1.75942C8.99444 1.51328 8.6606 1.375 8.3125 1.375H5.6875C5.3394 1.375 5.00556 1.51328 4.75942 1.75942C4.51328 2.00556 4.375 2.3394 4.375 2.6875V3.125H2.1875C2.07147 3.125 1.96019 3.17109 1.87814 3.25314C1.79609 3.33519 1.75 3.44647 1.75 3.5625C1.75 3.67853 1.79609 3.78981 1.87814 3.87186C1.96019 3.95391 2.07147 4 2.1875 4H2.625V11.875C2.625 12.1071 2.71719 12.3296 2.88128 12.4937C3.04538 12.6578 3.26794 12.75 3.5 12.75H10.5C10.7321 12.75 10.9546 12.6578 11.1187 12.4937C11.2828 12.3296 11.375 12.1071 11.375 11.875V4H11.8125C11.9285 4 12.0398 3.95391 12.1219 3.87186C12.2039 3.78981 12.25 3.67853 12.25 3.5625C12.25 3.44647 12.2039 3.33519 12.1219 3.25314C12.0398 3.17109 11.9285 3.125 11.8125 3.125ZM5.25 2.6875C5.25 2.57147 5.29609 2.46019 5.37814 2.37814C5.46019 2.29609 5.57147 2.25 5.6875 2.25H8.3125C8.42853 2.25 8.53981 2.29609 8.62186 2.37814C8.70391 2.46019 8.75 2.57147 8.75 2.6875V3.125H5.25V2.6875ZM10.5 11.875H3.5V4H10.5V11.875ZM6.125 6.1875V9.6875C6.125 9.80353 6.07891 9.91481 5.99686 9.99686C5.91481 10.0789 5.80353 10.125 5.6875 10.125C5.57147 10.125 5.46019 10.0789 5.37814 9.99686C5.29609 9.91481 5.25 9.80353 5.25 9.6875V6.1875C5.25 6.07147 5.29609 5.96019 5.37814 5.87814C5.46019 5.79609 5.57147 5.75 5.6875 5.75C5.80353 5.75 5.91481 5.79609 5.99686 5.87814C6.07891 5.96019 6.125 6.07147 6.125 6.1875ZM8.75 6.1875V9.6875C8.75 9.80353 8.70391 9.91481 8.62186 9.99686C8.53981 10.0789 8.42853 10.125 8.3125 10.125C8.19647 10.125 8.08519 10.0789 8.00314 9.99686C7.92109 9.91481 7.875 9.80353 7.875 9.6875V6.1875C7.875 6.07147 7.92109 5.96019 8.00314 5.87814C8.08519 5.79609 8.19647 5.75 8.3125 5.75C8.42853 5.75 8.53981 5.79609 8.62186 5.87814C8.70391 5.96019 8.75 6.07147 8.75 6.1875Z"
fill="white"
/>
</svg>
</div>
<div>Remove</div>
</div>
</Button>
</div>
) : (
<div className="flex-1" />
)}
</div>
))}
</div>
<div>
<Button className="rounded-[4px]" onClick={addMember}>
Add another member
</Button>
</div>
</div>
</div>
);
};

View File

@ -1,67 +1,115 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import {fetchBackend} from "@gitroom/helpers/utils/custom.fetch.func";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { fetchBackend } from '@gitroom/helpers/utils/custom.fetch.func';
// This function can be marked `async` if using `await` inside
export async function middleware(request: NextRequest) {
const nextUrl = request.nextUrl;
const authCookie = request.cookies.get('auth');
// If the URL is logout, delete the cookie and redirect to login
if (nextUrl.href.indexOf('/auth/logout') > -1) {
const response = NextResponse.redirect(new URL('/auth/login', nextUrl.href));
response.cookies.set('auth', '', {
path: '/',
sameSite: false,
httpOnly: true,
secure: true,
maxAge: -1,
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname
const nextUrl = request.nextUrl;
const authCookie = request.cookies.get('auth');
const showorg = request.cookies.get('showorg');
// If the URL is logout, delete the cookie and redirect to login
if (nextUrl.href.indexOf('/auth/logout') > -1) {
const response = NextResponse.redirect(
new URL('/auth/login', nextUrl.href)
);
response.cookies.set('auth', '', {
path: '/',
sameSite: false,
httpOnly: true,
secure: true,
maxAge: -1,
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
});
return response;
}
const org = nextUrl.searchParams.get('org');
const orgUrl = org ? '?org=' + org : '';
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {
return NextResponse.redirect(new URL(`/auth${orgUrl}`, nextUrl.href));
}
// If the url is /auth and the cookie exists, redirect to /
if (nextUrl.href.indexOf('/auth') > -1 && authCookie) {
return NextResponse.redirect(new URL(`/${orgUrl}`, nextUrl.href));
}
if (nextUrl.href.indexOf('/auth') > -1 && !authCookie) {
if (org) {
const redirect = NextResponse.redirect(new URL(`/`, nextUrl.href));
redirect.cookies.set('org', org, {
path: '/',
sameSite: false,
httpOnly: true,
secure: true,
expires: new Date(Date.now() + 15 * 60 * 1000),
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
});
return redirect;
}
return NextResponse.next();
}
try {
if (org) {
const { id } = await (
await fetchBackend('/user/join-org', {
body: JSON.stringify({
org,
}),
headers: {
auth: authCookie?.value!,
},
method: 'POST',
})
).json();
const redirect = NextResponse.redirect(
new URL(`/?added=true`, nextUrl.href)
);
if (id) {
redirect.cookies.set('showorg', id, {
path: '/',
sameSite: false,
httpOnly: true,
secure: true,
expires: new Date(Date.now() + 15 * 60 * 1000),
domain: '.' + new URL(process.env.FRONTEND_URL!).hostname,
});
return response;
}
return redirect;
}
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {
return NextResponse.redirect(new URL('/auth', nextUrl.href));
const userResponse = await fetchBackend('/user/self', {
headers: {
auth: authCookie?.value!,
...(showorg?.value ? { showorg: showorg?.value! } : {}),
},
});
if (userResponse.status === 401) {
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
}
// If the url is /auth and the cookie exists, redirect to /
if (nextUrl.href.indexOf('/auth') > -1 && authCookie) {
return NextResponse.redirect(new URL('/', nextUrl.href));
if ([200, 201].indexOf(userResponse.status) === -1) {
return NextResponse.redirect(new URL('/err', nextUrl.href));
}
if (nextUrl.href.indexOf('/auth') > -1) {
return NextResponse.next();
}
const user = await userResponse.json();
try {
const userResponse = await fetchBackend('/user/self', {
headers: {
auth: authCookie?.value!
}
});
const next = NextResponse.next();
next.headers.set('user', JSON.stringify(user));
if (userResponse.status === 401) {
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
}
if ([200, 201].indexOf(userResponse.status) === -1) {
return NextResponse.redirect(new URL('/err', nextUrl.href));
}
const user = await userResponse.json();
const next = NextResponse.next();
next.headers.set('user', JSON.stringify(user));
return next;
}
catch (err) {
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
}
return next;
} catch (err) {
return NextResponse.redirect(new URL('/auth/logout', nextUrl.href));
}
}
// See "Matching Paths" below to learn more
export const config = {
matcher: "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)",
}
matcher: '/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)',
};

View File

@ -43,10 +43,12 @@ module.exports = {
overflow: 'overFlow 0.5s ease-in-out forwards',
overflowReverse: 'overFlowReverse 0.5s ease-in-out forwards',
fadeDown: 'fadeDown 4s ease-in-out forwards',
normalFadeDown: 'normalFadeDown 0.5s ease-in-out forwards',
newMessages: 'newMessages 1s ease-in-out 4s forwards',
},
boxShadow: {
yellow: '0 0 60px 20px #6b6237',
green: '0px 0px 50px rgba(60, 124, 90, 0.3)'
green: '0px 0px 50px rgba(60, 124, 90, 0.3)',
},
// that is actual animation
keyframes: (theme) => ({
@ -71,6 +73,15 @@ module.exports = {
'90%': { opacity: 1, transform: 'translateY(10px)' },
'100%': { opacity: 0, transform: 'translateY(-30px)' },
},
normalFadeDown: {
'0%': { opacity: 0, transform: 'translateY(-30px)' },
'100%': { opacity: 1, transform: 'translateY(0)' },
},
newMessages: {
'0%': { backgroundColor: '#d7d7d7', fontWeight: 'bold' },
'99%': { backgroundColor: '#fff', fontWeight: 'bold' },
'100%': { backgroundColor: '#fff', fontWeight: 'normal' },
},
}),
},
},

View File

@ -5,9 +5,13 @@ export interface Params {
url: string,
options: RequestInit,
response: Response
) => Promise<void>;
) => Promise<boolean>;
}
export const customFetch = (params: Params, auth?: string) => {
export const customFetch = (
params: Params,
auth?: string,
showorg?: string
) => {
return async function newFetch(url: string, options: RequestInit = {}) {
const newRequestObject = await params?.beforeRequest?.(url, options);
const fetchRequest = await fetch(params.baseUrl + url, {
@ -15,16 +19,28 @@ export const customFetch = (params: Params, auth?: string) => {
...(newRequestObject || options),
headers: {
...(auth ? { auth } : {}),
...(showorg ? { showorg } : {}),
...(options.body instanceof FormData
? {}
: { 'Content-Type': 'application/json' }),
Accept: 'application/json',
...options?.headers,
},
cache: options.cache || 'no-store',
// @ts-ignore
...(!options.next && options.cache !== 'force-cache'
? { cache: options.cache || 'no-store' }
: {}),
});
await params?.afterRequest?.(url, options, fetchRequest);
return fetchRequest;
if (
!params?.afterRequest ||
(await params?.afterRequest?.(url, options, fetchRequest))
) {
return fetchRequest;
}
// @ts-ignore
return new Promise((res) => {}) as Response;
};
};

View File

@ -8,7 +8,9 @@ const FetchProvider = createContext(customFetch(
{
baseUrl: '',
beforeRequest: () => {},
afterRequest: () => {}
afterRequest: () => {
return true;
}
} as Params));
export const FetchWrapperComponent: FC<Params & {children: ReactNode}> = (props) => {

View File

@ -1,4 +1,9 @@
import {customFetch} from "./custom.fetch.func";
import {cookies} from "next/headers";
import { customFetch } from './custom.fetch.func';
import { cookies } from 'next/headers';
export const internalFetch = (url: string, options: RequestInit = {}) => customFetch({baseUrl: process.env.BACKEND_INTERNAL_URL!}, cookies()?.get('auth')?.value!)(url, options);
export const internalFetch = (url: string, options: RequestInit = {}) =>
customFetch(
{ baseUrl: process.env.BACKEND_INTERNAL_URL! },
cookies()?.get('auth')?.value!,
cookies()?.get('showorg')?.value!
)(url, options);

View File

@ -8,7 +8,7 @@ import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/st
import { StarsRepository } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.repository';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { SubscriptionRepository } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.repository';
import { NotificationService } from '@gitroom/nestjs-libraries/notifications/notification.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
@ -18,6 +18,8 @@ import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/me
import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media/media.repository';
import { CommentsRepository } from '@gitroom/nestjs-libraries/database/prisma/comments/comments.repository';
import { CommentsService } from '@gitroom/nestjs-libraries/database/prisma/comments/comments.service';
import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
@Global()
@Module({
@ -35,6 +37,7 @@ import { CommentsService } from '@gitroom/nestjs-libraries/database/prisma/comme
SubscriptionService,
SubscriptionRepository,
NotificationService,
NotificationsRepository,
IntegrationService,
IntegrationRepository,
PostsService,
@ -44,6 +47,7 @@ import { CommentsService } from '@gitroom/nestjs-libraries/database/prisma/comme
CommentsRepository,
CommentsService,
IntegrationManager,
EmailService,
],
get exports() {
return this.providers;

View File

@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
@Injectable()
export class NotificationService {
constructor(
private _notificationRepository: NotificationsRepository,
private _emailService: EmailService,
private _organizationRepository: OrganizationRepository
) {}
getMainPageCount(organizationId: string, userId: string) {
return this._notificationRepository.getMainPageCount(
organizationId,
userId
);
}
getNotifications(organizationId: string, userId: string) {
return this._notificationRepository.getNotifications(
organizationId,
userId
);
}
async inAppNotification(orgId: string, subject: string, message: string, sendEmail = false) {
await this._notificationRepository.createNotification(orgId, message);
if (!sendEmail) {
return;
}
const userOrg = await this._organizationRepository.getAllUsersOrgs(orgId);
for (const user of userOrg?.users || []) {
await this.sendEmail(user.user.email, subject, message);
}
}
async sendEmail(to: string, subject: string, html: string) {
await this._emailService.sendEmail(to, subject, html);
}
}

View File

@ -0,0 +1,79 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationsRepository {
constructor(
private _notifications: PrismaRepository<'notifications'>,
private _user: PrismaRepository<'user'>
) {}
getLastReadNotification(userId: string) {
return this._user.model.user.findFirst({
where: {
id: userId,
},
select: {
lastReadNotifications: true,
},
});
}
async getMainPageCount(organizationId: string, userId: string) {
const { lastReadNotifications } = (await this.getLastReadNotification(
userId
))!;
return {
total: await this._notifications.model.notifications.count({
where: {
organizationId,
createdAt: {
gt: lastReadNotifications!,
},
},
}),
};
}
async createNotification(organizationId: string, content: string) {
await this._notifications.model.notifications.create({
data: {
organizationId,
content,
},
});
}
async getNotifications(organizationId: string, userId: string) {
const { lastReadNotifications } = (await this.getLastReadNotification(
userId
))!;
await this._user.model.user.update({
where: {
id: userId,
},
data: {
lastReadNotifications: new Date(),
},
});
return {
lastReadNotifications,
notifications: await this._notifications.model.notifications.findMany({
orderBy: {
createdAt: 'desc',
},
take: 20,
where: {
organizationId,
},
select: {
createdAt: true,
content: true,
},
}),
};
}
}

View File

@ -1,63 +1,189 @@
import {PrismaRepository} from "@gitroom/nestjs-libraries/database/prisma/prisma.service";
import {Role} from '@prisma/client';
import {Injectable} from "@nestjs/common";
import {AuthService} from "@gitroom/helpers/auth/auth.service";
import {CreateOrgUserDto} from "@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto";
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Role, SubscriptionTier } from '@prisma/client';
import { Injectable } from '@nestjs/common';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
@Injectable()
export class OrganizationRepository {
constructor(
private _organization: PrismaRepository<'organization'>
constructor(
private _organization: PrismaRepository<'organization'>,
private _userOrg: PrismaRepository<'userOrganization'>,
private _user: PrismaRepository<'user'>
) {}
async getOrgsByUserId(userId: string) {
return this._organization.model.organization.findMany({
where: {
users: {
some: {
userId,
},
},
},
include: {
users: {
where: {
userId,
},
select: {
role: true,
},
},
subscription: {
select: {
subscriptionTier: true,
},
},
},
});
}
async getOrgById(id: string) {
return this._organization.model.organization.findUnique({
where: {
id,
},
});
}
async addUserToOrg(
userId: string,
id: string,
orgId: string,
role: 'USER' | 'ADMIN'
) {
const checkIfInviteExists = await this._user.model.user.findFirst({
where: {
inviteId: id,
},
});
if (checkIfInviteExists) {
return false;
}
const checkForSubscription =
await this._organization.model.organization.findFirst({
where: {
id: orgId,
},
select: {
subscription: true,
},
});
if (
!process.env.STRIPE_PUBLISHABLE_KEY ||
checkForSubscription?.subscription?.subscriptionTier !==
SubscriptionTier.PRO
) {
return false;
}
async getFirstOrgByUserId(userId: string) {
return this._organization.model.organization.findFirst({
where: {
users: {
some: {
userId
}
}
}
});
}
const create = await this._userOrg.model.userOrganization.create({
data: {
role,
userId,
organizationId: orgId,
},
});
async getOrgById(id: string) {
return this._organization.model.organization.findUnique({
where: {
id
}
});
}
await this._user.model.user.update({
where: {
id: userId,
},
data: {
inviteId: id,
},
});
async createOrgAndUser(body: Omit<CreateOrgUserDto, 'providerToken'> & {providerId?: string}) {
return this._organization.model.organization.create({
data: {
name: body.company,
users: {
create: {
role: Role.USER,
user: {
create: {
email: body.email,
password: body.password ? AuthService.hashPassword(body.password) : '',
providerName: body.provider,
providerId: body.providerId || '',
timezone: 0
}
}
}
}
return create;
}
async createOrgAndUser(
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
) {
return this._organization.model.organization.create({
data: {
name: body.company,
users: {
create: {
role: Role.SUPERADMIN,
user: {
create: {
email: body.email,
password: body.password
? AuthService.hashPassword(body.password)
: '',
providerName: body.provider,
providerId: body.providerId || '',
timezone: 0,
},
},
select: {
},
},
},
select: {
id: true,
users: {
select: {
user: true,
},
},
},
});
}
async getTeam(orgId: string) {
return this._organization.model.organization.findUnique({
where: {
id: orgId,
},
select: {
users: {
select: {
role: true,
user: {
select: {
email: true,
id: true,
users: {
select: {
user: true
}
}
}
});
}
}
},
},
},
},
},
});
}
getAllUsersOrgs(orgId: string) {
return this._organization.model.organization.findUnique({
where: {
id: orgId,
},
select: {
users: {
select: {
user: {
select: {
email: true,
id: true,
},
},
},
},
},
});
}
async deleteTeamMember(orgId: string, userId: string) {
return this._userOrg.model.userOrganization.delete({
where: {
userId_organizationId: {
userId,
organizationId: orgId,
},
},
});
}
}

View File

@ -1,26 +1,79 @@
import {CreateOrgUserDto} from "@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto";
import {Injectable} from "@nestjs/common";
import {OrganizationRepository} from "@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository";
import {NotificationService} from "@gitroom/nestjs-libraries/notifications/notification.service";
import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto';
import { Injectable } from '@nestjs/common';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { AddTeamMemberDto } from '@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import dayjs from 'dayjs';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { Organization } from '@prisma/client';
@Injectable()
export class OrganizationService {
constructor(
private _organizationRepository: OrganizationRepository,
private _notificationsService: NotificationService
){}
async createOrgAndUser(body: Omit<CreateOrgUserDto, 'providerToken'> & {providerId?: string}) {
const register = await this._organizationRepository.createOrgAndUser(body);
await this._notificationsService.identifyUser(register.users[0].user);
await this._notificationsService.registerUserToTopic(register.users[0].user.id, `organization:${register.id}`);
return register;
constructor(
private _organizationRepository: OrganizationRepository,
private _notificationsService: NotificationService
) {}
async createOrgAndUser(
body: Omit<CreateOrgUserDto, 'providerToken'> & { providerId?: string }
) {
return this._organizationRepository.createOrgAndUser(body);
}
addUserToOrg(
userId: string,
id: string,
orgId: string,
role: 'USER' | 'ADMIN'
) {
return this._organizationRepository.addUserToOrg(userId, id, orgId, role);
}
getOrgById(id: string) {
return this._organizationRepository.getOrgById(id);
}
getOrgsByUserId(userId: string) {
return this._organizationRepository.getOrgsByUserId(userId);
}
getTeam(orgId: string) {
return this._organizationRepository.getTeam(orgId);
}
async inviteTeamMember(orgId: string, body: AddTeamMemberDto) {
const timeLimit = dayjs().add(15, 'minutes').format('YYYY-MM-DD HH:mm:ss');
const id = makeId(5);
const url =
process.env.FRONTEND_URL +
`/?org=${AuthService.signJWT({ ...body, orgId, timeLimit, id })}`;
if (body.sendEmail) {
await this._notificationsService.sendEmail(
body.email,
'You have been invited to join an organization',
`You have been invited to join an organization. Click <a href="${url}">here</a> to join.<br />The link will expire in 15 minutes.`
);
}
return { url };
}
async deleteTeamMember(org: Organization, userId: string) {
const userOrgs = await this._organizationRepository.getOrgsByUserId(userId);
const findOrgToDelete = userOrgs.find((orgUser) => orgUser.id === org.id);
if (!findOrgToDelete) {
throw new Error('User is not part of this organization');
}
getOrgById(id: string) {
return this._organizationRepository.getOrgById(id);
// @ts-ignore
const myRole = org.users[0].role;
const userRole = findOrgToDelete.users[0].role;
const myLevel = myRole === 'USER' ? 0 : myRole === 'ADMIN' ? 1 : 2;
const userLevel = userRole === 'USER' ? 0 : userRole === 'ADMIN' ? 1 : 2;
if (myLevel < userLevel) {
throw new Error('You do not have permission to delete this user');
}
getFirstOrgByUserId(userId: string) {
return this._organizationRepository.getFirstOrgByUserId(userId);
}
}
return this._organizationRepository.deleteTeamMember(org.id, userId);
}
}

View File

@ -153,6 +153,28 @@ export class PostsRepository {
});
}
countPostsFromDay(orgId: string, date: Date) {
return this._post.model.post.count({
where: {
organizationId: orgId,
publishDate: {
gte: date,
},
OR: [
{
deletedAt: null,
state: {
in: ['QUEUE'],
},
},
{
state: 'PUBLISHED',
},
],
},
});
}
async createOrUpdatePost(
state: 'draft' | 'schedule' | 'now',
orgId: string,

View File

@ -157,6 +157,10 @@ export class PostsService {
}
}
async countPostsFromDay(orgId: string, date: Date) {
return this._postRepository.countPostsFromDay(orgId, date);
}
async createPost(orgId: string, body: CreatePostDto) {
for (const post of body.posts) {
const { previousPost, posts } =

View File

@ -24,6 +24,7 @@ model Organization {
Integration Integration[]
post Post[]
Comments Comments[]
notifications Notifications[]
}
model User {
@ -37,8 +38,12 @@ model User {
comments Comments[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastReadNotifications DateTime @default(now())
inviteId String?
@@unique([email, providerName])
@@index([lastReadNotifications])
@@index([inviteId])
}
model UserOrganization {
@ -51,8 +56,7 @@ model UserOrganization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([userId])
@@unique([userId, organizationId])
}
model GitHub {
@ -118,14 +122,16 @@ model Subscription {
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id])
subscriptionTier SubscriptionTier
subscriptionState SubscriptionState
identifier String?
cancelAt DateTime?
period Period
totalChannels Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([organizationId])
@@index([deletedAt])
}
model Integration {
@ -203,6 +209,21 @@ model Post {
@@index([parentPostId])
}
model Notifications {
id String @id @default(uuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
content String
link String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([organizationId])
@@index([deletedAt])
}
enum State {
QUEUE
PUBLISHED
@ -211,15 +232,10 @@ enum State {
}
enum SubscriptionTier {
BASIC
STANDARD
PRO
}
enum SubscriptionState {
ACTIVE
INACTIVE
}
enum Period {
MONTHLY
YEARLY

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { StarsRepository } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.repository';
import { chunk, groupBy } from 'lodash';
import dayjs from 'dayjs';
import { NotificationService } from '@gitroom/nestjs-libraries/notifications/notification.service';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport/client/bull-mq.client';
import { mean } from 'simple-statistics';
@ -189,33 +189,31 @@ export class StarsService {
const getOrganizationsByGitHubLogin =
await this._starsRepository.getOrganizationsByGitHubLogin(person.name);
for (const org of getOrganizationsByGitHubLogin) {
const topic = `organization:${org.organizationId}`;
switch (type) {
case Inform.Removed:
return this._notificationsService.sendNotificationToTopic(
'trending',
topic,
{ message: `You are not trending anymore in ${language}` }
return this._notificationsService.inAppNotification(
org.organizationId,
'You are not trending on GitHub anymore',
`You are not trending anymore in ${language}`,
true
);
case Inform.New:
return this._notificationsService.sendNotificationToTopic(
'trending',
topic,
{
message: `You are trending in ${
language || 'On the main feed'
} position #${person.position}`,
}
return this._notificationsService.inAppNotification(
org.organizationId,
'You are trending on GitHub',
`You are trending in ${
language || 'On the main feed'
} position #${person.position}`,
true
);
case Inform.Changed:
return this._notificationsService.sendNotificationToTopic(
'trending',
topic,
{
message: `You changed position in ${
language || 'On the main feed'
} position #${person.position}`,
}
return this._notificationsService.inAppNotification(
org.organizationId,
'You have changed trending position on GitHub',
`You changed position in ${
language || 'On the main feed'
} position #${person.position}`,
true
);
}
}
@ -336,10 +334,13 @@ export class StarsService {
async predictTrending() {
const trendings = (await this.getTrending('')).reverse();
const dates = await this.predictTrendingLoop(trendings);
return dates.map(d => dayjs(d).format('YYYY-MM-DDTHH:mm:00'));
return dates.map((d) => dayjs(d).format('YYYY-MM-DDTHH:mm:00'));
}
async predictTrendingLoop(trendings: Array<{ date: Date }>, current = 0): Promise<Date[]> {
async predictTrendingLoop(
trendings: Array<{ date: Date }>,
current = 0
): Promise<Date[]> {
const dates = trendings.map((result) => dayjs(result.date).toDate());
const intervals = dates
.slice(1)
@ -357,7 +358,7 @@ export class StarsService {
).toDate();
if (!nextTrendingDate) {
return [];
return [];
}
return [

View File

@ -1,60 +1,39 @@
export interface PricingInnerInterface {
channel?: number;
posts_per_month: number;
team_members: boolean;
community_features: boolean;
featured_by_gitroom: boolean;
ai: boolean;
import_from_channels: boolean;
}
export interface PricingInterface {
[key: string]: {
pricing: {
monthly: number;
yearly: number;
},
friends: boolean;
crossPosting: boolean;
repository: number;
ai: boolean;
integrations: number;
totalPosts: number;
medias: number;
influencers: boolean;
}
[key: string]: PricingInnerInterface;
}
export const pricing: PricingInterface = {
FREE: {
pricing: {
monthly: 0,
yearly: 0,
},
friends: false,
crossPosting: false,
repository: 1,
ai: false,
integrations: 2,
totalPosts: 20,
medias: 2,
influencers: false,
},
BASIC: {
pricing: {
monthly: 50,
yearly: 500,
},
friends: false,
crossPosting: true,
repository: 2,
ai: false,
integrations: 4,
totalPosts: 100,
medias: 5,
influencers: true,
},
PRO: {
pricing: {
monthly: 100,
yearly: 1000,
},
friends: true,
crossPosting: true,
repository: 4,
ai: true,
integrations: 10,
totalPosts: 300,
medias: 10,
influencers: true,
}
}
FREE: {
channel: 3,
posts_per_month: 30,
team_members: false,
community_features: false,
featured_by_gitroom: false,
ai: false,
import_from_channels: false,
},
STANDARD: {
posts_per_month: 400,
team_members: false,
ai: true,
community_features: false,
featured_by_gitroom: false,
import_from_channels: true,
},
PRO: {
posts_per_month: 1000000,
community_features: true,
team_members: true,
featured_by_gitroom: true,
ai: true,
import_from_channels: true,
},
};

View File

@ -1,19 +1,18 @@
import {Injectable} from "@nestjs/common";
import {PrismaRepository} from "@gitroom/nestjs-libraries/database/prisma/prisma.service";
import { Injectable } from '@nestjs/common';
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
@Injectable()
export class SubscriptionRepository {
constructor(
private readonly _subscription: PrismaRepository<'subscription'>,
private readonly _organization: PrismaRepository<'organization'>,
) {
}
private readonly _organization: PrismaRepository<'organization'>
) {}
getSubscriptionByOrganizationId(organizationId: string) {
return this._subscription.model.subscription.findFirst({
where: {
organizationId,
subscriptionState: 'ACTIVE'
deletedAt: null,
},
});
}
@ -23,7 +22,7 @@ export class SubscriptionRepository {
where: {
organizationId,
identifier: subscriptionId,
subscriptionState: 'ACTIVE'
deletedAt: null,
},
});
}
@ -32,20 +31,20 @@ export class SubscriptionRepository {
return this._subscription.model.subscription.deleteMany({
where: {
organization: {
paymentId: customerId
}
}
paymentId: customerId,
},
},
});
}
updateCustomerId(organizationId: string, customerId: string) {
return this._organization.model.organization.update({
where: {
id: organizationId
id: organizationId,
},
data: {
paymentId: customerId
}
paymentId: customerId,
},
});
}
@ -53,44 +52,62 @@ export class SubscriptionRepository {
return this._subscription.model.subscription.findFirst({
where: {
organization: {
paymentId: customerId
}
}
paymentId: customerId,
},
},
});
}
async getOrganizationByCustomerId(customerId: string) {
return this._organization.model.organization.findFirst({
where: {
paymentId: customerId
}
paymentId: customerId,
},
});
}
async createOrUpdateSubscription(identifier: string, customerId: string, billing: 'BASIC' | 'PRO', period: 'MONTHLY' | 'YEARLY', cancelAt: number | null) {
async createOrUpdateSubscription(
identifier: string,
customerId: string,
totalChannels: number,
billing: 'STANDARD' | 'PRO',
period: 'MONTHLY' | 'YEARLY',
cancelAt: number | null
) {
const findOrg = (await this.getOrganizationByCustomerId(customerId))!;
await this._subscription.model.subscription.upsert({
where: {
organizationId: findOrg.id,
organization: {
paymentId: customerId,
}
},
},
update: {
subscriptionTier: billing,
totalChannels,
period,
subscriptionState: 'ACTIVE',
identifier,
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
deletedAt: null,
},
create: {
organizationId: findOrg.id,
subscriptionTier: billing,
totalChannels,
period,
subscriptionState: 'ACTIVE',
cancelAt: cancelAt ? new Date(cancelAt * 1000) : null,
identifier
}
identifier,
deletedAt: null,
},
});
}
getSubscription(organizationId: string) {
return this._subscription.model.subscription.findFirst({
where: {
organizationId,
deletedAt: null,
},
});
}
}

View File

@ -25,7 +25,7 @@ export class SubscriptionService {
return this._subscriptionRepository.checkSubscription(organizationId, subscriptionId);
}
async modifySubscription(customerId: string, billing: 'FREE' | 'BASIC' | 'PRO') {
async modifySubscription(customerId: string, billing: 'FREE' | 'STANDARD' | 'PRO') {
const getCurrentSubscription = (await this._subscriptionRepository.getSubscriptionByCustomerId(customerId))!;
const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE'];
const to = pricing[billing];
@ -48,8 +48,12 @@ export class SubscriptionService {
// }
}
async createOrUpdateSubscription(identifier: string, customerId: string, billing: 'BASIC' | 'PRO', period: 'MONTHLY' | 'YEARLY', cancelAt: number | null) {
async createOrUpdateSubscription(identifier: string, customerId: string, totalChannels: number, billing: 'STANDARD' | 'PRO', period: 'MONTHLY' | 'YEARLY', cancelAt: number | null) {
await this.modifySubscription(customerId, billing);
return this._subscriptionRepository.createOrUpdateSubscription(identifier, customerId, billing, period, cancelAt);
return this._subscriptionRepository.createOrUpdateSubscription(identifier, customerId, totalChannels, billing, period, cancelAt);
}
async getSubscription(organizationId: string) {
return this._subscriptionRepository.getSubscription(organizationId);
}
}

View File

@ -1,9 +1,13 @@
import {IsIn} from "class-validator";
import {IsIn, Max, Min} from "class-validator";
export class BillingSubscribeDto {
@Min(1)
@Max(60)
total: number;
@IsIn(['MONTHLY', 'YEARLY'])
period: 'MONTHLY' | 'YEARLY';
@IsIn(['BASIC', 'PRO'])
billing: 'BASIC' | 'PRO';
@IsIn(['STANDARD', 'PRO'])
billing: 'STANDARD' | 'PRO';
}

View File

@ -0,0 +1,16 @@
import {IsBoolean, IsDefined, IsEmail, IsIn, IsString, ValidateIf} from 'class-validator';
export class AddTeamMemberDto {
@IsDefined()
@IsEmail()
@ValidateIf((o) => o.sendEmail)
email: string;
@IsString()
@IsIn(['USER', 'ADMIN'])
role: string;
@IsDefined()
@IsBoolean()
sendEmail: boolean;
}

View File

@ -1,34 +0,0 @@
import {Injectable} from "@nestjs/common";
import {Novu, TriggerRecipientsTypeEnum} from '@novu/node';
import {User} from "@prisma/client";
const novu = new Novu(process.env.NOVU_API_KEY!);
@Injectable()
export class NotificationService {
async registerUserToTopic(userId: string, topic: string) {
try {
await novu.topics.create({
name: 'organization topic',
key: topic
});
}
catch (err) { /* empty */ }
await novu.topics.addSubscribers(topic, {
subscribers: [userId]
});
}
async identifyUser(user: User) {
await novu.subscribers.identify(user.id, {
email: user.email,
});
}
async sendNotificationToTopic(workflow: string, topic: string, payload = {}) {
await novu.trigger(workflow, {
to: [{type: TriggerRecipientsTypeEnum.TOPIC, topicKey: topic}],
payload
});
}
}

View File

@ -1,15 +1,13 @@
import {Global, Module} from "@nestjs/common";
import {RedisService} from "@gitroom/nestjs-libraries/redis/redis.service";
import { Global, Module } from '@nestjs/common';
import { RedisService } from '@gitroom/nestjs-libraries/redis/redis.service';
@Global()
@Module({
imports: [],
controllers: [],
providers: [RedisService],
get exports() {
return this.providers;
}
imports: [],
controllers: [],
providers: [RedisService],
get exports() {
return this.providers;
},
})
export class RedisModule {
}
export class RedisModule {}

View File

@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
@Injectable()
export class EmailService {
async sendEmail(to: string, subject: string, html: string) {
await resend.emails.send({
from: 'Gitroom <no-reply@gitroom.com>',
to,
subject,
html,
});
}
}

View File

@ -1,40 +1,72 @@
import Stripe from 'stripe';
import {Injectable} from "@nestjs/common";
import {Organization} from "@prisma/client";
import {SubscriptionService} from "@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service";
import {OrganizationService} from "@gitroom/nestjs-libraries/database/prisma/organizations/organization.service";
import {makeId} from "@gitroom/nestjs-libraries/services/make.is";
import {BillingSubscribeDto} from "@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto";
import { Injectable } from '@nestjs/common';
import { Organization } from '@prisma/client';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto';
import { groupBy } from 'lodash';
const stripe = new Stripe(process.env.PAYMENT_SECRET_KEY!, {
apiVersion: '2023-10-16'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
@Injectable()
export class StripeService {
constructor(
private _subscriptionService: SubscriptionService,
private _organizationService: OrganizationService,
) {
}
private _organizationService: OrganizationService
) {}
validateRequest(rawBody: Buffer, signature: string, endpointSecret: string) {
return stripe.webhooks.constructEvent(rawBody, signature, endpointSecret);
}
createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {id, billing, period}: { billing: 'BASIC' | 'PRO', period: 'MONTHLY' | 'YEARLY', id: string} = event.data.object.metadata;
return this._subscriptionService.createOrUpdateSubscription(id, event.data.object.customer as string, billing, period, event.data.object.cancel_at);
const {
id,
billing,
period,
}: {
billing: 'STANDARD' | 'PRO';
period: 'MONTHLY' | 'YEARLY';
id: string;
} = event.data.object.metadata;
return this._subscriptionService.createOrUpdateSubscription(
id,
event.data.object.customer as string,
event?.data?.object?.items?.data?.[0]?.quantity || 0,
billing,
period,
event.data.object.cancel_at
);
}
updateSubscription(event: Stripe.CustomerSubscriptionUpdatedEvent) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const {id, billing, period}: { billing: 'BASIC' | 'PRO', period: 'MONTHLY' | 'YEARLY', id: string} = event.data.object.metadata;
return this._subscriptionService.createOrUpdateSubscription(id, event.data.object.customer as string, billing, period, event.data.object.cancel_at);
const {
id,
billing,
period,
}: {
billing: 'STANDARD' | 'PRO';
period: 'MONTHLY' | 'YEARLY';
id: string;
} = event.data.object.metadata;
return this._subscriptionService.createOrUpdateSubscription(
id,
event.data.object.customer as string,
event?.data?.object?.items?.data?.[0]?.quantity || 0,
billing,
period,
event.data.object.cancel_at
);
}
async deleteSubscription(event: Stripe.CustomerSubscriptionDeletedEvent) {
await this._subscriptionService.deleteSubscription(event.data.object.customer as string);
await this._subscriptionService.deleteSubscription(
event.data.object.customer as string
);
}
async createOrGetCustomer(organization: Organization) {
@ -43,10 +75,86 @@ export class StripeService {
}
const customer = await stripe.customers.create();
await this._subscriptionService.updateCustomerId(organization.id, customer.id);
await this._subscriptionService.updateCustomerId(
organization.id,
customer.id
);
return customer.id;
}
async getPackages() {
const products = await stripe.prices.list({
active: true,
expand: ['data.tiers', 'data.product'],
lookup_keys: [
'standard_monthly',
'standard_yearly',
'pro_monthly',
'pro_yearly',
],
});
const productsList = groupBy(
products.data.map((p) => ({
// @ts-ignore
name: p.product?.name,
recurring: p?.recurring?.interval!,
price: p?.tiers?.[0]?.unit_amount! / 100,
})),
'recurring'
);
return { ...productsList };
}
async prorate(organizationId: string, body: BillingSubscribeDto) {
const org = await this._organizationService.getOrgById(organizationId);
const customer = await this.createOrGetCustomer(org!);
const allProducts = await stripe.products.list({
active: true,
expand: ['data.prices'],
});
const findProduct = allProducts.data.find(
(product) => product.name.toLowerCase() === body.billing.toLowerCase()
);
const pricesList = await stripe.prices.list({
active: true,
product: findProduct!.id,
});
const findPrice = pricesList.data.find(
(p) =>
p?.recurring?.interval?.toLowerCase() ===
body?.period?.toLowerCase().replace('ly', '')
);
const proration_date = Math.floor(Date.now() / 1000);
const currentUserSubscription = await stripe.subscriptions.list({
customer,
status: 'active',
});
const price = await stripe.invoices.retrieveUpcoming({
customer,
subscription: currentUserSubscription.data[0].id,
subscription_proration_behavior: 'create_prorations',
subscription_billing_cycle_anchor: 'now',
subscription_items: [
{
id: currentUserSubscription.data[0].items.data[0].id,
price: findPrice!.id,
quantity: body.total,
},
],
subscription_proration_date: proration_date,
});
console.log(price);
return { price: price.amount_remaining / 100 };
}
async setToCancel(organizationId: string) {
const id = makeId(10);
const org = await this._organizationService.getOrgById(organizationId);
@ -56,15 +164,22 @@ export class StripeService {
status: 'active',
});
await stripe.subscriptions.update(currentUserSubscription.data[0].id, {
cancel_at_period_end: !currentUserSubscription.data[0].cancel_at_period_end,
metadata: {
service: 'gitroom',
id
const { cancel_at } = await stripe.subscriptions.update(
currentUserSubscription.data[0].id,
{
cancel_at_period_end:
!currentUserSubscription.data[0].cancel_at_period_end,
metadata: {
service: 'gitroom',
id,
},
}
});
);
return {id};
return {
id,
cancel_at: cancel_at ? new Date(cancel_at * 1000) : undefined,
};
}
async getCustomerByOrganizationId(organizationId: string) {
@ -75,9 +190,41 @@ export class StripeService {
async createBillingPortalLink(customer: string) {
return stripe.billingPortal.sessions.create({
customer,
flow_data: {
type: 'payment_method_update',
},
});
}
private async createCheckoutSession(
uniqueId: string,
customer: string,
metaData: any,
price: string,
quantity: number
) {
const { url } = await stripe.checkout.sessions.create({
customer,
success_url: process.env['FRONTEND_URL'] + `/billing?check=${uniqueId}`,
mode: 'subscription',
subscription_data: {
metadata: {
service: 'gitroom',
...metaData,
uniqueId,
},
},
line_items: [
{
price: price,
quantity: quantity,
},
],
});
return { url };
}
async subscribe(organizationId: string, body: BillingSubscribeDto) {
const id = makeId(10);
@ -87,58 +234,58 @@ export class StripeService {
active: true,
expand: ['data.prices'],
});
const findProduct = allProducts.data.find(product => product.name.toLowerCase() === body.billing.toLowerCase());
const findProduct = allProducts.data.find(
(product) => product.name.toLowerCase() === body.billing.toLowerCase()
);
const pricesList = await stripe.prices.list({
active: true,
product: findProduct!.id,
});
const findPrice = pricesList.data.find(p => p?.recurring?.interval?.toLowerCase() === body?.period?.toLowerCase().replace('ly', ''));
const findPrice = pricesList.data.find(
(p) =>
p?.recurring?.interval?.toLowerCase() ===
body?.period?.toLowerCase().replace('ly', '')
);
const currentUserSubscription = await stripe.subscriptions.list({
customer,
status: 'active',
});
if (!currentUserSubscription.data.length) {
const {url} = await stripe.checkout.sessions.create({
return this.createCheckoutSession(
id,
customer,
success_url: process.env['FRONTEND_URL'] + `/billing?check=${id}`,
mode: 'subscription',
subscription_data: {
metadata: {
service: 'gitroom',
...body,
id
}
},
line_items: [
{
price: findPrice!.id,
quantity: 1,
}],
});
return {url};
body,
findPrice!.id,
body.total
);
}
try {
await stripe.subscriptions.update(currentUserSubscription.data[0].id, {
cancel_at_period_end: false,
metadata: {
service: 'gitroom',
...body,
id
}, items: [{
id: currentUserSubscription.data[0].items.data[0].id, price: findPrice!.id,
}]
id,
},
proration_behavior: 'always_invoice',
items: [
{
id: currentUserSubscription.data[0].items.data[0].id,
price: findPrice!.id,
quantity: body.total,
},
],
});
return {id};
}
catch (err) {
const {url} = await this.createBillingPortalLink(customer);
return { id };
} catch (err) {
const { url } = await this.createBillingPortalLink(customer);
return {
portal: url
}
portal: url,
};
}
}
}

View File

@ -1,8 +1,60 @@
import {ButtonHTMLAttributes, DetailedHTMLProps, FC} from "react";
import {clsx} from "clsx";
'use client';
export const Button: FC<DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {secondary?: boolean}> = (props) => {
return (
<button {...props} type={props.type || 'button'} className={clsx(props.disabled && 'opacity-50 pointer-events-none' ,`${props.secondary ? 'bg-third' : 'bg-forth'} px-[24px] h-[40px] cursor-pointer items-center justify-center flex`, props?.className)} />
)
}
import {
ButtonHTMLAttributes,
DetailedHTMLProps,
FC,
useEffect,
useRef,
useState,
} from 'react';
import { clsx } from 'clsx';
import ReactLoading from 'react-loading';
export const Button: FC<
DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & { secondary?: boolean; loading?: boolean }
> = ({ children, loading, ...props }) => {
const ref = useRef<HTMLButtonElement | null>(null);
const [height, setHeight] = useState<number | null>(null);
useEffect(() => {
setHeight(ref.current?.offsetHeight || 40);
}, []);
return (
<button
{...props}
type={props.type || 'button'}
ref={ref}
className={clsx(
(props.disabled || loading) && 'opacity-50 pointer-events-none',
`${
props.secondary ? 'bg-third' : 'bg-forth'
} px-[24px] h-[40px] cursor-pointer items-center justify-center flex relative`,
props?.className
)}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<ReactLoading
type="spin"
color="#fff"
width={height! / 2}
height={height! / 2}
/>
</div>
)}
<div
className={clsx(
'flex-1 items-center justify-center flex',
loading && 'invisible'
)}
>
{children}
</div>
</button>
);
};

View File

@ -1,19 +1,46 @@
"use client";
import {FC, useCallback, useState} from "react";
import clsx from "clsx";
import Image from "next/image";
'use client';
import { FC, useCallback, useState } from 'react';
import clsx from 'clsx';
import Image from 'next/image';
import { useFormContext, useWatch } from 'react-hook-form';
export const Checkbox: FC<{checked: boolean, className?: string, onChange?: (event: {target: {value: string}}) => void}> = (props) => {
const {checked, className} = props;
const [currentStatus, setCurrentStatus] = useState(checked);
const changeStatus = useCallback(() => {
setCurrentStatus(!currentStatus);
props?.onChange?.({target: {value: `${!currentStatus}`}});
}, [currentStatus]);
export const Checkbox: FC<{
checked?: boolean;
disableForm?: boolean;
name?: string;
className?: string;
onChange?: (event: { target: { name?: string, value: boolean } }) => void;
}> = (props) => {
const { checked, className, disableForm } = props;
const form = useFormContext();
const register = disableForm ? {} : form.register(props.name!);
return (
<div onClick={changeStatus} className={clsx("cursor-pointer rounded-[4px] select-none bg-forth w-[24px] h-[24px] flex justify-center items-center", className)}>
{currentStatus && <Image src="/form/checked.svg" alt="Checked" width={20} height={20} />}
</div>
)
}
const watch = disableForm ? undefined : useWatch({
name: props.name!,
});
const [currentStatus, setCurrentStatus] = useState(watch || checked);
const changeStatus = useCallback(() => {
setCurrentStatus(!currentStatus);
props?.onChange?.({ target: { name: props.name!, value: !currentStatus } });
if (!disableForm) {
// @ts-ignore
register?.onChange?.({ target: { name: props.name!, value: !currentStatus } });
}
}, [currentStatus]);
return (
<div
{...register}
onClick={changeStatus}
className={clsx(
'cursor-pointer rounded-[4px] select-none bg-forth w-[24px] h-[24px] flex justify-center items-center',
className
)}
>
{currentStatus && (
<Image src="/form/checked.svg" alt="Checked" width={20} height={20} />
)}
</div>
);
};

View File

@ -0,0 +1,19 @@
"use client";
import {FC, useCallback} from "react";
import clsx from "clsx";
export const Slider: FC<{value: 'on' | 'off', onChange: (value: 'on' | 'off') => void}> = (props) => {
const {value, onChange} = props;
const change = useCallback(() => {
onChange(value === 'on' ? 'off' : 'on');
}, [value]);
return (
<div className="w-[57px] h-[32px] p-[4px] border-fifth border rounded-[100px]" onClick={change}>
<div className="w-full h-full relative rounded-[100px]">
<div className={clsx("absolute left-0 top-0 w-[24px] h-[24px] bg-[#E9E9F1] rounded-full transition-all cursor-pointer", value === 'on' ? 'left-[100%] -translate-x-[100%]' : 'left-0')} />
</div>
</div>
)
}

View File

@ -0,0 +1,32 @@
'use client';
import { Slider } from '@mantine/core';
import { FC } from 'react';
export const Track: FC<{
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}> = (props) => {
const { value, onChange, min, max } = props;
return (
<Slider
color="violet"
labelAlwaysOn={true}
value={value}
onChange={onChange}
size="xl"
classNames={{
track: 'before:bg-[#0B0F1C] before:border before:border-[#172034]',
mark: 'border-[#172034]',
markFilled: 'border-[#7950F2]',
}}
min={min}
max={max}
// classNames={{
// track: 'h-[15px]',
// thumb: 'w-[24px] h-[24px]',
// }}
/>
);
};

View File

@ -1,8 +1,8 @@
import Swal from "sweetalert2";
export const deleteDialog = async (message: string, confirmButton?: string) => {
export const deleteDialog = async (message: string, confirmButton?: string, title?: string) => {
const fire = await Swal.fire({
title: 'Are you sure?',
title: title || 'Are you sure?',
text: message,
icon: 'warning',
showCancelButton: true,

808
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
"version": "0.0.0",
"license": "MIT",
"scripts": {
"dev": "concurrently \"stripe listen --forward-to localhost:3000/payment\" \"nx run-many --target=serve --projects=frontend,backend,workers --parallel=4\"",
"dev": "concurrently \"stripe listen --forward-to localhost:3000/stripe\" \"nx run-many --target=serve --projects=frontend,backend,workers --parallel=4\"",
"workers": "nx run workers:serve:development",
"cron": "nx run cron:serve:development",
"command": "nx run commands:build && nx run commands:command",
@ -16,14 +16,13 @@
"@hookform/resolvers": "^3.3.4",
"@mantine/core": "^5.10.5",
"@mantine/modals": "^5.10.5",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.0.2",
"@nestjs/core": "^10.0.2",
"@nestjs/microservices": "^10.3.1",
"@nestjs/platform-express": "^10.0.2",
"@nestjs/schedule": "^4.0.0",
"@nestjs/serve-static": "^4.0.1",
"@novu/node": "^0.23.0",
"@novu/notification-center": "^0.23.0",
"@prisma/client": "^5.8.1",
"@swc/helpers": "~0.5.2",
"@sweetalert2/theme-dark": "^5.0.16",
@ -40,12 +39,14 @@
"@virtual-grid/react": "^2.0.2",
"axios": "^1.0.0",
"bcrypt": "^5.1.1",
"bufferutil": "^4.0.8",
"bullmq": "^5.1.5",
"chart.js": "^4.4.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"clsx": "^2.1.0",
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.3",
"dayjs": "^1.11.10",
"ioredis": "^5.3.2",
"json-to-graphql-query": "^2.2.5",
@ -62,14 +63,17 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.50.1",
"react-loading": "^2.0.3",
"react-query": "^3.39.3",
"react-router-dom": "6.11.2",
"react-slider": "^2.0.6",
"react-tag-autocomplete": "^7.2.0",
"react-tooltip": "^5.26.2",
"react-use-keypress": "^1.3.1",
"redis": "^4.6.12",
"reflect-metadata": "^0.1.13",
"remove-markdown": "^0.5.0",
"resend": "^3.2.0",
"rxjs": "^7.8.0",
"sharp": "^0.33.2",
"simple-statistics": "^7.8.3",
@ -79,6 +83,7 @@
"tslib": "^2.3.0",
"twitter-api-v2": "^1.16.0",
"use-debounce": "^10.0.0",
"utf-8-validate": "^5.0.10",
"yargs": "^17.7.2"
},
"devDependencies": {