diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b3ac467..4b68cf15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,13 @@ - Added same site property to the clear cookies function ([#218](https://github.com/chingu-x/chingu-dashboard-be/pull/218)) - Added routes for teams to create own tech stack categories([#208](https://github.com/chingu-x/chingu-dashboard-be/pull/208)) - Added unit tests for Features controller and services ([#220](https://github.com/chingu-x/chingu-dashboard-be/pull/220)) +- Added GET endpoint for solo project ([#223](https://github.com/chingu-x/chingu-dashboard-be/pull/223)) ### Changed - Updated cors origin list ([#218](https://github.com/chingu-x/chingu-dashboard-be/pull/218)) - refactored unit tests for the ideations controller and services([#219](https://github.com/chingu-x/chingu-dashboard-be/pull/219)) - revised tech selections route to update only one tech per request([#221](https://github.com/chingu-x/chingu-dashboard-be/pull/221)) + ### Fixed ### Removed diff --git a/prisma/seed/forms/solo-project.ts b/prisma/seed/forms/solo-project.ts index a25196fb..b210e76f 100644 --- a/prisma/seed/forms/solo-project.ts +++ b/prisma/seed/forms/solo-project.ts @@ -32,6 +32,16 @@ export const populateSoloProjectForm = async () => { text: "Deployed Url", answerRequired: true, }, + { + order: 3, + inputType: { + connect: { + name: "radio", + }, + }, + text: "Tier", + answerRequired: true, + }, ], }, }, diff --git a/prisma/seed/responses/helper.ts b/prisma/seed/responses/helper.ts index 22116157..6925cead 100644 --- a/prisma/seed/responses/helper.ts +++ b/prisma/seed/responses/helper.ts @@ -165,7 +165,6 @@ export const populateQuestionResponses = async ( } case "teamMembersCheckbox": { if (teamMemberId === 0) { - console.log(question); throw new Error( `teamMemberId required for input type ${question.inputType.name} (question id:${question.id}).`, ); diff --git a/prisma/seed/solo-project.ts b/prisma/seed/solo-project.ts index 318634c6..672f5850 100644 --- a/prisma/seed/solo-project.ts +++ b/prisma/seed/solo-project.ts @@ -24,6 +24,9 @@ export const populateSoloProjects = async () => { select: { id: true, questions: { + orderBy: { + order: "asc", + }, select: { id: true, }, @@ -58,10 +61,12 @@ export const populateSoloProjects = async () => { createMany: { data: [ { + authorId: users[1].id, content: "This is a tier 2 project, not tier 3", type: "SoloProject", }, { + authorId: users[2].id, content: "ok", parentCommentId: 1, type: "SoloProject", @@ -123,5 +128,64 @@ export const populateSoloProjects = async () => { }, }); + // Solo Project 3 (with option choices) + const responseGroup3 = await prisma.responseGroup.create({ + data: { + responses: { + createMany: { + data: [ + { + questionId: soloProjectForm!.questions[0].id, + text: "www.github.com/repo3", + }, + { + questionId: soloProjectForm!.questions[1].id, + text: "www.vercel.com/3", + }, + { + questionId: soloProjectForm!.questions[2].id, + optionChoiceId: 44, + }, + ], + }, + }, + }, + }); + + await prisma.soloProject.create({ + data: { + userId: users[6].id, + evaluatorUserId: users[3].id, + evaluatorFeedback: passedSampleFeedback, + statusId: (await prisma.soloProjectStatus.findUnique({ + where: { + status: "Requested Changes", + }, + }))!.id, + formId: soloProjectForm!.id, + responseGroupId: responseGroup3.id, + }, + }); + + const statuses = await prisma.soloProjectStatus.findMany({}); + + for (let i = 0; i < 40; i++) { + await prisma.soloProject.create({ + data: { + userId: users[5].id, + evaluatorUserId: users[2].id, + evaluatorFeedback: passedSampleFeedback, + statusId: (await prisma.soloProjectStatus.findUnique({ + where: { + status: statuses[ + Math.floor(Math.random() * statuses.length) + ].status, + }, + }))!.id, + formId: soloProjectForm!.id, + }, + }); + } + console.log("Solo projects populated."); }; diff --git a/prisma/seed/voyage-teams.ts b/prisma/seed/voyage-teams.ts index 058b9d57..c8a21657 100644 --- a/prisma/seed/voyage-teams.ts +++ b/prisma/seed/voyage-teams.ts @@ -1532,7 +1532,7 @@ export const populateVoyageTeams = async () => { }, }); - //Add Tech Stack Categories + //Add Tech Stack Categories for (let teamId = 1; teamId <= 11; teamId += 1) { for (const category of techStackCategoriesData) { category.voyageTeamId = teamId; diff --git a/src/app.module.ts b/src/app.module.ts index 1c45fe34..f3b1e0f9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { DevelopmentModule } from "./development/development.module"; import { AppConfigModule } from "./config/app/appConfig.module"; import { MailConfigModule } from "./config/mail/mailConfig.module"; import { DbConfigModule } from "./config/database/dbConfig.module"; +import { SoloProjectsModule } from "./solo-projects/solo-projects.module"; @Module({ imports: [ @@ -64,6 +65,7 @@ import { DbConfigModule } from "./config/database/dbConfig.module"; VoyagesModule, AbilityModule, DevelopmentModule, + SoloProjectsModule, ], controllers: [HealthCheckController], providers: [ diff --git a/src/global/constants/sortMaps.ts b/src/global/constants/sortMaps.ts new file mode 100644 index 00000000..0e19862a --- /dev/null +++ b/src/global/constants/sortMaps.ts @@ -0,0 +1,12 @@ +/* + key - a more user friendly name for sort field + value - prisma sort field + sorting is only supported for fields listed here +*/ +export const soloProjectSortMap: Map = new Map( + Object.entries({ + status: "statusId", + createdAt: "createdAt", + updatedAt: "updatedAt", + }), +); diff --git a/src/global/global.service.ts b/src/global/global.service.ts index 372e63e3..9632a56f 100644 --- a/src/global/global.service.ts +++ b/src/global/global.service.ts @@ -7,6 +7,7 @@ import { import { PrismaService } from "@/prisma/prisma.service"; import { CustomRequest } from "./types/CustomRequest"; import { FormResponseDto } from "./dtos/FormResponse.dto"; +import { UserWithProfile } from "@/global/types/users.types"; @Injectable() export class GlobalService { @@ -193,4 +194,60 @@ export class GlobalService { return dbItem; }; + + public formatUser = (user: UserWithProfile) => { + return { + firstname: user.firstName, + lastname: user.lastName, + email: user.email, + discordId: user.oAuthProfiles?.find( + (profile) => profile.provider.name === "discord", + )?.providerUserId, + discordUsername: user.oAuthProfiles?.find( + (profile) => profile.provider.name === "discord", + )?.providerUsername, + github: user.oAuthProfiles?.find( + (profile) => profile.provider.name === "github", + )?.providerUsername, + }; + }; + + public formatResponses = (responses: any) => { + return responses?.map((response: any) => { + return { + question: response.question.text, + inputType: response.question.inputType.name, + text: response.text, + number: response.numeric, + boolean: response.boolean, + choice: response.optionChoice?.text || null, + }; + }); + }; + + /* + parse sort strings into format usable by prisma + sort string is in the form of "-createdAt;+status" + - for descending, + (or nothing) for ascending + valid sort fields are defined in /src/global/constants/sortMaps.ts + */ + public parseSortString = ( + sortString: string, + sortFieldMap: Map, + ) => { + return sortString.split(";").map((field) => { + const direction = field[0] === "-" ? "desc" : "asc"; + const fieldName = + field.charAt(0) === "+" || field.charAt(0) === "-" + ? field.slice(1) + : field; + if (!sortFieldMap.get(fieldName)) + throw new BadRequestException( + `Sort field ${fieldName} is not valid.`, + ); + return { + [sortFieldMap.get(fieldName)!]: direction, + }; + }); + }; } diff --git a/src/global/selects/users.select.ts b/src/global/selects/users.select.ts index 32f3731d..4bdd8237 100644 --- a/src/global/selects/users.select.ts +++ b/src/global/selects/users.select.ts @@ -136,3 +136,16 @@ export const publicUserDetailSelect = { countryCode: true, timezone: true, }; + +export const userSelectBasicWithSocial = { + firstName: true, + lastName: true, + oAuthProfiles: { + select: { + provider: true, + providerId: true, + providerUserId: true, + providerUsername: true, + }, + }, +}; diff --git a/src/global/types/solo-project.types.ts b/src/global/types/solo-project.types.ts new file mode 100644 index 00000000..e07f75ff --- /dev/null +++ b/src/global/types/solo-project.types.ts @@ -0,0 +1,24 @@ +import { Prisma } from "@prisma/client"; +import { userSelectBasicWithSocial } from "@/global/selects/users.select"; + +export type SoloProjectWithPayload = Prisma.SoloProjectGetPayload<{ + include: { + user: { + include: typeof userSelectBasicWithSocial; + }; + evaluator: { + include: typeof userSelectBasicWithSocial; + }; + status: true; + comments: true; + responseGroup: { + select: { + responses: { + include: { + question: true; + }; + }; + }; + }; + }; +}>; diff --git a/src/global/types/users.types.ts b/src/global/types/users.types.ts new file mode 100644 index 00000000..7ffd038e --- /dev/null +++ b/src/global/types/users.types.ts @@ -0,0 +1,14 @@ +import { Prisma } from "@prisma/client"; + +export type UserWithProfile = Prisma.UserGetPayload<{ + include: { + oAuthProfiles: { + select: { + provider: true; + providerId: true; + providerUserId: true; + providerUsername: true; + }; + }; + }; +}>; diff --git a/src/pipes/non-negative-int-default-value-pipe.ts b/src/pipes/non-negative-int-default-value-pipe.ts new file mode 100644 index 00000000..4e9bcfb9 --- /dev/null +++ b/src/pipes/non-negative-int-default-value-pipe.ts @@ -0,0 +1,23 @@ +// This pipe is used to set a default value for a parameter in a route handler. +// Without the pipe, controller returns NaN for optional query, results in default value (in service files) not being applied + +import { + ArgumentMetadata, + BadRequestException, + Injectable, + PipeTransform, +} from "@nestjs/common"; + +@Injectable() +export class NonNegativeIntDefaultValuePipe implements PipeTransform { + constructor(private readonly defaultValue: number) {} + + transform(value: string, metadata: ArgumentMetadata): any { + const val = parseInt(value, 10); + if (val < 0) + throw new BadRequestException( + `Invalid ${metadata.data} value. ${metadata.data} must be non negative.`, + ); + return isNaN(val) ? this.defaultValue : val; + } +} diff --git a/src/solo-projects/dto/create-solo-project.dto.ts b/src/solo-projects/dto/create-solo-project.dto.ts new file mode 100644 index 00000000..6452dc49 --- /dev/null +++ b/src/solo-projects/dto/create-solo-project.dto.ts @@ -0,0 +1 @@ +export class CreateSoloProjectDto {} diff --git a/src/solo-projects/dto/update-solo-project.dto.ts b/src/solo-projects/dto/update-solo-project.dto.ts new file mode 100644 index 00000000..6ebc9125 --- /dev/null +++ b/src/solo-projects/dto/update-solo-project.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from "@nestjs/swagger"; +import { CreateSoloProjectDto } from "./create-solo-project.dto"; + +export class UpdateSoloProjectDto extends PartialType(CreateSoloProjectDto) {} diff --git a/src/solo-projects/entities/solo-project.entity.ts b/src/solo-projects/entities/solo-project.entity.ts new file mode 100644 index 00000000..2c833a3d --- /dev/null +++ b/src/solo-projects/entities/solo-project.entity.ts @@ -0,0 +1 @@ +export class SoloProject {} diff --git a/src/solo-projects/solo-projects.controller.spec.ts b/src/solo-projects/solo-projects.controller.spec.ts new file mode 100644 index 00000000..272200e4 --- /dev/null +++ b/src/solo-projects/solo-projects.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { SoloProjectsController } from "./solo-projects.controller"; +import { SoloProjectsService } from "./solo-projects.service"; + +describe("SoloProjectsController", () => { + let controller: SoloProjectsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SoloProjectsController], + providers: [SoloProjectsService], + }).compile(); + + controller = module.get(SoloProjectsController); + }); + + xit("should be defined", () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/solo-projects/solo-projects.controller.ts b/src/solo-projects/solo-projects.controller.ts new file mode 100644 index 00000000..98299418 --- /dev/null +++ b/src/solo-projects/solo-projects.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + HttpStatus, +} from "@nestjs/common"; +import { SoloProjectsService } from "./solo-projects.service"; +import { CreateSoloProjectDto } from "./dto/create-solo-project.dto"; +import { UpdateSoloProjectDto } from "./dto/update-solo-project.dto"; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { NonNegativeIntDefaultValuePipe } from "@/pipes/non-negative-int-default-value-pipe"; +import { SoloProjectsResponse } from "@/solo-projects/solo-projects.response"; +import { + BadRequestErrorResponse, + ForbiddenErrorResponse, + UnauthorizedErrorResponse, +} from "@/global/responses/errors"; + +@Controller("solo-projects") +@ApiTags("Solo Projects") +export class SoloProjectsController { + constructor(private readonly soloProjectsService: SoloProjectsService) {} + + @Post() + create(@Body() createSoloProjectDto: CreateSoloProjectDto) { + return this.soloProjectsService.create(createSoloProjectDto); + } + + @ApiOperation({ + summary: "[Permission: admin, evaluator] Get all solo projects", + }) + @ApiResponse({ + status: HttpStatus.OK, + description: + "Successfully gets all solo projects based on query params", + isArray: true, + type: SoloProjectsResponse, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: "Invalid input/query params", + type: BadRequestErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "Unauthorized access: user is not logged in", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: "Forbidden - user does not have the required permission", + type: ForbiddenErrorResponse, + }) + @ApiQuery({ + name: "offset", + type: Number, + description: "Offset for pagination (default: 0)", + required: false, + }) + @ApiQuery({ + name: "pageSize", + type: Number, + description: + "page size (number of results) for pagination (default: 30)", + required: false, + }) + @ApiQuery({ + name: "sort", + type: String, + description: + "Sort. - for descending, + (or nothing) for ascending (default: -createdAt)" + + "
Example: '+status;-createdAt' will sort by status ascending then createdAt descending" + + "
Valid sort fields are: 'status', 'createdAt', 'updatedAt'", + required: false, + }) + @Get() + getAllSoloProjects( + @Query("offset", new NonNegativeIntDefaultValuePipe(0)) offset: number, + @Query("pageSize", new NonNegativeIntDefaultValuePipe(30)) + pageSize: number, + @Query("sort") sort: string, + ) { + return this.soloProjectsService.getAllSoloProjects( + offset, + pageSize, + sort, + ); + } + + @Get(":id") + findOne(@Param("id") id: string) { + return this.soloProjectsService.findOne(+id); + } + + @Patch(":id") + update( + @Param("id") id: string, + @Body() updateSoloProjectDto: UpdateSoloProjectDto, + ) { + return this.soloProjectsService.update(+id, updateSoloProjectDto); + } + + @Delete(":id") + remove(@Param("id") id: string) { + return this.soloProjectsService.remove(+id); + } +} diff --git a/src/solo-projects/solo-projects.module.ts b/src/solo-projects/solo-projects.module.ts new file mode 100644 index 00000000..61d84c93 --- /dev/null +++ b/src/solo-projects/solo-projects.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { SoloProjectsService } from "./solo-projects.service"; +import { SoloProjectsController } from "./solo-projects.controller"; + +@Module({ + controllers: [SoloProjectsController], + providers: [SoloProjectsService], +}) +export class SoloProjectsModule {} diff --git a/src/solo-projects/solo-projects.response.ts b/src/solo-projects/solo-projects.response.ts new file mode 100644 index 00000000..ef7d2e95 --- /dev/null +++ b/src/solo-projects/solo-projects.response.ts @@ -0,0 +1,105 @@ +import { ApiProperty } from "@nestjs/swagger"; + +class UserWithSocials { + @ApiProperty({ + example: "Jessica", + }) + firstname: string; + + @ApiProperty({ example: "Williamson" }) + lastname: string; + + @ApiProperty({ + example: "12345436342323", + description: "Discord ID", + }) + discordId: string; + + @ApiProperty({ + example: "jessica-discord", + description: "Discord ID", + }) + discordUsername: string; + + @ApiProperty({ + example: "jessica-github", + description: "github ID / username", + }) + github: string; +} + +class Comment { + @ApiProperty({ example: 2 }) + id: number; + + @ApiProperty({ example: 1 }) + parentCommentId: number | null; + + @ApiProperty({ example: "This is a tier 2 project, not tier 3" }) + content: number; + + @ApiProperty({ example: UserWithSocials }) + author: UserWithSocials; +} + +class Response { + @ApiProperty({ example: "Repo Url" }) + question: string; + + @ApiProperty({ example: "text" }) + inputType: string; + + @ApiProperty({ example: "www.github.com/repo" }) + text: string | null; + + @ApiProperty({ example: "12" }) + number: number | null; + + @ApiProperty({ example: true }) + boolean: boolean | null; + + @ApiProperty({ example: true }) + choice: string | null; +} + +class SoloProjectsResponseData { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: UserWithSocials }) + user: UserWithSocials; + + @ApiProperty({ example: UserWithSocials }) + evaluator: UserWithSocials; + + @ApiProperty({ example: "Waiting Evaluation" }) + status: string; + + @ApiProperty({ example: Comment, isArray: true }) + comments: Comment; + + @ApiProperty({ example: Response, isArray: true }) + responses: Response; + + @ApiProperty({ example: "2024-01-08T00:00:00.000Z" }) + createdAt: Date; + + @ApiProperty({ example: "2024-01-08T00:00:00.000Z" }) + updatedAt: Date; +} + +class SoloProjectResponseMeta { + @ApiProperty({ example: 30 }) + pageSize: number; + + @ApiProperty({ example: 0 }) + offset: number; +} + +export class SoloProjectsResponse { + @ApiProperty({ example: SoloProjectsResponseData, isArray: true }) + data: SoloProjectsResponseData; + + @ApiProperty({ example: SoloProjectResponseMeta }) + meta: SoloProjectResponseMeta; +} diff --git a/src/solo-projects/solo-projects.service.spec.ts b/src/solo-projects/solo-projects.service.spec.ts new file mode 100644 index 00000000..a338f1a8 --- /dev/null +++ b/src/solo-projects/solo-projects.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { SoloProjectsService } from "./solo-projects.service"; + +describe("SoloProjectsService", () => { + let service: SoloProjectsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SoloProjectsService], + }).compile(); + + service = module.get(SoloProjectsService); + }); + + xit("should be defined", () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/solo-projects/solo-projects.service.ts b/src/solo-projects/solo-projects.service.ts new file mode 100644 index 00000000..52ae1beb --- /dev/null +++ b/src/solo-projects/solo-projects.service.ts @@ -0,0 +1,131 @@ +import { Injectable } from "@nestjs/common"; +import { CreateSoloProjectDto } from "./dto/create-solo-project.dto"; +import { UpdateSoloProjectDto } from "./dto/update-solo-project.dto"; +import { PrismaService } from "@/prisma/prisma.service"; +import { userSelectBasicWithSocial } from "@/global/selects/users.select"; +import { SoloProjectWithPayload } from "@/global/types/solo-project.types"; +import { GlobalService } from "@/global/global.service"; +import { soloProjectSortMap } from "@/global/constants/sortMaps"; + +@Injectable() +export class SoloProjectsService { + constructor( + private prisma: PrismaService, + private globalService: GlobalService, + ) {} + + private formatSoloProject = (soloProject: SoloProjectWithPayload) => { + return { + id: soloProject.id, + user: this.globalService.formatUser(soloProject.user), + evaluator: + soloProject.evaluator && + this.globalService.formatUser(soloProject.evaluator), + // TODO: uncomment below, commented out so results are easier to see + // evaluatorFeedback: soloProject.evaluatorFeedback, + status: soloProject.status?.status, + comments: soloProject.comments, + responses: this.globalService.formatResponses( + soloProject.responseGroup?.responses, + ), + createdAt: soloProject.createdAt, + updatedAt: soloProject.updatedAt, + }; + }; + + create(_createSoloProjectDto: CreateSoloProjectDto) { + return "This action adds a new soloProject"; + } + + async getAllSoloProjects( + offset: number, + pageSize: number, + sort: string = "-createdAt", + ) { + const soloProjects = await this.prisma.soloProject.findMany({ + skip: offset, + take: pageSize, + orderBy: this.globalService.parseSortString( + sort, + soloProjectSortMap, + ), + select: { + id: true, + user: { + select: userSelectBasicWithSocial, + }, + evaluator: { + select: userSelectBasicWithSocial, + }, + evaluatorFeedback: true, + status: { + select: { + status: true, + }, + }, + comments: { + select: { + id: true, + content: true, + parentCommentId: true, + author: { + select: userSelectBasicWithSocial, + }, + }, + }, + responseGroup: { + select: { + responses: { + select: { + question: { + select: { + text: true, + inputType: { + select: { + name: true, + }, + }, + }, + }, + numeric: true, + text: true, + boolean: true, + optionChoice: { + select: { + text: true, + }, + }, + }, + }, + }, + }, + createdAt: true, + updatedAt: true, + }, + }); + + const data = soloProjects.map((sp) => + this.formatSoloProject(sp as unknown as SoloProjectWithPayload), + ); + + return { + data, + meta: { + pageSize, + offset, + }, + }; + } + + findOne(id: number) { + return `This action returns a #${id} soloProject`; + } + + update(id: number, _updateSoloProjectDto: UpdateSoloProjectDto) { + return `This action updates a #${id} soloProject`; + } + + remove(id: number) { + return `This action removes a #${id} soloProject`; + } +}