feat: full text search for TeamCollections and TeamRequests (#3857)
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
This commit is contained in:
@@ -112,17 +112,17 @@ services:
|
|||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
target: prod
|
target: dev
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
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)
|
# 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
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
# 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/
|
- /usr/src/app/node_modules/
|
||||||
depends_on:
|
depends_on:
|
||||||
hoppscotch-db:
|
hoppscotch-db:
|
||||||
|
|||||||
@@ -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 {
|
model TeamCollection {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
parentID String?
|
parentID String?
|
||||||
data Json?
|
data Json?
|
||||||
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
|
||||||
children TeamCollection[] @relation("TeamCollectionChildParent")
|
children TeamCollection[] @relation("TeamCollectionChildParent")
|
||||||
requests TeamRequest[]
|
requests TeamRequest[]
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model TeamRequest {
|
model TeamRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
collectionID String
|
collectionID String
|
||||||
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
|
||||||
teamID String
|
teamID String
|
||||||
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
|
||||||
title String
|
title String
|
||||||
request Json
|
request Json
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
createdOn DateTime @default(now()) @db.Timestamp(3)
|
createdOn DateTime @default(now()) @db.Timestamp(3)
|
||||||
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
updatedOn DateTime @updatedAt @db.Timestamp(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Shortcode {
|
model Shortcode {
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||||
import {
|
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
|
||||||
AuthProvider,
|
|
||||||
authCookieHandler,
|
|
||||||
authProviderCheck,
|
|
||||||
throwHTTPErr,
|
|
||||||
} from './helper';
|
|
||||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||||
import { MicrosoftSSOGuard } from './guards/microsoft-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 { SkipThrottle } from '@nestjs/throttler';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
@Controller({ path: 'auth', version: '1' })
|
@Controller({ path: 'auth', version: '1' })
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
RefreshTokenPayload,
|
RefreshTokenPayload,
|
||||||
} from 'src/types/AuthTokens';
|
} from 'src/types/AuthTokens';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
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 { AuthUser, IsAdmin } from 'src/types/AuthUser';
|
||||||
import { VerificationToken } from '@prisma/client';
|
import { VerificationToken } from '@prisma/client';
|
||||||
import { Origin } from './helper';
|
import { Origin } from './helper';
|
||||||
@@ -117,7 +117,7 @@ export class AuthService {
|
|||||||
userUid,
|
userUid,
|
||||||
);
|
);
|
||||||
if (E.isLeft(updatedUser))
|
if (E.isLeft(updatedUser))
|
||||||
return E.left(<AuthError>{
|
return E.left(<RESTError>{
|
||||||
message: updatedUser.left,
|
message: updatedUser.left,
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
@@ -255,7 +255,7 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
async verifyMagicLinkTokens(
|
async verifyMagicLinkTokens(
|
||||||
magicLinkIDTokens: VerifyMagicDto,
|
magicLinkIDTokens: VerifyMagicDto,
|
||||||
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
|
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
|
||||||
const passwordlessTokens = await this.validatePasswordlessTokens(
|
const passwordlessTokens = await this.validatePasswordlessTokens(
|
||||||
magicLinkIDTokens,
|
magicLinkIDTokens,
|
||||||
);
|
);
|
||||||
@@ -373,7 +373,7 @@ export class AuthService {
|
|||||||
if (usersCount === 1) {
|
if (usersCount === 1) {
|
||||||
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
const elevatedUser = await this.usersService.makeAdmin(user.uid);
|
||||||
if (E.isLeft(elevatedUser))
|
if (E.isLeft(elevatedUser))
|
||||||
return E.left(<AuthError>{
|
return E.left(<RESTError>{
|
||||||
message: elevatedUser.left,
|
message: elevatedUser.left,
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
import { AuthProvider, authProviderCheck } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
import { AuthProvider, authProviderCheck } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
import { AuthProvider, authProviderCheck } from '../helper';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicrosoftSSOGuard
|
export class MicrosoftSSOGuard
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { AuthError } from 'src/types/AuthError';
|
|
||||||
import { AuthTokens } from 'src/types/AuthTokens';
|
import { AuthTokens } from 'src/types/AuthTokens';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
@@ -25,15 +24,6 @@ export enum AuthProvider {
|
|||||||
EMAIL = 'EMAIL',
|
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
|
* Sets and returns the cookies in the response object on successful authentication
|
||||||
* @param res Express Response Object
|
* @param res Express Response Object
|
||||||
|
|||||||
@@ -228,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
|
|||||||
export const TEAM_COL_SAME_NEXT_COLL =
|
export const TEAM_COL_SAME_NEXT_COLL =
|
||||||
'team_coll/collection_and_next_collection_are_same';
|
'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
|
* Team Collection Re-Ordering Failed
|
||||||
* (TeamCollectionService)
|
* (TeamCollectionService)
|
||||||
@@ -283,6 +289,13 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
|
|||||||
export const TEAM_COLL_DATA_INVALID =
|
export const TEAM_COLL_DATA_INVALID =
|
||||||
'team_coll/team_coll_data_invalid' as const;
|
'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
|
* Tried to perform an action on a request that doesn't accept their member role level
|
||||||
* (GqlRequestTeamMemberGuard)
|
* (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;
|
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
|
* No Postmark Sender Email defined
|
||||||
* (AuthService)
|
* (AuthService)
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { InfraConfigService } from './infra-config.service';
|
|||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
|
||||||
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
|
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
|
||||||
import { throwHTTPErr } from 'src/auth/helper';
|
import { RESTError } from 'src/types/RESTError';
|
||||||
import { AuthError } from 'src/types/AuthError';
|
|
||||||
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
|
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
|
||||||
|
import { throwHTTPErr } from 'src/utils';
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
@Controller({ path: 'site', version: '1' })
|
@Controller({ path: 'site', version: '1' })
|
||||||
@@ -21,7 +21,7 @@ export class SiteController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (E.isLeft(status))
|
if (E.isLeft(status))
|
||||||
throwHTTPErr(<AuthError>{
|
throwHTTPErr(<RESTError>{
|
||||||
message: status.left,
|
message: status.left,
|
||||||
statusCode: HttpStatus.NOT_FOUND,
|
statusCode: HttpStatus.NOT_FOUND,
|
||||||
});
|
});
|
||||||
@@ -38,7 +38,7 @@ export class SiteController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (E.isLeft(res))
|
if (E.isLeft(res))
|
||||||
throwHTTPErr(<AuthError>{
|
throwHTTPErr(<RESTError>{
|
||||||
message: res.left,
|
message: res.left,
|
||||||
statusCode: HttpStatus.FORBIDDEN,
|
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 { TeamModule } from '../team/team.module';
|
||||||
import { UserModule } from '../user/user.module';
|
import { UserModule } from '../user/user.module';
|
||||||
import { PubSubModule } from '../pubsub/pubsub.module';
|
import { PubSubModule } from '../pubsub/pubsub.module';
|
||||||
|
import { TeamCollectionController } from './team-collection.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
|
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
|
||||||
@@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module';
|
|||||||
GqlCollectionTeamMemberGuard,
|
GqlCollectionTeamMemberGuard,
|
||||||
],
|
],
|
||||||
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
|
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
|
||||||
|
controllers: [TeamCollectionController],
|
||||||
})
|
})
|
||||||
export class TeamCollectionModule {}
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TeamCollection } from './team-collection.model';
|
import { TeamCollection } from './team-collection.model';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
TEAM_COL_SAME_NEXT_COLL,
|
TEAM_COL_SAME_NEXT_COLL,
|
||||||
TEAM_COL_REORDERING_FAILED,
|
TEAM_COL_REORDERING_FAILED,
|
||||||
TEAM_COLL_DATA_INVALID,
|
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';
|
} from '../errors';
|
||||||
import { PubSubService } from '../pubsub/pubsub.service';
|
import { PubSubService } from '../pubsub/pubsub.service';
|
||||||
import { isValidLength } from 'src/utils';
|
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 { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
|
||||||
import { CollectionFolder } from 'src/types/CollectionFolder';
|
import { CollectionFolder } from 'src/types/CollectionFolder';
|
||||||
import { stringToJson } from 'src/utils';
|
import { stringToJson } from 'src/utils';
|
||||||
|
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
|
||||||
|
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
|
||||||
|
import { RESTError } from 'src/types/RESTError';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamCollectionService {
|
export class TeamCollectionService {
|
||||||
@@ -1056,4 +1063,266 @@ export class TeamCollectionService {
|
|||||||
return E.left(TEAM_COLL_NOT_FOUND);
|
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';
|
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
|
** Since its REST we need to return the HTTP status code along with the error message
|
||||||
*/
|
*/
|
||||||
export type AuthError = {
|
export type RESTError = {
|
||||||
message: string;
|
message: string;
|
||||||
statusCode: HttpStatus;
|
statusCode: HttpStatus;
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ExecutionContext } from '@nestjs/common';
|
import { ExecutionContext, HttpException } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
import { pipe } from 'fp-ts/lib/function';
|
import { pipe } from 'fp-ts/lib/function';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
JSON_INVALID,
|
JSON_INVALID,
|
||||||
} from './errors';
|
} from './errors';
|
||||||
import { AuthProvider } from './auth/helper';
|
import { AuthProvider } from './auth/helper';
|
||||||
|
import { RESTError } from './types/RESTError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A workaround to throw an exception in an expression.
|
* A workaround to throw an exception in an expression.
|
||||||
@@ -27,6 +28,15 @@ export function throwErr(errMessage: string): never {
|
|||||||
throw new Error(errMessage);
|
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.
|
* Prints the given value to log and returns the same value.
|
||||||
* Used for debugging functional pipelines.
|
* Used for debugging functional pipelines.
|
||||||
|
|||||||
Reference in New Issue
Block a user