diff --git a/docker-compose.yml b/docker-compose.yml index f8e085502..62b36a367 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,17 +112,17 @@ services: build: dockerfile: packages/hoppscotch-backend/Dockerfile context: . - target: prod + target: dev env_file: - ./.env restart: always environment: # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) - # - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 + - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 - PORT=3000 volumes: # Uncomment the line below when modifying code. Only applicable when using the "dev" target. - # - ./packages/hoppscotch-backend/:/usr/src/app + - ./packages/hoppscotch-backend/:/usr/src/app - /usr/src/app/node_modules/ depends_on: hoppscotch-db: diff --git a/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql new file mode 100644 index 000000000..8d5ee2fc2 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240226053141_full_text_search_additions/migration.sql @@ -0,0 +1,17 @@ +-- AlterTable +ALTER TABLE + "TeamCollection" +ADD + titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED; + +-- AlterTable +ALTER TABLE + "TeamRequest" +ADD + titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED; + +-- CreateIndex +CREATE INDEX "TeamCollection_textSearch_idx" ON "TeamCollection" USING GIN (titleSearch); + +-- CreateIndex +CREATE INDEX "TeamRequest_textSearch_idx" ON "TeamRequest" USING GIN (titleSearch); diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 13b364dcb..3225d9ccc 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -41,31 +41,31 @@ model TeamInvitation { } model TeamCollection { - id String @id @default(cuid()) + id String @id @default(cuid()) parentID String? data Json? - parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) - children TeamCollection[] @relation("TeamCollectionChildParent") + parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) + children TeamCollection[] @relation("TeamCollectionChildParent") requests TeamRequest[] teamID String - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) title String orderIndex Int - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) } model TeamRequest { - id String @id @default(cuid()) + id String @id @default(cuid()) collectionID String - collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) + collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) teamID String - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) title String request Json orderIndex Int - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) } model Shortcode { diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index 27534d003..b8cde8f3d 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -18,12 +18,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { GqlUser } from 'src/decorators/gql-user.decorator'; import { AuthUser } from 'src/types/AuthUser'; import { RTCookie } from 'src/decorators/rt-cookie.decorator'; -import { - AuthProvider, - authCookieHandler, - authProviderCheck, - throwHTTPErr, -} from './helper'; +import { AuthProvider, authCookieHandler, authProviderCheck } from './helper'; import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard'; @@ -31,6 +26,7 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua import { SkipThrottle } from '@nestjs/throttler'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; @UseGuards(ThrottlerBehindProxyGuard) @Controller({ path: 'auth', version: '1' }) diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index 12db5c328..9f5db2ec7 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -24,7 +24,7 @@ import { RefreshTokenPayload, } from 'src/types/AuthTokens'; import { JwtService } from '@nestjs/jwt'; -import { AuthError } from 'src/types/AuthError'; +import { RESTError } from 'src/types/RESTError'; import { AuthUser, IsAdmin } from 'src/types/AuthUser'; import { VerificationToken } from '@prisma/client'; import { Origin } from './helper'; @@ -117,7 +117,7 @@ export class AuthService { userUid, ); if (E.isLeft(updatedUser)) - return E.left({ + return E.left({ message: updatedUser.left, statusCode: HttpStatus.NOT_FOUND, }); @@ -255,7 +255,7 @@ export class AuthService { */ async verifyMagicLinkTokens( magicLinkIDTokens: VerifyMagicDto, - ): Promise | E.Left> { + ): Promise | E.Left> { const passwordlessTokens = await this.validatePasswordlessTokens( magicLinkIDTokens, ); @@ -373,7 +373,7 @@ export class AuthService { if (usersCount === 1) { const elevatedUser = await this.usersService.makeAdmin(user.uid); if (E.isLeft(elevatedUser)) - return E.left({ + return E.left({ message: elevatedUser.left, statusCode: HttpStatus.NOT_FOUND, }); diff --git a/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts b/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts index 322a2d388..fc69bccf9 100644 --- a/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts +++ b/packages/hoppscotch-backend/src/auth/guards/github-sso.guard.ts @@ -1,9 +1,10 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; +import { AuthProvider, authProviderCheck } from '../helper'; import { Observable } from 'rxjs'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; @Injectable() export class GithubSSOGuard extends AuthGuard('github') implements CanActivate { diff --git a/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts b/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts index 56dbb8a4c..5bb27fe05 100644 --- a/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts +++ b/packages/hoppscotch-backend/src/auth/guards/google-sso.guard.ts @@ -1,9 +1,10 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; +import { AuthProvider, authProviderCheck } from '../helper'; import { Observable } from 'rxjs'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; @Injectable() export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate { diff --git a/packages/hoppscotch-backend/src/auth/guards/microsoft-sso-.guard.ts b/packages/hoppscotch-backend/src/auth/guards/microsoft-sso-.guard.ts index 06c6ce39e..e2e5e2577 100644 --- a/packages/hoppscotch-backend/src/auth/guards/microsoft-sso-.guard.ts +++ b/packages/hoppscotch-backend/src/auth/guards/microsoft-sso-.guard.ts @@ -1,9 +1,10 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; -import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper'; +import { AuthProvider, authProviderCheck } from '../helper'; import { Observable } from 'rxjs'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; +import { throwHTTPErr } from 'src/utils'; @Injectable() export class MicrosoftSSOGuard diff --git a/packages/hoppscotch-backend/src/auth/helper.ts b/packages/hoppscotch-backend/src/auth/helper.ts index 339d7edca..bd2a9fcfd 100644 --- a/packages/hoppscotch-backend/src/auth/helper.ts +++ b/packages/hoppscotch-backend/src/auth/helper.ts @@ -1,6 +1,5 @@ import { HttpException, HttpStatus } from '@nestjs/common'; import { DateTime } from 'luxon'; -import { AuthError } from 'src/types/AuthError'; import { AuthTokens } from 'src/types/AuthTokens'; import { Response } from 'express'; import * as cookie from 'cookie'; @@ -25,15 +24,6 @@ export enum AuthProvider { EMAIL = 'EMAIL', } -/** - * This function allows throw to be used as an expression - * @param errMessage Message present in the error message - */ -export function throwHTTPErr(errorData: AuthError): never { - const { message, statusCode } = errorData; - throw new HttpException(message, statusCode); -} - /** * Sets and returns the cookies in the response object on successful authentication * @param res Express Response Object diff --git a/packages/hoppscotch-backend/src/errors.ts b/packages/hoppscotch-backend/src/errors.ts index c246782ac..ef7fbc0df 100644 --- a/packages/hoppscotch-backend/src/errors.ts +++ b/packages/hoppscotch-backend/src/errors.ts @@ -228,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT = export const TEAM_COL_SAME_NEXT_COLL = 'team_coll/collection_and_next_collection_are_same'; +/** + * Team Collection search failed + * (TeamCollectionService) + */ +export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed'; + /** * Team Collection Re-Ordering Failed * (TeamCollectionService) @@ -283,6 +289,13 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const; export const TEAM_COLL_DATA_INVALID = 'team_coll/team_coll_data_invalid' as const; +/** + * Team Collection parent tree generation failed + * (TeamCollectionService) + */ +export const TEAM_COLL_PARENT_TREE_GEN_FAILED = + 'team_coll/team_coll_parent_tree_generation_failed'; + /** * Tried to perform an action on a request that doesn't accept their member role level * (GqlRequestTeamMemberGuard) @@ -308,6 +321,19 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID = */ export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const; +/** + * Team Request search failed + * (TeamRequestService) + */ +export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed'; + +/** + * Team Request parent tree generation failed + * (TeamRequestService) + */ +export const TEAM_REQ_PARENT_TREE_GEN_FAILED = + 'team_req/team_req_parent_tree_generation_failed'; + /** * No Postmark Sender Email defined * (AuthService) diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts index 117008b22..92468fe3f 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.controller.ts @@ -4,9 +4,9 @@ import { InfraConfigService } from './infra-config.service'; import * as E from 'fp-ts/Either'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard'; -import { throwHTTPErr } from 'src/auth/helper'; -import { AuthError } from 'src/types/AuthError'; +import { RESTError } from 'src/types/RESTError'; import { InfraConfigEnumForClient } from 'src/types/InfraConfig'; +import { throwHTTPErr } from 'src/utils'; @UseGuards(ThrottlerBehindProxyGuard) @Controller({ path: 'site', version: '1' }) @@ -21,7 +21,7 @@ export class SiteController { ); if (E.isLeft(status)) - throwHTTPErr({ + throwHTTPErr({ message: status.left, statusCode: HttpStatus.NOT_FOUND, }); @@ -38,7 +38,7 @@ export class SiteController { ); if (E.isLeft(res)) - throwHTTPErr({ + throwHTTPErr({ message: res.left, statusCode: HttpStatus.FORBIDDEN, }); diff --git a/packages/hoppscotch-backend/src/team-collection/helper.ts b/packages/hoppscotch-backend/src/team-collection/helper.ts new file mode 100644 index 000000000..1958a0a50 --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/helper.ts @@ -0,0 +1,14 @@ +// Type of data returned from the query to obtain all search results +export type SearchQueryReturnType = { + id: string; + title: string; + type: 'collection' | 'request'; + method?: string; +}; + +// Type of data returned from the query to obtain all parents +export type ParentTreeQueryReturnType = { + id: string; + parentID: string; + title: string; +}; diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts new file mode 100644 index 000000000..4da0eec9c --- /dev/null +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { TeamCollectionService } from './team-collection.service'; +import * as E from 'fp-ts/Either'; +import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator'; +import { TeamMemberRole } from '@prisma/client'; +import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard'; +import { throwHTTPErr } from 'src/utils'; + +@UseGuards(ThrottlerBehindProxyGuard) +@Controller({ path: 'team-collection', version: '1' }) +export class TeamCollectionController { + constructor(private readonly teamCollectionService: TeamCollectionService) {} + + @Get('search/:teamID/:searchQuery') + @RequiresTeamRole( + TeamMemberRole.VIEWER, + TeamMemberRole.EDITOR, + TeamMemberRole.OWNER, + ) + @UseGuards(JwtAuthGuard, RESTTeamMemberGuard) + async searchByTitle( + @Param('searchQuery') searchQuery: string, + @Param('teamID') teamID: string, + @Query('take') take: string, + @Query('skip') skip: string, + ) { + const res = await this.teamCollectionService.searchByTitle( + searchQuery, + teamID, + parseInt(take), + parseInt(skip), + ); + if (E.isLeft(res)) throwHTTPErr(res.left); + return res.right; + } +} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts index cafdd5448..53aae5bb5 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.module.ts @@ -6,6 +6,7 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe import { TeamModule } from '../team/team.module'; import { UserModule } from '../user/user.module'; import { PubSubModule } from '../pubsub/pubsub.module'; +import { TeamCollectionController } from './team-collection.controller'; @Module({ imports: [PrismaModule, TeamModule, UserModule, PubSubModule], @@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module'; GqlCollectionTeamMemberGuard, ], exports: [TeamCollectionService, GqlCollectionTeamMemberGuard], + controllers: [TeamCollectionController], }) export class TeamCollectionModule {} diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index 734e9e679..6def6a2fe 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { TeamCollection } from './team-collection.model'; import { @@ -14,6 +14,10 @@ import { TEAM_COL_SAME_NEXT_COLL, TEAM_COL_REORDERING_FAILED, TEAM_COLL_DATA_INVALID, + TEAM_REQ_SEARCH_FAILED, + TEAM_COL_SEARCH_FAILED, + TEAM_REQ_PARENT_TREE_GEN_FAILED, + TEAM_COLL_PARENT_TREE_GEN_FAILED, } from '../errors'; import { PubSubService } from '../pubsub/pubsub.service'; import { isValidLength } from 'src/utils'; @@ -22,6 +26,9 @@ import * as O from 'fp-ts/Option'; import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client'; import { CollectionFolder } from 'src/types/CollectionFolder'; import { stringToJson } from 'src/utils'; +import { CollectionSearchNode } from 'src/types/CollectionSearchNode'; +import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper'; +import { RESTError } from 'src/types/RESTError'; @Injectable() export class TeamCollectionService { @@ -1056,4 +1063,266 @@ export class TeamCollectionService { return E.left(TEAM_COLL_NOT_FOUND); } } + + /** + * Search for TeamCollections and TeamRequests by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + async searchByTitle( + searchQuery: string, + teamID: string, + take = 10, + skip = 0, + ) { + // Fetch all collections and requests that match the search query + const searchResults: SearchQueryReturnType[] = []; + + const matchedCollections = await this.searchCollections( + searchQuery, + teamID, + take, + skip, + ); + if (E.isLeft(matchedCollections)) + return E.left({ + message: matchedCollections.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResults.push(...matchedCollections.right); + + const matchedRequests = await this.searchRequests( + searchQuery, + teamID, + take, + skip, + ); + if (E.isLeft(matchedRequests)) + return E.left({ + message: matchedRequests.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResults.push(...matchedRequests.right); + + // Generate the parent tree for searchResults + const searchResultsWithTree: CollectionSearchNode[] = []; + + for (let i = 0; i < searchResults.length; i++) { + const fetchedParentTree = await this.fetchParentTree(searchResults[i]); + if (E.isLeft(fetchedParentTree)) + return E.left({ + message: fetchedParentTree.left, + statusCode: HttpStatus.NOT_FOUND, + }); + searchResultsWithTree.push({ + type: searchResults[i].type, + title: searchResults[i].title, + method: searchResults[i].method, + id: searchResults[i].id, + path: !fetchedParentTree + ? [] + : ([fetchedParentTree.right] as CollectionSearchNode[]), + }); + } + + return E.right({ data: searchResultsWithTree }); + } + + /** + * Search for TeamCollections by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + private async searchCollections( + searchQuery: string, + teamID: string, + take: number, + skip: number, + ) { + const query = Prisma.sql` + select id,title,'collection' AS type + from "TeamCollection" + where "TeamCollection"."teamID"=${teamID} + and titlesearch @@ to_tsquery(${searchQuery}) + order by ts_rank(titlesearch,to_tsquery(${searchQuery})) + limit ${take} + OFFSET ${skip === 0 ? 0 : (skip - 1) * take}; + `; + try { + const res = await this.prisma.$queryRaw(query); + return E.right(res); + } catch (error) { + return E.left(TEAM_COL_SEARCH_FAILED); + } + } + + /** + * Search for TeamRequests by title + * + * @param searchQuery The search query + * @param teamID The Team ID + * @param take Number of items we want returned + * @param skip Number of items we want to skip + * @returns An Either of the search results + */ + private async searchRequests( + searchQuery: string, + teamID: string, + take: number, + skip: number, + ) { + const query = Prisma.sql` + select id,title,request->>'method' as method,'request' AS type + from "TeamRequest" + where "TeamRequest"."teamID"=${teamID} + and titlesearch @@ to_tsquery(${searchQuery}) + order by ts_rank(titlesearch,to_tsquery(${searchQuery})) + limit ${take} + OFFSET ${skip === 0 ? 0 : (skip - 1) * take}; + `; + + try { + const res = await this.prisma.$queryRaw(query); + return E.right(res); + } catch (error) { + return E.left(TEAM_REQ_SEARCH_FAILED); + } + } + + /** + * Generate the parent tree of a search result + * + * @param searchResult The search result for which we want to generate the parent tree + * @returns The parent tree of the search result + */ + private async fetchParentTree(searchResult: SearchQueryReturnType) { + return searchResult.type === 'collection' + ? await this.fetchCollectionParentTree(searchResult.id) + : await this.fetchRequestParentTree(searchResult.id); + } + + /** + * Generate the parent tree of a collection + * + * @param id The ID of the collection + * @returns The parent tree of the collection + */ + private async fetchCollectionParentTree(id: string) { + try { + const query = Prisma.sql` + WITH RECURSIVE collection_tree AS ( + SELECT tc.id, tc."parentID", tc.title + FROM "TeamCollection" AS tc + JOIN "TeamCollection" AS tr ON tc.id = tr."parentID" + WHERE tr.id = ${id} + + UNION ALL + + SELECT parent.id, parent."parentID", parent.title + FROM "TeamCollection" AS parent + JOIN collection_tree AS ct ON parent.id = ct."parentID" + ) + SELECT * FROM collection_tree; + `; + const res = await this.prisma.$queryRaw( + query, + ); + + const collectionParentTree = this.generateParentTree(res); + return E.right(collectionParentTree); + } catch (error) { + E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED); + } + } + + /** + * Generate the parent tree from the collections + * + * @param parentCollections The parent collections + * @returns The parent tree of the parent collections + */ + private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) { + function findChildren(id) { + const collection = parentCollections.filter((item) => item.id === id)[0]; + if (collection.parentID == null) { + return { + id: collection.id, + title: collection.title, + type: 'collection', + path: [], + }; + } + + const res = { + id: collection.id, + title: collection.title, + type: 'collection', + path: findChildren(collection.parentID), + }; + return res; + } + + if (parentCollections.length > 0) { + if (parentCollections[0].parentID == null) { + return { + id: parentCollections[0].id, + title: parentCollections[0].title, + type: 'collection', + path: [], + }; + } + + return { + id: parentCollections[0].id, + title: parentCollections[0].title, + type: 'collection', + path: findChildren(parentCollections[0].parentID), + }; + } + + return null; + } + + /** + * Generate the parent tree of a request + * + * @param id The ID of the request + * @returns The parent tree of the request + */ + private async fetchRequestParentTree(id: string) { + try { + const query = Prisma.sql` + WITH RECURSIVE request_collection_tree AS ( + SELECT tc.id, tc."parentID", tc.title + FROM "TeamCollection" AS tc + JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID" + WHERE tr.id = ${id} + + UNION ALL + + SELECT parent.id, parent."parentID", parent.title + FROM "TeamCollection" AS parent + JOIN request_collection_tree AS ct ON parent.id = ct."parentID" + ) + SELECT * FROM request_collection_tree; + + `; + const res = await this.prisma.$queryRaw( + query, + ); + + const requestParentTree = this.generateParentTree(res); + return E.right(requestParentTree); + } catch (error) { + return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED); + } + } } diff --git a/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts b/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts new file mode 100644 index 000000000..f47de81f1 --- /dev/null +++ b/packages/hoppscotch-backend/src/team/guards/rest-team-member.guard.ts @@ -0,0 +1,47 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { TeamService } from '../../team/team.service'; +import { TeamMemberRole } from '../../team/team.model'; +import { + BUG_TEAM_NO_REQUIRE_TEAM_ROLE, + BUG_AUTH_NO_USER_CTX, + BUG_TEAM_NO_TEAM_ID, + TEAM_MEMBER_NOT_FOUND, + TEAM_NOT_REQUIRED_ROLE, +} from 'src/errors'; +import { throwHTTPErr } from 'src/utils'; + +@Injectable() +export class RESTTeamMemberGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly teamService: TeamService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requireRoles = this.reflector.get( + 'requiresTeamRole', + context.getHandler(), + ); + if (!requireRoles) + throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 }); + + const request = context.switchToHttp().getRequest(); + + const { user } = request; + if (user == undefined) + throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 }); + + const teamID = request.params.teamID; + if (!teamID) + throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 }); + + const teamMember = await this.teamService.getTeamMember(teamID, user.uid); + if (!teamMember) + throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 }); + + if (requireRoles.includes(teamMember.role)) return true; + + throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 }); + } +} diff --git a/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts b/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts new file mode 100644 index 000000000..77cf80d7b --- /dev/null +++ b/packages/hoppscotch-backend/src/types/CollectionSearchNode.ts @@ -0,0 +1,17 @@ +// Response type of results from the search query +export type CollectionSearchNode = { + /** Encodes the hierarchy of where the node is **/ + path: CollectionSearchNode[]; +} & ( + | { + type: 'request'; + title: string; + method: string; + id: string; + } + | { + type: 'collection'; + title: string; + id: string; + } +); diff --git a/packages/hoppscotch-backend/src/types/AuthError.ts b/packages/hoppscotch-backend/src/types/RESTError.ts similarity index 63% rename from packages/hoppscotch-backend/src/types/AuthError.ts rename to packages/hoppscotch-backend/src/types/RESTError.ts index d1d2d387e..367c51dc0 100644 --- a/packages/hoppscotch-backend/src/types/AuthError.ts +++ b/packages/hoppscotch-backend/src/types/RESTError.ts @@ -1,10 +1,10 @@ import { HttpStatus } from '@nestjs/common'; /** - ** Custom interface to handle errors specific to Auth module + ** Custom interface to handle errors for REST modules such as Auth, Admin modules ** Since its REST we need to return the HTTP status code along with the error message */ -export type AuthError = { +export type RESTError = { message: string; statusCode: HttpStatus; }; diff --git a/packages/hoppscotch-backend/src/utils.ts b/packages/hoppscotch-backend/src/utils.ts index 76cf26ebc..043bd46f9 100644 --- a/packages/hoppscotch-backend/src/utils.ts +++ b/packages/hoppscotch-backend/src/utils.ts @@ -1,4 +1,4 @@ -import { ExecutionContext } from '@nestjs/common'; +import { ExecutionContext, HttpException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { GqlExecutionContext } from '@nestjs/graphql'; import { pipe } from 'fp-ts/lib/function'; @@ -16,6 +16,7 @@ import { JSON_INVALID, } from './errors'; import { AuthProvider } from './auth/helper'; +import { RESTError } from './types/RESTError'; /** * A workaround to throw an exception in an expression. @@ -27,6 +28,15 @@ export function throwErr(errMessage: string): never { throw new Error(errMessage); } +/** + * This function allows throw to be used as an expression + * @param errMessage Message present in the error message + */ +export function throwHTTPErr(errorData: RESTError): never { + const { message, statusCode } = errorData; + throw new HttpException(message, statusCode); +} + /** * Prints the given value to log and returns the same value. * Used for debugging functional pipelines.