diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index b435a1cb7a..1cd0299a28 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -11,7 +11,7 @@ services: redis-stack-server: image: redis/redis-stack-server:latest ports: - - 6379:6379 + - 6380:6379 volumes: - redis-data:/data/redis diff --git a/package.json b/package.json index 69c31d2f42..f29229fe54 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "import:sample-data": "tsx ./src/utilities/loadSampleData.ts", "import:sample-data:prod": "node ./build/utilities/loadSampleData.js", "gen:schema": "graphql-inspector introspect ./src/typeDefs/**/**/*.ts --write ./schema.graphql ", - "update:toc": "node scripts/githooks/update-toc.js" + "update:toc": "node scripts/githooks/update-toc.js", + "docker:dev" : "sudo /usr/libexec/docker/cli-plugins/docker-compose -f docker-compose.dev.yaml up" }, "repository": { "type": "git", diff --git a/schema.graphql b/schema.graphql index c5fb18a0dd..d978cff89f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -285,7 +285,11 @@ input CreateActionItemInput { preCompletionNotes: String } -union CreateAdminError = OrganizationMemberNotFoundError | OrganizationNotFoundError | UserNotAuthorizedError | UserNotFoundError +union CreateAdminError = + | OrganizationMemberNotFoundError + | OrganizationNotFoundError + | UserNotAuthorizedError + | UserNotFoundError type CreateAdminPayload { user: AppUserProfile @@ -340,7 +344,12 @@ type CreateCommentPayload { union CreateDirectChatError = OrganizationNotFoundError | UserNotFoundError -union CreateMemberError = MemberNotFoundError | OrganizationNotFoundError | UserNotAuthorizedAdminError | UserNotAuthorizedError | UserNotFoundError +union CreateMemberError = + | MemberNotFoundError + | OrganizationNotFoundError + | UserNotAuthorizedAdminError + | UserNotAuthorizedError + | UserNotFoundError type CreateMemberPayload { organization: Organization @@ -803,6 +812,17 @@ input EventWhereInput { title_starts_with: String } +type EventsConnection { + edges: [EventsConnectionEdge!]! + pageInfo: DefaultConnectionPageInfo! + totalCount: Int +} + +type EventsConnectionEdge { + cursor: String! + node: Event! +} + type ExtendSession { accessToken: String! refreshToken: String! @@ -1075,16 +1095,30 @@ type Mutation { addEventAttendee(data: EventAttendeeInput!): User! addFeedback(data: FeedbackInput!): Feedback! addLanguageTranslation(data: LanguageInput!): Language! - addOrganizationCustomField(name: String!, organizationId: ID!, type: String!): OrganizationCustomField! + addOrganizationCustomField( + name: String! + organizationId: ID! + type: String! + ): OrganizationCustomField! addOrganizationImage(file: String!, organizationId: String!): Organization! - addPledgeToFundraisingCampaign(campaignId: ID!, pledgeId: ID!): FundraisingCampaignPledge! - addUserCustomData(dataName: String!, dataValue: Any!, organizationId: ID!): UserCustomData! + addPledgeToFundraisingCampaign( + campaignId: ID! + pledgeId: ID! + ): FundraisingCampaignPledge! + addUserCustomData( + dataName: String! + dataValue: Any! + organizationId: ID! + ): UserCustomData! addUserImage(file: String!): User! addUserToGroupChat(chatId: ID!, userId: ID!): GroupChat! addUserToUserFamily(familyId: ID!, userId: ID!): UserFamily! adminRemoveGroup(groupId: ID!): GroupChat! assignUserTag(input: ToggleUserTagAssignInput!): User - blockPluginCreationBySuperadmin(blockUser: Boolean!, userId: ID!): AppUserProfile! + blockPluginCreationBySuperadmin( + blockUser: Boolean! + userId: ID! + ): AppUserProfile! blockUser(organizationId: ID!, userId: ID!): User! cancelMembershipRequest(membershipRequestId: ID!): MembershipRequest! checkIn(data: CheckInCheckOutInput!): CheckIn! @@ -1092,25 +1126,46 @@ type Mutation { createActionItem(actionItemCategoryId: ID!, data: CreateActionItemInput!): ActionItem! createActionItemCategory(isDisabled: Boolean!, name: String!, organizationId: ID!): ActionItemCategory! createAdmin(data: UserAndOrganizationInput!): CreateAdminPayload! - createAdvertisement(input: CreateAdvertisementInput!): CreateAdvertisementPayload + createAdvertisement( + input: CreateAdvertisementInput! + ): CreateAdvertisementPayload createAgendaCategory(input: CreateAgendaCategoryInput!): AgendaCategory! createAgendaItem(input: CreateAgendaItemInput!): AgendaItem! createAgendaSection(input: CreateAgendaSectionInput!): AgendaSection! createComment(data: CommentInput!, postId: ID!): Comment createDirectChat(data: createChatInput!): DirectChat! - createDonation(amount: Float!, nameOfOrg: String!, nameOfUser: String!, orgId: ID!, payPalId: ID!, userId: ID!): Donation! - createEvent(data: EventInput!, recurrenceRuleData: RecurrenceRuleInput): Event! + createDonation( + amount: Float! + nameOfOrg: String! + nameOfUser: String! + orgId: ID! + payPalId: ID! + userId: ID! + ): Donation! + createEvent( + data: EventInput! + recurrenceRuleData: RecurrenceRuleInput + ): Event! createEventVolunteer(data: EventVolunteerInput!): EventVolunteer! - createEventVolunteerGroup(data: EventVolunteerGroupInput!): EventVolunteerGroup! + createEventVolunteerGroup( + data: EventVolunteerGroupInput! + ): EventVolunteerGroup! createFund(data: FundInput!): Fund! createFundraisingCampaign(data: FundCampaignInput!): FundraisingCampaign! - createFundraisingCampaignPledge(data: FundCampaignPledgeInput!): FundraisingCampaignPledge! + createFundraisingCampaignPledge( + data: FundCampaignPledgeInput! + ): FundraisingCampaignPledge! createGroupChat(data: createGroupChatInput!): GroupChat! createMember(input: UserAndOrganizationInput!): CreateMemberPayload! createMessageChat(data: MessageChatInput!): MessageChat! createNote(data: NoteInput!): Note! createOrganization(data: OrganizationInput, file: String): Organization! - createPlugin(pluginCreatedBy: String!, pluginDesc: String!, pluginName: String!, uninstalledOrgs: [ID!]): Plugin! + createPlugin( + pluginCreatedBy: String! + pluginDesc: String! + pluginName: String! + uninstalledOrgs: [ID!] + ): Plugin! createPost(data: PostInput!, file: String): Post createSampleOrganization: Boolean! createUserFamily(data: createUserFamilyInput!): UserFamily! @@ -1143,7 +1198,10 @@ type Mutation { removeAgendaSection(id: ID!): ID! removeComment(id: ID!): Comment removeDirectChat(chatId: ID!, organizationId: ID!): DirectChat! - removeEvent(id: ID!, recurringEventDeleteType: RecurringEventMutationType): Event! + removeEvent( + id: ID! + recurringEventDeleteType: RecurringEventMutationType + ): Event! removeEventAttendee(data: EventAttendeeInput!): User! removeEventVolunteer(id: ID!): EventVolunteer! removeEventVolunteerGroup(id: ID!): EventVolunteerGroup! @@ -1151,7 +1209,10 @@ type Mutation { removeGroupChat(chatId: ID!): GroupChat! removeMember(data: UserAndOrganizationInput!): Organization! removeOrganization(id: ID!): UserData! - removeOrganizationCustomField(customFieldId: ID!, organizationId: ID!): OrganizationCustomField! + removeOrganizationCustomField( + customFieldId: ID! + organizationId: ID! + ): OrganizationCustomField! removeOrganizationImage(organizationId: String!): Organization! removePost(id: ID!): Post removeSampleOrganization: Boolean! @@ -1165,8 +1226,14 @@ type Mutation { revokeRefreshTokenForUser: Boolean! saveFcmToken(token: String): Boolean! sendMembershipRequest(organizationId: ID!): MembershipRequest! - sendMessageToDirectChat(chatId: ID!, messageContent: String!): DirectChatMessage! - sendMessageToGroupChat(chatId: ID!, messageContent: String!): GroupChatMessage! + sendMessageToDirectChat( + chatId: ID! + messageContent: String! + ): DirectChatMessage! + sendMessageToGroupChat( + chatId: ID! + messageContent: String! + ): GroupChatMessage! signUp(data: UserInput!, file: String): AuthData! togglePostPin(id: ID!, title: String): Post! unassignUserTag(input: ToggleUserTagAssignInput!): User @@ -1175,26 +1242,59 @@ type Mutation { unlikePost(id: ID!): Post unregisterForEventByUser(id: ID!): Event! updateActionItem(data: UpdateActionItemInput!, id: ID!): ActionItem - updateActionItemCategory(data: UpdateActionItemCategoryInput!, id: ID!): ActionItemCategory - updateAdvertisement(input: UpdateAdvertisementInput!): UpdateAdvertisementPayload - updateAgendaCategory(id: ID!, input: UpdateAgendaCategoryInput!): AgendaCategory + updateActionItemCategory( + data: UpdateActionItemCategoryInput! + id: ID! + ): ActionItemCategory + updateAdvertisement( + input: UpdateAdvertisementInput! + ): UpdateAdvertisementPayload + updateAgendaCategory( + id: ID! + input: UpdateAgendaCategoryInput! + ): AgendaCategory updateAgendaItem(id: ID!, input: UpdateAgendaItemInput!): AgendaItem updateAgendaSection(id: ID!, input: UpdateAgendaSectionInput!): AgendaSection updateCommunity(data: UpdateCommunityInput!): Boolean! - updateEvent(data: UpdateEventInput!, id: ID!, recurrenceRuleData: RecurrenceRuleInput, recurringEventUpdateType: RecurringEventMutationType): Event! - updateEventVolunteer(data: UpdateEventVolunteerInput, id: ID!): EventVolunteer! - updateEventVolunteerGroup(data: UpdateEventVolunteerGroupInput, id: ID!): EventVolunteerGroup! + updateEvent( + data: UpdateEventInput! + id: ID! + recurrenceRuleData: RecurrenceRuleInput + recurringEventUpdateType: RecurringEventMutationType + ): Event! + updateEventVolunteer( + data: UpdateEventVolunteerInput + id: ID! + ): EventVolunteer! + updateEventVolunteerGroup( + data: UpdateEventVolunteerGroupInput + id: ID! + ): EventVolunteerGroup! updateFund(data: UpdateFundInput!, id: ID!): Fund! - updateFundraisingCampaign(data: UpdateFundCampaignInput!, id: ID!): FundraisingCampaign! - updateFundraisingCampaignPledge(data: UpdateFundCampaignPledgeInput!, id: ID!): FundraisingCampaignPledge! + updateFundraisingCampaign( + data: UpdateFundCampaignInput! + id: ID! + ): FundraisingCampaign! + updateFundraisingCampaignPledge( + data: UpdateFundCampaignPledgeInput! + id: ID! + ): FundraisingCampaignPledge! updateLanguage(languageCode: String!): User! updateNote(data: UpdateNoteInput!, id: ID!): Note! - updateOrganization(data: UpdateOrganizationInput, file: String, id: ID!): Organization! + updateOrganization( + data: UpdateOrganizationInput + file: String + id: ID! + ): Organization! updatePluginStatus(id: ID!, orgId: ID!): Plugin! updatePost(data: PostUpdateInput, id: ID!): Post! updateUserPassword(data: UpdateUserPasswordInput!): UserData! updateUserProfile(data: UpdateUserInput, file: String): User! - updateUserRoleInOrganization(organizationId: ID!, role: String!, userId: ID!): Organization! + updateUserRoleInOrganization( + organizationId: ID! + role: String! + userId: ID! + ): Organization! updateUserTag(input: UpdateUserTagInput!): UserTag } @@ -1222,7 +1322,12 @@ type Organization { actionItemCategories: [ActionItemCategory] address: Address admins(adminId: ID): [User!] - advertisements(after: String, before: String, first: Int, last: Int): AdvertisementsConnection + advertisements( + after: String + before: String + first: Int + last: Int + ): AdvertisementsConnection agendaCategories: [AgendaCategory] apiUrl: URL! blockedUsers: [User] @@ -1233,13 +1338,27 @@ type Organization { funds: [Fund] image: String members: [User] - membershipRequests(first: Int, skip: Int, where: MembershipRequestsWhereInput): [MembershipRequest] + membershipRequests( + first: Int + skip: Int + where: MembershipRequestsWhereInput + ): [MembershipRequest] name: String! pinnedPosts: [Post] - posts(after: String, before: String, first: PositiveInt, last: PositiveInt): PostsConnection + posts( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): PostsConnection updatedAt: DateTime! userRegistrationRequired: Boolean! - userTags(after: String, before: String, first: PositiveInt, last: PositiveInt): UserTagsConnection + userTags( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): UserTagsConnection venues: [Venue] visibleInSearch: Boolean! } @@ -1327,14 +1446,20 @@ type OtpData { otpToken: String! } -"""Information about pagination in a connection.""" +""" +Information about pagination in a connection. +""" type PageInfo { currPageNo: Int - """When paginating forwards, are there more items?""" + """ + When paginating forwards, are there more items? + """ hasNextPage: Boolean! - """When paginating backwards, are there more items?""" + """ + When paginating backwards, are there more items? + """ hasPreviousPage: Boolean! nextPageNo: Int prevPageNo: Int @@ -1488,7 +1613,12 @@ type Query { actionItemsByEvent(eventId: ID!): [ActionItem] actionItemsByOrganization(eventId: ID, orderBy: ActionItemsOrderByInput, organizationId: ID!, where: ActionItemWhereInput): [ActionItem] adminPlugin(orgId: ID!): [Plugin] - advertisementsConnection(after: String, before: String, first: PositiveInt, last: PositiveInt): AdvertisementsConnection + advertisementsConnection( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): AdvertisementsConnection agendaCategory(id: ID!): AgendaCategory! agendaItemByEvent(relatedEventId: ID!): [AgendaItem] agendaItemByOrganization(organizationId: ID!): [AgendaItem] @@ -1502,8 +1632,17 @@ type Query { event(id: ID!): Event eventVolunteersByEvent(id: ID!): [EventVolunteer] eventsByOrganization(id: ID, orderBy: EventOrderByInput): [Event] - eventsByOrganizationConnection(first: Int, orderBy: EventOrderByInput, skip: Int, where: EventWhereInput): [Event!]! - fundsByOrganization(orderBy: FundOrderByInput, organizationId: ID!, where: FundWhereInput): [Fund] + eventsByOrganizationConnection( + first: Int + orderBy: EventOrderByInput + skip: Int + where: EventWhereInput + ): [Event!]! + fundsByOrganization( + orderBy: FundOrderByInput + organizationId: ID! + where: FundWhereInput + ): [Fund] getAgendaItem(id: ID!): AgendaItem getAgendaSection(id: ID!): AgendaSection getAllAgendaItems: [AgendaItem] @@ -1511,20 +1650,45 @@ type Query { getCommunityData: Community getDonationById(id: ID!): Donation! getDonationByOrgId(orgId: ID!): [Donation] - getDonationByOrgIdConnection(first: Int, orgId: ID!, skip: Int, where: DonationWhereInput): [Donation!]! + getDonationByOrgIdConnection( + first: Int + orgId: ID! + skip: Int + where: DonationWhereInput + ): [Donation!]! getEventAttendee(eventId: ID!, userId: ID!): EventAttendee getEventAttendeesByEventId(eventId: ID!): [EventAttendee] getEventInvitesByUserId(userId: ID!): [EventAttendee!]! - getEventVolunteerGroups(where: EventVolunteerGroupWhereInput): [EventVolunteerGroup]! - getFundById(id: ID!, orderBy: CampaignOrderByInput, where: CampaignWhereInput): Fund! + getEventVolunteerGroups( + where: EventVolunteerGroupWhereInput + ): [EventVolunteerGroup]! + getFundById( + id: ID! + orderBy: CampaignOrderByInput + where: CampaignWhereInput + ): Fund! getFundraisingCampaignPledgeById(id: ID!): FundraisingCampaignPledge! - getFundraisingCampaigns(campaignOrderby: CampaignOrderByInput, pledgeOrderBy: PledgeOrderByInput, where: CampaignWhereInput): [FundraisingCampaign]! + getFundraisingCampaigns( + campaignOrderby: CampaignOrderByInput + pledgeOrderBy: PledgeOrderByInput + where: CampaignWhereInput + ): [FundraisingCampaign]! getNoteById(id: ID!): Note! - getPledgesByUserId(orderBy: PledgeOrderByInput, userId: ID!, where: PledgeWhereInput): [FundraisingCampaignPledge] + getPledgesByUserId( + orderBy: PledgeOrderByInput + userId: ID! + where: PledgeWhereInput + ): [FundraisingCampaignPledge] getPlugins: [Plugin] getUserTag(id: ID!): UserTag getUserTagAncestors(id: ID!): [UserTag] - getVenueByOrgId(first: Int, orderBy: VenueOrderByInput, orgId: ID!, skip: Int, where: VenueWhereInput): [Venue] + getVenueByOrgId( + first: Int + orderBy: VenueOrderByInput + orgId: ID! + skip: Int + where: VenueWhereInput + ): [Venue] getlanguage(lang_code: String!): [Translation] groupChatById(id: ID!): GroupChat groupChatsByUserId(id: ID!): [GroupChat] @@ -1533,17 +1697,44 @@ type Query { joinedOrganizations(id: ID): [Organization] me: UserData! myLanguage: String - organizations(first: Int, id: ID, orderBy: OrganizationOrderByInput, skip: Int, where: MembershipRequestsWhereInput): [Organization] - organizationsConnection(first: Int, orderBy: OrganizationOrderByInput, skip: Int, where: OrganizationWhereInput): [Organization]! - organizationsMemberConnection(first: Int, orderBy: UserOrderByInput, orgId: ID!, skip: Int, where: UserWhereInput): UserConnection! + organizations( + first: Int + id: ID + orderBy: OrganizationOrderByInput + skip: Int + where: MembershipRequestsWhereInput + ): [Organization] + organizationsConnection( + first: Int + orderBy: OrganizationOrderByInput + skip: Int + where: OrganizationWhereInput + ): [Organization]! + organizationsMemberConnection( + first: Int + orderBy: UserOrderByInput + orgId: ID! + skip: Int + where: UserWhereInput + ): UserConnection! plugin(orgId: ID!): [Plugin] post(id: ID!): Post registeredEventsByUser(id: ID, orderBy: EventOrderByInput): [Event] registrantsByEvent(id: ID!): [User] user(id: ID!): UserData! userLanguage(userId: ID!): String - users(first: Int, orderBy: UserOrderByInput, skip: Int, where: UserWhereInput): [UserData] - usersConnection(first: Int, orderBy: UserOrderByInput, skip: Int, where: UserWhereInput): [UserData]! + users( + first: Int + orderBy: UserOrderByInput + skip: Int + where: UserWhereInput + ): [UserData] + usersConnection( + first: Int + orderBy: UserOrderByInput + skip: Int + where: UserWhereInput + ): [UserData]! venue(id: ID!): Venue } @@ -1838,9 +2029,20 @@ type User { organizationsBlockedBy: [Organization] phone: UserPhone pluginCreationAllowed: Boolean! - posts(after: String, before: String, first: PositiveInt, last: PositiveInt): PostsConnection + posts( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): PostsConnection registeredEvents: [Event] - tagsAssignedWith(after: String, before: String, first: PositiveInt, last: PositiveInt, organizationId: ID): UserTagsConnection + tagsAssignedWith( + after: String + before: String + first: PositiveInt + last: PositiveInt + organizationId: ID + ): UserTagsConnection updatedAt: DateTime! } @@ -1922,39 +2124,61 @@ input UserPhoneInput { } type UserTag { - """A field to get the mongodb object id identifier for this UserTag.""" + """ + A field to get the mongodb object id identifier for this UserTag. + """ _id: ID! """ A connection field to traverse a list of UserTag this UserTag is a parent to. """ - childTags(after: String, before: String, first: PositiveInt, last: PositiveInt): UserTagsConnection + childTags( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): UserTagsConnection - """A field to get the name of this UserTag.""" + """ + A field to get the name of this UserTag. + """ name: String! - """A field to traverse the Organization that created this UserTag.""" + """ + A field to traverse the Organization that created this UserTag. + """ organization: Organization - """A field to traverse the parent UserTag of this UserTag.""" + """ + A field to traverse the parent UserTag of this UserTag. + """ parentTag: UserTag """ A connection field to traverse a list of User this UserTag is assigned to. """ - usersAssignedTo(after: String, before: String, first: PositiveInt, last: PositiveInt): UsersConnection + usersAssignedTo( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): UsersConnection } -"""A default connection on the UserTag type.""" +""" +A default connection on the UserTag type. +""" type UserTagsConnection { edges: [UserTagsConnectionEdge!]! pageInfo: DefaultConnectionPageInfo! totalCount: Int } -"""A default connection edge on the UserTag type for UserTagsConnection.""" +""" +A default connection edge on the UserTag type for UserTagsConnection. +""" type UserTagsConnectionEdge { cursor: String! node: UserTag! @@ -1995,14 +2219,18 @@ input UserWhereInput { lastName_starts_with: String } -"""A default connection on the User type.""" +""" +A default connection on the User type. +""" type UsersConnection { edges: [UsersConnectionEdge!]! pageInfo: DefaultConnectionPageInfo! totalCount: Int } -"""A default connection edge on the User type for UsersConnection.""" +""" +A default connection edge on the User type for UsersConnection. +""" type UsersConnectionEdge { cursor: String! node: User! @@ -2066,4 +2294,4 @@ input createGroupChatInput { input createUserFamilyInput { title: String! userIds: [ID!]! -} \ No newline at end of file +} diff --git a/src/resolvers/Organization/events.ts b/src/resolvers/Organization/events.ts new file mode 100644 index 0000000000..d3d68f090c --- /dev/null +++ b/src/resolvers/Organization/events.ts @@ -0,0 +1,123 @@ +import type { OrganizationResolvers } from "../../types/generatedGraphQLTypes"; +import { MAXIMUM_FETCH_LIMIT } from "../../constants"; +import { + type DefaultGraphQLArgumentError, + type ParseGraphQLConnectionCursorArguments, + type ParseGraphQLConnectionCursorResult, + getCommonGraphQLConnectionFilter, + getCommonGraphQLConnectionSort, + parseGraphQLConnectionArguments, + transformToDefaultGraphQLConnection, +} from "../../utilities/graphQLConnection"; +import { GraphQLError } from "graphql"; +import type { InterfaceEvent } from "../../models"; +import { Event } from "../../models"; +import type { Types } from "mongoose"; + +/** + * This is a non-root field connection resolver for fetching events created wihtin an + * organization using relay specification compliant cursor pagination. More info here:- + * {@link https://relay.dev/graphql/connections.htm.} + */ +export const events: OrganizationResolvers["events"] = async (parent, args) => { + const parseArgsResult = await parseGraphQLConnectionArguments({ + args, + parseCursor: (args) => + parseCursor({ + ...args, + organizationId: parent._id, + }), + maximumLimit: MAXIMUM_FETCH_LIMIT, + }); + + if (parseArgsResult.isSuccessful === false) { + throw new GraphQLError("Invalid arguments provided.", { + extensions: { + code: "INVALID_ARGUMENTS", + errors: parseArgsResult.errors, + }, + }); + } + + const { parsedArgs } = parseArgsResult; + + const filter = getCommonGraphQLConnectionFilter({ + cursor: parsedArgs.cursor, + direction: parsedArgs.direction, + }); + + const sort = getCommonGraphQLConnectionSort({ + direction: parsedArgs.direction, + }); + + const [objectList, totalCount] = await Promise.all([ + Event.find({ + ...filter, + organization: parent._id, + }) + .sort(sort) + .limit(parsedArgs.limit) + .lean() + .exec(), + + Event.find({ + organization: parent._id, + }) + .countDocuments() + .exec(), + ]); + + return transformToDefaultGraphQLConnection< + ParsedCursor, + InterfaceEvent, + InterfaceEvent + >({ + objectList, + parsedArgs, + totalCount, + }); +}; + +/* +This is typescript type of the parsed cursor for this connection resolver. +*/ +type ParsedCursor = string; + +/* +This function is used to validate and transform the cursor passed to this connnection +resolver. +*/ +export const parseCursor = async ({ + cursorValue, + cursorName, + cursorPath, + organizationId, +}: ParseGraphQLConnectionCursorArguments & { + organizationId: string | Types.ObjectId; +}): ParseGraphQLConnectionCursorResult => { + const errors: DefaultGraphQLArgumentError[] = []; + const event = await Event.findOne({ + _id: cursorValue, + organization: organizationId, + }); + + if (!event) { + errors.push({ + message: `Argument ${cursorName} is an invalid cursor.`, + path: cursorPath, + }); + } + + if (errors.length !== 0) { + return { + errors, + isSuccessful: false, + }; + } + + return { + isSuccessful: true, + parsedCursor: cursorValue, + }; +}; + \ No newline at end of file diff --git a/src/typeDefs/types.ts b/src/typeDefs/types.ts index 8faf1a6521..cc33f7db89 100644 --- a/src/typeDefs/types.ts +++ b/src/typeDefs/types.ts @@ -278,6 +278,16 @@ export const types = gql` agendaItems: [AgendaItem] } + type EventsConnection { + edges: [EventsConnectionEdge!]! + pageInfo: DefaultConnectionPageInfo! + totalCount: Int + } + type EventsConnectionEdge { + cursor: String! + node: Event! + } + type EventVolunteer { _id: ID! createdAt: DateTime! @@ -462,6 +472,12 @@ export const types = gql` actionItemCategories: [ActionItemCategory] agendaCategories: [AgendaCategory] admins(adminId: ID): [User!] + events( + after: String + before: String + first: Int + last: Int + ): EventsConnection membershipRequests( first: Int skip: Int diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index 0f4178071b..4624d16651 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -885,6 +885,19 @@ export type EventWhereInput = { title_starts_with?: InputMaybe; }; +export type EventsConnection = { + __typename?: 'EventsConnection'; + edges: Array; + pageInfo: DefaultConnectionPageInfo; + totalCount?: Maybe; +}; + +export type EventsConnectionEdge = { + __typename?: 'EventsConnectionEdge'; + cursor: Scalars['String']['output']; + node: Event; +}; + export type ExtendSession = { __typename?: 'ExtendSession'; accessToken: Scalars['String']['output']; @@ -1969,6 +1982,7 @@ export type Organization = { advertisements?: Maybe; agendaCategories?: Maybe>>; apiUrl: Scalars['URL']['output']; + events?: Maybe; blockedUsers?: Maybe>>; createdAt: Scalars['DateTime']['output']; creator?: Maybe; @@ -1988,6 +2002,13 @@ export type Organization = { visibleInSearch: Scalars['Boolean']['output']; }; +export type OrganizationEventsArgs = { + after?: InputMaybe; + before?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; +}; + export type OrganizationAdminsArgs = { adminId?: InputMaybe; @@ -3374,6 +3395,8 @@ export type ResolversTypes = { EventVolunteerInput: EventVolunteerInput; EventVolunteerResponse: EventVolunteerResponse; EventWhereInput: EventWhereInput; + EventsConnection: ResolverTypeWrapper & { edges: Array }>; + EventsConnectionEdge: ResolverTypeWrapper & { node: ResolversTypes['Event'] }>; ExtendSession: ResolverTypeWrapper; Feedback: ResolverTypeWrapper; FeedbackInput: FeedbackInput; @@ -3583,6 +3606,8 @@ export type ResolversParentTypes = { EventVolunteerGroupWhereInput: EventVolunteerGroupWhereInput; EventVolunteerInput: EventVolunteerInput; EventWhereInput: EventWhereInput; + EventsConnection: Omit & { edges: Array }; + EventsConnectionEdge: Omit & { node: ResolversParentTypes['Event'] }; ExtendSession: ExtendSession; Feedback: InterfaceFeedbackModel; FeedbackInput: FeedbackInput; @@ -4115,6 +4140,19 @@ export type EventVolunteerGroupResolvers; }; +export type EventsConnectionResolvers = { + edges?: Resolver, ParentType, ContextType>; + pageInfo?: Resolver; + totalCount?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type EventsConnectionEdgeResolvers = { + cursor?: Resolver; + node?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type ExtendSessionResolvers = { accessToken?: Resolver; refreshToken?: Resolver; @@ -4458,6 +4496,7 @@ export type OrganizationResolvers, ParentType, ContextType>; customFields?: Resolver, ParentType, ContextType>; description?: Resolver; + events?: Resolver, ParentType, ContextType, Partial>; funds?: Resolver>>, ParentType, ContextType>; image?: Resolver, ParentType, ContextType>; members?: Resolver>>, ParentType, ContextType>; @@ -4889,6 +4928,8 @@ export type Resolvers = { EventAttendee?: EventAttendeeResolvers; EventVolunteer?: EventVolunteerResolvers; EventVolunteerGroup?: EventVolunteerGroupResolvers; + EventsConnection?: EventsConnectionResolvers; + EventsConnectionEdge?: EventsConnectionEdgeResolvers; ExtendSession?: ExtendSessionResolvers; Feedback?: FeedbackResolvers; FieldError?: FieldErrorResolvers; diff --git a/tests/resolvers/Organization/events.spec.ts b/tests/resolvers/Organization/events.spec.ts new file mode 100644 index 0000000000..9b8cb15bef --- /dev/null +++ b/tests/resolvers/Organization/events.spec.ts @@ -0,0 +1,188 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import "dotenv/config"; +import { GraphQLError } from "graphql"; +import type mongoose from "mongoose"; +import { Types } from "mongoose"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + type InterfaceOrganization, + Event, + Organization, + User, +} from "../../../src/models"; +import { + events as eventsResolver, + parseCursor, +} from "../../../src/resolvers/Organization/events"; +import type { DefaultGraphQLArgumentError } from "../../../src/utilities/graphQLConnection"; +import { connect, disconnect } from "../../helpers/db"; +import type { TestEventType } from "../../helpers/events"; +import { nanoid } from "nanoid"; + +let MONGOOSE_INSTANCE: typeof mongoose; +let testEvent1: TestEventType, + testEvent2: TestEventType, + testOrganization: InterfaceOrganization; + +beforeAll(async () => { + MONGOOSE_INSTANCE = await connect(); + + const testUser = await User.create({ + email: `email${nanoid()}@email.com`, + firstName: "firstName", + lastName: "lastName", + password: "passwordHash", + }); + + testOrganization = await Organization.create({ + creatorId: testUser._id, + description: "description", + name: "name", + }); + + const testEvents = await Event.insertMany([ + { + allDay: true, + creatorId: testUser._id, + description: "description", + isPublic: true, + isRegisterable: true, + organization: testOrganization._id, + startDate: new Date(), + title: "title", + }, + { + allDay: true, + creatorId: testUser._id, + description: "description", + isPublic: true, + isRegisterable: true, + organization: testOrganization._id, + startDate: new Date(), + title: "title", + }, + ]); + testEvent1 = testEvents[0].toObject(); + testEvent2 = testEvents[1].toObject(); +}); + +afterAll(async () => { + await disconnect(MONGOOSE_INSTANCE); +}); + +describe("events resolver", () => { + const parent = testOrganization; + it(`throws GraphQLError if invalid arguments are provided to the resolver`, async () => { + try { + await eventsResolver?.(parent, {}, {}); + } catch (error) { + if (error instanceof GraphQLError) { + expect(error.extensions.code).toEqual("INVALID_ARGUMENTS"); + expect( + (error.extensions.errors as DefaultGraphQLArgumentError[]).length + ).toBeGreaterThan(0); + } + } + }); + + it(`returns the expected connection object`, async () => { + const parent = testOrganization; + const connection = await eventsResolver?.( + parent, + { + first: 2, + }, + {} + ); + + const totalCount = await Event.find({ + organization: testOrganization?._id, + }).countDocuments(); + + expect(connection).toEqual({ + edges: [ + { + cursor: testEvent2?._id.toString(), + node: { + ...testEvent2, + _id: testEvent2?._id.toString(), + }, + }, + { + cursor: testEvent1?._id.toString(), + node: { + ...testEvent1, + _id: testEvent1?._id.toString(), + }, + }, + ], + pageInfo: { + endCursor: testEvent1?._id.toString(), + hasNextPage: false, + hasPreviousPage: false, + startCursor: testEvent2?._id.toString(), + }, + totalCount, + }); + }); + + it(`returns the expected connection object with cursor argument`, async () => { + const connection = await eventsResolver?.( + parent, + { + first: 1, + after: testEvent2?._id.toString(), + }, + {}, + ); + + expect(connection).toEqual({ + edges: [ + { + cursor: testEvent1?._id.toString(), + node: { + ...testEvent1, + _id: testEvent1?._id.toString(), + }, + }, + ], + pageInfo: { + endCursor: testEvent1?._id.toString(), + hasNextPage: false, + hasPreviousPage: true, + startCursor: testEvent1?._id.toString(), + }, + totalCount: 2, + }); + }); +}); + +describe("parseCursor function", () => { + it("returns failure state if argument cursorValue is an invalid cursor", async () => { + const result = await parseCursor({ + cursorName: "after", + cursorPath: ["after"], + cursorValue: new Types.ObjectId().toString(), + organizationId: testOrganization?._id.toString() as string, + }); + + expect(result.isSuccessful).toEqual(false); + if (result.isSuccessful === false) { + expect(result.errors.length).toBeGreaterThan(0); + } + }); + + it("returns success state if argument cursorValue is a valid cursor", async () => { + const result = await parseCursor({ + cursorName: "after", + cursorPath: ["after"], + cursorValue: testEvent1?._id.toString() as string, + organizationId: testOrganization?._id.toString() as string, + }); + + expect(result.isSuccessful).toEqual(true); + if (result.isSuccessful === true) { + expect(result.parsedCursor).toEqual(testEvent1?._id.toString()); + } + }); +}); \ No newline at end of file