feat: full text search for TeamCollections and TeamRequests (#3857)
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
This commit is contained in:
@@ -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);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: updatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -255,7 +255,7 @@ export class AuthService {
|
||||
*/
|
||||
async verifyMagicLinkTokens(
|
||||
magicLinkIDTokens: VerifyMagicDto,
|
||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
||||
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
||||
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(<AuthError>{
|
||||
return E.left(<RESTError>{
|
||||
message: elevatedUser.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(<AuthError>{
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: status.left,
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export class SiteController {
|
||||
);
|
||||
|
||||
if (E.isLeft(res))
|
||||
throwHTTPErr(<AuthError>{
|
||||
throwHTTPErr(<RESTError>{
|
||||
message: res.left,
|
||||
statusCode: HttpStatus.FORBIDDEN,
|
||||
});
|
||||
|
||||
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal file
14
packages/hoppscotch-backend/src/team-collection/helper.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(<RESTError>{
|
||||
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(<RESTError>{
|
||||
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(<RESTError>{
|
||||
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<SearchQueryReturnType[]>(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<SearchQueryReturnType[]>(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<ParentTreeQueryReturnType[]>(
|
||||
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<ParentTreeQueryReturnType[]>(
|
||||
query,
|
||||
);
|
||||
|
||||
const requestParentTree = this.generateParentTree(res);
|
||||
return E.right(requestParentTree);
|
||||
} catch (error) {
|
||||
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
const requireRoles = this.reflector.get<TeamMemberRole[]>(
|
||||
'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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user