diff --git a/CHANGELOG.md b/CHANGELOG.md index 52488dd6..e162f447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ Another example [here](https://co-pilot.dev/changelog) - Add voyage project submission form seed ([#131](https://github.com/chingu-x/chingu-dashboard-be/pull/131)) - Add voyage project submission controller, service, e2e tests, responses seed ([#133](https://github.com/chingu-x/chingu-dashboard-be/pull/133)) + +- Add new endpoints to select/reset team project ideation ([#136](https://github.com/chingu-x/chingu-dashboard-be/pull/136)) + ### Changed - Update docker compose and scripts in package.json to include a test database container and remove usage of .env.dev to avoid confusion ([#100](https://github.com/chingu-x/chingu-dashboard-be/pull/100)) diff --git a/prisma/migrations/20240404195203_add_is_selected_value_to_projectideas/migration.sql b/prisma/migrations/20240404195203_add_is_selected_value_to_projectideas/migration.sql new file mode 100644 index 00000000..902517b0 --- /dev/null +++ b/prisma/migrations/20240404195203_add_is_selected_value_to_projectideas/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ProjectIdea" ADD COLUMN "isSelected" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 090cff3a..8a5c5197 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -292,6 +292,7 @@ model ProjectIdea { title String description String vision String + isSelected Boolean @default(false) createdAt DateTime @default(now()) @db.Timestamptz() updatedAt DateTime @updatedAt diff --git a/src/ideations/entities/ideation.entity.ts b/src/ideations/entities/ideation.entity.ts index a39e1ec8..458499a5 100644 --- a/src/ideations/entities/ideation.entity.ts +++ b/src/ideations/entities/ideation.entity.ts @@ -20,6 +20,9 @@ export class Ideation implements ProjectIdea { }) vision: string; + @ApiProperty({ example: "false" }) + isSelected: boolean; + @ApiProperty({ example: "2024-01-08T00:00:00.000Z" }) createdAt: Date; diff --git a/src/ideations/ideations.controller.ts b/src/ideations/ideations.controller.ts index 8a302405..4abb82ee 100644 --- a/src/ideations/ideations.controller.ts +++ b/src/ideations/ideations.controller.ts @@ -13,7 +13,7 @@ import { import { IdeationsService } from "./ideations.service"; import { CreateIdeationDto } from "./dto/create-ideation.dto"; import { UpdateIdeationDto } from "./dto/update-ideation.dto"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiOperation, ApiResponse, ApiTags, ApiParam } from "@nestjs/swagger"; import { BadRequestErrorResponse, ConflictErrorResponse, @@ -28,7 +28,13 @@ import { import { AppPermissions } from "../auth/auth.permissions"; import { Permissions } from "../global/decorators/permissions.decorator"; import { CustomRequest } from "../global/types/CustomRequest"; +import { Roles } from "../global/decorators/roles.decorator"; +import { AppRoles } from "../auth/auth.roles"; +// +//OWN_TEAM permission requires a :teamId param, but does not correctly enforce same team rule +//:ideationId param is not currently used to check if user has permission to change it +// @Controller() @ApiTags("Voyage - Ideations") export class IdeationsController { @@ -237,4 +243,91 @@ export class IdeationsController { ideationId, ); } + + @ApiOperation({ + summary: "Selects one ideation as team project for voyage.", + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "User is not authorized to perform this action.", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Ideation with given ID does not exist.", + type: NotFoundErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "An ideation has already been selected.", + type: ConflictErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: "Successfully selected ideation.", + type: IdeationResponse, + }) + @ApiParam({ + name: "teamId", + description: "voyage team Id", + type: "Integer", + required: true, + example: 1, + }) + @ApiParam({ + name: "ideationId", + description: "ideation Id", + type: "Integer", + required: true, + example: 1, + }) + @Permissions(AppPermissions.OWN_TEAM) + @Post("/:ideationId/select") + setIdeationSelection( + @Request() req: CustomRequest, + @Param("teamId", ParseIntPipe) teamId: number, + @Param("ideationId", ParseIntPipe) ideationId: number, + ) { + return this.ideationsService.setIdeationSelection( + req, + teamId, + ideationId, + ); + } + + @ApiOperation({ + summary: "Clears the current ideation selection for team.", + description: "Admin only allowed.", + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "User is not authorized to perform this action.", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: "Ideation for this team does not exist.", + type: NotFoundErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: "Successfully cleared ideation selection.", + type: IdeationResponse, + }) + @ApiParam({ + name: "teamId", + description: "voyage team Id", + type: "Integer", + required: true, + example: 1, + }) + @Permissions(AppPermissions.OWN_TEAM) + @Roles(AppRoles.Admin) + @Post("/reset-selection") + resetIdeationSelection( + @Request() req: CustomRequest, + @Param("teamId", ParseIntPipe) teamId: number, + ) { + return this.ideationsService.resetIdeationSelection(req, teamId); + } } diff --git a/src/ideations/ideations.response.ts b/src/ideations/ideations.response.ts index 70d2919f..abbb75f1 100644 --- a/src/ideations/ideations.response.ts +++ b/src/ideations/ideations.response.ts @@ -66,6 +66,9 @@ export class IdeationResponse { }) vision: string; + @ApiProperty({ example: "true" }) + isSelected: boolean; + @ApiProperty({ example: "2024-01-08T00:00:00.000Z" }) createdAt: Date; diff --git a/src/ideations/ideations.service.ts b/src/ideations/ideations.service.ts index 2fa4e827..00179e1e 100644 --- a/src/ideations/ideations.service.ts +++ b/src/ideations/ideations.service.ts @@ -107,6 +107,7 @@ export class IdeationsService { title: true, description: true, vision: true, + isSelected: true, createdAt: true, updatedAt: true, contributedBy: { @@ -258,6 +259,78 @@ export class IdeationsService { } } + async getSelectedIdeation(teamId: number) { + //get all team member ids + const teamMemberIds = await this.prisma.voyageTeamMember.findMany({ + where: { + voyageTeamId: teamId, + }, + select: { + id: true, + }, + }); + //extract ids into array + const idArray = teamMemberIds.map((member) => member.id); + //search all team member project ideas for isSelected === true + return await this.prisma.projectIdea.findFirst({ + where: { + voyageTeamMemberId: { + in: idArray, + }, + isSelected: true, + }, + }); + } + + async setIdeationSelection( + req: CustomRequest, + teamId: number, + ideationId: number, + ) { + try { + const currentSelection = await this.getSelectedIdeation(teamId); + if (currentSelection) { + throw new ConflictException( + `Ideation already selected for team ${teamId}`, + ); + } + return await this.prisma.projectIdea.update({ + where: { + id: ideationId, + }, + data: { + isSelected: true, + }, + }); + } catch (e) { + if (e.code === "P2025") { + throw new NotFoundException(e.meta.cause); + } + throw e; + } + } + + async resetIdeationSelection(req: CustomRequest, teamId: number) { + try { + //find current selection, if any + const selection = await this.getSelectedIdeation(teamId); + if (selection) { + return await this.prisma.projectIdea.update({ + where: { + id: selection.id, + }, + data: { + isSelected: false, + }, + }); + } + } catch (e) { + throw e; + } + //default + throw new NotFoundException(`no ideation found for team ${teamId}`); + } + // TODO: this function seems to be unused but might be useful for making new permission guard private async getTeamMemberIdByIdeation(ideationId: number) { const voyageTeamMemberId = await this.prisma.projectIdea.findFirst({ diff --git a/test/ideations.e2e-spec.ts b/test/ideations.e2e-spec.ts index d1e5abee..53f68993 100644 --- a/test/ideations.e2e-spec.ts +++ b/test/ideations.e2e-spec.ts @@ -44,6 +44,7 @@ describe("IdeationsController (e2e)", () => { title: expect.any(String), description: expect.any(String), vision: expect.any(String), + isSelected: expect.any(Boolean), createdAt: expect.any(String), updatedAt: expect.any(String), }; @@ -55,6 +56,24 @@ describe("IdeationsController (e2e)", () => { let newVoyageTeam: VoyageTeam; let newVoyageTeamMember: VoyageTeamMember; let newUserAccessToken: string; + let adminAccessToken: string; + + async function loginAdmin() { + await request(app.getHttpServer()).post("/auth/logout"); + await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: "jessica.williamson@gmail.com", + password: "password", + }) + .expect(200) + .then((res) => { + adminAccessToken = extractResCookieValueByKey( + res.headers["set-cookie"], + "access_token", + ); + }); + } async function truncate() { await prisma.$executeRawUnsafe( @@ -72,28 +91,14 @@ describe("IdeationsController (e2e)", () => { await prisma.$executeRawUnsafe( `TRUNCATE TABLE "Voyage" RESTART IDENTITY CASCADE;`, ); - await prisma.$executeRawUnsafe( - `TRUNCATE TABLE "User" RESTART IDENTITY CASCADE;`, - ); + // await prisma.$executeRawUnsafe( + // `TRUNCATE TABLE "User" RESTART IDENTITY CASCADE;`, + // ); } async function reseed() { await truncate(); - newUser = await prisma.user.create({ - data: { - firstName: "Test", - lastName: "User", - githubId: "testuser-github", - discordId: "testuser-discord", - email: "testuser@outlook.com", - password: await hashPassword("password"), - avatar: "https://gravatar.com/avatar/3bfaef00e02a22f99e17c66e7a9fdd31?s=400&d=monsterid&r=x", - timezone: "America/Los_Angeles", - comment: "Member seems to be inactive", - countryCode: "US", - }, - }); newVoyage = await prisma.voyage.create({ data: { number: "47", @@ -181,10 +186,33 @@ describe("IdeationsController (e2e)", () => { prisma = moduleFixture.get(PrismaService); app.useGlobalPipes(new ValidationPipe()); await app.init(); + try { + newUser = await prisma.user.create({ + data: { + firstName: "Test", + lastName: "User", + githubId: "testuser-github", + discordId: "testuser-discord", + email: "testuser@outlook.com", + password: await hashPassword("password"), + avatar: "https://gravatar.com/avatar/3bfaef00e02a22f99e17c66e7a9fdd31?s=400&d=monsterid&r=x", + timezone: "America/Los_Angeles", + comment: "Member seems to be inactive", + countryCode: "US", + }, + }); + } catch (e) { + newUser = await prisma.user.findFirst({ + where: { + firstName: "Test", + lastName: "User", + }, + }); + } }); afterAll(async () => { - await truncate(); + await reseed(); await prisma.$disconnect(); await app.close(); @@ -331,4 +359,111 @@ describe("IdeationsController (e2e)", () => { expect(res.body).toEqual(ideationVoteShape); }); }); + + describe("POST /voyages/teams/:teamId/ideations/:ideationId/select - selects project ideation for voyage", () => { + it("should return 201 if successfully selecting ideation", async () => { + const teamId: number = 1; + const ideationId: number = 1; + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/${ideationId}/select`) + .set("Authorization", `Bearer ${newUserAccessToken}`) + .expect(201); + }); + + it("should return 409 if an ideation is already selected", async () => { + const teamId: number = 1; + const ideationId: number = 1; + + try { + await prisma.projectIdea.update({ + where: { + id: ideationId, + }, + data: { + isSelected: true, + }, + }); + } catch (e) { + throw e; + } + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/${ideationId}/select`) + .set("Authorization", `Bearer ${newUserAccessToken}`) + .expect(409); + }); + + it("should return 404 if ideation is not found", async () => { + const teamId: number = 1; + const ideationId: number = 99; + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/${ideationId}/select`) + .set("Authorization", `Bearer ${newUserAccessToken}`) + .expect(404); + }); + + it("should return 401 unauthorized if not logged in", async () => { + const teamId: number = 1; + const ideationId: number = 1; + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/${ideationId}/select`) + .set("Authorization", `Bearer ${undefined}`) + .expect(401); + }); + }); + + describe("POST /voyages/teams/:teamId/ideations/reset-selection - clears current team ideation selection", () => { + it("should return 201 if selection successfully cleared", async () => { + const teamId: number = 1; + const ideationId: number = 1; + await loginAdmin(); + + try { + await prisma.projectIdea.update({ + where: { + id: ideationId, + }, + data: { + isSelected: true, + }, + }); + } catch (e) { + throw e; + } + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/reset-selection`) + .set("Authorization", `Bearer ${adminAccessToken}`) + .expect(201); + }); + + it("should return 403 if not logged in as admin", async () => { + const teamId: number = 99; + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/reset-selection`) + .set("Authorization", `Bearer ${newUserAccessToken}`) + .expect(403); + }); + + it("should return 404 if team id is not found", async () => { + const teamId: number = 99; + await loginAdmin(); + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/reset-selection`) + .set("Authorization", `Bearer ${adminAccessToken}`) + .expect(404); + }); + + it("should return 401 unauthorized if not logged in", async () => { + const teamId: number = 1; + + return request(app.getHttpServer()) + .post(`/voyages/teams/${teamId}/ideations/reset-selection`) + .set("Authorization", `Bearer ${undefined}`) + .expect(401); + }); + }); });