diff --git a/CHANGELOG.md b/CHANGELOG.md index 619f6055..67fee84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Another example [here](https://co-pilot.dev/changelog) - Add new endpoint to revoke refresh token ([#116](https://github.com/chingu-x/chingu-dashboard-be/pull/116)) - Add meetingId to sprints/teams endpoint (([#119](https://github.com/chingu-x/chingu-dashboard-be/pull/119))) - Add new endpoint to select tech stack items ([#125](https://github.com/chingu-x/chingu-dashboard-be/pull/125)) +- Add check in form response table, seed data, POST endpoint for submitting check in form ([#126](https://github.com/chingu-x/chingu-dashboard-be/pull/126)) - Add multiple device support ([#128](https://github.com/chingu-x/chingu-dashboard-be/pull/128)) ### Changed diff --git a/README.md b/README.md index 64342750..733f17a6 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,11 @@ $ yarn docker:down $ yarn docker:clean ``` +## Custom Pipes +### FormInputValidationPipe +For use with form responses, this pipe validates that the responses or response (array) values include a questionId and at least one input value of any type. + +Example: `@Body(new FormInputValidationPipe())` ## Custom Decorators diff --git a/package.json b/package.json index cfb146f8..a1dba961 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.11", - "@prisma/client": "^5.3.1", + "@prisma/client": "^5.10.2", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/prisma/migrations/20240401064842_add_checkin_form_tables_and_fields/migration.sql b/prisma/migrations/20240401064842_add_checkin_form_tables_and_fields/migration.sql new file mode 100644 index 00000000..481b9f89 --- /dev/null +++ b/prisma/migrations/20240401064842_add_checkin_form_tables_and_fields/migration.sql @@ -0,0 +1,31 @@ +-- DropIndex +DROP INDEX "Response_questionId_responseGroupId_key"; + +-- CreateTable +CREATE TABLE "FormResponseCheckin" ( + "id" SERIAL NOT NULL, + "voyageTeamMemberId" INTEGER NOT NULL, + "sprintId" INTEGER NOT NULL, + "adminComments" TEXT, + "feedbackSent" BOOLEAN NOT NULL DEFAULT false, + "responseGroupId" INTEGER NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FormResponseCheckin_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FormResponseCheckin_responseGroupId_key" ON "FormResponseCheckin"("responseGroupId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FormResponseCheckin_voyageTeamMemberId_sprintId_key" ON "FormResponseCheckin"("voyageTeamMemberId", "sprintId"); + +-- AddForeignKey +ALTER TABLE "FormResponseCheckin" ADD CONSTRAINT "FormResponseCheckin_voyageTeamMemberId_fkey" FOREIGN KEY ("voyageTeamMemberId") REFERENCES "VoyageTeamMember"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormResponseCheckin" ADD CONSTRAINT "FormResponseCheckin_sprintId_fkey" FOREIGN KEY ("sprintId") REFERENCES "Sprint"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FormResponseCheckin" ADD CONSTRAINT "FormResponseCheckin_responseGroupId_fkey" FOREIGN KEY ("responseGroupId") REFERENCES "ResponseGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2088d8ea..c066f2fd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -139,6 +139,7 @@ model Sprint { updatedAt DateTime @updatedAt teamMeetings TeamMeeting[] + checkinForms FormResponseCheckin[] @@unique(fields: [voyageId, number], name: "voyageSprintNumber") } @@ -221,6 +222,7 @@ model VoyageTeamMember { projectIdeaVotes ProjectIdeaVote[] teamResources TeamResource[] projectFeatures ProjectFeature[] + checkinForms FormResponseCheckin[] @@unique(fields: [userId, voyageTeamId], name: "userVoyageId") } @@ -532,8 +534,6 @@ model Response { createdAt DateTime @default(now()) @db.Timestamptz() updatedAt DateTime @updatedAt - - @@unique(fields: [questionId, responseGroupId], name: "questionResponseGroup") } model ResponseGroup { @@ -546,20 +546,37 @@ model ResponseGroup { soloProject SoloProject? voyageApplication VoyageApplication? formResponseMeeting FormResponseMeeting? + formResponseCheckin FormResponseCheckin? } model FormResponseMeeting { - id Int @id @default(autoincrement()) - formId Int - form Form @relation(fields: [formId], references: [id]) - meetingId Int - meeting TeamMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + formId Int + form Form @relation(fields: [formId], references: [id]) + meetingId Int + meeting TeamMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade) + responseGroupId Int? @unique + responseGroup ResponseGroup? @relation(fields: [responseGroupId], references: [id], onDelete: Restrict) createdAt DateTime @default(now()) @db.Timestamptz() updatedAt DateTime @updatedAt - responseGroupId Int? @unique - responseGroup ResponseGroup? @relation(fields: [responseGroupId], references: [id], onDelete: Restrict) - @@unique(fields: [formId, meetingId], name: "meetingFormId") } + +model FormResponseCheckin { + id Int @id @default(autoincrement()) + voyageTeamMemberId Int + voyageTeamMember VoyageTeamMember @relation(fields: [voyageTeamMemberId], references: [id], onDelete: Cascade) + sprintId Int + sprint Sprint @relation(fields: [sprintId], references: [id], onDelete: Cascade) + adminComments String? + feedbackSent Boolean @default(false) + responseGroupId Int @unique + responseGroup ResponseGroup @relation(fields: [responseGroupId], references: [id], onDelete: Restrict) + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt + + @@unique(fields: [voyageTeamMemberId, sprintId], name: "voyageTeamMemberSprintId") +} diff --git a/prisma/seed/checkinform-responses.ts b/prisma/seed/checkinform-responses.ts new file mode 100644 index 00000000..f5abd5f7 --- /dev/null +++ b/prisma/seed/checkinform-responses.ts @@ -0,0 +1,234 @@ +import { formSelect } from "../../src/forms/forms.service"; +import { prisma } from "./prisma-client"; + +// TODO: move these to a helper function file +const getRandomOptionId = async ( + optionGroupId: number, + numberOfChoices: number, +) => { + const choicesArray = []; + const choices = await prisma.optionChoice.findMany({ + where: { + optionGroupId, + }, + }); + while (choicesArray.length < numberOfChoices) { + const choice = choices[Math.floor(Math.random() * choices.length)].id; + if (!choicesArray.includes(choice)) { + choicesArray.push(choice); + } + } + return choicesArray; +}; + +const getTeamMembers = async (teamMemberId: number) => { + const team = await prisma.voyageTeam.findFirst({ + where: { + voyageTeamMembers: { + some: { + id: teamMemberId, + }, + }, + }, + select: { + voyageTeamMembers: { + select: { + member: { + select: { + discordId: true, + }, + }, + }, + }, + }, + }); + return team.voyageTeamMembers.map((m) => m.member.discordId); +}; + +const populateQuestionResponses = async ( + question: any, + teamMemberId: number, + responseGroupId: number, +) => { + const data: any = { + questionId: question.id, + responseGroupId: responseGroupId, + }; + switch (question.inputType.name) { + case "text": { + await prisma.response.create({ + data: { + ...data, + text: `Text response for Question id ${question.id}.`, + }, + }); + break; + } + case "radio": { + const radioChoices = await getRandomOptionId( + question.optionGroupId, + 1, + ); + await prisma.response.create({ + data: { + ...data, + optionChoiceId: radioChoices[0], + }, + }); + break; + } + case "radioGroup": { + // get all subquestions + const subQuestions = await prisma.question.findMany({ + where: { + parentQuestionId: question.id, + }, + select: { + id: true, + inputType: { + select: { + name: true, + }, + }, + optionGroupId: true, + }, + }); + // popuate all subquestions + await Promise.all( + subQuestions.map((subq) => { + // assign subquestion optionGroupId to be same as parent, as it's null for subquestions + subq.optionGroupId = question.optionGroupId; + populateQuestionResponses( + subq, + teamMemberId, + responseGroupId, + ); + }), + ); + break; + } + case "checkbox": { + // check 2 randomly, + // 2 response entries as scalar list or optional list is not supported for relations + const checkboxChoices = await getRandomOptionId( + question.optionGroupId, + 2, + ); + + await prisma.response.createMany({ + data: [ + { + ...data, + optionChoiceId: checkboxChoices[0], + }, + { + ...data, + optionChoiceId: checkboxChoices[1], + }, + ], + }); + break; + } + case "teamMembersCheckbox": { + const selectedTeamMembers = await getTeamMembers(teamMemberId); + await prisma.response.create({ + data: { + ...data, + text: selectedTeamMembers + .filter((m) => m !== null) + .join(";"), + }, + }); + break; + } + case "number": { + await prisma.response.create({ + data: { + ...data, + numeric: Math.floor(Math.random() * 100), + }, + }); + break; + } + case "yesNo": { + await prisma.response.create({ + data: { + ...data, + boolean: Math.random() > 0.5, + }, + }); + break; + } + default: { + throw new Error("Prisma seed: Unexpected question type"); + } + } +}; + +export const populateCheckinFormResponse = async () => { + const teamMemberId = 1; + const teamMember = await prisma.voyageTeamMember.findUnique({ + where: { + id: teamMemberId, + }, + select: { + id: true, + voyageTeam: { + select: { + voyage: { + select: { + sprints: true, + }, + }, + }, + }, + }, + }); + + // get questions + const checkinForm = await prisma.form.findUnique({ + where: { + title: "Sprint Check-in", + }, + select: formSelect, + }); + + const questions = await prisma.question.findMany({ + where: { + formId: checkinForm.id, + parentQuestionId: null, + }, + select: { + id: true, + order: true, + inputType: { + select: { + name: true, + }, + }, + optionGroupId: true, + parentQuestionId: true, + }, + orderBy: { + order: "asc", + }, + }); + + const responseGroup = await prisma.responseGroup.create({ + data: {}, + }); + + await Promise.all( + questions.map((question) => { + populateQuestionResponses(question, teamMemberId, responseGroup.id); + }), + ); + + await prisma.formResponseCheckin.create({ + data: { + voyageTeamMemberId: teamMember.id, + sprintId: teamMember.voyageTeam.voyage.sprints[0].id, + responseGroupId: responseGroup.id, + }, + }); +}; diff --git a/prisma/seed/checklist.ts b/prisma/seed/checklist.ts index dc9b8c95..be5e864c 100644 --- a/prisma/seed/checklist.ts +++ b/prisma/seed/checklist.ts @@ -1,5 +1,4 @@ -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateChecklists = async () => { await prisma.checklistItem.createMany({ diff --git a/prisma/seed/forms/checkinform.ts b/prisma/seed/forms/checkinform.ts index c69ec5a4..2d86f06c 100644 --- a/prisma/seed/forms/checkinform.ts +++ b/prisma/seed/forms/checkinform.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { prisma } from "../prisma-client"; export const populateCheckinForm = async () => { // Sprint - checkin form diff --git a/prisma/seed/forms/index.ts b/prisma/seed/forms/index.ts index 449b0400..ffabd09e 100644 --- a/prisma/seed/forms/index.ts +++ b/prisma/seed/forms/index.ts @@ -1,9 +1,7 @@ -import { PrismaClient } from "@prisma/client"; import { populateCheckinForm } from "./checkinform"; import { populateSoloProjectForm } from "./solo-project"; import { populateVoyageApplicationForm } from "./voyage-app"; - -const prisma = new PrismaClient(); +import { prisma } from "../prisma-client"; export const populateFormsAndResponses = async () => { // test option choices for Voyage Application form diff --git a/prisma/seed/forms/solo-project.ts b/prisma/seed/forms/solo-project.ts index 150db6a1..e59fd9a7 100644 --- a/prisma/seed/forms/solo-project.ts +++ b/prisma/seed/forms/solo-project.ts @@ -1,8 +1,6 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); - // TODO: this is incomplete. just added some fields for testing +import { prisma } from "../prisma-client"; + export const populateSoloProjectForm = async () => { await prisma.form.create({ data: { diff --git a/prisma/seed/forms/voyage-app.ts b/prisma/seed/forms/voyage-app.ts index a12ac1de..2849344d 100644 --- a/prisma/seed/forms/voyage-app.ts +++ b/prisma/seed/forms/voyage-app.ts @@ -1,8 +1,6 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); - // TODO: this is incomplete. just added some fields for testing +import { prisma } from "../prisma-client"; + export const populateVoyageApplicationForm = async () => { await prisma.form.create({ data: { diff --git a/prisma/seed/index.ts b/prisma/seed/index.ts index 35515357..7538774f 100644 --- a/prisma/seed/index.ts +++ b/prisma/seed/index.ts @@ -1,7 +1,4 @@ import { seed } from "./seed"; -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); (async function () { try { @@ -9,7 +6,5 @@ const prisma = new PrismaClient(); } catch (e) { console.error(e); process.exit(1); - } finally { - await prisma.$disconnect(); } })(); diff --git a/prisma/seed/meetings.ts b/prisma/seed/meetings.ts index 478af630..ac504302 100644 --- a/prisma/seed/meetings.ts +++ b/prisma/seed/meetings.ts @@ -1,6 +1,5 @@ import { getRandomDateDuringSprint, getSprintId } from "./utils"; -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateMeetings = async () => { // connect teamMeetings and form id diff --git a/prisma/seed/prisma-client.ts b/prisma/seed/prisma-client.ts new file mode 100644 index 00000000..901f3a0d --- /dev/null +++ b/prisma/seed/prisma-client.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); diff --git a/prisma/seed/resources-project-ideas.ts b/prisma/seed/resources-project-ideas.ts index 7c2b69bf..a46f9312 100644 --- a/prisma/seed/resources-project-ideas.ts +++ b/prisma/seed/resources-project-ideas.ts @@ -1,7 +1,6 @@ -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); - // VoyageTeams, VoyageMembers, Tech Stack, sprints +import { prisma } from "./prisma-client"; + export const populateTeamResourcesAndProjectIdeas = async () => { const voyageTeamMembers = await prisma.voyageTeamMember.findMany({}); diff --git a/prisma/seed/seed.ts b/prisma/seed/seed.ts index 33b9b057..3617614e 100644 --- a/prisma/seed/seed.ts +++ b/prisma/seed/seed.ts @@ -9,9 +9,8 @@ import { populateMeetings } from "./meetings"; import { populateSoloProjects } from "./solo-project"; import { populateVoyageApplications } from "./voyage-app"; import { populateChecklists } from "./checklist"; -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { populateCheckinFormResponse } from "./checkinform-responses"; +import { prisma } from "./prisma-client"; export const deleteAllTables = async () => { const tablenames = await prisma.$queryRaw< @@ -47,5 +46,8 @@ export const seed = async () => { await populateSoloProjects(); await populateVoyageApplications(); await populateChecklists(); + await populateCheckinFormResponse(); console.log("===\n🌱 Database seeding completed.\n==="); + + prisma.$disconnect(); }; diff --git a/prisma/seed/solo-project.ts b/prisma/seed/solo-project.ts index b9deac3d..4aa0f524 100644 --- a/prisma/seed/solo-project.ts +++ b/prisma/seed/solo-project.ts @@ -1,7 +1,5 @@ -import { PrismaClient } from "@prisma/client"; import { passedSampleFeedback } from "./data/text/solo-project-feedback"; - -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateSoloProjects = async () => { // solo project status diff --git a/prisma/seed/sprints.ts b/prisma/seed/sprints.ts index 2cfb888e..ad449ea5 100644 --- a/prisma/seed/sprints.ts +++ b/prisma/seed/sprints.ts @@ -1,6 +1,5 @@ -import { PrismaClient } from "@prisma/client"; import { addDays } from "./utils"; -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateSprints = async () => { const voyages = await prisma.voyage.findMany({}); diff --git a/prisma/seed/tables.ts b/prisma/seed/tables.ts index 72c70b69..39eb64d7 100644 --- a/prisma/seed/tables.ts +++ b/prisma/seed/tables.ts @@ -1,6 +1,3 @@ -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); - import Genders from "./data/genders"; import Tiers from "./data/tiers"; import VoyageRoles from "./data/voyage-roles"; @@ -13,6 +10,7 @@ import FormTypes from "./data/form-types"; import InputTypes from "./data/input-types"; import OptionGroups from "./data/option-groups"; import Roles from "./data/roles"; +import { prisma } from "./prisma-client"; const populateTable = async (tableName: string, data) => { await prisma[tableName].createMany({ diff --git a/prisma/seed/users.ts b/prisma/seed/users.ts index efedd0bd..a16d29f5 100644 --- a/prisma/seed/users.ts +++ b/prisma/seed/users.ts @@ -1,7 +1,5 @@ -import { PrismaClient } from "@prisma/client"; import { hashPassword } from "../../src/utils/auth"; - -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; const getRoleId = (roles, name) => { return roles.filter((role) => role.name == name)[0].id; diff --git a/prisma/seed/utils/index.ts b/prisma/seed/utils/index.ts index 6e426ead..9815ea02 100644 --- a/prisma/seed/utils/index.ts +++ b/prisma/seed/utils/index.ts @@ -1,5 +1,4 @@ -import { PrismaClient } from "@prisma/client"; -const prisma = new PrismaClient(); +import { prisma } from "../prisma-client"; export const addDays = (date, days) => { const newDate = new Date(date); diff --git a/prisma/seed/voyage-app.ts b/prisma/seed/voyage-app.ts index 7a7bdfd7..ed12a44b 100644 --- a/prisma/seed/voyage-app.ts +++ b/prisma/seed/voyage-app.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateVoyageApplications = async () => { const users = await prisma.user.findMany({}); diff --git a/prisma/seed/voyage-teams.ts b/prisma/seed/voyage-teams.ts index 53085b0e..032334ae 100644 --- a/prisma/seed/voyage-teams.ts +++ b/prisma/seed/voyage-teams.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateVoyageTeams = async () => { const users = await prisma.user.findMany({}); diff --git a/prisma/seed/voyage.ts b/prisma/seed/voyage.ts index bf4ec4ad..b405de6f 100644 --- a/prisma/seed/voyage.ts +++ b/prisma/seed/voyage.ts @@ -1,6 +1,4 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); +import { prisma } from "./prisma-client"; export const populateVoyages = async () => { await prisma.voyage.create({ diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index 9f5ec4dd..10341b54 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +import { Prisma } from "@prisma/client"; export const formSelect = { id: true, @@ -12,6 +13,9 @@ export const formSelect = { title: true, description: true, questions: { + orderBy: { + order: "asc", + }, select: { id: true, order: true, @@ -37,7 +41,7 @@ export const formSelect = { }, }, }, -}; +} as Prisma.FormSelect; @Injectable() export class FormsService { diff --git a/src/global/constants/formTitles.ts b/src/global/constants/formTitles.ts new file mode 100644 index 00000000..5f7b5da4 --- /dev/null +++ b/src/global/constants/formTitles.ts @@ -0,0 +1,7 @@ +export const FormTitles = { + sprintRetroAndReview: "Retrospective & Review", + sprintPlanning: "Sprint Planning", + sprintCheckin: "Sprint Check-in", + soloProjectSubmission: "Solo Project Submission Form", + voyageApplication: "Voyage Application Form", +}; diff --git a/src/global/global.service.ts b/src/global/global.service.ts index 4bba1abe..851378df 100644 --- a/src/global/global.service.ts +++ b/src/global/global.service.ts @@ -5,6 +5,7 @@ import { } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { CustomRequest } from "./types/CustomRequest"; +import { FormResponseDto } from "./dtos/FormResponse.dto"; @Injectable() export class GlobalService { @@ -42,4 +43,89 @@ export class GlobalService { } return teamMemberId; } + + // ======= FORM responses helper functions ===== + + // pass in any form response DTO, this will extract responses from the DTO, + // and parse it into and array for prisma bulk insert/update + public responseDtoToArray = (responses: any) => { + const responsesArray = []; + const responseIndex = ["response", "responses"]; + for (const index in responses) { + if (responseIndex.includes(index)) { + responses[index].forEach((v: FormResponseDto) => { + responsesArray.push({ + questionId: v.questionId, + ...(v.text ? { text: v.text } : { text: null }), + ...(v.numeric + ? { numeric: v.numeric } + : { numeric: null }), + ...(v.boolean + ? { boolean: v.boolean } + : { boolean: null }), + ...(v.optionChoiceId + ? { optionChoiceId: v.optionChoiceId } + : { optionChoiceId: null }), + }); + }); + } + } + return responsesArray; + }; + + // Checks that questions submitted for update match the form questions + // using the formId + public checkQuestionsInFormById = async ( + formId: number, + responsesArray: FormResponseDto[], + ) => { + const form = await this.prisma.form.findUnique({ + where: { id: formId }, + select: { + title: true, + questions: { + select: { + id: true, + }, + }, + }, + }); + + const questionIds = form.questions.flatMap((question) => question.id); + + responsesArray.forEach((response) => { + if (questionIds.indexOf(response.questionId) === -1) + throw new BadRequestException( + `Question Id ${response.questionId} is not in form ${form.title} (id: ${formId})`, + ); + }); + }; + + // Checks that questions submitted for update match the form questions + // using the form title + public checkQuestionsInFormByTitle = async ( + title: string, + responsesArray: FormResponseDto[], + ) => { + const form = await this.prisma.form.findUnique({ + where: { title }, + select: { + id: true, + questions: { + select: { + id: true, + }, + }, + }, + }); + + const questionIds = form.questions.flatMap((question) => question.id); + + responsesArray.forEach((response) => { + if (questionIds.indexOf(response.questionId) === -1) + throw new BadRequestException( + `Question Id ${response.questionId} is not in form ${title} (id: ${form.id})`, + ); + }); + }; } diff --git a/src/global/types/CustomRequest.ts b/src/global/types/CustomRequest.ts index d4609430..4bb22495 100644 --- a/src/global/types/CustomRequest.ts +++ b/src/global/types/CustomRequest.ts @@ -1,3 +1,5 @@ +import { Request } from "express"; + type VoyageTeam = { teamId: number; memberId: number; diff --git a/src/pipes/form-input-validation.ts b/src/pipes/form-input-validation.ts index dd1068f6..d0440e18 100644 --- a/src/pipes/form-input-validation.ts +++ b/src/pipes/form-input-validation.ts @@ -13,6 +13,11 @@ export class FormInputValidationPipe implements PipeTransform { `'responses' is not an array`, ); value[index].forEach((v: FormResponseDto) => { + if (!v.questionId) { + throw new BadRequestException( + `Question id is missing for one of the responses.`, + ); + } if ( !v.text && !v.numeric && diff --git a/src/sprints/dto/create-checkin-form-response.dto.ts b/src/sprints/dto/create-checkin-form-response.dto.ts deleted file mode 100644 index 7eb067a4..00000000 --- a/src/sprints/dto/create-checkin-form-response.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { FormResponseDto } from "../../global/dtos/FormResponse.dto"; - -export class CreateCheckinFormResponseDto { - responses: FormResponseDto[]; -} diff --git a/src/sprints/dto/create-checkin-form.dto.ts b/src/sprints/dto/create-checkin-form.dto.ts new file mode 100644 index 00000000..f8c0e374 --- /dev/null +++ b/src/sprints/dto/create-checkin-form.dto.ts @@ -0,0 +1,29 @@ +import { FormResponseDto } from "../../global/dtos/FormResponse.dto"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty } from "class-validator"; + +export class CreateCheckinFormDto { + @ApiProperty({ + description: "voyage team member id, not userId", + example: 1, + }) + @IsNotEmpty() + voyageTeamMemberId: number; + + @ApiProperty({ + description: "sprint id, not sprint number", + example: 1, + }) + @IsNotEmpty() + sprintId: number; + + @ApiProperty({ + description: "An array of form responses", + example: [ + { questionId: 11, text: "All" }, + { questionId: 12, text: "Deploy app" }, + ], + }) + @IsNotEmpty() + responses: FormResponseDto[]; +} diff --git a/src/sprints/sprints.controller.ts b/src/sprints/sprints.controller.ts index 9db3a324..a5f08364 100644 --- a/src/sprints/sprints.controller.ts +++ b/src/sprints/sprints.controller.ts @@ -20,6 +20,7 @@ import { FormInputValidationPipe } from "../pipes/form-input-validation"; import { UpdateMeetingFormResponseDto } from "./dto/update-meeting-form-response.dto"; import { AgendaResponse, + CheckinSubmissionResponse, MeetingFormResponse, MeetingResponse, MeetingResponseWithSprintAndAgenda, @@ -29,8 +30,10 @@ import { BadRequestErrorResponse, ConflictErrorResponse, NotFoundErrorResponse, + UnauthorizedErrorResponse, } from "../global/responses/errors"; import { FormResponse, ResponseResponse } from "../forms/forms.response"; +import { CreateCheckinFormDto } from "./dto/create-checkin-form.dto"; @Controller() @ApiTags("Voyage - Sprints") @@ -418,4 +421,64 @@ export class SprintsController { updateMeetingFormResponse, ); } + + @ApiOperation({ + summary: "Submit end of sprint check in form", + description: + "Inputs (choiceId, text, boolean, number are all optional),
" + + "depends on the question type, but AT LEAST ONE of them must be present,
" + + "questionId is required

" + + "responses example" + + "" + + JSON.stringify([ + { + questionId: 1, + text: "All", + }, + { + questionId: 2, + optionChoiceId: 1, + }, + { + questionId: 3, + boolean: true, + }, + { + questionId: 4, + numeric: 352, + }, + ]) + + "
", + }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: "The check in form has been successfully Submitted.", + type: CheckinSubmissionResponse, + isArray: true, + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: + "request body data error, e.g. missing question id, missing response inputs", + type: BadRequestErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: "User is not logged in", + type: UnauthorizedErrorResponse, + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: "User has already submitted a check in for that sprint.", + type: ConflictErrorResponse, + }) + @Post("check-in") + addCheckinFormResponse( + @Body(new FormInputValidationPipe()) + createCheckinFormResponse: CreateCheckinFormDto, + ) { + return this.sprintsService.addCheckinFormResponse( + createCheckinFormResponse, + ); + } } diff --git a/src/sprints/sprints.response.ts b/src/sprints/sprints.response.ts index 3ed4f0b7..dabf6069 100644 --- a/src/sprints/sprints.response.ts +++ b/src/sprints/sprints.response.ts @@ -147,3 +147,20 @@ export class MeetingFormResponse { @ApiProperty({ example: 5 }) responseGroupId: number; } + +export class CheckinSubmissionResponse { + @ApiProperty({ example: 1 }) + id: number; + + @ApiProperty({ example: 1 }) + voyageTeamMemberId: number; + + @ApiProperty({ example: 1 }) + sprintId: number; + + @ApiProperty({ example: 1 }) + responseGroupId: 5; + + @ApiProperty({ example: "2023-11-30T06:47:11.694Z" }) + createdAt: Date; +} diff --git a/src/sprints/sprints.service.ts b/src/sprints/sprints.service.ts index ef26fad0..44c78154 100644 --- a/src/sprints/sprints.service.ts +++ b/src/sprints/sprints.service.ts @@ -9,45 +9,19 @@ import { PrismaService } from "../prisma/prisma.service"; import { CreateTeamMeetingDto } from "./dto/create-team-meeting.dto"; import { CreateAgendaDto } from "./dto/create-agenda.dto"; import { UpdateAgendaDto } from "./dto/update-agenda.dto"; -import { CreateMeetingFormResponseDto } from "./dto/create-meeting-form-response.dto"; import { FormsService } from "../forms/forms.service"; import { UpdateMeetingFormResponseDto } from "./dto/update-meeting-form-response.dto"; -import { FormResponseDto } from "../global/dtos/FormResponse.dto"; +import { CreateCheckinFormDto } from "./dto/create-checkin-form.dto"; +import { GlobalService } from "../global/global.service"; @Injectable() export class SprintsService { constructor( private prisma: PrismaService, private formServices: FormsService, + private globalServices: GlobalService, ) {} - private responseDtoToArray = ( - responses: CreateMeetingFormResponseDto | UpdateMeetingFormResponseDto, - ) => { - const responsesArray = []; - const responseIndex = ["response", "responses"]; - for (const index in responses) { - if (responseIndex.includes(index)) { - responses[index].forEach((v: FormResponseDto) => { - responsesArray.push({ - questionId: v.questionId, - ...(v.text ? { text: v.text } : { text: null }), - ...(v.numeric - ? { numeric: v.numeric } - : { numeric: null }), - ...(v.boolean - ? { boolean: v.boolean } - : { boolean: null }), - ...(v.optionChoiceId - ? { optionChoiceId: v.optionChoiceId } - : { optionChoiceId: null }), - }); - }); - } - } - return responsesArray; - }; - // this checks if the form with the given formId is of formType = "meeting" private isMeetingForm = async (formId) => { const form = await this.prisma.form.findUnique({ @@ -517,47 +491,101 @@ export class SprintsService { ); } - const responsesArray = this.responseDtoToArray(responses); + const responsesArray = + this.globalServices.responseDtoToArray(responses); - // Checks that questions submitted for update match the form questions - const form = await this.prisma.form.findUnique({ - where: { id: formId }, - select: { - questions: { - select: { - id: true, - }, - }, - }, - }); + await this.globalServices.checkQuestionsInFormById( + formId, + responsesArray, + ); - const questionIds = form.questions.flatMap((question) => question.id); + return this.prisma.$transaction( + async (tx) => + await Promise.all( + responsesArray.map(async (response) => { + const responseToUpdate = await tx.response.findFirst({ + where: { + responseGroupId: + formResponseMeeting.responseGroupId, + questionId: response.questionId, + }, + }); - responsesArray.forEach((response) => { - if (questionIds.indexOf(response.questionId) === -1) - throw new BadRequestException( - `Question Id ${response.questionId} is not in form ${formId}`, - ); - }); + // if response does not already exist, update + // (this happens for a fresh meeting form) + // else create a new response + if (!responseToUpdate) { + return tx.response.create({ + data: { + responseGroupId: + formResponseMeeting.responseGroupId, + ...response, + }, + }); + } + return tx.response.update({ + where: { + id: responseToUpdate.id, + }, + data: { + responseGroupId: + formResponseMeeting.responseGroupId, + ...response, + }, + }); + }), + ), + ); + } - return this.prisma.$transaction( - responsesArray.map((response) => { - const { questionId, ...data } = response; - return this.prisma.response.upsert({ - where: { - questionResponseGroup: { - responseGroupId: - formResponseMeeting.responseGroupId, - questionId: response.questionId, - }, - }, - update: data, - create: { - responseGroupId: formResponseMeeting.responseGroupId, - ...response, - }, - }); - }), + async addCheckinFormResponse(createCheckinForm: CreateCheckinFormDto) { + const responsesArray = + this.globalServices.responseDtoToArray(createCheckinForm); + + await this.globalServices.checkQuestionsInFormByTitle( + "Sprint Check-in", + responsesArray, ); + + // TODO: do we need to check if sprintID is a reasonable sprint Id? + + try { + const checkinSubmission = await this.prisma.$transaction( + async (tx) => { + const responseGroup = await tx.responseGroup.create({ + data: { + responses: { + createMany: { + data: responsesArray, + }, + }, + }, + }); + return tx.formResponseCheckin.create({ + data: { + voyageTeamMemberId: + createCheckinForm.voyageTeamMemberId, + sprintId: createCheckinForm.sprintId, + responseGroupId: responseGroup.id, + }, + }); + }, + ); + return { + id: checkinSubmission.id, + voyageTeamMemberId: checkinSubmission.voyageTeamMemberId, + sprintId: checkinSubmission.sprintId, + responseGroupId: checkinSubmission.responseGroupId, + createdAt: checkinSubmission.createdAt, + }; + } catch (e) { + if (e.code === "P2002") { + throw new ConflictException( + `User ${createCheckinForm.voyageTeamMemberId} has already submitted a checkin form for sprint id ${createCheckinForm.sprintId}.`, + ); + } else { + console.log(e); + } + } } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index f831194d..fd4d8693 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -11,12 +11,7 @@ import { Request, } from "@nestjs/common"; import { UsersService } from "./users.service"; -import { - ApiOperation, - ApiParam, - ApiResponse, - ApiTags, -} from "@nestjs/swagger"; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from "@nestjs/swagger"; import { FullUserResponse, PrivateUserResponse } from "./users.response"; import { @@ -129,12 +124,12 @@ export class UsersController { description: "Given email is not in a valid email syntax.", type: BadRequestErrorResponse, }) - @Roles(AppRoles.Admin) @HttpCode(200) @Post("/lookup-by-email") - async getUserDetailsByEmail(@Body() userLookupByEmailDto: UserLookupByEmailDto) { - + async getUserDetailsByEmail( + @Body() userLookupByEmailDto: UserLookupByEmailDto, + ) { const userDetails = await this.usersService.getUserDetailsByEmail(userLookupByEmailDto); if (!userDetails) { diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts index 69ab8f77..4adde236 100644 --- a/test/auth.e2e-spec.ts +++ b/test/auth.e2e-spec.ts @@ -4,7 +4,11 @@ import { AppModule } from "../src/app.module"; import { seed } from "../prisma/seed/seed"; import * as request from "supertest"; import * as cookieParser from "cookie-parser"; -import { extractCookieByKey, extractResCookieValueByKey } from "./utils"; +import { + extractCookieByKey, + extractResCookieValueByKey, + loginAndGetTokens, +} from "./utils"; import { PrismaService } from "../src/prisma/prisma.service"; import { comparePassword } from "../src/utils/auth"; @@ -16,27 +20,6 @@ const verifyUrl = "/auth/verify-email"; const resetRequestUrl = "/auth/reset-password/request"; const resetPWUrl = "/auth/reset-password"; const revokeRTUrl = "/auth/refresh/revoke"; -const loginAndGetTokens = async ( - email: string, - password: string, - app: INestApplication, -) => { - const r = await request(app.getHttpServer()).post(loginUrl).send({ - email, - password, - }); - - const access_token = extractCookieByKey( - r.headers["set-cookie"], - "access_token", - ); - const refresh_token = extractCookieByKey( - r.headers["set-cookie"], - "refresh_token", - ); - - return { access_token, refresh_token }; -}; const getUserIdByEmail = async (email: string, prisma: PrismaService) => { const user = await prisma.user.findUnique({ diff --git a/test/sprints.e2e-spec.ts b/test/sprints.e2e-spec.ts index e9892c6b..a35a5708 100644 --- a/test/sprints.e2e-spec.ts +++ b/test/sprints.e2e-spec.ts @@ -4,32 +4,18 @@ import * as request from "supertest"; import { AppModule } from "../src/app.module"; import { PrismaService } from "../src/prisma/prisma.service"; import { seed } from "../prisma/seed/seed"; -import { extractResCookieValueByKey } from "./utils"; +import { loginAndGetTokens } from "./utils"; import { CreateAgendaDto } from "src/sprints/dto/create-agenda.dto"; import { toBeOneOf } from "jest-extended"; +import * as cookieParser from "cookie-parser"; +import { FormTitles } from "../src/global/constants/formTitles"; expect.extend({ toBeOneOf }); describe("Sprints Controller (e2e)", () => { let app: INestApplication; let prisma: PrismaService; - let userAccessToken: string; - - async function loginUser() { - await request(app.getHttpServer()) - .post("/auth/login") - .send({ - email: "jessica.williamson@gmail.com", - password: "password", - }) - .expect(200) - .then((res) => { - userAccessToken = extractResCookieValueByKey( - res.headers["set-cookie"], - "access_token", - ); - }); - } + let accessToken: any; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -40,6 +26,7 @@ describe("Sprints Controller (e2e)", () => { app = moduleFixture.createNestApplication(); prisma = moduleFixture.get(PrismaService); app.useGlobalPipes(new ValidationPipe()); + app.use(cookieParser()); await app.init(); }); @@ -49,14 +36,20 @@ describe("Sprints Controller (e2e)", () => { }); beforeEach(async () => { - await loginUser(); + await loginAndGetTokens( + "jessica.williamson@gmail.com", + "password", + app, + ).then((tokens) => { + accessToken = tokens.access_token; + }); }); describe("GET /voyages/sprints - gets all voyage and sprints data", () => { it("should return 200 if fetching all voyage and sprints data", async () => { return request(app.getHttpServer()) .get(`/voyages/sprints`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -107,7 +100,7 @@ describe("Sprints Controller (e2e)", () => { const teamId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/teams/${teamId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -136,7 +129,7 @@ describe("Sprints Controller (e2e)", () => { const teamId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/teams/${teamId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(404); }); @@ -154,7 +147,7 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect("Content-Type", /json/) .expect((res) => { @@ -235,7 +228,7 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(404); }); @@ -253,7 +246,7 @@ describe("Sprints Controller (e2e)", () => { const meetingId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Test title", dateTime: "2024-02-29T17:17:50.100Z", @@ -297,7 +290,7 @@ describe("Sprints Controller (e2e)", () => { .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Sprint Planning", dateTime: "2024-03-01T23:11:20.271Z", @@ -339,7 +332,7 @@ describe("Sprints Controller (e2e)", () => { .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Sprint Planning", dateTime: "2024-03-01T23:11:20.271Z", @@ -356,7 +349,7 @@ describe("Sprints Controller (e2e)", () => { .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Sprint Planning", dateTime: "2024-03-01T23:11:20.271Z", @@ -373,7 +366,7 @@ describe("Sprints Controller (e2e)", () => { .post( `/voyages/sprints/${sprintNumber}/teams/${teamId}/meetings`, ) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: 1, //bad request - title should be string dateTime: "2024-03-01T23:11:20.271Z", @@ -394,7 +387,7 @@ describe("Sprints Controller (e2e)", () => { }; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/agendas`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send(createAgendaDto) .expect(201) .expect((res) => { @@ -426,7 +419,7 @@ describe("Sprints Controller (e2e)", () => { const meetingId = " "; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/agendas`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Contribute to the agenda!", description: @@ -441,7 +434,7 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/agendas/${agendaId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Title updated", description: "New agenda", @@ -477,7 +470,7 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 9999; return request(app.getHttpServer()) .patch(`/voyages/sprints/agendas/${agendaId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ title: "Title updated", description: "New agenda", @@ -491,7 +484,7 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 1; return request(app.getHttpServer()) .delete(`/voyages/sprints/agendas/${agendaId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect((res) => { expect(res.body).toEqual( @@ -520,7 +513,7 @@ describe("Sprints Controller (e2e)", () => { const agendaId = 9999; return request(app.getHttpServer()) .delete(`/voyages/sprints/agendas/${agendaId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(404); }); }); @@ -531,7 +524,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(201) .expect((res) => { expect(res.body).toEqual( @@ -562,7 +555,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(409); }); @@ -571,7 +564,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(400); }); @@ -580,7 +573,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 999; return request(app.getHttpServer()) .post(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(400); }); }); @@ -590,7 +583,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(200) .expect((res) => { expect(res.body).toEqual( @@ -628,7 +621,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(404); }); @@ -637,25 +630,22 @@ describe("Sprints Controller (e2e)", () => { const formId = 9999; return request(app.getHttpServer()) .get(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .expect(400); }); }); describe("PATCH /voyages/sprints/meetings/:meetingId/forms/:formId - updates a meeting form", () => { - it("should return 200 if successfully update the meeting form with responses", async () => { + it("should return 200 if successfully create a meeting form response", async () => { const meetingId = 1; const formId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ responses: [ { questionId: 1, - optionChoiceId: 1, text: "Team member x landed a job this week.", - boolean: true, - number: 1, }, ], }) @@ -666,12 +656,6 @@ describe("Sprints Controller (e2e)", () => { expect.objectContaining({ id: expect.any(Number), questionId: expect.any(Number), - optionChoiceId: expect.any(Number), - numeric: expect.toBeOneOf([ - null, - expect.any(Number), - ]), - boolean: expect.any(Boolean), text: expect.any(String), responseGroupId: expect.any(Number), createdAt: expect.any(String), @@ -681,15 +665,54 @@ describe("Sprints Controller (e2e)", () => { ); }); }); - it("- verify meeting response found in database", async () => { + it("- verify meeting response found in database (create)", async () => { const response = await prisma.response.findMany({ where: { questionId: 1, - optionChoiceId: 1, text: "Team member x landed a job this week.", - boolean: true, }, }); + expect(response.length).toBe(1); + return expect(response[0].questionId).toEqual(1); + }); + it("should return 200 if successfully update a meeting form response", async () => { + const meetingId = 1; + const formId = 1; + return request(app.getHttpServer()) + .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) + .set("Cookie", accessToken) + .send({ + responses: [ + { + questionId: 1, + text: "Update - Team member x landed a job this week.", + }, + ], + }) + .expect(200) + .expect((res) => { + expect(res.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(Number), + questionId: expect.any(Number), + text: expect.any(String), + responseGroupId: expect.any(Number), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }), + ]), + ); + }); + }); + it("- verify meeting response is updated database", async () => { + const response = await prisma.response.findMany({ + where: { + questionId: 1, + text: "Update - Team member x landed a job this week.", + }, + }); + expect(response.length).toBe(1); return expect(response[0].questionId).toEqual(1); }); @@ -698,7 +721,7 @@ describe("Sprints Controller (e2e)", () => { const formId = "Bad request"; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ responses: [ { @@ -718,7 +741,7 @@ describe("Sprints Controller (e2e)", () => { const formId = 1; return request(app.getHttpServer()) .patch(`/voyages/sprints/meetings/${meetingId}/forms/${formId}`) - .set("Authorization", `Bearer ${userAccessToken}`) + .set("Cookie", accessToken) .send({ responses: { questionId: 1, @@ -731,4 +754,262 @@ describe("Sprints Controller (e2e)", () => { .expect(400); }); }); + + describe("POST /voyages/sprints/check-in - submit sprint check in form", () => { + const sprintCheckinUrl = "/voyages/sprints/check-in"; + let checkinForm: any; + let questions: any; + + beforeEach(async () => { + checkinForm = await prisma.form.findUnique({ + where: { + title: FormTitles.sprintCheckin, + }, + }); + questions = await prisma.question.findMany({ + where: { + formId: checkinForm.id, + }, + select: { + id: true, + }, + }); + }); + + it("should return 201 if successfully submitted a check in form", async () => { + const responsesBefore = await prisma.response.count(); + const responseGroupBefore = await prisma.responseGroup.count(); + const checkinsBefore = await prisma.formResponseCheckin.count(); + + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 2, // voyageTeamMemberId 1 is already in the seed + sprintId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + { + questionId: questions[1].id, + boolean: true, + }, + { + questionId: questions[2].id, + numeric: 12, + }, + { + questionId: questions[3].id, + optionChoiceId: 1, + }, + ], + }) + .expect(201); + + const responsesAfter = await prisma.response.count(); + const responseGroupAfter = await prisma.responseGroup.count(); + const checkinsAfter = await prisma.formResponseCheckin.count(); + + expect(responsesAfter).toEqual(responsesBefore + 4); + expect(responseGroupAfter).toEqual(responseGroupBefore + 1); + expect(checkinsAfter).toEqual(checkinsBefore + 1); + }); + it("should return 400 for invalid inputs", async () => { + const responsesBefore = await prisma.response.count(); + const responseGroupBefore = await prisma.responseGroup.count(); + const checkinsBefore = await prisma.formResponseCheckin.count(); + // missing voyageTeamMemberId + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + sprintId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + ], + }) + .expect(400); + + // missing sprintId" + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + ], + }) + .expect(400); + + // missing responses + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + sprintId: 1, + }) + .expect(400); + + // missing questionId in responses - response validation pipe + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + text: "Text input value", + }, + ], + }) + .expect(400); + + // missing input in responses - response validation pipe + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + }, + ], + }) + .expect(400); + + // wrong response input types + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + numeric: "not a number", + }, + ], + }) + .expect(400); + + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + responseGroupId: "not an id", + }, + ], + }) + .expect(400); + + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + optionGroupId: "not an id", + }, + ], + }) + .expect(400); + + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + responses: [ + { + questionId: questions[0].id, + boolean: "not a boolean", + }, + ], + }) + .expect(400); + + const responsesAfter = await prisma.response.count(); + const responseGroupAfter = await prisma.responseGroup.count(); + const checkinsAfter = await prisma.formResponseCheckin.count(); + + expect(responsesAfter).toEqual(responsesBefore); + expect(responseGroupAfter).toEqual(responseGroupBefore); + expect(checkinsAfter).toEqual(checkinsBefore); + }); + + it("should return 401 if user is not logged in", async () => { + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .send({ + voyageTeamMemberId: 1, + sprintId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + ], + }) + .expect(401); + }); + + it("should return 409 if user has already submitted the check in form for the same sprint", async () => { + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + sprintId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + ], + }); + const responsesBefore = await prisma.response.count(); + const responseGroupBefore = await prisma.responseGroup.count(); + const checkinsBefore = await prisma.formResponseCheckin.count(); + + await request(app.getHttpServer()) + .post(sprintCheckinUrl) + .set("Cookie", accessToken) + .send({ + voyageTeamMemberId: 1, + sprintId: 1, + responses: [ + { + questionId: questions[0].id, + text: "Text input value", + }, + ], + }) + .expect(409); + + const responsesAfter = await prisma.response.count(); + const responseGroupAfter = await prisma.responseGroup.count(); + const checkinsAfter = await prisma.formResponseCheckin.count(); + + expect(responsesAfter).toEqual(responsesBefore); + expect(responseGroupAfter).toEqual(responseGroupBefore); + expect(checkinsAfter).toEqual(checkinsBefore); + }); + }); }); diff --git a/test/utils.ts b/test/utils.ts index aaf97d99..7db4c5e2 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -41,18 +41,24 @@ export const loginAndGetTokens = async ( }; export const getNonAdminUser = async () => { - const adminRole = await prisma.role.findUnique({ - where: { - name: "admin", - }, - }); - return prisma.user.findFirst({ - where: { - roles: { - none: { - roleId: adminRole.id, + try { + const adminRole = await prisma.role.findUnique({ + where: { + name: "admin", + }, + }); + return prisma.user.findFirst({ + where: { + roles: { + none: { + roleId: adminRole.id, + }, }, }, - }, - }); + }); + } catch (e) { + console.log(e); + } finally { + await prisma.$disconnect(); + } };