From 07aed907be37fa1218e2f9ed4d696b1e2d1454b0 Mon Sep 17 00:00:00 2001 From: Eshaan Aggarwal <96648934+EshaanAgg@users.noreply.github.com> Date: Wed, 20 Sep 2023 20:18:51 +0530 Subject: [PATCH] Introduce Feedback System (#1387) * Add feedback model and typedefs * Add field level resolvers * Add mutation and queries * Add tests for field level resolvers * Complete testing for feedback system --- codegen.ts | 2 + schema.graphql | 18 ++ src/constants.ts | 13 + src/models/CheckIn.ts | 6 + src/models/Feedback.ts | 41 +++ src/models/index.ts | 1 + src/resolvers/Event/attendees.ts | 1 + src/resolvers/Event/averageFeedbackScore.ts | 21 ++ src/resolvers/Event/feedback.ts | 8 + src/resolvers/Event/index.ts | 4 + src/resolvers/Feedback/event.ts | 8 + src/resolvers/Feedback/index.ts | 6 + src/resolvers/Mutation/addFeedback.ts | 79 ++++++ src/resolvers/Mutation/index.ts | 2 + src/resolvers/Query/hasSubmittedFeedback.ts | 60 +++++ src/resolvers/index.ts | 2 + src/typeDefs/inputs.ts | 6 + src/typeDefs/mutations.ts | 2 + src/typeDefs/queries.ts | 2 + src/typeDefs/types.ts | 10 + src/types/generatedGraphQLTypes.ts | 49 ++++ tests/helpers/feedback.ts | 42 +++ .../Event/averageFeedbackScore.test.ts | 52 ++++ tests/resolvers/Event/feedback.test.ts | 31 +++ tests/resolvers/Feedback/event.spec.ts | 30 +++ tests/resolvers/Mutation/addFeedback.spec.ts | 239 ++++++++++++++++++ .../Query/hasSubmittedFeedback.spec.ts | 174 +++++++++++++ 27 files changed, 909 insertions(+) create mode 100644 src/models/Feedback.ts create mode 100644 src/resolvers/Event/averageFeedbackScore.ts create mode 100644 src/resolvers/Event/feedback.ts create mode 100644 src/resolvers/Feedback/event.ts create mode 100644 src/resolvers/Feedback/index.ts create mode 100644 src/resolvers/Mutation/addFeedback.ts create mode 100644 src/resolvers/Query/hasSubmittedFeedback.ts create mode 100644 tests/helpers/feedback.ts create mode 100644 tests/resolvers/Event/averageFeedbackScore.test.ts create mode 100644 tests/resolvers/Event/feedback.test.ts create mode 100644 tests/resolvers/Feedback/event.spec.ts create mode 100644 tests/resolvers/Mutation/addFeedback.spec.ts create mode 100644 tests/resolvers/Query/hasSubmittedFeedback.spec.ts diff --git a/codegen.ts b/codegen.ts index 4eccc61652..d0f9fc4a95 100644 --- a/codegen.ts +++ b/codegen.ts @@ -44,6 +44,8 @@ const config: CodegenConfig = { EventProject: "../models/EventProject#InterfaceEventProject", + Feedback: "../models/Feedback#InterfaceFeedback", + // File: '../models/File#InterfaceFile', Group: "../models/Group#InterfaceGroup", diff --git a/schema.graphql b/schema.graphql index 4b8a19ee81..f1d037cdc1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -31,6 +31,7 @@ type CheckIn { allotedRoom: String allotedSeat: String event: Event! + feedbackSubmitted: Boolean! time: DateTime! user: User! } @@ -145,10 +146,12 @@ type Event { allDay: Boolean! attendees: [User!]! attendeesCheckInStatus: [CheckInStatus!]! + averageFeedbackScore: Float creator: User! description: String! endDate: Date! endTime: Time + feedback: [Feedback!]! isPublic: Boolean! isRegisterable: Boolean! latitude: Latitude @@ -257,6 +260,19 @@ type ExtendSession { refreshToken: String! } +type Feedback { + _id: ID! + event: Event! + rating: Int! + review: String +} + +input FeedbackInput { + eventId: ID! + rating: Int! + review: String +} + interface FieldError { message: String! path: [String!]! @@ -393,6 +409,7 @@ type Mutation { acceptAdmin(id: ID!): Boolean! acceptMembershipRequest(membershipRequestId: ID!): MembershipRequest! addEventAttendee(data: EventAttendeeInput!): User! + addFeedback(data: FeedbackInput!): Feedback! addLanguageTranslation(data: LanguageInput!): Language! addOrganizationImage(file: String!, organizationId: String!): Organization! addUserImage(file: String!): User! @@ -710,6 +727,7 @@ type Query { getDonationByOrgIdConnection(first: Int, orgId: ID!, skip: Int, where: DonationWhereInput): [Donation!]! getPlugins: [Plugin] getlanguage(lang_code: String!): [Translation] + hasSubmittedFeedback(eventId: ID!, userId: ID!): Boolean joinedOrganizations(id: ID): [Organization] me: User! myLanguage: String diff --git a/src/constants.ts b/src/constants.ts index 3770a8916e..8691e8849c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -31,6 +31,13 @@ export const EVENT_PROJECT_NOT_FOUND_ERROR = { MESSAGE: "eventProject.notFound", PARAM: "eventProject", }; + +export const FEEDBACK_ALREADY_SUBMITTED = { + MESSAGE: "The user has already submitted a feedback for this event.", + CODE: "feedback.alreadySubmitted", + PARAM: "feedback.alreadySubmitted", +}; + export const INVALID_OTP = "Invalid OTP"; export const IN_PRODUCTION = process.env.NODE_ENV === "production"; @@ -146,6 +153,12 @@ export const USER_NOT_REGISTERED_FOR_EVENT = { PARAM: "user.notRegistered", }; +export const USER_NOT_CHECKED_IN = { + MESSAGE: "The user did not check in for the event.", + CODE: "user.notCheckedIn", + PARAM: "user.notCheckedIn", +}; + export const USER_NOT_ORGANIZATION_ADMIN = { MESSAGE: "Error: User must be an ADMIN", CODE: "role.notValid.admin", diff --git a/src/models/CheckIn.ts b/src/models/CheckIn.ts index 419fa35f29..4a1b394956 100644 --- a/src/models/CheckIn.ts +++ b/src/models/CheckIn.ts @@ -15,6 +15,7 @@ export interface InterfaceCheckIn { time: Date; allotedRoom: string; allotedSeat: string; + feedbackSubmitted: boolean; } const checkInSchema = new Schema({ @@ -36,6 +37,11 @@ const checkInSchema = new Schema({ type: String, required: false, }, + feedbackSubmitted: { + type: Boolean, + required: true, + default: false, + }, }); // We will also create an index here for faster database querying diff --git a/src/models/Feedback.ts b/src/models/Feedback.ts new file mode 100644 index 0000000000..00ebd478e9 --- /dev/null +++ b/src/models/Feedback.ts @@ -0,0 +1,41 @@ +import type { Types, PopulatedDoc, Document, Model } from "mongoose"; +import { Schema, model, models } from "mongoose"; +import type { InterfaceEvent } from "./Event"; + +export interface InterfaceFeedback { + _id: Types.ObjectId; + eventId: PopulatedDoc; + rating: number; + review: string | null; +} + +const feedbackSchema = new Schema({ + eventId: { + type: Schema.Types.ObjectId, + ref: "Event", + required: true, + }, + rating: { + type: Number, + required: true, + default: 0, + max: 10, + }, + review: { + type: String, + required: false, + }, +}); + +// We will also create an index here for faster database querying +feedbackSchema.index({ + eventId: 1, +}); + +const feedbackModel = (): Model => + model("Feedback", feedbackSchema); + +// This syntax is needed to prevent Mongoose OverwriteModelError while running tests. +export const Feedback = (models.Feedback || feedbackModel()) as ReturnType< + typeof feedbackModel +>; diff --git a/src/models/index.ts b/src/models/index.ts index fbf5e4ee1a..15bfe169b5 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -7,6 +7,7 @@ export * from "./Donation"; export * from "./Event"; export * from "./EventAttendee"; export * from "./EventProject"; +export * from "./Feedback"; export * from "./File"; export * from "./Group"; export * from "./GroupChat"; diff --git a/src/resolvers/Event/attendees.ts b/src/resolvers/Event/attendees.ts index ca500fc4fa..e057ae09b0 100644 --- a/src/resolvers/Event/attendees.ts +++ b/src/resolvers/Event/attendees.ts @@ -7,6 +7,7 @@ export const attendees: EventResolvers["attendees"] = async (parent) => { }) .populate("userId") .lean(); + return eventAttendeeObjects.map( (eventAttendeeObject) => eventAttendeeObject.userId ); diff --git a/src/resolvers/Event/averageFeedbackScore.ts b/src/resolvers/Event/averageFeedbackScore.ts new file mode 100644 index 0000000000..e53e431280 --- /dev/null +++ b/src/resolvers/Event/averageFeedbackScore.ts @@ -0,0 +1,21 @@ +import type { EventResolvers } from "../../types/generatedGraphQLTypes"; +import { Feedback } from "../../models"; + +export const averageFeedbackScore: EventResolvers["averageFeedbackScore"] = + async (parent) => { + const feedbacks = await Feedback.find({ + eventId: parent._id, + }) + .select("rating") + .lean(); + + // Return null if no feedback has been submitted + if (feedbacks.length === 0) return null; + + // Return the average feedback score + const sum = feedbacks.reduce( + (accumulator, feedback) => accumulator + feedback.rating, + 0 + ); + return sum / feedbacks.length; + }; diff --git a/src/resolvers/Event/feedback.ts b/src/resolvers/Event/feedback.ts new file mode 100644 index 0000000000..f362b6b2af --- /dev/null +++ b/src/resolvers/Event/feedback.ts @@ -0,0 +1,8 @@ +import type { EventResolvers } from "../../types/generatedGraphQLTypes"; +import { Feedback } from "../../models"; + +export const feedback: EventResolvers["feedback"] = async (parent) => { + return Feedback.find({ + eventId: parent._id, + }).lean(); +}; diff --git a/src/resolvers/Event/index.ts b/src/resolvers/Event/index.ts index bf07fb4e70..8faa3ec5c2 100644 --- a/src/resolvers/Event/index.ts +++ b/src/resolvers/Event/index.ts @@ -1,12 +1,16 @@ import type { EventResolvers } from "../../types/generatedGraphQLTypes"; import { attendees } from "./attendees"; import { attendeesCheckInStatus } from "./attendeesCheckInStatus"; +import { averageFeedbackScore } from "./averageFeedbackScore"; +import { feedback } from "./feedback"; import { organization } from "./organization"; import { projects } from "./projects"; export const Event: EventResolvers = { attendees, attendeesCheckInStatus, + averageFeedbackScore, + feedback, organization, projects, }; diff --git a/src/resolvers/Feedback/event.ts b/src/resolvers/Feedback/event.ts new file mode 100644 index 0000000000..59eaa7688b --- /dev/null +++ b/src/resolvers/Feedback/event.ts @@ -0,0 +1,8 @@ +import type { FeedbackResolvers } from "../../types/generatedGraphQLTypes"; +import { Event } from "../../models"; + +export const event: FeedbackResolvers["event"] = async (parent) => { + return await Event.findOne({ + _id: parent.eventId, + }).lean(); +}; diff --git a/src/resolvers/Feedback/index.ts b/src/resolvers/Feedback/index.ts new file mode 100644 index 0000000000..6e54db99b3 --- /dev/null +++ b/src/resolvers/Feedback/index.ts @@ -0,0 +1,6 @@ +import type { FeedbackResolvers } from "../../types/generatedGraphQLTypes"; +import { event } from "./event"; + +export const Feedback: FeedbackResolvers = { + event, +}; diff --git a/src/resolvers/Mutation/addFeedback.ts b/src/resolvers/Mutation/addFeedback.ts new file mode 100644 index 0000000000..ebb8a30b02 --- /dev/null +++ b/src/resolvers/Mutation/addFeedback.ts @@ -0,0 +1,79 @@ +import { + EVENT_NOT_FOUND_ERROR, + USER_NOT_FOUND_ERROR, + USER_NOT_CHECKED_IN, + USER_NOT_REGISTERED_FOR_EVENT, + FEEDBACK_ALREADY_SUBMITTED, +} from "../../constants"; +import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; +import { errors, requestContext } from "../../libraries"; +import { User, Event, EventAttendee, CheckIn, Feedback } from "../../models"; + +export const addFeedback: MutationResolvers["addFeedback"] = async ( + _parent, + args, + context +) => { + const currentUserExists = await User.exists({ + _id: context.userId, + }); + + if (!currentUserExists) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM + ); + } + + const currentEventExists = await Event.exists({ + _id: args.data.eventId, + }); + + if (!currentEventExists) { + throw new errors.NotFoundError( + requestContext.translate(EVENT_NOT_FOUND_ERROR.MESSAGE), + EVENT_NOT_FOUND_ERROR.CODE, + EVENT_NOT_FOUND_ERROR.PARAM + ); + } + + const eventAttendeeObject = await EventAttendee.findOne({ + eventId: args.data.eventId, + userId: context.userId, + }) + .populate("checkInId") + .lean(); + + if (eventAttendeeObject === null) { + throw new errors.ConflictError( + requestContext.translate(USER_NOT_REGISTERED_FOR_EVENT.MESSAGE), + USER_NOT_REGISTERED_FOR_EVENT.CODE, + USER_NOT_REGISTERED_FOR_EVENT.PARAM + ); + } + + if (eventAttendeeObject.checkInId === null) { + throw new errors.ConflictError( + requestContext.translate(USER_NOT_CHECKED_IN.MESSAGE), + USER_NOT_CHECKED_IN.CODE, + USER_NOT_CHECKED_IN.PARAM + ); + } + + if (eventAttendeeObject.checkInId.feedbackSubmitted) { + throw new errors.ConflictError( + requestContext.translate(FEEDBACK_ALREADY_SUBMITTED.MESSAGE), + FEEDBACK_ALREADY_SUBMITTED.CODE, + FEEDBACK_ALREADY_SUBMITTED.PARAM + ); + } + + await CheckIn.findByIdAndUpdate(eventAttendeeObject.checkInId, { + feedbackSubmitted: true, + }); + + const feedback = await Feedback.create({ ...args.data }); + + return feedback; +}; diff --git a/src/resolvers/Mutation/index.ts b/src/resolvers/Mutation/index.ts index 60efe276f4..96d6e3534e 100644 --- a/src/resolvers/Mutation/index.ts +++ b/src/resolvers/Mutation/index.ts @@ -2,6 +2,7 @@ import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; import { acceptAdmin } from "./acceptAdmin"; import { acceptMembershipRequest } from "./acceptMembershipRequest"; import { addEventAttendee } from "./addEventAttendee"; +import { addFeedback } from "./addFeedback"; import { addLanguageTranslation } from "./addLanguageTranslation"; import { addOrganizationImage } from "./addOrganizationImage"; import { addUserImage } from "./addUserImage"; @@ -85,6 +86,7 @@ export const Mutation: MutationResolvers = { acceptAdmin, acceptMembershipRequest, addEventAttendee, + addFeedback, addLanguageTranslation, addOrganizationImage, addUserImage, diff --git a/src/resolvers/Query/hasSubmittedFeedback.ts b/src/resolvers/Query/hasSubmittedFeedback.ts new file mode 100644 index 0000000000..ae8300bf25 --- /dev/null +++ b/src/resolvers/Query/hasSubmittedFeedback.ts @@ -0,0 +1,60 @@ +import type { QueryResolvers } from "../../types/generatedGraphQLTypes"; +import { User, Event, EventAttendee } from "../../models"; +import { errors, requestContext } from "../../libraries"; +import { + EVENT_NOT_FOUND_ERROR, + USER_NOT_FOUND_ERROR, + USER_NOT_CHECKED_IN, + USER_NOT_REGISTERED_FOR_EVENT, +} from "../../constants"; + +export const hasSubmittedFeedback: QueryResolvers["hasSubmittedFeedback"] = + async (_parent, args) => { + const currentUserExists = await User.exists({ + _id: args.userId, + }); + + if (!currentUserExists) { + throw new errors.NotFoundError( + requestContext.translate(USER_NOT_FOUND_ERROR.MESSAGE), + USER_NOT_FOUND_ERROR.CODE, + USER_NOT_FOUND_ERROR.PARAM + ); + } + + const currentEventExists = await Event.exists({ + _id: args.eventId, + }); + + if (!currentEventExists) { + throw new errors.NotFoundError( + requestContext.translate(EVENT_NOT_FOUND_ERROR.MESSAGE), + EVENT_NOT_FOUND_ERROR.CODE, + EVENT_NOT_FOUND_ERROR.PARAM + ); + } + + const eventAttendeeObject = await EventAttendee.findOne({ + ...args, + }) + .populate("checkInId") + .lean(); + + if (eventAttendeeObject === null) { + throw new errors.ConflictError( + requestContext.translate(USER_NOT_REGISTERED_FOR_EVENT.MESSAGE), + USER_NOT_REGISTERED_FOR_EVENT.CODE, + USER_NOT_REGISTERED_FOR_EVENT.PARAM + ); + } + + if (eventAttendeeObject.checkInId === null) { + throw new errors.ConflictError( + requestContext.translate(USER_NOT_CHECKED_IN.MESSAGE), + USER_NOT_CHECKED_IN.CODE, + USER_NOT_CHECKED_IN.PARAM + ); + } + + return eventAttendeeObject.checkInId.feedbackSubmitted; + }; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index d21581e861..99104e316d 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -5,6 +5,7 @@ import { DirectChat } from "./DirectChat"; import { DirectChatMessage } from "./DirectChatMessage"; import { Event } from "./Event"; import { EventProject } from "./EventProject"; +import { Feedback } from "./Feedback"; import { GroupChat } from "./GroupChat"; import { GroupChatMessage } from "./GroupChatMessage"; import { MembershipRequest } from "./MembershipRequest"; @@ -37,6 +38,7 @@ export const resolvers: Resolvers = { DirectChatMessage, Event, EventProject, + Feedback, GroupChat, GroupChatMessage, MembershipRequest, diff --git a/src/typeDefs/inputs.ts b/src/typeDefs/inputs.ts index dc37d0dc82..227e667ebd 100644 --- a/src/typeDefs/inputs.ts +++ b/src/typeDefs/inputs.ts @@ -113,6 +113,12 @@ export const inputs = gql` eventId: ID! } + input FeedbackInput { + eventId: ID! + rating: Int! + review: String + } + input ForgotPasswordData { userOtp: String! newPassword: String! diff --git a/src/typeDefs/mutations.ts b/src/typeDefs/mutations.ts index 13ae8de402..4873d27c0a 100644 --- a/src/typeDefs/mutations.ts +++ b/src/typeDefs/mutations.ts @@ -11,6 +11,8 @@ export const mutations = gql` addEventAttendee(data: EventAttendeeInput!): User! @auth + addFeedback(data: FeedbackInput!): Feedback! @auth + addLanguageTranslation(data: LanguageInput!): Language! @auth addOrganizationImage(file: String!, organizationId: String!): Organization! diff --git a/src/typeDefs/queries.ts b/src/typeDefs/queries.ts index eea828678e..f206980ac1 100644 --- a/src/typeDefs/queries.ts +++ b/src/typeDefs/queries.ts @@ -39,6 +39,8 @@ export const queries = gql` getPlugins: [Plugin] + hasSubmittedFeedback(userId: ID!, eventId: ID!): Boolean + joinedOrganizations(id: ID): [Organization] me: User! @auth diff --git a/src/typeDefs/types.ts b/src/typeDefs/types.ts index b9b10352f0..f1caf7c9cb 100644 --- a/src/typeDefs/types.ts +++ b/src/typeDefs/types.ts @@ -34,6 +34,7 @@ export const types = gql` allotedSeat: String user: User! event: Event! + feedbackSubmitted: Boolean! } # Used to show whether an user has checked in for an event @@ -121,6 +122,8 @@ export const types = gql` admins(adminId: ID): [User] status: Status! projects: [EventProject] + feedback: [Feedback!]! + averageFeedbackScore: Float } type EventProject { @@ -131,6 +134,13 @@ export const types = gql` tasks: [Task] } + type Feedback { + _id: ID! + event: Event! + rating: Int! + review: String + } + type Group { _id: ID title: String diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index f837381b0f..bfd655c651 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -8,6 +8,7 @@ import type { InterfaceDonation as InterfaceDonationModel } from '../models/Dona import type { InterfaceEvent as InterfaceEventModel } from '../models/Event'; import type { InterfaceEventAttendee as InterfaceEventAttendeeModel } from '../models/EventAttendee'; import type { InterfaceEventProject as InterfaceEventProjectModel } from '../models/EventProject'; +import type { InterfaceFeedback as InterfaceFeedbackModel } from '../models/Feedback'; import type { InterfaceGroup as InterfaceGroupModel } from '../models/Group'; import type { InterfaceGroupChat as InterfaceGroupChatModel } from '../models/GroupChat'; import type { InterfaceGroupChatMessage as InterfaceGroupChatMessageModel } from '../models/GroupChatMessage'; @@ -81,6 +82,7 @@ export type CheckIn = { allotedRoom?: Maybe; allotedSeat?: Maybe; event: Event; + feedbackSubmitted: Scalars['Boolean']; time: Scalars['DateTime']; user: User; }; @@ -197,10 +199,12 @@ export type Event = { allDay: Scalars['Boolean']; attendees: Array; attendeesCheckInStatus: Array; + averageFeedbackScore?: Maybe; creator: User; description: Scalars['String']; endDate: Scalars['Date']; endTime?: Maybe; + feedback: Array; isPublic: Scalars['Boolean']; isRegisterable: Scalars['Boolean']; latitude?: Maybe; @@ -315,6 +319,20 @@ export type ExtendSession = { refreshToken: Scalars['String']; }; +export type Feedback = { + __typename?: 'Feedback'; + _id: Scalars['ID']; + event: Event; + rating: Scalars['Int']; + review?: Maybe; +}; + +export type FeedbackInput = { + eventId: Scalars['ID']; + rating: Scalars['Int']; + review?: InputMaybe; +}; + export type FieldError = { message: Scalars['String']; path: Array; @@ -462,6 +480,7 @@ export type Mutation = { acceptAdmin: Scalars['Boolean']; acceptMembershipRequest: MembershipRequest; addEventAttendee: User; + addFeedback: Feedback; addLanguageTranslation: Language; addOrganizationImage: Organization; addUserImage: User; @@ -558,6 +577,11 @@ export type MutationAddEventAttendeeArgs = { }; +export type MutationAddFeedbackArgs = { + data: FeedbackInput; +}; + + export type MutationAddLanguageTranslationArgs = { data: LanguageInput; }; @@ -1216,6 +1240,7 @@ export type Query = { getDonationByOrgIdConnection: Array; getPlugins?: Maybe>>; getlanguage?: Maybe>>; + hasSubmittedFeedback?: Maybe; joinedOrganizations?: Maybe>>; me: User; myLanguage?: Maybe; @@ -1292,6 +1317,12 @@ export type QueryGetlanguageArgs = { }; +export type QueryHasSubmittedFeedbackArgs = { + eventId: Scalars['ID']; + userId: Scalars['ID']; +}; + + export type QueryJoinedOrganizationsArgs = { id?: InputMaybe; }; @@ -1803,6 +1834,8 @@ export type ResolversTypes = { EventProjectInput: EventProjectInput; EventWhereInput: EventWhereInput; ExtendSession: ResolverTypeWrapper; + Feedback: ResolverTypeWrapper; + FeedbackInput: FeedbackInput; FieldError: ResolversTypes['InvalidCursor'] | ResolversTypes['MaximumLengthError'] | ResolversTypes['MaximumValueError'] | ResolversTypes['MinimumLengthError'] | ResolversTypes['MinimumValueError']; Float: ResolverTypeWrapper; ForgotPasswordData: ForgotPasswordData; @@ -1926,6 +1959,8 @@ export type ResolversParentTypes = { EventProjectInput: EventProjectInput; EventWhereInput: EventWhereInput; ExtendSession: ExtendSession; + Feedback: InterfaceFeedbackModel; + FeedbackInput: FeedbackInput; FieldError: ResolversParentTypes['InvalidCursor'] | ResolversParentTypes['MaximumLengthError'] | ResolversParentTypes['MaximumValueError'] | ResolversParentTypes['MinimumLengthError'] | ResolversParentTypes['MinimumValueError']; Float: Scalars['Float']; ForgotPasswordData: ForgotPasswordData; @@ -2051,6 +2086,7 @@ export type CheckInResolvers, ParentType, ContextType>; allotedSeat?: Resolver, ParentType, ContextType>; event?: Resolver; + feedbackSubmitted?: Resolver; time?: Resolver; user?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -2144,10 +2180,12 @@ export type EventResolvers; attendees?: Resolver, ParentType, ContextType>; attendeesCheckInStatus?: Resolver, ParentType, ContextType>; + averageFeedbackScore?: Resolver, ParentType, ContextType>; creator?: Resolver; description?: Resolver; endDate?: Resolver; endTime?: Resolver, ParentType, ContextType>; + feedback?: Resolver, ParentType, ContextType>; isPublic?: Resolver; isRegisterable?: Resolver; latitude?: Resolver, ParentType, ContextType>; @@ -2179,6 +2217,14 @@ export type ExtendSessionResolvers; }; +export type FeedbackResolvers = { + _id?: Resolver; + event?: Resolver; + rating?: Resolver; + review?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type FieldErrorResolvers = { __resolveType: TypeResolveFn<'InvalidCursor' | 'MaximumLengthError' | 'MaximumValueError' | 'MinimumLengthError' | 'MinimumValueError', ParentType, ContextType>; message?: Resolver; @@ -2312,6 +2358,7 @@ export type MutationResolvers>; acceptMembershipRequest?: Resolver>; addEventAttendee?: Resolver>; + addFeedback?: Resolver>; addLanguageTranslation?: Resolver>; addOrganizationImage?: Resolver>; addUserImage?: Resolver>; @@ -2501,6 +2548,7 @@ export type QueryResolvers, ParentType, ContextType, RequireFields>; getPlugins?: Resolver>>, ParentType, ContextType>; getlanguage?: Resolver>>, ParentType, ContextType, RequireFields>; + hasSubmittedFeedback?: Resolver, ParentType, ContextType, RequireFields>; joinedOrganizations?: Resolver>>, ParentType, ContextType, Partial>; me?: Resolver; myLanguage?: Resolver, ParentType, ContextType>; @@ -2669,6 +2717,7 @@ export type Resolvers = { Event?: EventResolvers; EventProject?: EventProjectResolvers; ExtendSession?: ExtendSessionResolvers; + Feedback?: FeedbackResolvers; FieldError?: FieldErrorResolvers; Group?: GroupResolvers; GroupChat?: GroupChatResolvers; diff --git a/tests/helpers/feedback.ts b/tests/helpers/feedback.ts new file mode 100644 index 0000000000..95b04a906b --- /dev/null +++ b/tests/helpers/feedback.ts @@ -0,0 +1,42 @@ +import { nanoid } from "nanoid"; +import { CheckIn, type InterfaceFeedback, Feedback } from "../../src/models"; +import type { Document } from "mongoose"; +import { createEventWithCheckedInUser, type TestCheckInType } from "./checkIn"; +import type { TestOrganizationType, TestUserType } from "./userAndOrg"; +import type { TestEventType } from "./task"; + +export type TestFeedbackType = + | (InterfaceFeedback & Document) + | null; + +export const createFeedbackWithIDs = async ( + eventId: string, + eventAttendeeId: string +): Promise => { + const feedback = await Feedback.create({ + eventId, + rating: 7, + review: nanoid(), + }); + + await CheckIn.findByIdAndUpdate(eventAttendeeId, { + feedbackSubmitted: true, + }); + + return feedback; +}; + +export const createFeedback = async (): Promise< + [ + TestUserType, + TestOrganizationType, + TestEventType, + TestCheckInType, + TestFeedbackType + ] +> => { + const result = await createEventWithCheckedInUser(); + const feedback = await createFeedbackWithIDs(result[2]!._id, result[3]!._id); + + return [...result, feedback]; +}; diff --git a/tests/resolvers/Event/averageFeedbackScore.test.ts b/tests/resolvers/Event/averageFeedbackScore.test.ts new file mode 100644 index 0000000000..6b8b056a25 --- /dev/null +++ b/tests/resolvers/Event/averageFeedbackScore.test.ts @@ -0,0 +1,52 @@ +import "dotenv/config"; +import { averageFeedbackScore as averageFeedbackScoreResolver } from "../../../src/resolvers/Event/averageFeedbackScore"; +import { connect, disconnect } from "../../helpers/db"; +import type mongoose from "mongoose"; +import { beforeAll, afterAll, describe, it, expect } from "vitest"; +import type { TestEventType } from "../../helpers/events"; +import { createFeedbackWithIDs } from "../../helpers/feedback"; +import { + type TestCheckInType, + createEventWithCheckedInUser, +} from "../../helpers/checkIn"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testEvent: TestEventType; +let testCheckIn: TestCheckInType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [, , testEvent, testCheckIn] = await createEventWithCheckedInUser(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Event -> averageFeedbackScore", () => { + it(`Should return null if there are no submitted feedbacks for the event`, async () => { + const parent = testEvent!.toObject(); + + const averageFeedbackScorePayload = await averageFeedbackScoreResolver?.( + parent, + {}, + {} + ); + + expect(averageFeedbackScorePayload).toEqual(null); + }); + + it(`Should return the proper average score if there are some submitted feedbacks for the event`, async () => { + await createFeedbackWithIDs(testEvent!._id, testCheckIn!._id); + + const parent = testEvent!.toObject(); + + const averageFeedbackScorePayload = await averageFeedbackScoreResolver?.( + parent, + {}, + {} + ); + + expect(averageFeedbackScorePayload).toEqual(7); + }); +}); diff --git a/tests/resolvers/Event/feedback.test.ts b/tests/resolvers/Event/feedback.test.ts new file mode 100644 index 0000000000..1d380168b8 --- /dev/null +++ b/tests/resolvers/Event/feedback.test.ts @@ -0,0 +1,31 @@ +import "dotenv/config"; +import { feedback as feedbackResolver } from "../../../src/resolvers/Event/feedback"; +import { connect, disconnect } from "../../helpers/db"; +import type mongoose from "mongoose"; +import { beforeAll, afterAll, describe, it, expect } from "vitest"; +import type { TestEventType } from "../../helpers/events"; +import { type TestFeedbackType, createFeedback } from "../../helpers/feedback"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testEvent: TestEventType; +let testFeedback: TestFeedbackType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [, , testEvent, , testFeedback] = await createFeedback(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Event -> feedback", () => { + it(`returns all the feedback objects for parent event`, async () => { + const parent = testEvent!.toObject(); + + const feedbackPayload = await feedbackResolver?.(parent, {}, {}); + + expect(feedbackPayload!.length).toEqual(1); + expect(feedbackPayload![0]).toEqual(testFeedback!.toObject()); + }); +}); diff --git a/tests/resolvers/Feedback/event.spec.ts b/tests/resolvers/Feedback/event.spec.ts new file mode 100644 index 0000000000..0191fcc475 --- /dev/null +++ b/tests/resolvers/Feedback/event.spec.ts @@ -0,0 +1,30 @@ +import "dotenv/config"; +import { event as eventResolver } from "../../../src/resolvers/Feedback/event"; +import { connect, disconnect } from "../../helpers/db"; +import type mongoose from "mongoose"; +import { beforeAll, afterAll, describe, it, expect } from "vitest"; +import type { TestEventType } from "../../helpers/events"; +import { type TestFeedbackType, createFeedback } from "../../helpers/feedback"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testEvent: TestEventType; +let testFeedback: TestFeedbackType; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + [, , testEvent, , testFeedback] = await createFeedback(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Feedback -> event", () => { + it(`returns the correct event object for parent feedback`, async () => { + const parent = testFeedback!.toObject(); + + const eventPayload = await eventResolver?.(parent, {}, {}); + + expect(eventPayload).toEqual(testEvent!.toObject()); + }); +}); diff --git a/tests/resolvers/Mutation/addFeedback.spec.ts b/tests/resolvers/Mutation/addFeedback.spec.ts new file mode 100644 index 0000000000..46fe94d4bd --- /dev/null +++ b/tests/resolvers/Mutation/addFeedback.spec.ts @@ -0,0 +1,239 @@ +import "dotenv/config"; +import { connect, disconnect } from "../../helpers/db"; +import { beforeAll, afterAll, describe, it, expect, vi } from "vitest"; +import type { MutationAddFeedbackArgs } from "../../../src/types/generatedGraphQLTypes"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import { + EVENT_NOT_FOUND_ERROR, + USER_NOT_FOUND_ERROR, + USER_NOT_CHECKED_IN, + FEEDBACK_ALREADY_SUBMITTED, + USER_NOT_REGISTERED_FOR_EVENT, +} from "./../../../src/constants"; +import { type TestUserType, createTestUser } from "./../../helpers/userAndOrg"; +import { createTestEvent, type TestEventType } from "../../helpers/events"; +import { CheckIn, EventAttendee } from "../../../src/models"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let randomTestUser: TestUserType; +let testUser: TestUserType; +let testEvent: TestEventType; +let eventAttendeeId: string; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + randomTestUser = await createTestUser(); + [testUser, , testEvent] = await createTestEvent(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Query -> addFeedback", () => { + it(`throws NotFoundError if no user exists with _id === context.userId `, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddFeedbackArgs = { + data: { + eventId: Types.ObjectId().toString(), + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: Types.ObjectId().toString(), + }; + + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + await addFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_FOUND_ERROR.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws NotFoundError if no event exists with _id === args.eventId`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddFeedbackArgs = { + data: { + eventId: Types.ObjectId().toString(), + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: randomTestUser!._id, + }; + + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + await addFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${EVENT_NOT_FOUND_ERROR.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(EVENT_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws Error if the user is not registered to attend the event`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddFeedbackArgs = { + data: { + eventId: testEvent!._id, + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: testUser!._id, + }; + + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + await addFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_REGISTERED_FOR_EVENT.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith( + USER_NOT_REGISTERED_FOR_EVENT.MESSAGE + ); + } + }); + + it(`throws Error if the user is has not checked in to the event`, async () => { + const eventAttendee = await EventAttendee.create({ + eventId: testEvent!._id, + userId: testUser!._id, + }); + eventAttendeeId = eventAttendee._id; + + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddFeedbackArgs = { + data: { + eventId: testEvent!._id, + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: testUser!._id, + }; + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + await addFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_CHECKED_IN.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_CHECKED_IN.MESSAGE); + } + }); + + it(`creates and returns the feedback object correctly if the user has checked in to the event`, async () => { + const checkIn = await CheckIn.create({ + eventAttendeeId, + }); + + await EventAttendee.findByIdAndUpdate(eventAttendeeId, { + checkInId: checkIn._id, + }); + + const args: MutationAddFeedbackArgs = { + data: { + eventId: testEvent!._id, + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: testUser!._id, + }; + + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + const payload = await addFeedbackResolver?.({}, args, context); + + expect(payload).toMatchObject({ + eventId: testEvent!._id, + rating: 7, + review: "Test Review", + }); + }); + + it(`throws Error if the user has already has submitted feedback`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: MutationAddFeedbackArgs = { + data: { + eventId: testEvent!._id, + rating: 7, + review: "Test Review", + }, + }; + + const context = { + userId: testUser!._id, + }; + const { addFeedback: addFeedbackResolver } = await import( + "../../../src/resolvers/Mutation/addFeedback" + ); + + await addFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${FEEDBACK_ALREADY_SUBMITTED.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(FEEDBACK_ALREADY_SUBMITTED.MESSAGE); + } + }); +}); diff --git a/tests/resolvers/Query/hasSubmittedFeedback.spec.ts b/tests/resolvers/Query/hasSubmittedFeedback.spec.ts new file mode 100644 index 0000000000..10e903074e --- /dev/null +++ b/tests/resolvers/Query/hasSubmittedFeedback.spec.ts @@ -0,0 +1,174 @@ +import "dotenv/config"; +import { connect, disconnect } from "../../helpers/db"; +import { beforeAll, afterAll, describe, it, expect, vi } from "vitest"; +import type { QueryHasSubmittedFeedbackArgs } from "../../../src/types/generatedGraphQLTypes"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import { + EVENT_NOT_FOUND_ERROR, + USER_NOT_FOUND_ERROR, + USER_NOT_CHECKED_IN, + USER_NOT_REGISTERED_FOR_EVENT, +} from "./../../../src/constants"; +import { type TestUserType, createTestUser } from "./../../helpers/userAndOrg"; +import { createTestEvent, type TestEventType } from "../../helpers/events"; +import { CheckIn, EventAttendee } from "../../../src/models"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let randomTestUser: TestUserType; +let testUser: TestUserType; +let testEvent: TestEventType; +let eventAttendeeId: string; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + randomTestUser = await createTestUser(); + [testUser, , testEvent] = await createTestEvent(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("resolvers -> Query -> hasSubmittedFeedback", () => { + it(`throws NotFoundError if no user exists with _id === args.userId `, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: QueryHasSubmittedFeedbackArgs = { + userId: Types.ObjectId().toString(), + eventId: Types.ObjectId().toString(), + }; + + const context = {}; + + const { hasSubmittedFeedback: hasSubmittedFeedbackResolver } = + await import("../../../src/resolvers/Query/hasSubmittedFeedback"); + + await hasSubmittedFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_FOUND_ERROR.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws NotFoundError if no event exists with _id === args.eventId`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: QueryHasSubmittedFeedbackArgs = { + userId: randomTestUser!._id, + eventId: Types.ObjectId().toString(), + }; + + const context = {}; + + const { hasSubmittedFeedback: hasSubmittedFeedbackResolver } = + await import("../../../src/resolvers/Query/hasSubmittedFeedback"); + + await hasSubmittedFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${EVENT_NOT_FOUND_ERROR.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(EVENT_NOT_FOUND_ERROR.MESSAGE); + } + }); + + it(`throws Error if the user is not registered to attend the event`, async () => { + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: QueryHasSubmittedFeedbackArgs = { + userId: testUser!._id, + eventId: testEvent!._id, + }; + + const context = {}; + + const { hasSubmittedFeedback: hasSubmittedFeedbackResolver } = + await import("../../../src/resolvers/Query/hasSubmittedFeedback"); + + await hasSubmittedFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_REGISTERED_FOR_EVENT.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith( + USER_NOT_REGISTERED_FOR_EVENT.MESSAGE + ); + } + }); + + it(`throws Error if the user is has not checked in to the event`, async () => { + const eventAttendee = await EventAttendee.create({ + eventId: testEvent!._id, + userId: testUser!._id, + }); + eventAttendeeId = eventAttendee._id; + + const { requestContext } = await import("../../../src/libraries"); + + const spy = vi + .spyOn(requestContext, "translate") + .mockImplementationOnce((message) => `Translated ${message}`); + + try { + const args: QueryHasSubmittedFeedbackArgs = { + userId: testUser!._id, + eventId: testEvent!._id, + }; + + const context = {}; + + const { hasSubmittedFeedback: hasSubmittedFeedbackResolver } = + await import("../../../src/resolvers/Query/hasSubmittedFeedback"); + + await hasSubmittedFeedbackResolver?.({}, args, context); + } catch (error: any) { + expect(error.message).toEqual( + `Translated ${USER_NOT_CHECKED_IN.MESSAGE}` + ); + expect(spy).toHaveBeenLastCalledWith(USER_NOT_CHECKED_IN.MESSAGE); + } + }); + + it(`returns the feedback status correctly if the user has checked in to the event`, async () => { + const checkIn = await CheckIn.create({ + eventAttendeeId, + feedbackSubmitted: true, + }); + + await EventAttendee.findByIdAndUpdate(eventAttendeeId, { + checkInId: checkIn._id, + }); + + const args: QueryHasSubmittedFeedbackArgs = { + userId: testUser!._id, + eventId: testEvent!._id, + }; + + const context = {}; + + const { hasSubmittedFeedback: hasSubmittedFeedbackResolver } = await import( + "../../../src/resolvers/Query/hasSubmittedFeedback" + ); + + const payload = await hasSubmittedFeedbackResolver?.({}, args, context); + expect(payload).toBeTruthy(); + }); +});