From d66c4ded4ae20d5b64ee04c9f04f76278c093de9 Mon Sep 17 00:00:00 2001 From: Tibet Sprague Date: Sun, 18 Sep 2022 22:44:34 -0700 Subject: [PATCH 1/3] Reorganize and document our GraphQL Schema Fixes https://github.com/Hylozoic/hylo-node/issues/859 --- api/graphql/mutations/event.js | 4 +- api/graphql/schema.graphql | 2655 ++++++++++++++++++++++---------- 2 files changed, 1876 insertions(+), 783 deletions(-) diff --git a/api/graphql/mutations/event.js b/api/graphql/mutations/event.js index d1f5be5bd..dc9d7c914 100644 --- a/api/graphql/mutations/event.js +++ b/api/graphql/mutations/event.js @@ -30,8 +30,6 @@ export async function invitePeopleToEvent (userId, eventId, inviteeIds) { var eventInvitation = await EventInvitation.find({userId: inviteeId, eventId}) if (!eventInvitation) { - console.log('creating for invitation for ', inviteeId) - await EventInvitation.create({ userId: inviteeId, inviterId: userId, @@ -46,4 +44,4 @@ export async function invitePeopleToEvent (userId, eventId, inviteeIds) { await event.createInviteNotifications(userId, inviteeIds) return event -} \ No newline at end of file +} diff --git a/api/graphql/schema.graphql b/api/graphql/schema.graphql index 6036ef5d3..1174e9e29 100644 --- a/api/graphql/schema.graphql +++ b/api/graphql/schema.graphql @@ -7,1282 +7,2377 @@ enum LocationDisplayPrecision { region } -# The currently logged-in person. -type Me { - id: ID - active: Boolean - affiliations: AffiliationQuerySet - avatarUrl: String - bannerUrl: String - bio: String - blockedUsers: [Person] - blockedUsersTotal: Int - contactEmail: String - contactPhone: String - groups(first: Int, cursor: ID, order: String): [Membership] - groupsTotal: Int - groupInvitesPending: InvitationQuerySet - email: String - emailValidated: Boolean - facebookUrl: String - hasDevice: Boolean - hasRegistered: Boolean - hasStripeAccount: Boolean - intercomHash: String - isAdmin: Boolean - joinRequests(status: Int): JoinRequestQuerySet - linkedinUrl: String - location: String - locationObject: Location - memberships(first: Int, cursor: ID, order: String): [Membership] - membershipsTotal: Int - messageThreads(first: Int, offset: Int, order: String, sortBy: String): MessageThreadQuerySet - messageThreadsTotal: Int - name: String - newNotificationCount: Int - posts(first: Int, cursor: ID, order: String): [Post] - postsTotal: Int - settings: UserSettings - skills(first: Int, cursor: ID): SkillQuerySet - skillsToLearn(first: Int, cursor: ID): SkillQuerySet - tagline: String - twitterName: String - unseenThreadCount: Int - updatedAt: Date - url: String -} +type Query { + # Find an Activity by ID + activity(id: ID): Activity -type UserSettings { - alreadySeenTour: Boolean - commentNotifications: String - digestFrequency: String - dmNotifications: String - lastViewedMessagesAt: String - mapBaseLayer: String - signupInProgress: Boolean - streamViewMode: String - streamSortBy: String - streamPostType: String -} + # Check if a group invitation is still valid + checkInvitation(invitationToken: String, accessCode: String): CheckInvitationResult -type GroupTopicQuerySet { - total: Int - hasMore: Boolean - items: [GroupTopic] -} + # Find an Comment by ID + comment(id: ID): Comment -type GroupTopic { - id: ID - topic: Topic - group: Group - postsTotal(groupSlug: String): Int - followersTotal(groupSlug: String): Int - isDefault: Boolean - isSubscribed: Boolean - newPostCount: Int - visibility: Int - updatedAt: Date - createdAt: Date -} + # Query for PersonConnections + connections( + # Number of PersonConnections to load + first: Int, + # Start loading at this offset + offset: Int + ): PersonConnectionQuerySet -type TopicQuerySet { - total: Int - hasMore: Boolean - items: [Topic] -} + # Read a group by id or by slug + group( + id: ID, + slug: String, + # Set to true to mark all notifications as read for this group by the current logged in user + updateLastViewed: Boolean + ): Group -type Topic { - id: ID - name: String - postsTotal(groupSlug: String): Int - followersTotal(groupSlug: String): Int - groupTopics( + # Check whether a Group exists by URL slug + groupExists(slug: String): GroupExistsOutput + + # Query for groups + groups( + # Search for groups who's name starts with the autcomplete string + autocomplete: String, + # Find only groups in this geographic bounding box + boundingBox: [PointInput], + # If 'all' return only groups that the current logged in user is a member of. If 'public' return only groups with visibility = Public. + context: String, + """ + If set to a JSON object then only find 'farm' type groups with group.settings->hideExtensionData not set to true. + The possible keys of the JSON are: + farmType: Find only farms that have all of the types in this list of comma separated farm types. Possible types include: .... + productCategories: Find only farms that have all the products in this comma separate list. + certOrManagementPlan: Find only farms with certifications that match the comma separated list, OR with management plans that match the list + Example object + { + farmType: 'wholesale_farm, directsale_farm', + productCategories: 'grains_other', 'vegetables', + certOrManagementPlan: 'american_grassfed' + } + """ + farmQuery: JSON, + # XXX: NOT USED + filter: String, + # Number of groups to return first: Int, - offset: Int + # Only find these groups, by ID + groupIds: [ID], + # Only return groups of this type. Only valid option right now is 'farm' + groupType: String, + # Use along with sortBy: 'nearest' to return groups sorted by closeness to this coordinate + nearCoord: PointInput, + # For pagination, return groups after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Return only groups that are children of any of these parent groups by slug + parentSlugs: [String], + # Find groups where search text is in the name, description or location + search: String, + # 'name', 'size', or 'nearest' + sortBy: String, + # Filter groups by visibility: 0: hidden (only groups logged in user is a member of), 1: Protected, includes groups that are related to groups logged in user is a member of, 2: Public groups only + visibility: Int + ): GroupQuerySet + + # Query for group extension data by ID + groupExtension( + id: ID + ): GroupExtension + + # Query for group extensions + groupExtensions( + # Only return extension data with one of these IDs + extensionIds: [ID], + # Only return extension data for these groups by group ID + groupIds: [ID] + ): GroupExtensionQuerySet + + # Find a GroupTopic by Group's URL slug and Topic name + groupTopic( groupSlug: String, + topicName: String + ): GroupTopic + + # Query for GroupTopics (relationship between a Group and a Topic) + groupTopics( + # Return only topics whose name starts with the autocomplete string + autocomplete: String, + # Number of GroupTopics to return + first: Int, + # If true only return topics that are set to be a default topic in a group that the current logged in user is a member of isDefault: Boolean, + # For pagination, start loading GroupTopics after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Sort by 'name' or 'id' + sortBy: String, + # If true only return group topics that the current logged in user is subscribed to (following) + subscribed: Boolean, + # Only return topics that are set to this visibility in a group that the current logged in user is a member of: 0 = hidden in the group, 1 = visible, 2 = pinned visibility: [Int] ): GroupTopicQuerySet -} -type Person { - id: ID - name: String - avatarUrl: String - bannerUrl: String - bio: String - contactEmail: String - contactPhone: String - hasRegistered: Boolean - twitterName: String - linkedinUrl: String - facebookUrl: String - url: String - lastActiveAt: String - location: String - locationObject: Location - tagline: String - affiliations: AffiliationQuerySet - comments(first: Int, offset: Int, order: String): CommentQuerySet - eventsAttending: PostQuerySet - memberships(first: Int, cursor: ID, order: String): [Membership] - membershipsTotal: Int - moderatedGroupMemberships(first: Int, cursor: ID, order: String): [Membership] - moderatedGroupMembershipsTotal: Int + # Query for JoinRequests representing a user requesting to join a group + joinRequests( + # Only return JoinRequests for this group by ID + groupId: ID, + # Only return JoinRequests with this status: 0 = Pending, 1 = Accepted, 2 = Rejected, 3 = Canceled + status: Int + ): JoinRequestQuerySet + + # Get the currently logged in user + me: Me + + # Query notifications for the currently logged in user + notifications( + # Number of notifications to load + first: Int, + # Start loading notifications after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Reset the count of new notifications on all devices for currently logged in user + resetCount: Boolean + ): NotificationQuerySet + + # Query for a MessageThread by ID + messageThread(id: ID): MessageThread + + # Query for people + people( + # Only return People whose name starts with the autocomplete string + autocomplete: String, + # Only return People who have a location set that is in this geographic bounding box + boundingBox: [PointInput], + # NOT USED + filter: String, + # Number of People to return + first: Int, + # Only return members of these groups by group ID + groupIds: [String], + # For pagination return people after this offset + offset: Int, + # Number of People to return + order: String, + # Options are: 'name', 'location', 'join' (join date), 'last_active_at' + sortBy: String, + # Return People where the search term matches the name, bio or skills on their profile + search: String + ): PersonQuerySet + + # Find a Person by ID or by email + person(id: ID, email: String): Person + + # Query for a Post by ID + post(id: ID): Post + + # Query for a set of Posts posts( + # Only return posts that have not be fulfilled/completed and that are not past their end time activePostsOnly: Boolean, + # Only return posts that end after this time afterTime: Date, + # Only return posts that end before this time beforeTime: Date, + # Only return posts within this geographic bounding box boundingBox: [PointInput], - context: String, + # If 'all' only return posts from groups the current logged in user is a member of. If 'public' only return pubic posts + context: String + # Only return posts of this type: 'discussion', 'event', 'offer', 'project', 'request' or 'resource' filter: String, + # Number of posts to return first: Int, + # Only return posts from this set of groups by group URL slug groupSlugs: [String], + # true = Only return posts that have been marked fulfilled/complete, false = only return posts not marked fulfilled/complete isFulfilled: Boolean, + # For pagination return posts after this offset offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # Return posts whose name or description match the search term search: String, + # One of 'created' (created datetime), 'updated' (last updated datetime), 'votes' (number of votes), 'start_time' sortBy: String, + # Return only posts that have this topic by ID topic: ID, + # Return only posts that have any of these topics by topic ID topics: [ID], + # Return only posts that are one of these types: 'discussion', 'event', 'offer', 'project', 'request' and/or 'resource' types: [String] ): PostQuerySet - projects: PostQuerySet - skills(first: Int, cursor: ID): SkillQuerySet - skillsToLearn(first: Int, cursor: ID): SkillQuerySet - votes(first: Int, offset: Int, order: String): VoteQuerySet - votesTotal: Int - messageThreadId: ID + + # Query for SavedSearches by user ID + savedSearches(userId: ID): SavedSearchQuerySet + + # Full text search for posts, people and comments + search( + # Number of results to return + first: Int, + # For pagination, return results after this offset + offset: Int, + # Search term + term: String, + # ONly search for content of a specific type: 'post', 'person', or 'comment' + type: String + ): SearchResultQuerySet + + # Query for Skills + skills( + # Return skills that start with the autocomplete string + autocomplete: String, + # Number of skills to return + first: Int, + # For pagination, return results after this offset + offset: Int + ): SkillQuerySet, + + # Find a Topic by ID or by name (text) + topic(id: ID, name: String): Topic + + # Query for Topics + topics( + # Return only topics whose name starts with the autocomplete string + autocomplete: String, + # Number of topics to return + first: Int, + # Only return topics used in this Group by URL slug + groupSlug: String, + # If true only return topics that are set to be a default topic in a group that the current logged in user is a member of + isDefault: Boolean, + # For pagination, only return Topics after this offset + offset: Int, + # Sort by 'name' or 'num_followers' + sortBy: String, + # Only return topics that are set to this visibility in a group that the current logged in user is a member of: 0 = hidden in the group, 1 = visible, 2 = pinned + visibility: [Int] + ): TopicQuerySet +} + +# The meta data associated with an Activity +type ActivityMeta { + """ + The reasons for this activity, options are: + 'mention' = // you are mentioned in a post or comment + 'comment' = someone makes a comment on a post you follow + 'contribution' = someone add you as a contributor to a #request + 'followAdd' = you are added as a follower + 'follow' = someone follows your post + 'unfollow' = someone leaves your post + 'announcement' = an announcement was posted + 'approvedJoinRequest' = your request to join a group was approved + 'joinRequest' = there is a reques to join one of your groups + 'groupChildGroupInvite' = someone invited a group to join one of your groups + 'groupChildGroupInviteAccepted' = a child group accepted the invite to join one of your groups + 'groupParentGroupJoinRequest' = someone requested to join one of your groups to a new parent group + 'groupParentGroupJoinRequestAccepted' = a parent group accepted one of your groups into it + TODO: turn this into it's own Enum? + """ + reasons: [String] +} + +# An activity that someone may want to be notified about +type Activity { + id: ID + """ + The primary reason this activity occurred: + 'mention' = // you are mentioned in a post or comment + 'comment' = someone makes a comment on a post you follow + 'contribution' = someone add you as a contributor to a #request + 'followAdd' = you are added as a follower + 'follow' = someone follows your post + 'unfollow' = someone leaves your post + 'announcement' = an announcement was posted + 'approvedJoinRequest' = your request to join a group was approved + 'joinRequest' = there is a reques to join one of your groups + 'groupChildGroupInvite' = someone invited a group to join one of your groups + 'groupChildGroupInviteAccepted' = a child group accepted the invite to join one of your groups + 'groupParentGroupJoinRequest' = someone requested to join one of your groups to a new parent group + 'groupParentGroupJoinRequestAccepted' = a parent group accepted one of your groups into it + TODO: turn this into it's own Enum? + """ + action: String + # The Person who carried out this Activity + actor: Person + # If this activity is related to a comment then this is the Comment + comment: Comment + # NOT USED RIGHT NOW + contributionAmount: Int + # Additional metadata about the Activity + meta: ActivityMeta + # The Group related to the activity + group: Group + # The other Group related to this activity for ones involving multiple groups + otherGroup: Group + # If this activity involved a post then this is the Post + post: Post + # Whether the Notification associated with this Activity has been read by the "reader" of this activity + unread: Boolean +} + +# An Affiliation with an organization that someone has added to their profile +type Affiliation { + id: ID + createdAt: Date + isActive: Boolean + # The name of the organization the person is affiliated with + orgName: String + # Connector word between role and org. e.g. Steward 'of' Terran Collective, or Laywer 'for' Hylo + preposition: String + # The role this person has in the organization + role: String + updatedAt: Date + user: Person + # URL of the affiliated organization + url: String +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type AffiliationQuerySet { + total: Int + hasMore: Boolean + items: [Affiliation] +} + +# A post attachment, either an image, video or file +type Attachment { + id: ID + createdAt: Date + # To order how attachments appear in the post + position: Int + # URL to the thumbnail image + thumbnailUrl: String + # 'image', 'file' or 'video' + type: String + # URL of the attachment file + url: String +} + +# Return type for the checkInvitation query +type CheckInvitationResult { + # Whether the invitation is still valid or not + valid: Boolean +} + +# A comment on a post +type Comment { + id: ID + # Any Attachments on the comment + attachments(type: String): [Attachment] + # Number of attachments + attachmentsTotal: Int + # Child comments + childComments(first: Int, cursor: ID, order: String): CommentQuerySet + createdAt: Date + # 'email' if this comment was created via an email, otherwise null + createdFrom: String + # The Person who wrote the comment + creator: Person + # For threaded comments what's the parent of this one + parentComment: Comment + # The Post this comment is on + post: Post + # The content of the comment + text: String +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type CommentQuerySet { + total: Int + hasMore: Boolean + items: [Comment] +} + +# A CustomView for a Group +type CustomView { + id: ID + # Whether this view only displays active posts or not + activePostsOnly: Boolean + # URL for external link views + externalLink: String + # Is this view active or not + isActive: Boolean + group: Group + groupId: Int + # The icon to display for this CustomView + icon: String + # The label to display for this CustomView + name: String + # Not used right now, will be used to order views in the navigation menu + order: Int + # For post stream type views, filter by these post types + postTypes: [String] + # Not used yet, will be used to filter the post stream by search text + searchText: String + # For post stream type views, only show posts that have any of these topics + topics: [Topic] + # Number of topic filters for this CustomView + topicsTotal: Int + # For post stream type views, how to display the posts: 'cards', 'list', 'grid', 'bigGrid' + viewMode: String +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type CustomViewQuerySet { + total: Int + hasMore: Boolean + items: [CustomView] } -type Membership { +# An invitation to an Event +type EventInvitation { id: ID - role: Int - hasModeratorRole: Boolean - createdAt: Date - lastViewedAt: String - newPostCount: Int - group: Group + # Who was invited person: Person - settings: MembershipSettings + # 'yes', 'no' or 'interested' + response: String } -type MembershipSettings { - sendEmail: Boolean - showJoinForm: Boolean - sendPushNotifications: Boolean +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type EventInvitationQuerySet { + total: Int + hasMore: Boolean + items: [EventInvitation] +} + +# A type of data extension that can be added to a group for special group types like farms +type Extension { + id: ID + # Only option right now is 'farm-onboarding' for Groups of type 'farm' + type: String +} + +# NOT USED RIGHT NOW +type FeedItem { + type: String + content: FeedItemContent } +# NOT USED RIGHT NOW +union FeedItemContent = Post | Interstitial + +# A Hylo Group type Group { id: ID + # URL for a video to display at the top of the about text aboutVideoUri: String, + # Either 0 = Closed (invite only), 1 = Restricted (Anyone can request to join), 2 = Open (anyone can join if they can find/see the group) accessibility: Int - activeMembers( + # URL to the image of the avatar of the group + avatarUrl: String + # URL to the image of the banner for the group + bannerUrl: String + # When the group was created + createdAt: Date + # Group description text + description: String + # A geoJSON object outlining a geographic shape describing the border/boundaries of the group in the world + geoShape: JSON + # The current invite link for this group (only a moderator of this group can access this attribute) + invitePath: String + # A string describing the physical location of this group in the world + location: String + # The fully geocoded location object + locationObject: Location + # Number of members in this group + memberCount: Int + # Word used to describe an admin/moderator of this group. Defaults to "Moderator" + moderatorDescriptor: String + # Plural word used to describe admins/moderators of this group. Defaults to "Moderators" + moderatorDescriptorPlural: String + # Group name + name: String + # For the current logged in user how many prerequisite groups must they join before they can join this one + numPrerequisitesLeft: Int + # Total number of posts in this group + postCount: Int + # Group settings + settings: GroupSettings + # Unique URL slug + slug: String + # Right now can either by 'farm' or null + type: String + # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) + typeDescriptor: String + # Word used to describe this type of groups. Defaults to Groups, or if type is set then defaults to the type (e.g. Farms) + typeDescriptorPlural: String + # Either 0 = Hidden (only visible to members), 1 = Protected (visible to members of related groups), 2 = Public (anyone can find/see this group) + visibility: Int + + # NOT USED RIGHT NOW. Use `members` query instead + activeMembers( # TODO: remove + # Return people whose names start with the autocomplete string + autocomplete: String + # Return only people within the geographic bounding box boundingBox: [PointInput], + # Number of people to return first: Int, - order: String, - sortBy: String, + # For pagination, return people after this offset in the query results offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, search: String, - autocomplete: String + sortBy: String ): PersonQuerySet - avatarUrl: String - bannerUrl: String + + # Child groups of this group childGroups( + # Return groups whose names start with the autocomplete string + autocomplete: String, + # Return only groups in the geographic bounding box boundingBox: [PointInput], + # Number of groups to return first: Int, + # For pagination, return only results after this offset in the query results + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # 'name' or 'size' sortBy: String, - offset: Int, - search: String, - autocomplete: String + # Return groups where the search text matches in the name, description or location + search: String ): GroupQuerySet - createdAt: Date + + # CustomViews for this group customViews: CustomViewQuerySet - description: String - geoShape: JSON + + # The GroupExtension data for this group if it is a special group type like 'farm' groupExtensions: GroupExtensionQuerySet + + # Requests to join another group from this group groupRelationshipInvitesFrom: GroupRelationshipInviteQuerySet + + # Invitations to join another group sent to this group groupRelationshipInvitesTo: GroupRelationshipInviteQuerySet + + # Questions that a group requesting to join this group must answer groupToGroupJoinQuestions: GroupToGroupJoinQuestionQuerySet + + # Topics that have been used in this group groupTopics( - first: Int, - sortBy: String, - order: String, - offset: Int, + # Return only topics whose name starts with the autocomplete string autocomplete: String, + # Number of GroupTopics to return + first: Int, + # If true only return topics that are set to be a default topic in this group isDefault: Boolean, + # For pagination, start loading GroupTopics after this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Sort by 'name' or 'id' + sortBy: String, + # If true only return group topics that the current logged in user is subscribed to (following) in this group subscribed: Boolean, - visibility: Int + # Only return topics that are set to this visibility in this group: 0 = hidden in the group, 1 = visible, 2 = pinned + visibility: [Int] ): GroupTopicQuerySet - invitePath: String + + # Questions that must be answered when someone requests to join this group joinQuestions: GroupJoinQuestionQuerySet - location: String - locationObject: Location + + # Group members members( + # Return people whose names start with the autocomplete string + autocomplete: String + # Return only people within the geographic bounding box boundingBox: [PointInput], + # Number of people to return first: Int, - order: String, - sortBy: String, + # For pagination, return people after this offset in the query results offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # Search for members by name, bio or skills search: String, - autocomplete: String + # Sort members by 'name', 'location', 'join' (when joined the group), or 'last_active_at' + sortBy: String ): PersonQuerySet - memberCount: Int + + # Group memberships (returns the membership objects instead of the Person objects of the members) memberships: MembershipQuerySet - moderators(first: Int, cursor: ID, order: String): PersonQuerySet - moderatorDescriptor: String - moderatorDescriptorPlural: String - name: String - numPrerequisitesLeft: Int + + # The group moderators + moderators( + # For pagination, return moderators after this ID + cursor: ID, + # Number of moderators to return + first: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String + ): PersonQuerySet + + # Parent Groups of this Group parentGroups( + # Return groups whose names start with the autocomplete string + autocomplete: String, + # Return only groups in the geographic bounding box boundingBox: [PointInput], + # Number of groups to return first: Int, + # For pagination, return only results after this offset in the query results + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # 'name' or 'size' sortBy: String, - offset: Int, - search: String, - autocomplete: String + # Return groups where the search text matches in the name, description or location + search: String ): GroupQuerySet - pendingInvitations(first: Int, cursor: ID, order: String): InvitationQuerySet - prerequisiteGroups(onlyNotMember: Boolean): GroupQuerySet + + # Pending invitations to people to join this group + pendingInvitations( + # For pagination, return results after this ID + cursor: ID, + # Number of invitations to return + first: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String + ): InvitationQuerySet + + # Groups that must be joined before someone can join this group + prerequisiteGroups( + # Only return groups that the logged in user is not a member of already + onlyNotMember: Boolean + ): GroupQuerySet + posts( + # Only return posts that are not past their end date and have not been completed activePostsOnly: Boolean, + # Only return posts that were created after this time afterTime: Date, + # Only return posts that were created before this time beforeTime: Date, + # Only return posts within this geographic bounding box boundingBox: [PointInput], + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' filter: String, + # Load this number of posts first: Int, + # Load only posts from this set of groups by group slug + groupSlugs: [String], + # Only return posts that are marked as an announcement isAnnouncement: Boolean, + # Only return posts that are either completed/fulfilled (true) or not (false) isFulfilled: Boolean, + # Start loading posts at this index offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # Only load posts whose content matches this search string search: String, + # Sort posts by 'created', 'start_time', 'updated', 'votes' sortBy: String, + # Only load posts with this topic by topic id topic: ID, + # Only load posts that have one of these topics by topic id topics: [ID], + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' types: [String] ): PostQuerySet - postCount: Int - settings: GroupSettings + + # All skills of members in this group skills( + # Only return skills whose text starts with the autocomplete string + autocomplete: String, + # Number of skills to return first: Int, - offset: Int, - autocomplete: String + # For pagination, return results after this offset + offset: Int ): SkillQuerySet - slug: String + + # Skills suggested to new users joining this group to see if they want to add to their profile suggestedSkills: SkillQuerySet - type: String - typeDescriptor: String - typeDescriptorPlural: String + + # Posts to display in this group's stream. This includes posts from child groups that the logged in user is a member of viewPosts( + # Only return posts that are not past their end date and have not been completed activePostsOnly: Boolean, + # Only return posts that were created after this time afterTime: Date, + # Only return posts that were created before this time beforeTime: Date, + # Only return posts within this geographic bounding box boundingBox: [PointInput], - isFulfilled: Boolean, + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' filter: String, + # Load this number of posts first: Int, + # Load only posts from this set of groups by group slug + groupSlugs: [String], + # Only return posts that are either completed/fulfilled (true) or not (false) + isFulfilled: Boolean, + # Start loading posts at this index offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # Only load posts whose content matches this search string search: String, + # Sort posts by 'created', 'start_time', 'updated', 'votes' sortBy: String, + # Only load posts with this topic by topic id topic: ID, + # Only load posts that have one of these topics by topic id topics: [ID], + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' types: [String] ): PostQuerySet - visibility: Int + + # Widgets used on the Explore page widgets: GroupWidgetQuerySet } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupQuerySet { + total: Int + hasMore: Boolean + items: [Group] +} + +# Results of groupExists query +type GroupExistsOutput { + exists: Boolean +} + +# Extension data for a special group type e.g. farm type GroupExtension { id: ID active: Boolean + # The extension data data: JSON - type: String -} - -type Extension { - id: ID + # The group type of this data type: String } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set type GroupExtensionQuerySet { total: Int hasMore: Boolean items: [GroupExtension] } -type GroupSettings { - allowGroupInvites: Boolean - askGroupToGroupJoinQuestions: Boolean - askJoinQuestions: Boolean - hideExtensionData: Boolean - locationDisplayPrecision: LocationDisplayPrecision - publicMemberDirectory: Boolean - showSuggestedSkills: Boolean -} - -type GroupWidget { +# A question that must be answered when requesting to join a group +type GroupJoinQuestion { id: Int - name: String - isVisible: Boolean - order: Int - context: String - settings: GroupWidgetSettings - group: Group -} - -type GroupWidgetSettings { + questionId: Int + # Content of the question text: String - title: String } -type GroupWidgetQuerySet { +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupJoinQuestionQuerySet { total: Int hasMore: Boolean - items: [GroupWidget] -} - -type Widget { - id: Int - name: String -} - -type CustomView { - id: Int - name: String - groupId: Int - group: Group - isActive: Boolean - searchText: String - icon: String - externalLink: String - viewMode: String - order: Int - activePostsOnly: Boolean - postTypes: [String] - topics: [Topic] - topicsTotal: Int + items: [GroupJoinQuestion] } -type CustomViewQuerySet { - total: Int - hasMore: Boolean - items: [CustomView] +# Group settings +type GroupSettings { + # Whether this group allows non-moderators to invite people (not currently used) + allowGroupInvites: Boolean + # Whether to require questions being answered when a group requests to join this group + askGroupToGroupJoinQuestions: Boolean + # Whether to require questions being answered when a person requests to join this group + askJoinQuestions: Boolean + # Whether to display group extension data on the profile for special group types like farms + hideExtensionData: Boolean + # How precisely to display group locations on the map and the group profile, to enable obfuscating the location + locationDisplayPrecision: LocationDisplayPrecision + # Whether to display the members of the group to non-members + publicMemberDirectory: Boolean + # Whether to display suggested skills to new members when they join this group + showSuggestedSkills: Boolean } +# A relationship between two Groups type GroupRelationship { id: ID childGroup: Group createdAt: Date parentGroup: Group + # NOT CURRENTLY USED role: Int settings: GroupRelationshipSettings updatedAt: Date } type GroupRelationshipSettings { + # Whether the parent group in this relationship must be joined before someone can join the child group isPrerequisite: Boolean } +# An invitation or request from a group to connect to another group, either as a parent or a child type GroupRelationshipInvite { id: ID createdAt: Date + # The Person that initiated the invite or request createdBy: Person + # The group doing the inviting or requesting to join fromGroup: Group + # When a group is trying to join one that has questions, here are the answers to those questions questionAnswers: [JoinRequestQuestionAnswer] + # 0 = Pending, 1 = Accepted, 2 = Rejected, 3 = Canceled status: Int + # The group being invited or asked to join toGroup: Group + # 0 = ParentToChild, 1 = ChildToParent type: Int updatedAt: Date } -type MembershipQuerySet { +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupRelationshipInviteQuerySet { + total: Int + hasMore: Boolean + items: [GroupRelationshipInvite] +} + +# A question required to be answered when a group requests to join this group +type GroupToGroupJoinQuestion { + id: Int + questionId: Int + # The content of the question + text: String +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupToGroupJoinQuestionQuerySet { + total: Int + hasMore: Boolean + items: [GroupToGroupJoinQuestion] +} + +# Data related to a Topic within a specific Group +type GroupTopic { + id: ID + createdAt: Date + # Total number of people following this topic in this group + followersTotal(groupSlug: String): Int + group: Group + # Is this a 'default' topic in this group, meaning all new members are auto subscribed to it and it appears + isDefault: Boolean + # Whether the current logged in user is subscribed to this topic in this group + isSubscribed: Boolean + # The number of new posts in this topic in this group since the current logged in user looked at the stream for this topic/group + newPostCount: Int + # Total number of posts using this topic in this group + postsTotal(groupSlug: String): Int + topic: Topic + updatedAt: Date + """ + 0: This topic has been hidden by an admin in this group, + 1: Visible in this group, + 2: this topic has been pinned by an admin of this group. + (pinned topics appear at the top of every member's subscribed topics list if they are subscribed to this topic) + """ + visibility: Int +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupTopicQuerySet { + total: Int + hasMore: Boolean + items: [GroupTopic] +} + +# Data related to a widget used on the Explore page in a specific group +type GroupWidget { + id: ID + # Where this widget is being displayed. Only option right now is 'landing' which means the group Explore page + context: String + # Which group this widget is being displayed in + group: Group + # Whether this widget is to be displayed or not + isVisible: Boolean + # Name of the widget + name: String + # The ordering of widgets displayed in the context + order: Int + # Settings for this widget in this group/context + settings: GroupWidgetSettings +} + +type GroupWidgetSettings { + # Text content for this widget if it has some + text: String + # Title content for this widget if it has one + title: String +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type GroupWidgetQuerySet { total: Int hasMore: Boolean - items: [Membership] + items: [GroupWidget] } -type PersonQuerySet { - total: Int - hasMore: Boolean - items: [Person] +# NOT USED RIGHT NOW +type Interstitial { + text: String } +# An invitation to join a group type Invitation { id: Int createdAt: Date + # Who created this invitation creator: Person + # The email address being invited email: String + # NOT USED? error: String + # Which group they are being invited to group: Group + # When this invitation was last sent out via email lastSentAt: Date + # The login token for the person receiving the invitation to accept it even if they are not logged into Hylo token: String } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set type InvitationQuerySet { total: Int hasMore: Boolean items: [Invitation] } +# A request from a person to join a group type JoinRequest { id: Int createdAt: Date + # Which group is being requested to join group: Group + # Answers to questions required by Restricted groups questionAnswers: [JoinRequestQuestionAnswer] + # 0 = Pending, 1 = Accepted, 2 = Rejected, 3 = Canceled status: Int updatedAt: Date + # The person who is requesting to join user: Person } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set type JoinRequestQuerySet { total: Int hasMore: Boolean items: [JoinRequest] } +# An answer to a required question when requesting to join a group type JoinRequestQuestionAnswer { id: Int + # THe answer text answer: String + # The question question: Question + # The person answering user: Person } -type Question { - id: Int - text: String -} - -type PostQuerySet { - total: Int - hasMore: Boolean - items: [Post] -} - -type FeedItem { - type: String - content: FeedItemContent -} - -union FeedItemContent = Post | Interstitial - -type Interstitial { - text: String -} - -type Post { +# A generated preview of a link in a Post +type LinkPreview { id: ID - acceptContributions: Boolean - activeMembers(first: Int, cursor: ID, order: String): PersonQuerySet - announcement: Boolean - attachments(type: String): [Attachment] - attachmentsTotal: Int - comments(first: Int, cursor: ID, order: String): CommentQuerySet - commenters(first: Int): [Person] - commentersTotal: Int - commentsTotal: Int - createdAt: Date - creator: Person - details: String - donationsLink: String - endTime: Date - eventInvitations(first: Int, cursor: ID, order: String): EventInvitationQuerySet - groups(first: Int, cursor: ID, order: String): [Group] - groupsTotal: Int - followers(first: Int, cursor: ID, order: String): [Person] - followersTotal: Int - fulfilledAt: Date - linkPreview: LinkPreview - location: String - locationObject: Location - myVote: Boolean - postMemberships: [PostMembership] - postMembershipsTotal: Int - projectManagementLink: String - isPublic: Boolean - members(first: Int, cursor: ID, order: String): PersonQuerySet - myEventResponse: String - startTime: Date + # The page description pulled from the URL + description: String + # Height of the preview image pulled from the URL + imageHeight: String + # Width of the preview image pulled from the URL + imageWidth: String + # Height of the preview image pulled from the URL + imageUrl: String + # NOT USED? + status: String + # The page title pulled from the URL title: String - topics: [Topic] - topicsTotal: Int - totalContributions: Int - type: String - updatedAt: Date - votesTotal: Int -} - -type PostMembership { - id: ID - pinned: Boolean - group: Group -} - -type PostUser { - id: ID - post: Post - user: Person -} - -type Attachment { - id: ID - type: String - position: Int + # The link URL url: String - thumbnailUrl: String - createdAt: Date -} - -type PersonConnection { - id: ID - person: Person - type: String - createdAt: Date - updatedAt: Date -} - -type PersonConnectionQuerySet { - total: Int - hasMore: Boolean - items: [PersonConnection] -} - -type CommentQuerySet { - total: Int - hasMore: Boolean - items: [Comment] -} - -type Comment { - id: ID - text: String - creator: Person - post: Post - parentComment: Comment - childComments(first: Int, cursor: ID, order: String): CommentQuerySet - createdAt: Date - createdFrom: String - attachments(type: String): [Attachment] - attachmentsTotal: Int } +# A Geocoded location by Mapbox type Location { + id: ID + # Not used right now. Can be 'rooftop', 'parcel', 'point', 'interpolated', 'intersection', or 'street' accuracy: String + # Street number like 123 addressNumber: String + # Street name e.g. Baker Rd addressStreet: String + # Geographic bounding box bbox: [Point] + # Geographic center point center: Point + # City name city: String + # Short country code e.g. us countryCode: String + # Full country name (not used right now) country: String createdAt: Date + # String describing the full address fullText: String + # A set of points describing the spatial geometry of the returned feature geometry: [Point] - id: ID + # Sub-city features in countries where this additional admin layer is used in addressing, or where such features are commonly referred to in local parlance. e.g. city districts in Brazil and Chile and arrondissements in France. locality: String + # Colloquial neighborhood name neighborhood: String + # Top-level sub-national administrative features, such as states in the United States or provinces in Canada or China. region: String + # Postal codes used in country-specific national addressing systems. postcode: String updatedAt: Date + # Optional. The Wikidata identifier for the returned feature. wikidata: String } -type Point { - lat: String - lng: String -} +# The currently logged-in person. +type Me { + id: ID + # Is logged in user active + active: Boolean + # The URL of logged in user's avatar image + avatarUrl: String + # The URL of logged in user's banner image on their profile + bannerUrl: String + # Personal bio of the logged in user + bio: String + # Number of Users the logged in user has blocked + blockedUsersTotal: Int + # Email address made visible on the logged in user's profile to people who can see them + contactEmail: String + # Phone number made visible on the logged in user's profile to people who can see them + contactPhone: String + # The number of Groups the logged in user is a member of + groupsTotal: Int + # The email used to log in (not publicly shared in the app) + email: String + # Whether the logged in user has validated their email address or not + emailValidated: Boolean + # The URL to the logged in user Facebook profile + facebookUrl: String + # Whether the logged in user is using the Hylo app on any mobile devices + hasDevice: Boolean + # False for new users who have not completed the registration process + hasRegistered: Boolean + # NOT CURRENTLY USED + hasStripeAccount: Boolean + # IGNORE + intercomHash: String + # IGNORE + isAdmin: Boolean + # URL of the logged in user's LinkedIn Profile + linkedinUrl: String + # String representing the logged in user's physical location + location: String + # Object with the geocoded details of the logged in user's physical location + locationObject: Location + # Number of total group memberships + membershipsTotal: Int + # The number of MessageThreads the logged in user is a part of + messageThreadsTotal: Int + # The logged in user's full name + name: String + # The number of unread notifications the logged in user has + newNotificationCount: Int + # The number of Posts the logged in user has created + postsTotal: Int + # The logged in user's settings + settings: UserSettings + # Short tagline + tagline: String + # Twitter handle + twitterName: String + # The number of message threads with unread content + unseenThreadCount: Int + # When this record was last updated + updatedAt: Date + # A website URL the logged in user has added to their profile + url: String -union SearchResultContent = Person | Post | Comment + # Organizational affiliations that are not Hylo Groups + affiliations: AffiliationQuerySet -type SearchResult { - id: ID - content: SearchResultContent -} + # Set of Users the logged in user has blocked + blockedUsers: [Person] -type SearchResultQuerySet { - total: Int - hasMore: Boolean - items: [SearchResult] + # Hylo Group memberships + groups( + # Start loading groups at this index + cursor: ID, + # Load this number of groups + first: Int, + # Determines sort order, either 'ASC' or 'DESC'. Groups always sorted by order they were created. + order: String + ): [Membership] + + # Set of pending invites to Groups + groupInvitesPending: InvitationQuerySet + + # The set of pending requests to join groups + joinRequests( + # Is set then only look up requests with this status. 0 = Pending, 1 = Accepted, 2 = Rejected, 3 = Canceled + status: Int + ): JoinRequestQuerySet + + # Hylo Group memberships + memberships( + # Start loading memberships at this index + cursor: ID, + # Load this number of memberships + first: Int, + # Determines sort order, either 'ASC' or 'DESC'. Memberships always sorted by order they were created. + order: String + ): [Membership] + + # The set of MessageThreads + messageThreads( + # Load this number of threads + first: Int, + # Starting at this offset + offset: Int, + # Determines sort order, either 'ASC' or 'DESC' + order: String, + # NOT USED RIGHT NOW + sortBy: String + ): MessageThreadQuerySet + + # The set of Posts the logged in user has created + posts( + # Start loading posts at this index + cursor: ID, + # Load this number of posts + first: Int, + # Determines sort order, either 'ASC' or 'DESC'. Posts are sorted by order they were created. + order: String + ): [Post] + + # The set of Skills the logged in user has added to their profile + skills( + # Start loading skills after this ID + cursor: ID, + # Number of skills to load + first: Int + ): SkillQuerySet + + # The set of Skills the logged in user want to learn + skillsToLearn( + # Start loading skills after this id + cursor: ID, + # Number of skills to load + first: Int + ): SkillQuerySet } -type EventInvitation { +# A user's group membership +type Membership { id: ID - response: String + createdAt: Date + group: Group + # Is this group membership a moderator role + hasModeratorRole: Boolean + # The last time the current logged in user viewed this group (if they are a member of it) + lastViewedAt: String + # Number of new posts in this group since the current logged in user viewed it (if they are a member of it) + newPostCount: Int person: Person + # 0: default, 1: moderator + role: Int + settings: MembershipSettings } -type EventInvitationQuerySet { +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type MembershipQuerySet { total: Int hasMore: Boolean - items: [EventInvitation] + items: [Membership] } -type MessageThreadQuerySet { - total: Int - hasMore: Boolean - items: [MessageThread] +# Settinga for a user's group membership +type MembershipSettings { + # Whether the user wants to get email notifications from this group + sendEmail: Boolean + # Whether this user has yet seen the form that appears when first joining a new group + showJoinForm: Boolean + # Whether the user wants to get mobile app push notifications from this group + sendPushNotifications: Boolean } -type MessageThread { +# A direct message sent in a specific thread +type Message { id: ID createdAt: Date - updatedAt: Date - participants(first: Int, cursor: ID, order: String): [Person] - participantsTotal: Int - messages(first: Int, cursor: ID, order: String): MessageQuerySet - unreadCount: Int - lastReadAt: String + # The person sending the message + creator: Person + # The message thread this message is a part of + messageThread: MessageThread + # The content of the Message + text: String } +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set type MessageQuerySet { total: Int hasMore: Boolean items: [Message] } -type Message { +# A direct message thread between 2 or more Persons +type MessageThread { id: ID - text: String - creator: Person - messageThread: MessageThread createdAt: Date + # When logged in user last read this thread + lastReadAt: String + # Total number of people in the thread + participantsTotal: Int + # Number of unread messages since logged in user last looked at this thread + unreadCount: Int + updatedAt: Date + + # The set of messages in the thread + messages(first: Int, cursor: ID, order: String): MessageQuerySet + + # The set of people in the thread + participants(first: Int, cursor: ID, order: String): [Person] } -type Vote { - id: ID - post: Post - voter: Person +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type MessageThreadQuerySet { + total: Int + hasMore: Boolean + items: [MessageThread] +} + +# A notification of an activity for a person +type Notification { + id: ID + # The activity being notified about + activity: Activity createdAt: Date } -type VoteQuerySet { +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type NotificationQuerySet { total: Int hasMore: Boolean - items: [Vote] + items: [Notification] } -type GroupExistsOutput { - exists: Boolean -} +# A user of Hylo +type Person { + id: ID + # The URL of this person's avatar image + avatarUrl: String + # The URL of this person's banner image on their profile + bannerUrl: String + # Personal bio + bio: String + # Email address made visible on this person's profile + contactEmail: String + # Phone number made visible on this person's profile + contactPhone: String + # The URL to this person's Facebook profile + facebookUrl: String + # False for new users who have not completed the registration process + hasRegistered: Boolean + # When this person last viewed Hylo + lastActiveAt: String + # URL of this person's LinkedIn Profile + linkedinUrl: String + # String representing this person's physical location + location: String + # Object with the geocoded details of this person's physical location + locationObject: Location + # Number of total group memberships + membershipsTotal: Int + # The id of the message thread this person has with the currently logged in user (if any) + messageThreadId: ID + # Number of groups this person moderates + moderatedGroupMembershipsTotal: Int + # This person's name + name: String + # Short tagline + tagline: String + # This person's twitter handle + twitterName: String + # A website URL this person has added to their profile + url: String + # Total number of upvotes this person has made on posts + votesTotal: Int -type Query { - activity(id: ID): Activity - comment(id: ID): Comment - groupExists(slug: String): GroupExistsOutput - me: Me - notifications( + # Organizational affiliations that are not Hylo Groups + affiliations: AffiliationQuerySet + # Comments this person has made (that are visible to the logged in user) + comments( + # Load this number of posts first: Int, - order: String, - offset: Int, - resetCount: Boolean - ): NotificationQuerySet - group(id: ID, slug: String, updateLastViewed: Boolean): Group - groups( - autocomplete: String, - boundingBox: [PointInput], - context: String, - farmQuery: JSON, - filter: String, + # Start loading posts at this index + offset: ID, + # Determines sort order, either 'ASC' or 'DESC'. Posts are sorted by order they were created. + order: String + ): CommentQuerySet + # The events this person is attending (that are visible to logged in user) + eventsAttending: PostQuerySet + # The set of Group Memberships this person has (that are visible to logged in user) + memberships( + # Start loading memberships at this index + cursor: ID, + # Load this number of memberships first: Int, - groupIds: [ID], - groupType: String, - nearCoord: PointInput, - offset: Int, - order: String, - parentSlugs: [String], - search: String, - sortBy: String, - visibility: Int - ): GroupQuerySet - groupExtension( - id: ID - ): GroupExtension - groupExtensions( - extensionIds: [ID], - groupIds: [ID] - ): GroupExtensionQuerySet - joinRequests(groupId: ID, status: Int): JoinRequestQuerySet - messageThread(id: ID): MessageThread - post(id: ID): Post + # Determines sort order, either 'ASC' or 'DESC'. Memberships always sorted by order they were created. + order: String + ): [Membership] + # The set of Memberships to groups this person moderates (that are visible to logged in user + moderatedGroupMemberships(first: Int, cursor: ID, order: String): [Membership] + # The set of Posts this person has created (that are visible to logged in user) posts( + # Only return posts that are not past their end date and have not been completed activePostsOnly: Boolean, + # Only return posts that were created after this time afterTime: Date, + # Only return posts that were created before this time beforeTime: Date, + # Only return posts within this geographic bounding box boundingBox: [PointInput], - context: String + # Only return posts within this context: 'public' = only return posts set to Public. 'all' = only return posts within a group that the current logged in user is a member of + context: String, + # Filter posts by a post type: 'discussion', 'event', 'offer', 'project', request', 'resource' filter: String, + # Load this number of posts first: Int, + # Load only posts from this set of groups by group slug groupSlugs: [String], + # Only return posts that are either completed/fulfilled (true) or not (false) isFulfilled: Boolean, + # Start loading posts at this index offset: Int, + # Determines sort order, either 'ASC' or 'DESC' order: String, + # Only load posts whose content matches this search string search: String, + # Sort posts by 'created', 'start_time', 'updated', 'votes' sortBy: String, + # Only load posts with this topic by topic id topic: ID, + # Only load posts that have one of these topics by topic id topics: [ID], + # Filter posts by a set of post types: 'discussion', 'event', 'offer', 'project', request', 'resource' types: [String] ): PostQuerySet - people( - boundingBox: [PointInput], - first: Int, - order: String, - sortBy: String, - offset: Int, - search: String, - autocomplete: String, - groupIds: [String], - filter: String - ): PersonQuerySet - person(id: ID, email: String): Person - topic(id: ID, name: String): Topic - groupTopic( - groupSlug: String, - topicName: String - ): GroupTopic - topics( - groupSlug: String, - autocomplete: String, - isDefault: Boolean, - visibility: [Int], - sortBy: String, - first: Int, - offset: Int - ): TopicQuerySet - connections( - first: Int, - offset: Int - ): PersonConnectionQuerySet - groupTopics( - autocomplete: String, - isDefault: Boolean, - subscribed: Boolean, - visibility: [Int], - sortBy: String, - order: String, - first: Int, - offset: Int - ): GroupTopicQuerySet - search( - term: String, - type: String, - first: Int, - offset: Int - ): SearchResultQuerySet - savedSearches(userId: ID): SavedSearchQuerySet - skills( - first: Int, - offset: Int, - autocomplete: String - ): SkillQuerySet, - checkInvitation(invitationToken: String, accessCode: String): CheckInvitationResult + + # Project posts that this person is a member of + projects: PostQuerySet + + # The set of Skills this person has added to their profile + skills(first: Int, cursor: ID): SkillQuerySet + + # The set of Skills this person want to learn + skillsToLearn(first: Int, cursor: ID): SkillQuerySet + + # Upvotes on posts that this person has made + votes(first: Int, offset: Int, order: String): VoteQuerySet } -input AffiliationInput { - role: String - preposition: String - orgName: String - url: String +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type PersonQuerySet { + total: Int + hasMore: Boolean + items: [Person] } -input AttachmentInput { - url: String - attachmentType: String +# Only type of connection tracked right now is who has sent a message to who. Also this can only be used to find connections for the current logged in user +type PersonConnection { + id: ID + createdAt: Date + # Who the current user is connected to + person: Person + # Only option right now is 'message' + type: String + updatedAt: Date } -input MeInput { - name: String - email: String - avatarUrl: String - bannerUrl: String - bio: String - contactEmail: String - contactPhone: String - twitterName: String - linkedinUrl: String - facebookUrl: String - location: String - locationId: ID - tagline: String - password: String - settings: UserSettingsInput - newNotificationCount: Int - url: String +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type PersonConnectionQuerySet { + total: Int + hasMore: Boolean + items: [PersonConnection] } -input PostInput { +type Post { + id: ID + # NOT USED RIGHT NOW. Whether this post can accept financial contributions acceptContributions: Boolean + # Was this post marked an announcement by a group moderator? announcement: Boolean + # Number of attachments on this post + attachmentsTotal: Int + # Number of people who have commented on this post + commentersTotal: Int + # Total number of comments on this post + commentsTotal: Int + createdAt: Date + # The body of the post details: String + # Link to a donation page, only used for projects only right now donationsLink: String + # When this post "ends". Used for events, resources, projects, requests and offers right now endTime: Date - eventInviteeIds: [ID] - fileUrls: [String] - groupIds: [String] - imageUrls: [String] + # Number of people following this post (when someone comments on or reacts to a post they follow it) + followersTotal: Int + # When the posr creator marked it as "fulfilled" or "complete" + fulfilledAt: Date + # Number of groups this has been posted to + groupsTotal: Int + # Whether this post is publicly visible (outside of the groups it is posted to) isPublic: Boolean - linkPreviewId: String + # Full location string location: String - locationId: ID - memberIds: [ID] + # Object with geocoded location deatils + locationObject: Location + # Logged in user's attendance response to an event post: 'yes', 'maybe' or 'no' + myEventResponse: String + # Whether the logged in user has upvoted the post or not + myVote: Boolean + # Number of total "members" of the post. Used for Projects only right now + postMembershipsTotal: Int + # Link to a project management tool or service. Only used for Projects right now projectManagementLink: String + # When this post "starts". Used for events, resources, projects, requests and offers right now startTime: Date + # The title text for the post title: String - topicNames: [String] + # Number of topics added to the post + topicsTotal: Int + # NOT USED RIGHT NOW + totalContributions: Int + # Post type: 'discussion', 'event', 'offer', 'project', 'request', or 'resource' type: String -} + updatedAt: Date + # Number of upvotes on the post + votesTotal: Int -input CommentInput { - text: String - postId: String - parentCommentId: String - attachments: [AttachmentInput] -} + # The attachments (images, videos or files) added to the post + attachments(type: String): [Attachment] -input MessageInput { - text: String - messageThreadId: String - createdAt: Date -} + # The comments on this post + comments(first: Int, cursor: ID, order: String): CommentQuerySet -input MessageThreadInput { - participantIds: [String] -} + # All the people who have commented on this post + commenters(first: Int): [Person] -input LinkPreviewInput { - url: String -} + # The person who created this post + creator: Person -input LocationInput { - accuracy: String - addressNumber: String - addressStreet: String - bbox: [PointInput] - center: PointInput - city: String - country: String - countryCode: String - createdAt: Date - fullText: String - geometry: [PointInput] - lat: String - lng: String - locality: String - neighborhood: String - region: String - postcode: String - updatedAt: Date - wikidata: String -} + # Invitations to event posts + eventInvitations(first: Int, cursor: ID, order: String): EventInvitationQuerySet -input PointInput { - lat: Float - lng: Float -} + # People following this post (from commenting or voting on it) + followers(first: Int, cursor: ID, order: String): [Person] -input MembershipInput { - newPostCount: Int - settings: MembershipSettingsInput -} + # The groups this post has been posted to + groups(first: Int, cursor: ID, order: String): [Group] -input GroupTopicInput { - isDefault: Boolean - visibility: Int + # The link preview of the first link in the post that is shown as a card on the post + linkPreview: LinkPreview + + # The members of a project type Post + members(first: Int, cursor: ID, order: String): PersonQuerySet + + # The post's "memberships" in the groups it has been posted in + postMemberships: [PostMembership] + + # The topics that have been added to this post + topics: [Topic] } -input GroupTopicFollowInput { - newPostCount: Int +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type PostQuerySet { + total: Int + hasMore: Boolean + items: [Post] } -input InviteInput { - emails: [String] - message: String +# The relationship between a Post and a Group +type PostMembership { + id: ID + # The group this post was posted in + group: Group + # Whether this post is set to pinned in the group by a moderator + pinned: Boolean } -input GroupInput { - aboutVideoUri: String - accessibility: Int - active: Boolean - avatarUrl: String - bannerUrl: String - customViews: [CustomViewInput] - description: String - geoShape: String - groupToGroupJoinQuestions: [QuestionInput] - groupExtensions: [GroupExtensionInput] - joinQuestions: [QuestionInput] - location: String - locationId: ID - moderatorDescriptor: String - moderatorDescriptorPlural: String - name: String - parentIds: [ID] - prerequisiteGroupIds: [ID] - settings: GroupSettingsInput - slackHookUrl: String - slackTeam: String - slackConfigureUrl: String - slug: String - type: String - typeDescriptor: String - typeDescriptorPlural: String - visibility: Int +# Relationship of a user to a post, indicating "following the post". Created when a user comments on or upvotes a post +type PostUser { + id: ID + post: Post + user: Person } -input GroupSettingsInput { - allowGroupInvites: Boolean - askGroupToGroupJoinQuestions: Boolean - askJoinQuestions: Boolean - hideExtensionData: Boolean - locationDisplayPrecision: LocationDisplayPrecision - publicMemberDirectory: Boolean - showSuggestedSkills: Boolean +# A geographic point by latitude + longitude +type Point { + lat: String + lng: String } -input GroupExtensionInput { - data: JSON - type: String +# A question asked when someone is trying to join a group +type Question { + id: Int + # The question content + text: String } -input CustomViewInput { +# A saved set of search/filter parameters for the map that can be reloaded later by a user +type SavedSearch { id: ID - name: String - groupId: Int + # The geographic bounding box in the formar of [lng, lat, lng, lat...] + boundingBox: [Float] + createdAt: Date + # 'groups' for a Group specific search, 'all' to search for posts in all logged in user's groups, or 'public' to search all public posts + context: String + # The group to search (optional) + group: Group + # Whether the saved search is active or not isActive: Boolean - searchText: String - icon: String - externalLink: String - viewMode: String - activePostsOnly: Boolean + # The name of the saves search + name: String + # New posts since the last time the search was checked for new posts to send in the email digest + newPosts: [Post] + # Only include posts that match these post types postTypes: [String] - order: Int - topics: [CustomViewTopicInput] + # Only include posts that match this search text in title or description + searchText: String + # Only include posts that match one of these topics + topics: [SavedSearchTopic] } -input CustomViewTopicInput { +# A topic associated with a Saved Search +type SavedSearchTopic { id: ID + # Topic title name: String } -input QuestionInput { - id: Int - questionId: Int - text: String +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type SavedSearchQuerySet { + total: Int + hasMore: Boolean + items: [SavedSearch] } -input MembershipSettingsInput { - sendEmail: Boolean - showJoinForm: Boolean - sendPushNotifications: Boolean +# Search result content item which can be a Person, a Post or a Comment +union SearchResultContent = Person | Post | Comment + +# Search result item which can be a Person, a Post or a Comment +type SearchResult { + id: ID + content: SearchResultContent } -input QuestionAnswerInput { - questionId: Int - answer: String +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type SearchResultQuerySet { + total: Int + hasMore: Boolean + items: [SearchResult] } -input SavedSearchInput { - boundingBox: [PointInput] - groupSlug: String - context: String +# A skill someone adds to their profile +type Skill { + id: ID + # The text content of the skill name: String - postTypes: [String] - searchText: String - topicIds: [ID] - userId: ID } -input UserSettingsInput { +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type SkillQuerySet { + total: Int + hasMore: Boolean + items: [Skill] +} + +# A Topic object represnts a tag on a post +type Topic { + id: ID + # The number of followers of this topic in a particular group by slug + followersTotal(groupSlug: String): Int + # The topic string + name: String + # The number of posts using this topic in a particular group by slug + postsTotal(groupSlug: String): Int + + # The relationships to all groups that this topic has been used in + groupTopics( + # Load this many groupTopics + first: Int, + # Filter by a specific group by URL slug + groupSlug: String, + # If true only load groupTopics that are set to default in their group + isDefault: Boolean, + # Start loading groupTopics at this offset + offset: Int + # Filter by visibility: 0: hidden in the group, 1: visible, 2: pinned + visibility: [Int] + ): GroupTopicQuerySet +} + +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type TopicQuerySet { + total: Int + hasMore: Boolean + items: [Topic] +} + +# Current user's personal settings +type UserSettings { + # Has this person seen the tour that displays the first time someone logs in alreadySeenTour: Boolean + # How this person wants to receive notifications about new comments on posts they're following? By Email, Mobile App, or Both commentNotifications: String + # How often this person wants to receive the email digest about new posts and comments: Daily, Weekly or Never digestFrequency: String + # How this person wants to receive notifications about Direct Messages: Email, Mobile App, or Both dmNotifications: String + # When person wants last viewed their Direct Messages lastViewedMessagesAt: String + # The most recent map base later viewed by this person mapBaseLayer: String + # Whether this person has finished viewing the welcome wizard after first signing up signupInProgress: Boolean + # The most recent stream view mode used by this person: cards, list, grid, bigGrid streamViewMode: String + # The most recent stream sort used by this person: updated, created, votes streamSortBy: String + # The most recent stream post type filter used by this person streamPostType: String } -input GroupWidgetInput { - isVisible: Boolean - settings: GroupWidgetSettingsInput +# An upvote on a post +type Vote { + id: ID + createdAt: Date + # The Post being voted on + post: Post + # The Person doing the voting + voter: Person } -input GroupWidgetSettingsInput { - text: String - title: String +# Query Sets are used for pagination, returning the total # of results for a query along with the current set of items and whether there is more to load in the final set +type VoteQuerySet { + total: Int + hasMore: Boolean + items: [Vote] +} + +# A widget is a block of styled content that appears in an Explore page for a group +type Widget { + id: Int + # The name of the widget + name: String } +# The mutations accepted by Hylo type Mutation { + # For a moderator to accept an invitation to one of their groups to be related to another group (either as a parent or a child) acceptGroupRelationshipInvite(groupRelationshipInviteId: ID): AcceptGroupRelationshipInviteResult + # For a moderator to accept a request from a Person to join a Group acceptJoinRequest(joinRequestId: ID): JoinRequest - addMember(userId: ID, groupId: ID, role: Int): GenericResult + # Add a person to a group. Only a moderator of the group can do this. + addMember( + groupId: ID, + userId: ID, + # 0 = Regular member, 1 = moderator + role: Int + ): GenericResult + # Add a new moderator to a Group. Only a moderator of the Group can do this. addModerator(personId: ID, groupId: ID): Group + # NOT USED RIGHGT NOW. Add one or more people to a Project as a Project member. addPeopleToProjectRole(peopleIds: [ID], projectRoleId: ID): GenericResult - addSkill(name: String, type: Int): Skill + # Add a skill to your profile + addSkill( + name: String, + # 0 = Has this skill already, 1 = a skill I want to learn + type: Int + ): Skill + # Add a skill you want to learn to your profile addSkillToLearn(name: String): Skill + # Add a suggested skill to a Group. Only a group moderator can do this. addSuggestedSkillToGroup(groupId: ID, name: String): Skill + # NOT USED RIGHT NOW. Toggle whether a Group allows non-moderators to invite new people to the Group. Only a group moderator can call this. allowGroupInvites(groupId: ID, data: Boolean): Group + # For logged in user to block a user, meaning they will no longer see the user's posts and that user can no longer see theirs or message them. blockUser(blockedUserId: ID): GenericResult + # For a moderator of a group that has invited a group to be in relationship with it, to cancel that invitation. cancelGroupRelationshipInvite(groupRelationshipInviteId: ID): GenericResult + # For a Person who has requested to join another group to cancel that request. cancelJoinRequest(joinRequestId: ID): GenericResult + # For logged in user to add an Affiliation to their profile createAffiliation(data: AffiliationInput): Affiliation + # Add a comment to a Post createComment(data: CommentInput): Comment - createGroup(data: GroupInput, asUserId: ID): Group + # Create a new Group + createGroup( + data: GroupInput, + # Create the group as a specific user. Only supported by particular API keys. + asUserId: ID + ): Group + # Invite an email address to join a Group createInvitation(groupId: ID, data: InviteInput): CreatedInvitations + # Ask to join a Group createJoinRequest(groupId: ID, questionAnswers: [QuestionAnswerInput]): CreatedRequest + # Send a direct message createMessage(data: MessageInput): Message + # Create a new Post createPost(data: PostInput): Post + # Create a new 'project' type Post createProject(data: PostInput): Post + # NOT USED RIGHT NOW createProjectRole(projectId: ID, roleName: String): GenericResult + # Create a new Saved Search for the map createSavedSearch(data: SavedSearchInput): SavedSearch + # Create a new topic within a Group createTopic(topicName: String, groupId: ID, isDefault: Boolean, isSubscribing: Boolean): Topic + # Used by current logged in user to deactivate their account deactivateMe(id: ID): GenericResult + # Used by current logged in user to delete their account permanently deleteMe: GenericResult + # For moderator to decline someone's request to join their Group declineJoinRequest(joinRequestId: ID): JoinRequest + # Delete an Affiliation from your profile deleteAffiliation(id: ID): ID + # Delete a comment deleteComment(id: ID): GenericResult + # Delete a group that you are a moderator of deleteGroup(id: ID): GenericResult + # Remove the relationship between two groups if you are a moderator of one of them deleteGroupRelationship(parentId: ID, childId: ID): GenericResult + # Hide a topic within a group (it will still appear on posts in the group) deleteGroupTopic(id: ID): GenericResult + # Delete a post you created deletePost(id: ID): GenericResult + # Remove a person as a member from a project Post deleteProjectRole(id: ID): GenericResult + # Delete a SavedSearch you created deleteSavedSearch(id: ID): ID + # Set an invitation to join a group as expired. Only a group moderator can call this. expireInvitation(invitationId: ID): GenericResult + # Look for a link preview of a URL, or if not found create one findOrCreateLinkPreviewByUrl(data: LinkPreviewInput): LinkPreview + # Look for a geocoded location object or if not found create one from the location data findOrCreateLocation(data: LocationInput): Location + # Look for an existing direct message thread between participants, or if not yet there, create it findOrCreateThread(data: MessageThreadInput): MessageThread + # Flag a post as innapropriate flagInappropriateContent(data: InappropriateContentInput): GenericResult + # Mark one of your posts as fulfilled or completed fulfillPost(postId: ID): GenericResult + # Invite a group to join one of your groups as a child of it. Only a group moderator can call this. inviteGroupToJoinParent(parentId: ID, childId: ID): CreatedGroupRelationshipInvite + # Invite one or more people to an event Post invitePeopleToEvent(eventId: ID, inviteeIds: [ID]): Post + # Join a group. Will fail if the person does not have permission to join this group. joinGroup(groupId: ID): Membership + # Join a project Post as a member joinProject(id: ID): GenericResult + # Leave a group leaveGroup(id: ID): ID + # Remove yourself as a member of a project Post leaveProject(id: ID): GenericResult + # Login to Hylo on current device using email and password. TODO: add more info about errors, sessions, etc. login(email: String, password: String): SignupResult - logout: GenericResult, + # Logout from Hylo on the current device + logout: GenericResult + # Mark a notification as read markActivityRead(id: ID): Activity + # Mark all notifications as read markAllActivitiesRead: GenericResult + # Send a message to all the moderators of a Group messageGroupModerators(groupId: ID): ID + # Pin a post within a Group. Only a group moderator can call this. pinPost(postId: ID, groupId: ID): GenericResult + # NOT USED RIGHT NOW processStripeToken(postId: ID, token: String, amount: Int): GenericResult + # For a deactivated user to reactivate their account. reactivateMe(id: ID): GenericResult + # Regenerate the invite URL used to invite people to a Group. Only a group moderator can call this. regenerateAccessCode(groupId: ID): Group + # Register a new mobile device to the current logged in user account registerDevice(playerId: String, platform: String, version: String): GenericResult + # Reinvite all pending invitations to the current group. Only a group moderator can call this. reinviteAll(groupId: ID): GenericResult + # Create a new user account register(name: String, password: String): SignupResult + # NOT USED RIGHT NOW registerStripeAccount(authorizationCode: String): GenericResult + # Reject a Group's invite or request to connect to one of your groups. Only a group moderator can call this. rejectGroupRelationshipInvite(groupRelationshipInviteId: ID): GenericResult + # Remove a Person from a Group. Only a group moderator can call this. removeMember(personId: ID, groupId: ID): Group - removeModerator(personId: ID, groupId: ID, isRemoveFromGroup: Boolean): Group + # Remove a moderator from a Group. + removeModerator( + groupId: ID, + # Whether to also remove the person from the Group completely + isRemoveFromGroup: Boolean, + personId: ID + ): Group + # Remove a Post from a group, by groupId or group slug removePost(postId: ID, slug: String, groupId: ID): GenericResult + # Remove a skill from your user profile removeSkill(id: ID, name: String): GenericResult + # Remove a skill you want to learn from your user profile removeSkillToLearn(id: ID, name: String): GenericResult + # Remove a suggested skill from a Group by skill ID or name/text removeSuggestedSkillFromGroup(groupId: ID, id: ID, name: String): GenericResult - requestToAddGroupToParent(parentId: ID, childId: ID, questionAnswers: [QuestionAnswerInput]): CreatedGroupRelationshipInvite + # Request to add one one of the groups you moderate to another group as a child group. Only a group moderator can call this. + requestToAddGroupToParent( + childId: ID, + # Answers to any required questions to connect to the group + questionAnswers: [QuestionAnswerInput], + parentId: ID + ): CreatedGroupRelationshipInvite + # Resend an invitation to the invited email address resendInvitation(invitationId: ID): GenericResult - respondToEvent(id: ID, response: String): GenericResult + # Change your response to an event Post + respondToEvent( + # The ID of the event Post + id: ID, + # 'yes', 'maybe' or 'no' + response: String + ): GenericResult + # When a new person signs up, send them an email to verify their email address so they can finish registering sendEmailVerification(email: String!): GenericResult, + # Send a password reset email sendPasswordReset(email: String!): GenericResult, + # Subscribe or unsubscribe to a Topic in a Group subscribe(groupId: ID, topicId: ID, isSubscribing: Boolean): GenericResult + # Unlink profile from previously linked social platform. Provider can be 'facebook', 'twitter', or 'linkedin' unlinkAccount(provider: String): GenericResult + # Unblock a user unblockUser(blockedUserId: ID): GenericResult + # Mark a post as unfulfilled/uncompleted unfulfillPost(postId: ID): GenericResult + # Edit a comment updateComment(id: ID, data: CommentInput): Comment + # Update a Group's settings. Only a group moderator can call this. updateGroupSettings(id: ID, changes: GroupInput): Group - updateGroup(id: ID, changes: GroupInput, asUserId: ID): Group + # Update a Group's settings. Only can be called via API key. + updateGroup( + id: ID, + changes: GroupInput, + # Update group as a particular User + asUserId: ID + ): Group + # Update settings related a Topic in a Group. Only a group moderator can call this. updateGroupTopic(id: ID, data: GroupTopicInput): GenericResult + # Update settings related to a Person following a Topic in a Group updateGroupTopicFollow(id: ID, data: GroupTopicFollowInput): GenericResult + # Update your settings or profile information updateMe(changes: MeInput): Me + # Update settings related to your relationship to a particular group by Group ID or slug updateMembership(groupId: ID, slug: String, data: MembershipInput): Membership + # Update a post you created updatePost(id: ID, data: PostInput): Post + # NOT USED RIGHT NOW updateStripeAccount(accountId: String): GenericResult + # Update settings for a GroupWidget updateWidget(id: ID, changes: GroupWidgetInput): GroupWidget - useInvitation(invitationToken: String, accessCode: String): InvitationUseResult - verifyEmail(code: String, email: String!, token: String): SignupResult + # Accept an invitation to a Group + useInvitation( + # The invite code for the Group + accessCode: String, + # Your unique invitation token + invitationToken: String + ): InvitationUseResult + # Verify your email address, manually by code or automatically by URL token + verifyEmail( + # The shortcode sent via email + code: String, + # Your email address + email: String!, + # For verifying by clicking on a URL, the unique verification token + token: String + ): SignupResult + # Upvote a post, or remove upvote vote(postId: ID, isUpvote: Boolean): Post } -type SignupResult { - me: Me +# Result of acceptGroupRelationshipInvite mutation +type AcceptGroupRelationshipInviteResult { + # Whether the mutation succeeded or not + success: Boolean + # The new GroupRelationship object if it succeeded + groupRelationship: GroupRelationship +} + +# The result of a createInvitation mutation +type CreatedInvitations { + # The invitations created + invitations: [Invitation] +} + +# Result of the createJoinRequest mutation +type CreatedRequest { + request: JoinRequest # TODO: why don't we just return the JoinRequest directly instead of this type? +} + +# Result of a mutation that creates a GroupRelationshipInvite +type CreatedGroupRelationshipInvite { + # Whether the mutation succeeded or not + success: Boolean + # In the case that the invite was from and to a group that the user is a moderator of then the invite automatically succeeds and a GroupRelationship is formed and returned + groupRelationship: GroupRelationship + # If the invite was to a group the user is not a moderator of then return the new GroupRelationshipInvite + groupRelationshipInvite: GroupRelationshipInvite +} + +# A generic result of a mutation +type GenericResult { + # If the mutation failed this will contain an error string error: String + # Did the mutation succeed or not + success: Boolean } +# Result of a useInvitation mutation type InvitationUseResult { - membership: Membership + # If the mutation failed this will contain an error string error: String + # If the mutation succeeded, the new group Membership object + membership: Membership } -type GenericResult { - success: Boolean +# Result of a login or signup related mutation +type SignupResult { + # If the mutation failed this will contain an error string error: String + # If the mutation succeeded return the data for the current user logging in or signup up + me: Me +} + +# An Affilation with an organization added to a User profile +input AffiliationInput { + # The name of the organization the person is affiliated with + orgName: String + # Connector word between role and org. e.g. Steward 'of' Terran Collective, or Laywer 'for' Hylo + preposition: String + # The role this person has in the organization + role: String + # URL of the affiliated organization + url: String +} + +# An Attachment on a Post +input AttachmentInput { + # 'image', 'file' or 'video' + attachmentType: String + # URL to the attachment + url: String +} + +# A Comment +input CommentInput { + # Image or file attachments on the comment + attachments: [AttachmentInput] + # Add Comment as a thread to this parent Comment + parentCommentId: String + # Post ID + postId: String + # Comment content + text: String +} + +# For adding or editing an existing CustomView on a Group +input CustomViewInput { + # The ID of an existing CustomView + id: ID + # Whether this view only displays active posts or not + activePostsOnly: Boolean + # URL for external link views + externalLink: String + groupId: Int + # The icon to display for this CustomView + icon: String + # Is this view active or not + isActive: Boolean + # The label to display for this CustomView + name: String + # Not used right now, will be used to order views in the navigation menu + order: Int + # For post stream type views, filter by these post types + postTypes: [String] + # Not used yet, will be used to filter the post stream by search text + searchText: String + # For post stream type views, only show posts that have any of these topics + topics: [CustomViewTopicInput] + # For post stream type views, how to display the posts: 'cards', 'list', 'grid', 'bigGrid' + viewMode: String +} + +# A Topic added to a CustomView +input CustomViewTopicInput { + # Topic ID + id: ID + # Topic content/text + name: String +} + +# For adding a LinkPreview to a Post +input LinkPreviewInput { + # The URL to preview + url: String +} + +input LocationInput { + # Not used right now. Can be 'rooftop', 'parcel', 'point', 'interpolated', 'intersection', or 'street' + accuracy: String + # Street number like 123 + addressNumber: String + # Street name e.g. Baker Rd + addressStreet: String + # Geographic bounding box + bbox: [PointInput] + # Geographic center point + center: PointInput + # City name + city: String + # Short country code e.g. us + countryCode: String + # Full country name (not used right now) + country: String + createdAt: Date + # String describing the full address + fullText: String + # A set of points describing the spatial geometry of the returned feature + geometry: [PointInput] + # Sub-city features in countries where this additional admin layer is used in addressing, or where such features are commonly referred to in local parlance. e.g. city districts in Brazil and Chile and arrondissements in France. + locality: String + # Colloquial neighborhood name + neighborhood: String + # Top-level sub-national administrative features, such as states in the United States or provinces in Canada or China. + region: String + # Postal codes used in country-specific national addressing systems. + postcode: String + updatedAt: Date + # Optional. The Wikidata identifier for the returned feature. + wikidata: String } -type LinkPreview { - id: ID - url: String - imageUrl: String - title: String +input GroupInput { + # URL for a video to display at the top of the about text + aboutVideoUri: String + # Either 0 = Closed (invite only), 1 = Restricted (Anyone can request to join), 2 = Open (anyone can join if they can find/see the group) + accessibility: Int + # Whether this Group is active or not + active: Boolean + # URL to the image of the avatar of the group + avatarUrl: String + # URL to the image of the banner for the group + bannerUrl: String + # CustomViews for this Group + customViews: [CustomViewInput] + # Group description text description: String - imageWidth: String - imageHeight: String - status: String + # A geoJSON object outlining a geographic shape describing the border/boundaries of the group in the world + geoShape: String + # Extension data for special group types like farms + groupExtensions: [GroupExtensionInput] + # Questions that a group requesting to connect must answer + groupToGroupJoinQuestions: [QuestionInput] + # The current invite link for this group (only a moderator of this group can access this attribute) + invitePath: String + # Questions a Person must answer when requesting to join this group + joinQuestions: [QuestionInput] + # A string describing the physical location of this group in the world + location: String + # ID of the geocoded location object + locationId: ID + # Word used to describe an admin/moderator of this group. Defaults to "Moderator" + moderatorDescriptor: String + # Plural word used to describe admins/moderators of this group. Defaults to "Moderators" + moderatorDescriptorPlural: String + # Group name + name: String + # IDs of parent Groups to add this Group to + parentIds: [ID] + # IDs of Groups that someone must join before they can join this one + prerequisiteGroupIds: [ID] + # Group settings + settings: GroupSettingsInput + # NOT USED RIGHT NOW. For Slack integratiom + slackHookUrl: String + # NOT USED RIGHT NOW. For Slack integratiom + slackTeam: String + # NOT USED RIGHT NOW. For Slack integratiom + slackConfigureUrl: String + # Unique URL slug + slug: String + # Right now can either by 'farm' or empty + type: String + # Word used to describe this type of group. Defaults to Group, or if type is set then defaults to the type (e.g. Farm) + typeDescriptor: String + # Word used to describe this type of groups. Defaults to Groups, or if type is set then defaults to the type (e.g. Farms) + typeDescriptorPlural: String + # Either 0 = Hidden (only visible to members), 1 = Protected (visible to members of related groups), 2 = Public (anyone can find/see this group) + visibility: Int } -type CreatedInvitations { - invitations: [Invitation] +# Extension data for special group types like farms +input GroupExtensionInput { + data: JSON + # Extension type. Only option right now is 'farm-onboarding', for groups of type 'farm' + type: String } -type CheckInvitationResult { - valid: Boolean +# Settings for a Topic's connections to a Group +input GroupTopicInput { + # true means this Topic is a default topic in the Group, meaning all new members are auomatically subscribed to it and it appears as a suggested topic when creating a post in the group. + isDefault: Boolean + # 0 = hidden in the group, 1 = visible, 2 = pinned (will appear at the top of the subscribed topics list for People who have subscribed to it in this Group) + visibility: Int } -type CreatedRequest { - request: JoinRequest +# Data related to a Person following a Topic in a Group +input GroupTopicFollowInput { + # Set the count of new posts with this Topic in the Group since the Person last looked a the Topic stream + newPostCount: Int } -type AcceptGroupRelationshipInviteResult { - success: Boolean - groupRelationship: GroupRelationship +# Group settings +input GroupSettingsInput { + # Whether this group allows non-moderators to invite people (not currently used) + allowGroupInvites: Boolean + # Whether to require questions being answered when a group requests to join this group + askGroupToGroupJoinQuestions: Boolean + # Whether to require questions being answered when a person requests to join this group + askJoinQuestions: Boolean + # Whether to display group extension data on the profile for special group types like farms + hideExtensionData: Boolean + # How precisely to display group locations on the map and the group profile, to enable obfuscating the location + locationDisplayPrecision: LocationDisplayPrecision + # Whether to display the members of the group to non-members + publicMemberDirectory: Boolean + # Whether to display suggested skills to new members when they join this group + showSuggestedSkills: Boolean } -type CreatedGroupRelationshipInvite { - success: Boolean - groupRelationship: GroupRelationship - groupRelationshipInvite: GroupRelationshipInvite +# Settings for a Widget in a particular Group's explore page +input GroupWidgetInput { + # Whether the widget is visible + isVisible: Boolean + # Additional Widget data for specific Widgets + settings: GroupWidgetSettingsInput } -type Notification { - id: ID - activity: Activity - createdAt: Date +input GroupWidgetSettingsInput { + # The text content of the widget, for Widgets that use this + text: String + # The title of the widget, for Widgets that use this + title: String } -type ActivityMeta { - reasons: [String] +# Details passed along when Flagging a Post +input InappropriateContentInput { + # 'inappropriate', 'offensive', 'abusive', 'illegal', 'safety', 'spam', or 'other' + category: String + # Link to the Post + linkData: LinkDataInput + # The custom text for why someone flagged the post + reason: String } -type Activity { - id: ID - actor: Person - comment: Comment - post: Post - group: Group - otherGroup: Group - action: String - meta: ActivityMeta - unread: Boolean - contributionAmount: Int +input InviteInput { + emails: [String] + message: String } -type NotificationQuerySet { - total: Int - hasMore: Boolean - items: [Notification] +# A link to a Post or a Person who has been Flagged +input LinkDataInput { + # Person or Post ID + id: ID + # Group slug + slug: String + # 'member' or 'post' + type: String } -type GroupQuerySet { - total: Int - hasMore: Boolean - items: [Group] +# For updating your settings or profile +input MeInput { + # The URL of your avatar image + avatarUrl: String + # The URL of the banner image on your profile + bannerUrl: String + # Personal bio text + bio: String + # Email address made visible on your profile + contactEmail: String + # Phone number made visible on your profile + contactPhone: String + # Account email address used to log in + email: String + # The URL to your Facebook profile + facebookUrl: String + # The URL to your LinkedIn profile + linkedinUrl: String + # String representing your physical location + location: String + # ID of the geocoded location object representing your physical location in the world + locationId: ID + # Full name + name: String + # Number of unread notifications + newNotificationCount: Int + # Login password + password: String + # User settings + settings: UserSettingsInput + # Short tagline + tagline: String + # Twitter handle + twitterName: String + # A website URL for your profile + url: String } -type GroupJoinQuestion { - id: Int - questionId: Int - text: String +# Data related to membership in a Group +input MembershipInput { + # Number of new Posts in the Group since you last looked at it + newPostCount: Int + # Settings related to membership in a Group + settings: MembershipSettingsInput } -type GroupJoinQuestionQuerySet { - total: Int - hasMore: Boolean - items: [GroupJoinQuestion] +# Personal settings for a User's relationship to a Group +input MembershipSettingsInput { + # Whether to receive emai notifications for this Group + sendEmail: Boolean + # Whether to show the form displayed upon first viewing a Group this Person just joined + showJoinForm: Boolean + # Whether to mobile push notifications for this Group + sendPushNotifications: Boolean } -type GroupToGroupJoinQuestion { - id: Int - questionId: Int +# Data for updating a message in a direct message thread +input MessageInput { + # When this thread was created + createdAt: Date + # The ID of the thread to update/edit + messageThreadId: String + # The content of the message text: String } -type GroupToGroupJoinQuestionQuerySet { - total: Int - hasMore: Boolean - items: [GroupToGroupJoinQuestion] +# Data for a direct MessageThread +input MessageThreadInput { + # The IDs of the participants in the thread + participantIds: [String] } -type GroupRelationshipInviteQuerySet { - total: Int - hasMore: Boolean - items: [GroupRelationshipInvite] +# A geographic point +input PointInput { + lat: Float + lng: Float } -type Skill { - id: ID - name: String +# A Post +input PostInput { + # NOT USED RIGHT NOW. Whether this post can accept financial contributions + acceptContributions: Boolean + # Was this post marked an announcement by a group moderator? + announcement: Boolean + # The body of the post + details: String + # Link to a donation page, only used for projects only right now + donationsLink: String + # When this post "ends". Used for events, resources, projects, requests and offers right now + endTime: Date + # IDs of people to invite to the event + eventInviteeIds: [ID] + # URLs for file attachments + fileUrls: [String] + # IDs of the groups to post to + groupIds: [String] + # URLs of images in the post + imageUrls: [String] + # Whether this post is publicly visible (outside of the groups it is posted to) + isPublic: Boolean + # ID of a LinkPreview to show in the post + linkPreviewId: String + # Full location string + location: String + # Id to location object with geocoded location deatils + locationId: ID + # People to add as members to a project type Post + memberIds: [ID] + # Link to a project management tool or service. Only used for Projects right now + projectManagementLink: String + # When this post "starts". Used for events, resources, projects, requests and offers right now + startTime: Date + # The title text for the post + title: String + # Topics to add to the post + topicNames: [String] + # Post type: 'discussion', 'event', 'offer', 'project', 'request', or 'resource' + type: String } -type SkillQuerySet { - total: Int - hasMore: Boolean - items: [Skill] +# A Question to be asked before joining a Group +input QuestionInput { + id: Int + # The ID of the question when editing one + questionId: Int + # Content of the question + text: String } -type SavedSearch { - id: ID - name: String - boundingBox: [Float] - group: Group - context: String - isActive: Boolean - searchText: String - topics: [SavedSearchTopic] - postTypes: [String] - newPosts: [Post] - createdAt: Date +# Answer to a Question +input QuestionAnswerInput { + # Answer content + answer: String + # Question ID + questionId: Int } -type SavedSearchTopic { - id: ID +# A SavedSearch on the Map +input SavedSearchInput { + # The geographic bounding box + boundingBox: [PointInput] + # 'groups' for a Group specific search, 'all' to search for posts in all logged in user's groups, or 'public' to search all public posts + context: String + # The group to search (optional) + groupSlug: String + # The name of the saves search name: String + # Only include posts that match these post types + postTypes: [String] + # Only include posts that match this search text in title or description + searchText: String + # Only include posts that match one of these topics + topicIds: [ID] + # The user creating the search + userId: ID } -type SavedSearchQuerySet { - total: Int - hasMore: Boolean - items: [SavedSearch] -} - -type Affiliation { - id: ID - user: Person - role: String - preposition: String - orgName: String - url: String - isActive: Boolean - createdAt: Date - updatedAt: Date -} - -type AffiliationQuerySet { - total: Int - hasMore: Boolean - items: [Affiliation] -} - -input InappropriateContentInput { - category: String, - reason: String, - linkData: LinkDataInput +input UserSettingsInput { + # Has this person seen the tour that displays the first time someone logs in + alreadySeenTour: Boolean + # How this person wants to receive notifications about new comments on posts they're following? By Email, Mobile App, or Both + commentNotifications: String + # How often this person wants to receive the email digest about new posts and comments: Daily, Weekly or Never + digestFrequency: String + # How this person wants to receive notifications about Direct Messages: Email, Mobile App, or Both + dmNotifications: String + # When person wants last viewed their Direct Messages + lastViewedMessagesAt: String + # The most recent map base later viewed by this person + mapBaseLayer: String + # Whether this person has finished viewing the welcome wizard after first signing up + signupInProgress: Boolean + # The most recent stream view mode used by this person: cards, list, grid, bigGrid + streamViewMode: String + # The most recent stream sort used by this person: updated, created, votes + streamSortBy: String + # The most recent stream post type filter used by this person + streamPostType: String } -input LinkDataInput { - id: ID - slug: String - type: String -} From 5acb9d38e7a868ff549e7020f6cf80f508ad1f61 Mon Sep 17 00:00:00 2001 From: Tibet Sprague Date: Mon, 19 Sep 2022 14:33:26 -0700 Subject: [PATCH 2/3] Move from old express-graphql to graphql-yoga Gets us the latest GraphQL features, an up to date graphiql and a maintained package. Fixes https://github.com/Hylozoic/hylo-node/issues/860 --- api/graphql/index.js | 50 +++---- api/graphql/makeModels.js | 2 +- package.json | 3 +- yarn.lock | 270 ++++++++++++++++++++++++++++++++++---- 4 files changed, 265 insertions(+), 60 deletions(-) diff --git a/api/graphql/index.js b/api/graphql/index.js index cf489572a..057a1b839 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -1,5 +1,6 @@ +import { envelop, useLazyLoadedSchema } from '@envelop/core' +const { createServer } = require('@graphql-yoga/node') import { readFileSync } from 'fs' -import { graphqlHTTP } from 'express-graphql' import { join } from 'path' import setupBridge from '../../lib/graphql-bookshelf-bridge' import { presentQuerySet } from '../../lib/graphql-bookshelf-bridge/util' @@ -100,12 +101,12 @@ import { merge, reduce } from 'lodash' const schemaText = readFileSync(join(__dirname, 'schema.graphql')).toString() -async function createSchema (expressContext) { +function createSchema (expressContext) { const { req } = expressContext const session = req.session const userId = session.userId const isAdmin = Admin.isSignedIn(req) - const models = await makeModels(userId, isAdmin, req.api_client) + const models = makeModels(userId, isAdmin, req.api_client) const { resolvers, fetchOne, fetchMany } = setupBridge(models) let allResolvers @@ -444,34 +445,23 @@ export function makeApiMutations () { } export const createRequestHandler = () => - graphqlHTTP(async (req, res) => { - if (process.env.DEBUG_GRAPHQL) { - sails.log.info('\n' + - red('graphql query start') + '\n' + - req.body.query + '\n' + - red('graphql query end') - ) - sails.log.info(inspect(req.body.variables)) - } - - // TODO: since this function can return a promise, we could run through some - // policies based on the current user here and assign them to context, so - // that the resolvers can use them to deny or restrict access... - // - // ideally we would be able to associate paths with policies, analyze the - // query to find the policies which should be tested, and run them to allow - // or deny access to those paths - - if (req.session.userId) { - await User.query().where({ id: req.session.userId }).update({ last_active_at: new Date() }) - } + createServer({ + plugins: [useLazyLoadedSchema(createSchema)], + context: async ({ query, req, variables }) => { + if (process.env.DEBUG_GRAPHQL) { + sails.log.info('\n' + + red('graphql query start') + '\n' + + query + '\n' + + red('graphql query end') + ) + sails.log.info(inspect(variables)) + } - const schema = await createSchema({ req, res }) - return { - schema, - graphiql: true, - customFormatErrorFn: process.env.NODE_ENV === 'development' ? logError : null - } + if (req.session.userId) { + await User.query().where({ id: req.session.userId }).update({ last_active_at: new Date() }) + } + }, + graphiql: true }) let modelToTypeMap diff --git a/api/graphql/makeModels.js b/api/graphql/makeModels.js index 7d79fd9a6..3baf8c4b8 100644 --- a/api/graphql/makeModels.js +++ b/api/graphql/makeModels.js @@ -25,7 +25,7 @@ import { // // keys in the returned object are GraphQL schema type names // -export default async function makeModels (userId, isAdmin, apiClient) { +export default function makeModels (userId, isAdmin, apiClient) { const nonAdminFilter = makeFilterToggle(!isAdmin) // XXX: for now give API users more access, in the future track which groups each client can access diff --git a/package.json b/package.json index 351e76256..145da4cd1 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,8 @@ "@babel/core": "^7.17.9", "@babel/preset-env": "^7.16.11", "@babel/register": "^7.17.7", + "@envelop/core": "^2.6.0", + "@graphql-yoga/node": "^2.13.13", "@mapbox/mapbox-sdk": "^0.13.3", "@sailshq/connect-redis": "^3.2.1", "@sailshq/socket.io-redis": "^5.2.0", @@ -85,7 +87,6 @@ "dotenv": "^0.4.0", "ejs": "^2.5.5", "ent": "^2.2.0", - "express-graphql": "^0.11.0", "file-type": "^6.1.0", "gaze": "^1.1.3", "glob": "^5.0.15", diff --git a/yarn.lock b/yarn.lock index f372c1e1f..b738f08c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1019,6 +1019,37 @@ "@babel/helper-validator-identifier" "^7.16.7" to-fast-properties "^2.0.0" +"@envelop/core@^2.5.0", "@envelop/core@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@envelop/core/-/core-2.6.0.tgz#1b7a346a37040c217f0f9b60c3358efc6c3b1b94" + integrity sha512-yTptKinJN//i6m1kXUbnLBl/FobzddI4ehURAMS08eRUOQwAuXqJU9r8VdTav8nIZLb4t6cuDWFb3n331LiwLw== + dependencies: + "@envelop/types" "2.4.0" + tslib "2.4.0" + +"@envelop/parser-cache@^4.6.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@envelop/parser-cache/-/parser-cache-4.7.0.tgz#fc438d8ed390c88fa24bf56da3e4da36f088e3fc" + integrity sha512-63NfXDcW/vGn4U6NFxaZ0JbYWAcJb9A6jhTvghsSz1ZS+Dny/ci8bVSgVmM1q+N56hPyGsVPuyI+rIc71mPU5g== + dependencies: + lru-cache "^6.0.0" + tslib "^2.4.0" + +"@envelop/types@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@envelop/types/-/types-2.4.0.tgz#129163df73503581b68950704f4064a6b0f2c6ed" + integrity sha512-pjxS98cDQBS84X29VcwzH3aJ/KiLCGwyMxuj7/5FkdiaCXAD1JEvKEj9LARWlFYj1bY43uII4+UptFebrhiIaw== + dependencies: + tslib "^2.4.0" + +"@envelop/validation-cache@^4.6.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@envelop/validation-cache/-/validation-cache-4.7.0.tgz#6871116c5387cd7c310b9ae9187d29c2793ae33f" + integrity sha512-PzL+GfWJRT+JjsJqZAIxHKEkvkM3hxkeytS5O0QLXT8kURNBV28r+Kdnn2RCF5+6ILhyGpiDb60vaquBi7g4lw== + dependencies: + lru-cache "^6.0.0" + tslib "^2.4.0" + "@eslint/eslintrc@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.3.0.tgz#d736d6963d7003b6514e6324bec9c602ac340318" @@ -1194,6 +1225,14 @@ "@graphql-tools/utils" "8.6.9" tslib "~2.3.0" +"@graphql-tools/merge@8.3.6": + version "8.3.6" + resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-8.3.6.tgz#97a936d4c8e8f935e58a514bb516c476437b5b2c" + integrity sha512-uUBokxXi89bj08P+iCvQk3Vew4vcfL5ZM6NTylWi8PIpoq4r5nJ625bRuN8h2uubEdRiH8ntN9M4xkd/j7AybQ== + dependencies: + "@graphql-tools/utils" "8.12.0" + tslib "^2.4.0" + "@graphql-tools/merge@^6.2.12", "@graphql-tools/merge@^6.2.4": version "6.2.17" resolved "https://registry.yarnpkg.com/@graphql-tools/merge/-/merge-6.2.17.tgz#4dedf87d8435a5e1091d7cc8d4f371ed1e029f1f" @@ -1266,6 +1305,16 @@ tslib "~2.3.0" value-or-promise "1.0.11" +"@graphql-tools/schema@^9.0.0": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-9.0.4.tgz#1a74608b57abf90fae6fd929d25e5482c57bc05d" + integrity sha512-B/b8ukjs18fq+/s7p97P8L1VMrwapYc3N2KvdG/uNThSazRRn8GsBK0Nr+FH+mVKiUfb4Dno79e3SumZVoHuOQ== + dependencies: + "@graphql-tools/merge" "8.3.6" + "@graphql-tools/utils" "8.12.0" + tslib "^2.4.0" + value-or-promise "1.0.11" + "@graphql-tools/stitch@^6.2.4": version "6.2.4" resolved "https://registry.yarnpkg.com/@graphql-tools/stitch/-/stitch-6.2.4.tgz#acfa6a577a33c0f02e4940ffff04753b23b87fd6" @@ -1312,6 +1361,13 @@ dependencies: tslib "~2.3.0" +"@graphql-tools/utils@8.12.0", "@graphql-tools/utils@^8.8.0": + version "8.12.0" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.12.0.tgz#243bc4f5fc2edbc9e8fd1038189e57d837cbe31f" + integrity sha512-TeO+MJWGXjUTS52qfK4R8HiPoF/R7X+qmgtOYd8DTH0l6b+5Y/tlg5aGeUJefqImRq7nvi93Ms40k/Uz4D5CWw== + dependencies: + tslib "^2.4.0" + "@graphql-tools/utils@8.6.9": version "8.6.9" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.6.9.tgz#fe1b81df29c9418b41b7a1ffe731710b93d3a1fe" @@ -1364,6 +1420,51 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== +"@graphql-yoga/common@^2.12.12": + version "2.12.12" + resolved "https://registry.yarnpkg.com/@graphql-yoga/common/-/common-2.12.12.tgz#8e90d94e1a3206f91363bd78278e5b0288d032b6" + integrity sha512-La2ygIw2qlIJZrRGT4nW70Nam7gQ2xZkOn0FDCnKWSJhQ4nHw4aFAkeHIJdZGK0u2TqtXRrNSAj5cb/TZoqUiQ== + dependencies: + "@envelop/core" "^2.5.0" + "@envelop/parser-cache" "^4.6.0" + "@envelop/validation-cache" "^4.6.0" + "@graphql-tools/schema" "^9.0.0" + "@graphql-tools/utils" "^8.8.0" + "@graphql-typed-document-node/core" "^3.1.1" + "@graphql-yoga/subscription" "^2.2.3" + "@whatwg-node/fetch" "^0.3.0" + dset "^3.1.1" + tslib "^2.3.1" + +"@graphql-yoga/node@^2.13.13": + version "2.13.13" + resolved "https://registry.yarnpkg.com/@graphql-yoga/node/-/node-2.13.13.tgz#8f42cab6be3d4d396483c33b490171772569e73b" + integrity sha512-3NmdEq3BkuVLRbo5yUi401sBiwowSKgY8O1DN1RwYdHRr0nu2dXzlYEETf4XLymyP6mKsVfQgsy7HQjwsc1oNw== + dependencies: + "@envelop/core" "^2.5.0" + "@graphql-tools/utils" "^8.8.0" + "@graphql-yoga/common" "^2.12.12" + "@graphql-yoga/subscription" "^2.2.3" + "@whatwg-node/fetch" "^0.3.0" + tslib "^2.3.1" + +"@graphql-yoga/subscription@^2.2.3": + version "2.2.3" + resolved "https://registry.yarnpkg.com/@graphql-yoga/subscription/-/subscription-2.2.3.tgz#e378fa17a12675ae7b34b2f51e39bc02df312ba0" + integrity sha512-It/Dfh+nW2ClTtmOylAa+o7fbKIRYRTH6jfKLj3YB75tkv/rFZ70bjlChDCrEMa46I+zDMg7+cdkrQOXov2fDg== + dependencies: + "@graphql-yoga/typed-event-target" "^0.1.1" + "@repeaterjs/repeater" "^3.0.4" + tslib "^2.3.1" + +"@graphql-yoga/typed-event-target@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@graphql-yoga/typed-event-target/-/typed-event-target-0.1.1.tgz#248d56a76046d805af8c0da3ef590cdb95d2c192" + integrity sha512-l23kLKHKhfD7jmv4OUlzxMTihSqgIjGWCSb0KdlLkeiaF2jjuo8pRhX200hFTrtjRHGSYS1fx2lltK/xWci+vw== + dependencies: + "@repeaterjs/repeater" "^3.0.4" + tslib "^2.3.1" + "@grpc/grpc-js@^1.2.11": version "1.6.7" resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.6.7.tgz#4c4fa998ff719fe859ac19fe977fdef097bb99aa" @@ -1584,6 +1685,33 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@peculiar/asn1-schema@^2.1.6": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.0.tgz#5368416eb336138770c692ffc2bab119ee3ae917" + integrity sha512-DtNLAG4vmDrdSJFPe7rypkcj597chNQL7u+2dBtYo5mh7VW2+im6ke+O0NVr8W1f4re4C3F71LhoMb0Yxqa48Q== + dependencies: + asn1js "^3.0.5" + pvtsutils "^1.3.2" + tslib "^2.4.0" + +"@peculiar/json-schema@^1.1.12": + version "1.1.12" + resolved "https://registry.yarnpkg.com/@peculiar/json-schema/-/json-schema-1.1.12.tgz#fe61e85259e3b5ba5ad566cb62ca75b3d3cd5339" + integrity sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w== + dependencies: + tslib "^2.0.0" + +"@peculiar/webcrypto@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@peculiar/webcrypto/-/webcrypto-1.4.0.tgz#f941bd95285a0f8a3d2af39ccda5197b80cd32bf" + integrity sha512-U58N44b2m3OuTgpmKgf0LPDOmP3bhwNz01vAnj1mBwxBASRhptWYK+M3zG+HBkDqGQM+bFsoIihTW8MdmPXEqg== + dependencies: + "@peculiar/asn1-schema" "^2.1.6" + "@peculiar/json-schema" "^1.1.12" + pvtsutils "^1.3.2" + tslib "^2.4.0" + webcrypto-core "^1.7.4" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -1637,6 +1765,11 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= +"@repeaterjs/repeater@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca" + integrity sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA== + "@sailshq/connect-redis@^3.2.1": version "3.2.1" resolved "https://registry.yarnpkg.com/@sailshq/connect-redis/-/connect-redis-3.2.1.tgz#aefb71a13dfe279baf9f0d0f241d411340345c81" @@ -1784,6 +1917,21 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== +"@whatwg-node/fetch@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.3.2.tgz#da4323795c26c135563ba01d49dc16037bec4287" + integrity sha512-Bs5zAWQs0tXsLa4mRmLw7Psps1EN78vPtgcLpw3qPY8s6UYPUM67zFZ9cy+7tZ64PXhfwzxJn+m7RH2Lq48RNQ== + dependencies: + "@peculiar/webcrypto" "^1.4.0" + abort-controller "^3.0.0" + busboy "^1.6.0" + event-target-polyfill "^0.0.3" + form-data-encoder "^1.7.1" + formdata-node "^4.3.1" + node-fetch "^2.6.7" + undici "^5.8.0" + web-streams-polyfill "^3.2.0" + "@wry/context@^0.6.0": version "0.6.1" resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.6.1.tgz#c3c29c0ad622adb00f6a53303c4f965ee06ebeb2" @@ -1822,14 +1970,14 @@ abbrev@1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== -abort-controller@3.0.0: +abort-controller@3.0.0, abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== dependencies: event-target-shim "^5.0.0" -accepts@^1.3.5, accepts@^1.3.7, accepts@~1.3.0, accepts@~1.3.4, accepts@~1.3.7, accepts@~1.3.8: +accepts@^1.3.5, accepts@~1.3.0, accepts@~1.3.4, accepts@~1.3.7, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== @@ -2272,6 +2420,15 @@ asn1@^0.2.4, asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" +asn1js@^3.0.1, asn1js@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== + dependencies: + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" + assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" @@ -2928,6 +3085,13 @@ busboy@^0.3.1: dependencies: dicer "0.3.0" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-2.1.0.tgz#ac93c410e2ffc9cc7cf4b464b38289067f5e47b4" @@ -4536,6 +4700,11 @@ double-ended-queue@^2.1.0-0: resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw= +dset@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" + integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -4990,6 +5159,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-polyfill@^0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/event-target-polyfill/-/event-target-polyfill-0.0.3.tgz#ed373295f3b257774b5d75afb2599331d9f3406c" + integrity sha512-ZMc6UuvmbinrCk4RzGyVmRyIsAyxMRlp4CqSrcQRO8Dy0A9ldbiRy5kdtBj4OtP7EClGdqGfIqo9JmOClMsGLQ== + event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" @@ -5072,16 +5246,6 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" -express-graphql@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/express-graphql/-/express-graphql-0.11.0.tgz#48089f0d40074d7783c65ff86dd9cae95afea2ef" - integrity sha512-IMYmF2aIBKKfo8c+EENBNR8FAy91QHboxfaHe1omCyb49GJXsToUgcjjIF/PfWJdzn0Ofp6JJvcsODQJrqpz2g== - dependencies: - accepts "^1.3.7" - content-type "^1.0.4" - http-errors "1.8.0" - raw-body "^2.4.1" - express-session@1.17.0: version "1.17.0" resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.0.tgz#9b50dbb5e8a03c3537368138f072736150b7f9b3" @@ -5636,6 +5800,11 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data-encoder@^1.7.1: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data@0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-0.1.3.tgz#4ee4346e6eb5362e8344a02075bd8dbd8c7373ea" @@ -5681,6 +5850,14 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +formdata-node@^4.3.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + formidable@1.0.14: version "1.0.14" resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.0.14.tgz#2b3f4c411cbb5fdd695c44843e2a23514a43231a" @@ -6577,17 +6754,6 @@ http-errors@1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -http-errors@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.0.tgz#75d1bbe497e1044f51e4ee9e704a62f28d336507" - integrity sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A== - dependencies: - depd "~1.1.2" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses ">= 1.5.0 < 2" - toidentifier "1.0.0" - http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -8893,6 +9059,11 @@ node-addon-api@^4.3.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-fetch@2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" @@ -10368,6 +10539,18 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +pvtsutils@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" + integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== + dependencies: + tslib "^2.4.0" + +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== + q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" @@ -12189,6 +12372,11 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -12800,16 +12988,16 @@ tsconfig-paths@^3.11.0: minimist "^1.2.6" strip-bom "^3.0.0" +tslib@2.4.0, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@^1.10.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - tslib@~2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" @@ -13026,6 +13214,11 @@ underscore@~1.6.0: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8" integrity sha1-izixDKze9jM3uLJOT/htRa6lKag= +undici@^5.8.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/undici/-/undici-5.10.0.tgz#dd9391087a90ccfbd007568db458674232ebf014" + integrity sha512-c8HsD3IbwmjjbLvoZuRI26TZic+TSEe8FPMLLOkN1AfYRhdjnKBU6yL+IwcSCbdZiX4e5t0lfMDLDCqj4Sq70g== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" @@ -13348,6 +13541,27 @@ void-elements@^2.0.1, void-elements@~2.0.1: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + +web-streams-polyfill@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz#71c2718c52b45fd49dbeee88634b3a60ceab42a6" + integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== + +webcrypto-core@^1.7.4: + version "1.7.5" + resolved "https://registry.yarnpkg.com/webcrypto-core/-/webcrypto-core-1.7.5.tgz#c02104c953ca7107557f9c165d194c6316587ca4" + integrity sha512-gaExY2/3EHQlRNNNVSrbG2Cg94Rutl7fAaKILS1w8ZDhGxdFOaw6EbCfHIxPy9vt/xwp5o0VQAx9aySPF6hU1A== + dependencies: + "@peculiar/asn1-schema" "^2.1.6" + "@peculiar/json-schema" "^1.1.12" + asn1js "^3.0.1" + pvtsutils "^1.3.2" + tslib "^2.4.0" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From 68f4e88d072f5bc9002b7732cc52776c2ea42d2c Mon Sep 17 00:00:00 2001 From: Tibet Sprague Date: Mon, 19 Sep 2022 23:43:42 -0700 Subject: [PATCH 3/3] Fix tests TODO: update all errors we want to expose to the outside world to be GraphQLYogaError objects --- api/graphql/index.js | 47 +-- api/graphql/index.test.js | 620 ++++++++++++++++++------------------ api/services/Search/util.js | 9 +- 3 files changed, 332 insertions(+), 344 deletions(-) diff --git a/api/graphql/index.js b/api/graphql/index.js index 057a1b839..8d4c1c12c 100644 --- a/api/graphql/index.js +++ b/api/graphql/index.js @@ -101,16 +101,36 @@ import { merge, reduce } from 'lodash' const schemaText = readFileSync(join(__dirname, 'schema.graphql')).toString() +export const createRequestHandler = () => + createServer({ + plugins: [useLazyLoadedSchema(createSchema)], + context: async ({ query, req, variables }) => { + if (process.env.DEBUG_GRAPHQL) { + sails.log.info('\n' + + red('graphql query start') + '\n' + + query + '\n' + + red('graphql query end') + ) + sails.log.info(inspect(variables)) + } + + if (req.session.userId) { + await User.query().where({ id: req.session.userId }).update({ last_active_at: new Date() }) + } + }, + graphiql: true + }) + function createSchema (expressContext) { const { req } = expressContext - const session = req.session + const { api_client, session } = req const userId = session.userId const isAdmin = Admin.isSignedIn(req) - const models = makeModels(userId, isAdmin, req.api_client) + const models = makeModels(userId, isAdmin, api_client) const { resolvers, fetchOne, fetchMany } = setupBridge(models) let allResolvers - if (req.api_client) { + if (api_client) { // TODO: check scope here, just api:write, just api_read, or both? allResolvers = { Query: makeApiQueries(fetchOne), @@ -123,6 +143,7 @@ function createSchema (expressContext) { } } else { // authenticated users + allResolvers = { Query: makeAuthenticatedQueries(userId, fetchOne, fetchMany), Mutation: makeMutations(expressContext, userId, isAdmin, fetchOne), @@ -444,26 +465,6 @@ export function makeApiMutations () { } } -export const createRequestHandler = () => - createServer({ - plugins: [useLazyLoadedSchema(createSchema)], - context: async ({ query, req, variables }) => { - if (process.env.DEBUG_GRAPHQL) { - sails.log.info('\n' + - red('graphql query start') + '\n' + - query + '\n' + - red('graphql query end') - ) - sails.log.info(inspect(variables)) - } - - if (req.session.userId) { - await User.query().where({ id: req.session.userId }).update({ last_active_at: new Date() }) - } - }, - graphiql: true - }) - let modelToTypeMap function getTypeForInstance (instance, models) { diff --git a/api/graphql/index.test.js b/api/graphql/index.test.js index 53325a6ef..99fdc9b7e 100644 --- a/api/graphql/index.test.js +++ b/api/graphql/index.test.js @@ -61,17 +61,21 @@ describe('graphql request handler', () => { req = factories.mock.request() req.url = '/noo/graphql' req.method = 'POST' + req.headers = { + 'Content-Type': 'application/json', + }, req.session = { userId: user.id, destroy: () => {} - } + }, + req.user = user res = factories.mock.response() }) describe('with a simple query', () => { - beforeEach(() => { - req.body = { - query: `{ + it('responds as expected', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ me { name memberships { @@ -86,36 +90,33 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + serverContext: { req, res } + }) - it('responds as expected', () => { - return handler(req, res).then(() => { - expectJSON(res, { - data: { - me: { - name: user.get('name'), - memberships: [ - { - group: { + return expect(executionResult).to.deep.nested.include({ + data: { + me: { + name: user.get('name'), + memberships: [ + { + group: { + name: group.get('name') + } + } + ], + posts: [ + { + title: post.get('name'), + groups: [ + { name: group.get('name') } - } - ], - posts: [ - { - title: post.get('name'), - groups: [ - { - name: group.get('name') - } - ] - } - ] - } + ] + } + ] } - }) + } }) }) }) @@ -137,9 +138,10 @@ describe('graphql request handler', () => { await thread.addFollowers([user.id, user2.id]) }) - beforeEach(() => { - req.body = { - query: `{ + + it('responds as expected', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ me { name memberships { @@ -185,88 +187,85 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + serverContext: { req, res } + }) - it('responds as expected', () => { - return handler(req, res).then(() => { - expectJSON(res, { - data: { - me: { - name: user.get('name'), - memberships: [ - { - group: { + return expect(executionResult).to.deep.nested.include({ + data: { + me: { + name: user.get('name'), + memberships: [ + { + group: { + name: group.get('name') + } + } + ], + posts: [ + { + title: post.get('name'), + groups: [ + { name: group.get('name') } - } - ], - posts: [ - { - title: post.get('name'), - groups: [ + ], + comments: { + items: [ { - name: group.get('name') + text: comment.text(), + creator: { + name: user2.get('name') + } } - ], - comments: { + ] + }, + followers: [ + { + name: user2.get('name') + } + ], + followersTotal: 1 + } + ], + messageThreads: { + hasMore: false, + total: 1, + items: [ + { + id: thread.id, + messages: { items: [ { - text: comment.text(), + text: message.get('text'), creator: { name: user2.get('name') } } ] }, - followers: [ + participants: [ + { + name: user.get('name') + }, { name: user2.get('name') } ], - followersTotal: 1 + participantsTotal: 2 } - ], - messageThreads: { - hasMore: false, - total: 1, - items: [ - { - id: thread.id, - messages: { - items: [ - { - text: message.get('text'), - creator: { - name: user2.get('name') - } - } - ] - }, - participants: [ - { - name: user.get('name') - }, - { - name: user2.get('name') - } - ], - participantsTotal: 2 - } - ] - } + ] } } - }) + } }) }) }) describe('querying Comment attachments', () => { - beforeEach(() => { - req.body = { - query: `{ + it('responds as expected', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ post (id: ${post.id}) { comments { items { @@ -280,33 +279,30 @@ describe('graphql request handler', () => { } } } - }` - } - }) + }`, + serverContext: { req, res } + }) - it('responds as expected', () => { - return handler(req, res).then(() => { - expectJSON(res, { - data: { - post: { - comments: { - items: [ - { - text: comment.text(), - attachments: [ - { - id: media.id, - type: media.get('type'), - position: media.get('position'), - url: media.get('url') - } - ] - } - ] - } + return expect(executionResult).to.deep.nested.include({ + data: { + post: { + comments: { + items: [ + { + text: comment.text(), + attachments: [ + { + id: media.id, + type: media.get('type'), + position: media.get('position'), + url: media.get('url') + } + ] + } + ] } } - }) + } }) }) }) @@ -316,52 +312,51 @@ describe('graphql request handler', () => { req.session = {} }) - it('shows "not logged in" errors for most queries', () => { - req.body = { - query: `{ + it('shows "not logged in" errors for most queries', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ me { name } group(id: 9) { name } - }` - } + }`, + serverContext: { req, res } + }) - return handler(req, res).then(() => { - expectJSON(res, { - data: { - me: null, - group: null - } - }) + return expect(executionResult).to.deep.nested.include({ + data: { + me: null, + group: null + } }) }) - it('allows checkInvitation', () => { - req.body = { - query: `{ + it('allows checkInvitation', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ checkInvitation(invitationToken: "foo") { valid } - }` - } - return handler(req, res).then(() => { - expectJSON(res, { - data: { - checkInvitation: { - valid: false - } + }`, + serverContext: { req, res } + }) + + return expect(executionResult).to.deep.nested.include({ + data: { + checkInvitation: { + valid: false } - }) + } }) }) }) describe('querying group data', () => { - it('works as expected', () => { - req.body = { - query: `{ + it('works as expected', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ group(id: "${group.id}") { slug members(first: 2, sortBy: "join") { @@ -381,45 +376,44 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + serverContext: { req, res } + }) - return handler(req, res).then(() => { - expectJSON(res, { - data: { - group: { - slug: group.get('slug'), - members: { - items: [ - {name: user2.get('name')}, - {name: user.get('name')} - ] - }, - posts: { - items: [ - {title: post2.get('name')} - ] - }, - groupExtensions: { - items: [ - { - type:'test', - data: { - "key-test": "value-test" - } + return expect(executionResult).to.deep.nested.include({ + data: { + group: { + slug: group.get('slug'), + members: { + items: [ + {name: user2.get('name')}, + {name: user.get('name')} + ] + }, + posts: { + items: [ + {title: post2.get('name')} + ] + }, + groupExtensions: { + items: [ + { + type:'test', + data: { + "key-test": "value-test" } - ] - } + } + ] } } - }) + } }) }) describe('with an invalid sort option', () => { - it('shows an error', () => { - req.body = { - query: `{ + it('shows an error', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ group(id: "${group.id}") { members(first: 2, sortBy: "height") { items { @@ -427,18 +421,19 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + serverContext: { req, res } + }) - return handler(req, res).then(() => { - expectJSON(res, { - 'errors[0].message': 'Cannot sort by "height"', - data: { - group: { - members: null - } + console.log("executionResultmoo", executionResult, executionResult.errors[0].locations) + + return expect(executionResult).to.deep.nested.include({ + 'errors[0].message': 'Cannot sort by "height"', + data: { + group: { + members: null } - }) + } }) }) }) @@ -450,9 +445,9 @@ describe('graphql request handler', () => { await FullTextSearch.createView() }) - it('works', () => { - req.body = { - query: `{ + it('works', async () => { + const { response, executionResult } = await handler.inject({ + document: `{ search(term: "${post.get('name').substring(0, 4)}") { items { content { @@ -463,24 +458,23 @@ describe('graphql request handler', () => { } } } - }` - } + }`, + serverContext: { req, res } + }) - return handler(req, res).then(() => { - expectJSON(res, { - data: { - search: { - items: [ - { - content: { - __typename: 'Post', - title: post.get('name') - } + return expect(executionResult).to.deep.nested.include({ + data: { + search: { + items: [ + { + content: { + __typename: 'Post', + title: post.get('name') } - ] - } + } + ] } - }) + } }) }) }) @@ -504,67 +498,67 @@ describe('graphql request handler', () => { )) }) - it('removes a skill with an id', () => { - req.body = { - query: `mutation { + it('removes a skill with an id', async () => { + const { response, executionResult } = await handler.inject({ + document: `mutation { removeSkill(id: ${skill1.id}) { success } - }` - } - return handler(req, res) - .then(() => user.load('skills')) - .then(() => { - expectJSON(res, { - data: { - removeSkill: { - success: true - } - } - }) - expect(user.relations.skills.length).to.equal(1) - expect(user.relations.skills.first().id).to.equal(skill2.id) + }`, + serverContext: { req, res } }) + + await user.load('skills') + + expect(executionResult).to.deep.nested.include({ + data: { + removeSkill: { + success: true + } + } + }) + expect(user.relations.skills.length).to.equal(1) + expect(user.relations.skills.first().id).to.equal(skill2.id) }) - it('removes a skill with a name', () => { - req.body = { - query: `mutation { + it('removes a skill with a name', async () => { + const { response, executionResult } = await handler.inject({ + document: `mutation { removeSkill(name: "${skill2.get('name')}") { success } - }` - } - return handler(req, res) - .then(() => user.load('skills')) - .then(() => { - expectJSON(res, { - data: { - removeSkill: { - success: true - } + }`, + serverContext: { req, res } + }) + + await user.load('skills') + + expect(executionResult).to.deep.nested.include({ + data: { + removeSkill: { + success: true } - }) - expect(user.relations.skills.length).to.equal(1) - expect(user.relations.skills.first().id).to.equal(skill1.id) + } }) + expect(user.relations.skills.length).to.equal(1) + expect(user.relations.skills.first().id).to.equal(skill1.id) }) }) describe('sendEmailVerification', function () { it('returns `success: true` if new user', async () => { - req.body = { - query: ` + const { response, executionResult } = await handler.inject({ + document: ` mutation { sendEmailVerification(email: "person@blah.com") { success } } - ` - } - await handler(req, res) + `, + serverContext: { req, res } + }) - expectJSON(res, { + return expect(executionResult).to.deep.nested.include({ data: { sendEmailVerification: { success: true @@ -575,18 +569,18 @@ describe('graphql request handler', () => { it('returns `success: true` if existing user with an unverified email', async () => { const testUser = await factories.user().save() - req.body = { - query: ` + const { response, executionResult } = await handler.inject({ + document: ` mutation { sendEmailVerification(email: "${testUser.get('email')}") { success } } - ` - } - await handler(req, res) + `, + serverContext: { req, res } + }) - expectJSON(res, { + return expect(executionResult).to.deep.nested.include({ data: { sendEmailVerification: { success: true @@ -599,18 +593,19 @@ describe('graphql request handler', () => { const testUser = await factories.user({ 'email_validated': true }).save() - req.body = { - query: ` + + const { response, executionResult } = await handler.inject({ + document: ` mutation { sendEmailVerification(email: "${testUser.get('email')}") { success } } - ` - } - await handler(req, res) + `, + serverContext: { req, res } + }) - expectJSON(res, { + return expect(executionResult).to.deep.nested.include({ data: { sendEmailVerification: { success: true @@ -629,9 +624,9 @@ describe('graphql request handler', () => { token = userVerificationCode.token }) - it('works', () => { - req.body = { - query: ` + it('works', async () => { + const { response, executionResult } = await handler.inject({ + document: ` mutation { verifyEmail(code: "${code}", email: "${user.get('email')}") { me { @@ -641,29 +636,28 @@ describe('graphql request handler', () => { error } } - ` - } - return handler(req, res) - .then(() => { - expectJSON(res, { - data: { - verifyEmail: { - me: { - id: user.id, - emailValidated: true - }, - error: null - } - } - }) - // expect(user.get('email_validated')).to.be.true - expect(req.session.userId).to.equal(user.id) - }) - }) + `, + serverContext: { req, res } + }) + + expect(executionResult).to.deep.nested.include({ + data: { + verifyEmail: { + me: { + id: user.id, + emailValidated: true + }, + error: null + } + } + }) + // expect(user.get('email_validated')).to.be.true + expect(req.session.userId).to.equal(user.id) + }) - it('returns invalid-code error when code is not valid', () => { - req.body = { - query: ` + it('returns invalid-code error when code is not valid', async () => { + const { response, executionResult } = await handler.inject({ + document: ` mutation { verifyEmail(code: "booop", email: "${user.get('email')}") { me { @@ -673,22 +667,21 @@ describe('graphql request handler', () => { error } } - ` - } - return handler(req, res) - .then(() => { - expectJSON(res, { - data: { - verifyEmail: { - me: null, - error: 'invalid-code' - } - } - }) - }) + `, + serverContext: { req, res } + }) + + return expect(executionResult).to.deep.nested.include({ + data: { + verifyEmail: { + me: null, + error: 'invalid-code' + } + } + }) }) - it('returns invalid-link error when token is bad ', () => { + it('returns invalid-link error when token is bad ', async () => { const testToken = jwt.sign({ iss: 'https://hylo.com/moo', // Bad iss here makes bad token aud: 'https://hylo.com', @@ -697,8 +690,8 @@ describe('graphql request handler', () => { code }, Buffer.from(process.env.OIDC_KEYS.split(',')[0], 'base64'), { algorithm: 'RS256' }) - req.body = { - query: ` + const { response, executionResult } = await handler.inject({ + document: ` mutation { verifyEmail(token: "${testToken}", email: "${user.get('email')}") { me { @@ -708,24 +701,23 @@ describe('graphql request handler', () => { error } } - ` - } - return handler(req, res) - .then(() => { - expectJSON(res, { - data: { - verifyEmail: { - me: null, - error: 'invalid-link' - } - } - }) - }) + `, + serverContext: { req, res } + }) + + return expect(executionResult).to.deep.nested.include({ + data: { + verifyEmail: { + me: null, + error: 'invalid-link' + } + } + }) }) it('validates email and creates user session on valid token', async () => { - req.body = { - query: ` + const { response, executionResult } = await handler.inject({ + document: ` mutation { verifyEmail(token: "${token}", email: "${user.get('email')}") { me { @@ -734,12 +726,11 @@ describe('graphql request handler', () => { } } } - ` - } - - await handler(req, res) + `, + serverContext: { req, res } + }) - expectJSON(res, { + return expect(executionResult).to.deep.nested.include({ data: { verifyEmail: { me: { @@ -854,8 +845,3 @@ describe('makeAuthenticatedQueries', () => { }) }) }) - -function expectJSON (res, expected) { - expect(res.body).to.exist - return expect(res.body).to.deep.nested.include(expected) -} diff --git a/api/services/Search/util.js b/api/services/Search/util.js index f8ea3f555..10b344c80 100644 --- a/api/services/Search/util.js +++ b/api/services/Search/util.js @@ -1,3 +1,4 @@ +import { GraphQLYogaError } from '@graphql-yoga/node' import { curry, includes, isEmpty, values } from 'lodash' import moment from 'moment' import addTermToQueryBuilder from './addTermToQueryBuilder' @@ -30,7 +31,7 @@ export const filterAndSortPosts = curry((opts, q) => { const sort = sortColumns[sortBy] || values(sortColumns).find(v => v === 'posts.' + sortBy || v === sortBy) if (!sort) { - throw new Error(`Cannot sort by "${sortBy}"`) + throw new GraphQLYogaError(`Cannot sort by "${sortBy}"`) } const { DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE } = Post.Type @@ -80,7 +81,7 @@ export const filterAndSortPosts = curry((opts, q) => { q.whereIn('posts.type', [DISCUSSION, REQUEST, OFFER, PROJECT, EVENT, RESOURCE]) } else { if (!includes(values(Post.Type), type)) { - throw new Error(`unknown post type: "${type}"`) + throw new GraphQLYogaError(`unknown post type: "${type}"`) } q.where({'posts.type': type}) } @@ -134,11 +135,11 @@ export const filterAndSortUsers = curry(({ autocomplete, boundingBox, order, sea } if (sortBy && !['name', 'location', 'join', 'last_active_at'].includes(sortBy)) { - throw new Error(`Cannot sort by "${sortBy}"`) + throw new GraphQLYogaError(`Cannot sort by "${sortBy}"`) } if (order && !['asc', 'desc'].includes(order.toLowerCase())) { - throw new Error(`Cannot use sort order "${order}"`) + throw new GraphQLYogaError(`Cannot use sort order "${order}"`) } if (sortBy === 'join') {